Files
BountyBoard/internal/sync/connector.go
T
etalon 458ba6a7ee feat: admin area with customers, users, settings, audit log, service status (phase 5)
- customer CRUD wizard backend: per-system credential shapes validated via
  connector construction, AES-256-GCM at rest, write-only over the API
- ticketing connectors (jira/azure_devops/youtrack/demo) with test-connection
- user management: roles, disable (revokes sessions), force password reset,
  delete; self-demotion/disable/delete guards
- admin-editable runtime settings ({_id:app}) incl. atomizer URL override
- audit log written on every privileged mutation + filterable list API
- service status panel: mongo/atomizer/work-performer health + latency with
  late-bound breaker, sync and jobs status providers
- tabbed admin UI (customers wizard dialog, users table, settings, status, audit)
- compose: mongo nofile ulimit 64000 (1024 default crashed WiredTiger)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:57:10 +02:00

136 lines
4.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package sync imports tickets from customer ticketing systems by polling
// (§5.3) and exposes per-system connectors used for both syncing and the
// admin "Test connection" button.
package sync
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"bountyboard/internal/domain"
)
// Ticket is the system-agnostic shape a connector returns.
type Ticket struct {
Key string
URL string
Type string // epic | story | task
Title string
Description string
Attachments []TicketAttachment
Raw map[string]any
}
type TicketAttachment struct {
Name string
URL string
MimeType string
}
// Connector talks to one customer's ticketing system.
type Connector interface {
// TestConnection performs a cheap authenticated call (§5.3).
TestConnection(ctx context.Context) error
// FetchUpdated returns tickets assigned to identity updated since the
// watermark (callers pass lastSyncAt 5 min for the overlap window).
FetchUpdated(ctx context.Context, identity string, since time.Time) ([]Ticket, error)
// ListAssignedKeys returns every ticket key currently assigned to
// identity — used to flag orphaned tasks.
ListAssignedKeys(ctx context.Context, identity string) ([]string, error)
// Authorize adds the system's auth headers to an attachment download.
Authorize(req *http.Request)
}
// Credentials is the decrypted per-system credential map (§4.2 shapes).
type Credentials map[string]string
// NewConnector builds the connector for a ticketing config + decrypted
// credentials.
func NewConnector(t domain.TicketingType, baseURL, projectKey string, creds Credentials) (Connector, error) {
switch t {
case domain.TicketingJira:
if creds["email"] == "" || creds["apiToken"] == "" {
return nil, fmt.Errorf("jira requires email and apiToken")
}
return &jiraConnector{baseURL: baseURL, projectKey: projectKey, email: creds["email"], apiToken: creds["apiToken"]}, nil
case domain.TicketingAzure:
if creds["organization"] == "" || creds["project"] == "" || creds["pat"] == "" {
return nil, fmt.Errorf("azure_devops requires organization, project and pat")
}
if baseURL == "" {
baseURL = "https://dev.azure.com"
}
return &azureConnector{baseURL: baseURL, organization: creds["organization"], project: creds["project"], pat: creds["pat"]}, nil
case domain.TicketingYouTrack:
if creds["permanentToken"] == "" {
return nil, fmt.Errorf("youtrack requires permanentToken")
}
return &youtrackConnector{baseURL: baseURL, projectKey: projectKey, token: creds["permanentToken"]}, nil
case domain.TicketingDemo:
return &demoConnector{projectKey: projectKey}, nil
default:
return nil, fmt.Errorf("unknown ticketing type %q", t)
}
}
var httpClient = &http.Client{Timeout: 30 * time.Second}
// getJSON performs an authorized GET and decodes JSON, with one retry on
// 5xx/network errors (§12).
func getJSON(ctx context.Context, authorize func(*http.Request), url string, dst any) error {
return doJSON(ctx, authorize, http.MethodGet, url, nil, dst)
}
func doJSON(ctx context.Context, authorize func(*http.Request), method, url string, body []byte, dst any) error {
var lastErr error
for attempt := 0; attempt < 2; attempt++ {
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader(body))
if err != nil {
return err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")
authorize(req)
resp, err := httpClient.Do(req)
if err != nil {
lastErr = err
continue
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 20<<20))
resp.Body.Close()
if err != nil {
lastErr = err
continue
}
if resp.StatusCode >= 500 {
lastErr = fmt.Errorf("%s %s: status %d", method, url, resp.StatusCode)
continue
}
if resp.StatusCode >= 400 {
return fmt.Errorf("%s %s: status %d: %.200s", method, url, resp.StatusCode, data)
}
if dst == nil {
return nil
}
if err := json.Unmarshal(data, dst); err != nil {
return fmt.Errorf("decode %s: %w", url, err)
}
return nil
}
return lastErr
}
func bodyReader(b []byte) io.Reader {
if b == nil {
return nil
}
return bytes.NewReader(b)
}