097d46886d
Atlassian removed GET /rest/api/3/search (HTTP 410, CHANGE-2046), which broke ticket sync for Jira Cloud customers. Migrate the connector's search() to POST /rest/api/3/search/jql with cursor pagination (nextPageToken/isLast) instead of the legacy startAt/total scheme. Verified end-to-end against a live Jira Cloud instance: TestConnection, search, FetchUpdated and ListAssignedKeys all succeed and parse issues correctly. Spec §5.3 updated to document the new endpoint. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
163 lines
4.5 KiB
Go
163 lines
4.5 KiB
Go
package sync
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"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"`
|
|
}
|
|
|
|
// search runs a JQL query via POST /rest/api/3/search/jql — the replacement
|
|
// for the removed GET /rest/api/3/search endpoint (Atlassian CHANGE-2046).
|
|
// It uses cursor pagination (nextPageToken/isLast); the legacy startAt/total
|
|
// fields no longer exist.
|
|
func (j *jiraConnector) search(ctx context.Context, jql, fields string) ([]jiraIssue, error) {
|
|
fieldList := strings.Split(fields, ",")
|
|
u := j.baseURL + "/rest/api/3/search/jql"
|
|
var all []jiraIssue
|
|
nextPageToken := ""
|
|
for {
|
|
reqBody := map[string]any{
|
|
"jql": jql,
|
|
"fields": fieldList,
|
|
"maxResults": 100,
|
|
}
|
|
if nextPageToken != "" {
|
|
reqBody["nextPageToken"] = nextPageToken
|
|
}
|
|
body, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("jira search: encode request: %w", err)
|
|
}
|
|
var page struct {
|
|
Issues []jiraIssue `json:"issues"`
|
|
NextPageToken string `json:"nextPageToken"`
|
|
IsLast bool `json:"isLast"`
|
|
}
|
|
if err := doJSON(ctx, j.Authorize, http.MethodPost, u, body, &page); err != nil {
|
|
return nil, fmt.Errorf("jira search: %w", err)
|
|
}
|
|
all = append(all, page.Issues...)
|
|
if page.IsLast || page.NextPageToken == "" || len(page.Issues) == 0 {
|
|
return all, nil
|
|
}
|
|
nextPageToken = page.NextPageToken
|
|
}
|
|
}
|
|
|
|
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())
|
|
}
|