458ba6a7ee
- 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>
136 lines
4.1 KiB
Go
136 lines
4.1 KiB
Go
// 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)
|
||
}
|