package sync import ( "context" "encoding/json" "fmt" "net/http" "regexp" "strings" "time" ) // azureConnector talks to Azure DevOps REST 7.1 with PAT basic auth. type azureConnector struct { baseURL string // https://dev.azure.com unless self-hosted organization string project string pat string } func (a *azureConnector) Authorize(req *http.Request) { req.SetBasicAuth("", a.pat) } func (a *azureConnector) orgURL() string { return strings.TrimRight(a.baseURL, "/") + "/" + a.organization } func (a *azureConnector) TestConnection(ctx context.Context) error { var out struct { Count int `json:"count"` } if err := getJSON(ctx, a.Authorize, a.orgURL()+"/_apis/projects?api-version=7.1", &out); err != nil { return fmt.Errorf("azure devops /_apis/projects: %w", err) } return nil } func (a *azureConnector) wiql(ctx context.Context, query string) ([]int, error) { body, _ := json.Marshal(map[string]string{"query": query}) var out struct { WorkItems []struct { ID int `json:"id"` } `json:"workItems"` } u := fmt.Sprintf("%s/%s/_apis/wit/wiql?api-version=7.1", a.orgURL(), a.project) if err := doJSON(ctx, a.Authorize, http.MethodPost, u, body, &out); err != nil { return nil, fmt.Errorf("azure wiql: %w", err) } ids := make([]int, len(out.WorkItems)) for i, wi := range out.WorkItems { ids[i] = wi.ID } return ids, nil } type azureWorkItem struct { ID int `json:"id"` Fields map[string]any `json:"fields"` Links struct { HTML struct { Href string `json:"href"` } `json:"html"` } `json:"_links"` Relations []struct { Rel string `json:"rel"` URL string `json:"url"` Attributes struct { Name string `json:"name"` } `json:"attributes"` } `json:"relations"` } func (a *azureConnector) FetchUpdated(ctx context.Context, identity string, since time.Time) ([]Ticket, error) { q := fmt.Sprintf(`Select [System.Id] From WorkItems Where [System.AssignedTo] = '%s' AND [System.TeamProject] = '%s' AND [System.ChangedDate] >= '%s'`, identity, a.project, since.UTC().Format("2006-01-02T15:04:05Z")) ids, err := a.wiql(ctx, q) if err != nil || len(ids) == 0 { return nil, err } var out []Ticket for start := 0; start < len(ids); start += 200 { end := min(start+200, len(ids)) idStrs := make([]string, 0, end-start) for _, id := range ids[start:end] { idStrs = append(idStrs, fmt.Sprint(id)) } var batch struct { Value []azureWorkItem `json:"value"` } u := fmt.Sprintf("%s/%s/_apis/wit/workitems?ids=%s&$expand=all&api-version=7.1", a.orgURL(), a.project, strings.Join(idStrs, ",")) if err := getJSON(ctx, a.Authorize, u, &batch); err != nil { return nil, fmt.Errorf("azure workitems batch: %w", err) } for _, wi := range batch.Value { out = append(out, a.toTicket(wi)) } } return out, nil } func (a *azureConnector) toTicket(wi azureWorkItem) Ticket { title, _ := wi.Fields["System.Title"].(string) descHTML, _ := wi.Fields["System.Description"].(string) witype, _ := wi.Fields["System.WorkItemType"].(string) url := wi.Links.HTML.Href if url == "" { url = fmt.Sprintf("%s/%s/_workitems/edit/%d", a.orgURL(), a.project, wi.ID) } t := Ticket{ Key: fmt.Sprintf("%s-%d", strings.ToUpper(a.project), wi.ID), URL: url, Type: azureType(witype), Title: title, Description: stripHTML(descHTML), Raw: map[string]any{"id": wi.ID, "workItemType": witype}, } for _, rel := range wi.Relations { if rel.Rel == "AttachedFile" { t.Attachments = append(t.Attachments, TicketAttachment{Name: rel.Attributes.Name, URL: rel.URL}) } } return t } func (a *azureConnector) ListAssignedKeys(ctx context.Context, identity string) ([]string, error) { q := fmt.Sprintf(`Select [System.Id] From WorkItems Where [System.AssignedTo] = '%s' AND [System.TeamProject] = '%s'`, identity, a.project) ids, err := a.wiql(ctx, q) if err != nil { return nil, err } keys := make([]string, len(ids)) for i, id := range ids { keys[i] = fmt.Sprintf("%s-%d", strings.ToUpper(a.project), id) } return keys, nil } func azureType(name string) string { switch strings.ToLower(name) { case "epic": return "epic" case "user story", "product backlog item": return "story" default: return "task" } } var tagRe = regexp.MustCompile(`<[^>]*>`) // stripHTML is a lossy HTML→text flattener for ADO descriptions. func stripHTML(s string) string { s = strings.ReplaceAll(s, "
", "\n") s = strings.ReplaceAll(s, "
", "\n") s = strings.ReplaceAll(s, "

", "\n") s = tagRe.ReplaceAllString(s, "") s = strings.ReplaceAll(s, " ", " ") s = strings.ReplaceAll(s, "&", "&") s = strings.ReplaceAll(s, "<", "<") s = strings.ReplaceAll(s, ">", ">") return strings.TrimSpace(s) }