package sync import ( "context" "fmt" "net/http" "net/url" "strings" "time" ) // jiraConnector talks to Jira Cloud REST v3 with email+API-token basic auth. type jiraConnector struct { baseURL string projectKey string email string apiToken string } func (j *jiraConnector) Authorize(req *http.Request) { req.SetBasicAuth(j.email, j.apiToken) } func (j *jiraConnector) TestConnection(ctx context.Context) error { var me struct { AccountID string `json:"accountId"` } if err := getJSON(ctx, j.Authorize, j.baseURL+"/rest/api/3/myself", &me); err != nil { return fmt.Errorf("jira /myself: %w", err) } return nil } type jiraIssue struct { Key string `json:"key"` Fields struct { Summary string `json:"summary"` Description map[string]any `json:"description"` // ADF document IssueType struct { Name string `json:"name"` } `json:"issuetype"` Attachment []struct { Filename string `json:"filename"` Content string `json:"content"` MimeType string `json:"mimeType"` } `json:"attachment"` } `json:"fields"` } func (j *jiraConnector) search(ctx context.Context, jql, fields string) ([]jiraIssue, error) { var all []jiraIssue for startAt := 0; ; { var page struct { Issues []jiraIssue `json:"issues"` Total int `json:"total"` } u := fmt.Sprintf("%s/rest/api/3/search?jql=%s&fields=%s&maxResults=100&startAt=%d", j.baseURL, url.QueryEscape(jql), url.QueryEscape(fields), startAt) if err := getJSON(ctx, j.Authorize, u, &page); err != nil { return nil, fmt.Errorf("jira search: %w", err) } all = append(all, page.Issues...) startAt += len(page.Issues) if len(page.Issues) == 0 || startAt >= page.Total { return all, nil } } } func (j *jiraConnector) FetchUpdated(ctx context.Context, identity string, since time.Time) ([]Ticket, error) { jql := fmt.Sprintf(`assignee = %q AND project = %q AND updated >= %q ORDER BY updated ASC`, identity, j.projectKey, since.UTC().Format("2006/01/02 15:04")) issues, err := j.search(ctx, jql, "summary,description,issuetype,attachment") if err != nil { return nil, err } out := make([]Ticket, 0, len(issues)) for _, is := range issues { t := Ticket{ Key: is.Key, URL: j.baseURL + "/browse/" + is.Key, Type: jiraType(is.Fields.IssueType.Name), Title: is.Fields.Summary, Description: adfToText(is.Fields.Description), Raw: map[string]any{"key": is.Key, "issueType": is.Fields.IssueType.Name}, } for _, a := range is.Fields.Attachment { t.Attachments = append(t.Attachments, TicketAttachment{Name: a.Filename, URL: a.Content, MimeType: a.MimeType}) } out = append(out, t) } return out, nil } func (j *jiraConnector) ListAssignedKeys(ctx context.Context, identity string) ([]string, error) { jql := fmt.Sprintf(`assignee = %q AND project = %q`, identity, j.projectKey) issues, err := j.search(ctx, jql, "key") if err != nil { return nil, err } keys := make([]string, len(issues)) for i, is := range issues { keys[i] = is.Key } return keys, nil } func jiraType(name string) string { switch strings.ToLower(name) { case "epic": return "epic" case "story", "user story": return "story" default: return "task" } } // adfToText flattens an Atlassian Document Format tree into plain text — // good enough for atomization input. func adfToText(node map[string]any) string { if node == nil { return "" } var sb strings.Builder var walk func(n map[string]any) walk = func(n map[string]any) { if text, ok := n["text"].(string); ok { sb.WriteString(text) } if t, _ := n["type"].(string); t == "paragraph" || t == "heading" || t == "listItem" { defer sb.WriteString("\n") } if content, ok := n["content"].([]any); ok { for _, child := range content { if m, ok := child.(map[string]any); ok { walk(m) } } } } walk(node) return strings.TrimSpace(sb.String()) }