package sync import ( "context" "fmt" "net/http" "net/url" "strings" "time" ) // youtrackConnector talks to YouTrack's REST API with a permanent token. type youtrackConnector struct { baseURL string projectKey string token string } func (y *youtrackConnector) Authorize(req *http.Request) { req.Header.Set("Authorization", "Bearer "+y.token) } func (y *youtrackConnector) TestConnection(ctx context.Context) error { var me struct { Login string `json:"login"` } if err := getJSON(ctx, y.Authorize, y.baseURL+"/api/users/me?fields=login", &me); err != nil { return fmt.Errorf("youtrack /api/users/me: %w", err) } return nil } type ytIssue struct { IDReadable string `json:"idReadable"` Summary string `json:"summary"` Description string `json:"description"` Attachments []struct { Name string `json:"name"` URL string `json:"url"` MimeType string `json:"mimeType"` } `json:"attachments"` CustomFields []struct { Name string `json:"name"` Value any `json:"value"` } `json:"customFields"` } func (y *youtrackConnector) query(ctx context.Context, q, fields string) ([]ytIssue, error) { var all []ytIssue for skip := 0; ; skip += 100 { var page []ytIssue u := fmt.Sprintf("%s/api/issues?query=%s&fields=%s&$top=100&$skip=%d", y.baseURL, url.QueryEscape(q), url.QueryEscape(fields), skip) if err := getJSON(ctx, y.Authorize, u, &page); err != nil { return nil, fmt.Errorf("youtrack issues: %w", err) } all = append(all, page...) if len(page) < 100 { return all, nil } } } const ytFields = "idReadable,summary,description,attachments(name,url,mimeType),customFields(name,value(name))" func (y *youtrackConnector) FetchUpdated(ctx context.Context, identity string, since time.Time) ([]Ticket, error) { q := fmt.Sprintf("assignee: %s project: %s updated: %s .. *", identity, y.projectKey, since.UTC().Format("2006-01-02T15:04")) issues, err := y.query(ctx, q, ytFields) if err != nil { return nil, err } out := make([]Ticket, 0, len(issues)) for _, is := range issues { out = append(out, y.toTicket(is)) } return out, nil } func (y *youtrackConnector) toTicket(is ytIssue) Ticket { issueType := "task" for _, cf := range is.CustomFields { if cf.Name == "Type" { if v, ok := cf.Value.(map[string]any); ok { if name, ok := v["name"].(string); ok { issueType = ytType(name) } } } } t := Ticket{ Key: is.IDReadable, URL: y.baseURL + "/issue/" + is.IDReadable, Type: issueType, Title: is.Summary, Description: is.Description, Raw: map[string]any{"idReadable": is.IDReadable}, } for _, a := range is.Attachments { u := a.URL if strings.HasPrefix(u, "/") { u = y.baseURL + u } t.Attachments = append(t.Attachments, TicketAttachment{Name: a.Name, URL: u, MimeType: a.MimeType}) } return t } func (y *youtrackConnector) ListAssignedKeys(ctx context.Context, identity string) ([]string, error) { q := fmt.Sprintf("assignee: %s project: %s", identity, y.projectKey) issues, err := y.query(ctx, q, "idReadable") if err != nil { return nil, err } keys := make([]string, len(issues)) for i, is := range issues { keys[i] = is.IDReadable } return keys, nil } func ytType(name string) string { switch strings.ToLower(name) { case "epic": return "epic" case "story", "user story", "feature": return "story" default: return "task" } }