// 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.TicketingWekan: hasLogin := creds["username"] != "" && creds["password"] != "" hasToken := creds["token"] != "" if !hasLogin && !hasToken { return nil, fmt.Errorf("wekan requires username+password (or a pre-issued token)") } if baseURL == "" { return nil, fmt.Errorf("wekan requires baseUrl") } if projectKey == "" { return nil, fmt.Errorf("wekan requires projectKey (the board id)") } return &wekanConnector{ baseURL: baseURL, boardID: projectKey, username: creds["username"], password: creds["password"], token: creds["token"], }, 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) }