package youtrack import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "sync" "time" ) const ( defaultPageSize = 100 maxPages = 50 issueFields = "idReadable,summary,description,updated,customFields(id,name,$type,value(name,presentation,$type),possibleEvents(id,presentation,$type))" ) // Issue is a minimal YouTrack issue used for sync. type Issue struct { IDReadable string Summary string Description string State string Updated time.Time } // WorkItem is a YouTrack time-tracking work item. type WorkItem struct { ID string Date time.Time Minutes int Text string } // Client talks to the YouTrack REST API. type Client struct { BaseURL string Token string HTTPClient *http.Client mu sync.Mutex projectIDByKey map[string]string } func (c *Client) http() *http.Client { if c.HTTPClient != nil { return c.HTTPClient } return http.DefaultClient } func (c *Client) base() string { return strings.TrimRight(strings.TrimSpace(c.BaseURL), "/") } func (c *Client) ensureAuth() error { if c.base() == "" { return fmt.Errorf("youtrack: base URL required") } if strings.TrimSpace(c.Token) == "" { return fmt.Errorf("youtrack: token required") } return nil } // ListIssues fetches issues matching query (paginated). func (c *Client) ListIssues(ctx context.Context, query string) ([]Issue, error) { if err := c.ensureAuth(); err != nil { return nil, err } var all []Issue for page := 0; page < maxPages; page++ { skip := page * defaultPageSize u, err := url.Parse(c.base() + "/api/issues") if err != nil { return nil, fmt.Errorf("youtrack: parse url: %w", err) } q := u.Query() q.Set("query", query) q.Set("fields", issueFields) q.Set("$top", fmt.Sprintf("%d", defaultPageSize)) q.Set("$skip", fmt.Sprintf("%d", skip)) u.RawQuery = q.Encode() var raw []rawIssue if err := c.doJSON(ctx, http.MethodGet, u.String(), nil, &raw); err != nil { return nil, err } for _, r := range raw { all = append(all, r.toIssue()) } if len(raw) < defaultPageSize { break } } return all, nil } // CreateIssue creates an issue in the project identified by shortName (e.g. PROJ). func (c *Client) CreateIssue(ctx context.Context, projectKey, summary, description, state string) (Issue, error) { if err := c.ensureAuth(); err != nil { return Issue{}, err } projectKey = strings.TrimSpace(projectKey) if projectKey == "" { return Issue{}, fmt.Errorf("youtrack: project key required") } if strings.TrimSpace(summary) == "" { return Issue{}, fmt.Errorf("youtrack: summary required") } projectID, err := c.resolveProjectID(ctx, projectKey) if err != nil { return Issue{}, err } body := map[string]any{ "project": map[string]string{"id": projectID}, "summary": summary, } if description != "" { body["description"] = description } // Do not set State via customFields — project field types differ // (StateIssueCustomField vs StateMachineIssueCustomField). Use ApplyCommand. u := c.base() + "/api/issues?fields=" + url.QueryEscape(issueFields) var raw rawIssue if err := c.doJSON(ctx, http.MethodPost, u, body, &raw); err != nil { return Issue{}, err } iss := raw.toIssue() if state != "" && iss.IDReadable != "" { if err := c.SetState(ctx, iss.IDReadable, state); err != nil { // Title/description already created — don't fail the whole create on state. iss.State = iss.State // keep whatever default YouTrack assigned _ = err } else { iss.State = state } } return iss, nil } // UpdateIssue updates summary, description and optional State on an existing issue. func (c *Client) UpdateIssue(ctx context.Context, idReadable, summary, description, state string) (Issue, error) { if err := c.ensureAuth(); err != nil { return Issue{}, err } idReadable = strings.TrimSpace(idReadable) if idReadable == "" { return Issue{}, fmt.Errorf("youtrack: issue id required") } body := map[string]any{ "summary": summary, "description": description, } u := fmt.Sprintf("%s/api/issues/%s?fields=%s", c.base(), url.PathEscape(idReadable), url.QueryEscape(issueFields)) var raw rawIssue if err := c.doJSON(ctx, http.MethodPost, u, body, &raw); err != nil { return Issue{}, err } iss := raw.toIssue() if iss.IDReadable == "" { iss.IDReadable = idReadable } if state != "" { if err := c.SetState(ctx, idReadable, state); err != nil { // Summary/description already saved — state mapping is best-effort // (field names / state-machine transitions vary per project). _ = err } else { iss.State = state } } return iss, nil } // GetIssue loads a single issue by readable id. func (c *Client) GetIssue(ctx context.Context, idReadable string) (Issue, error) { raw, err := c.getIssueRaw(ctx, idReadable) if err != nil { return Issue{}, err } return raw.toIssue(), nil } func (c *Client) getIssueRaw(ctx context.Context, idReadable string) (rawIssue, error) { if err := c.ensureAuth(); err != nil { return rawIssue{}, err } idReadable = strings.TrimSpace(idReadable) if idReadable == "" { return rawIssue{}, fmt.Errorf("youtrack: issue id required") } u := fmt.Sprintf("%s/api/issues/%s?fields=%s", c.base(), url.PathEscape(idReadable), url.QueryEscape(issueFields)) var raw rawIssue if err := c.doJSON(ctx, http.MethodGet, u, nil, &raw); err != nil { return rawIssue{}, err } return raw, nil } // SetState sets the issue's state-like custom field to desiredState. // Discovers the real field name/$type from the issue (works with renamed fields // and StateMachineIssueCustomField). func (c *Client) SetState(ctx context.Context, idReadable, desiredState string) error { desiredState = strings.TrimSpace(desiredState) if desiredState == "" { return nil } raw, err := c.getIssueRaw(ctx, idReadable) if err != nil { return err } sf := findStateField(raw.CustomFields) if sf == nil { return nil // project has no state-like field } current := parseStateValue(sf.Value) if strings.EqualFold(current, desiredState) { return nil } // State-machine fields must be updated via transition events. if strings.Contains(sf.Type, "StateMachine") { ev := matchStateEvent(sf.PossibleEvents, desiredState) if ev == nil { return fmt.Errorf("no transition toward %q on %s (available: %s)", desiredState, sf.Name, joinEventPresentations(sf.PossibleEvents)) } body := map[string]any{ "customFields": []map[string]any{{ "id": sf.ID, "name": sf.Name, "$type": sf.Type, "event": map[string]any{ "id": ev.ID, "presentation": ev.Presentation, "$type": typeOr(ev.Type, "Event"), }, }}, } u := fmt.Sprintf("%s/api/issues/%s", c.base(), url.PathEscape(idReadable)) return c.doJSON(ctx, http.MethodPost, u, body, nil) } // Plain state / enum-like field: use the field's actual name and $type. body := map[string]any{ "customFields": []map[string]any{{ "id": sf.ID, "name": sf.Name, "$type": typeOr(sf.Type, "StateIssueCustomField"), "value": map[string]string{"name": desiredState}, }}, } u := fmt.Sprintf("%s/api/issues/%s", c.base(), url.PathEscape(idReadable)) if err := c.doJSON(ctx, http.MethodPost, u, body, nil); err != nil { // Fallback: command with discovered field name (quoted for multi-word values). cmd := formatFieldCommand(sf.Name, desiredState) if cmdErr := c.ApplyCommand(ctx, idReadable, cmd); cmdErr != nil { return fmt.Errorf("%v (command %q also failed: %w)", err, cmd, cmdErr) } } return nil } // ApplyCommand runs a YouTrack command against one issue (e.g. `Status Fixed`). func (c *Client) ApplyCommand(ctx context.Context, idReadable, command string) error { if err := c.ensureAuth(); err != nil { return err } idReadable = strings.TrimSpace(idReadable) command = strings.TrimSpace(command) if idReadable == "" || command == "" { return fmt.Errorf("youtrack: issue id and command required") } body := map[string]any{ "query": command, "issues": []map[string]string{ {"idReadable": idReadable}, }, } u := c.base() + "/api/commands" return c.doJSON(ctx, http.MethodPost, u, body, nil) } func formatFieldCommand(fieldName, value string) string { fieldName = strings.TrimSpace(fieldName) value = strings.TrimSpace(value) if strings.ContainsAny(value, " \t") { return fmt.Sprintf("%s %s", fieldName, quoteCommandValue(value)) } return fieldName + " " + value } func quoteCommandValue(v string) string { return "{" + v + "}" } type stateEvent struct { ID string Presentation string Type string } func findStateField(fields []rawCustomField) *rawCustomField { var named *rawCustomField for i := range fields { cf := &fields[i] if strings.Contains(cf.Type, "State") { return cf } if strings.EqualFold(cf.Name, "State") || strings.EqualFold(cf.Name, "Status") { named = cf } } return named } func matchStateEvent(events []rawEvent, desired string) *rawEvent { if len(events) == 0 { return nil } desiredLower := strings.ToLower(strings.TrimSpace(desired)) for i := range events { p := strings.ToLower(events[i].Presentation) id := strings.ToLower(events[i].ID) if p == desiredLower || id == desiredLower { return &events[i] } } // Fuzzy: match by status family keywords. keywords := stateKeywords(desiredLower) for i := range events { p := strings.ToLower(events[i].Presentation) id := strings.ToLower(events[i].ID) for _, kw := range keywords { if strings.Contains(p, kw) || strings.Contains(id, kw) { return &events[i] } } } return nil } func stateKeywords(desired string) []string { switch { case strings.Contains(desired, "progress"), desired == "wip": return []string{"progress", "start", "wip", "ongoing", "bearbeitung"} case strings.Contains(desired, "fix"), strings.Contains(desired, "done"), strings.Contains(desired, "complete"), strings.Contains(desired, "resolve"): return []string{"fix", "done", "complete", "resolve", "close", "erledigt", "fertig"} default: return []string{"open", "reopen", "submit", "offen", "neu"} } } func joinEventPresentations(events []rawEvent) string { parts := make([]string, 0, len(events)) for _, e := range events { if e.Presentation != "" { parts = append(parts, e.Presentation) } else { parts = append(parts, e.ID) } } return strings.Join(parts, ", ") } func typeOr(v, fallback string) string { if strings.TrimSpace(v) == "" { return fallback } return v } func (c *Client) cacheProjectID(shortName, id string) { c.mu.Lock() defer c.mu.Unlock() if c.projectIDByKey == nil { c.projectIDByKey = map[string]string{} } c.projectIDByKey[shortName] = id } func (c *Client) resolveProjectID(ctx context.Context, shortName string) (string, error) { shortName = strings.TrimSpace(shortName) if shortName == "" { return "", fmt.Errorf("youtrack: project key required") } c.mu.Lock() if c.projectIDByKey == nil { c.projectIDByKey = map[string]string{} } if id, ok := c.projectIDByKey[shortName]; ok { c.mu.Unlock() return id, nil } c.mu.Unlock() // 1) Direct lookup — YouTrack accepts shortName as {projectID}. if id, err := c.fetchProjectByID(ctx, shortName); err == nil { c.cacheProjectID(shortName, id) return id, nil } // 2) List/filter — query is a free-text match on name/shortName (not "shortName: X"). if id, err := c.findProjectInList(ctx, shortName); err == nil { c.cacheProjectID(shortName, id) return id, nil } // 3) Fallback without admin rights: read project id from an existing issue. if id, err := c.findProjectViaIssue(ctx, shortName); err == nil { c.cacheProjectID(shortName, id) return id, nil } return "", fmt.Errorf("youtrack: project %q not found", shortName) } func (c *Client) fetchProjectByID(ctx context.Context, projectID string) (string, error) { u := fmt.Sprintf("%s/api/admin/projects/%s?fields=%s", c.base(), url.PathEscape(projectID), url.QueryEscape("id,shortName")) var p struct { ID string `json:"id"` ShortName string `json:"shortName"` } if err := c.doJSON(ctx, http.MethodGet, u, nil, &p); err != nil { return "", err } if p.ID == "" { return "", fmt.Errorf("empty project id") } return p.ID, nil } func (c *Client) findProjectInList(ctx context.Context, shortName string) (string, error) { u, err := url.Parse(c.base() + "/api/admin/projects") if err != nil { return "", err } q := u.Query() q.Set("fields", "id,shortName") q.Set("query", shortName) q.Set("$top", "100") u.RawQuery = q.Encode() var projects []struct { ID string `json:"id"` ShortName string `json:"shortName"` } if err := c.doJSON(ctx, http.MethodGet, u.String(), nil, &projects); err != nil { return "", err } for _, p := range projects { if strings.EqualFold(p.ShortName, shortName) && p.ID != "" { return p.ID, nil } } // Unfiltered list as last admin attempt (small instances). u2, err := url.Parse(c.base() + "/api/admin/projects") if err != nil { return "", err } q2 := u2.Query() q2.Set("fields", "id,shortName") q2.Set("$top", "1000") u2.RawQuery = q2.Encode() if err := c.doJSON(ctx, http.MethodGet, u2.String(), nil, &projects); err != nil { return "", err } for _, p := range projects { if strings.EqualFold(p.ShortName, shortName) && p.ID != "" { return p.ID, nil } } return "", fmt.Errorf("not in project list") } func (c *Client) findProjectViaIssue(ctx context.Context, shortName string) (string, error) { u, err := url.Parse(c.base() + "/api/issues") if err != nil { return "", err } q := u.Query() q.Set("query", "project: "+shortName) q.Set("fields", "idReadable,project(id,shortName)") q.Set("$top", "1") u.RawQuery = q.Encode() var issues []struct { Project *struct { ID string `json:"id"` ShortName string `json:"shortName"` } `json:"project"` } if err := c.doJSON(ctx, http.MethodGet, u.String(), nil, &issues); err != nil { return "", err } if len(issues) == 0 || issues[0].Project == nil || issues[0].Project.ID == "" { return "", fmt.Errorf("no issues in project %s to resolve id", shortName) } return issues[0].Project.ID, nil } // Project is a YouTrack project summary for the register/import UI. type Project struct { ID string ShortName string Name string } // ListProjects lists admin-accessible projects (requires sufficient token scope). func (c *Client) ListProjects(ctx context.Context) ([]Project, error) { if err := c.ensureAuth(); err != nil { return nil, err } var all []Project for page := 0; page < maxPages; page++ { skip := page * defaultPageSize u, err := url.Parse(c.base() + "/api/admin/projects") if err != nil { return nil, err } q := u.Query() q.Set("fields", "id,shortName,name") q.Set("$top", fmt.Sprintf("%d", defaultPageSize)) q.Set("$skip", fmt.Sprintf("%d", skip)) u.RawQuery = q.Encode() var raw []struct { ID string `json:"id"` ShortName string `json:"shortName"` Name string `json:"name"` } if err := c.doJSON(ctx, http.MethodGet, u.String(), nil, &raw); err != nil { return nil, err } for _, p := range raw { all = append(all, Project{ID: p.ID, ShortName: p.ShortName, Name: p.Name}) } if len(raw) < defaultPageSize { break } } return all, nil } // IssueURL builds the browser URL for an issue. func (c *Client) IssueURL(idReadable string) string { return c.base() + "/issue/" + idReadable } const workItemFields = "id,date,duration(minutes,presentation),text" // ListWorkItems returns time-tracking work items for an issue. func (c *Client) ListWorkItems(ctx context.Context, idReadable string) ([]WorkItem, error) { if err := c.ensureAuth(); err != nil { return nil, err } idReadable = strings.TrimSpace(idReadable) if idReadable == "" { return nil, fmt.Errorf("youtrack: issue id required") } u := fmt.Sprintf("%s/api/issues/%s/timeTracking/workItems?fields=%s&$top=%d", c.base(), url.PathEscape(idReadable), url.QueryEscape(workItemFields), defaultPageSize) var raw []rawWorkItem if err := c.doJSON(ctx, http.MethodGet, u, nil, &raw); err != nil { return nil, err } out := make([]WorkItem, 0, len(raw)) for _, r := range raw { out = append(out, r.toWorkItem()) } return out, nil } // CreateWorkItem adds a work item (duration in minutes) to an issue. func (c *Client) CreateWorkItem(ctx context.Context, idReadable string, minutes int, date time.Time, text string) (WorkItem, error) { if err := c.ensureAuth(); err != nil { return WorkItem{}, err } idReadable = strings.TrimSpace(idReadable) if idReadable == "" { return WorkItem{}, fmt.Errorf("youtrack: issue id required") } if minutes < 1 { minutes = 1 } body := map[string]any{ "duration": map[string]any{"minutes": minutes}, } if !date.IsZero() { // YouTrack expects epoch millis; time part is typically midnight UTC. midnight := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.UTC) body["date"] = midnight.UnixMilli() } if strings.TrimSpace(text) != "" { body["text"] = text } u := fmt.Sprintf("%s/api/issues/%s/timeTracking/workItems?fields=%s", c.base(), url.PathEscape(idReadable), url.QueryEscape(workItemFields)) var raw rawWorkItem if err := c.doJSON(ctx, http.MethodPost, u, body, &raw); err != nil { return WorkItem{}, err } return raw.toWorkItem(), nil } // DeleteWorkItem removes a work item from an issue. func (c *Client) DeleteWorkItem(ctx context.Context, idReadable, workItemID string) error { if err := c.ensureAuth(); err != nil { return err } idReadable = strings.TrimSpace(idReadable) workItemID = strings.TrimSpace(workItemID) if idReadable == "" || workItemID == "" { return fmt.Errorf("youtrack: issue id and work item id required") } u := fmt.Sprintf("%s/api/issues/%s/timeTracking/workItems/%s", c.base(), url.PathEscape(idReadable), url.PathEscape(workItemID)) return c.doJSON(ctx, http.MethodDelete, u, nil, nil) } type rawWorkItem struct { ID string `json:"id"` Date int64 `json:"date"` Text string `json:"text"` Duration *struct { Minutes int `json:"minutes"` } `json:"duration"` } func (r rawWorkItem) toWorkItem() WorkItem { w := WorkItem{ID: r.ID, Text: r.Text} if r.Date > 0 { w.Date = time.UnixMilli(r.Date).UTC() } if r.Duration != nil { w.Minutes = r.Duration.Minutes } return w } func (c *Client) doJSON(ctx context.Context, method, rawURL string, body any, dest any) error { var rdr io.Reader if body != nil { data, err := json.Marshal(body) if err != nil { return fmt.Errorf("youtrack: marshal: %w", err) } rdr = bytes.NewReader(data) } req, err := http.NewRequestWithContext(ctx, method, rawURL, rdr) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+c.Token) req.Header.Set("Accept", "application/json") if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := c.http().Do(req) if err != nil { return fmt.Errorf("youtrack: request: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("youtrack: read body: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("youtrack: %s: %s", resp.Status, truncate(string(respBody), 300)) } if dest == nil || len(respBody) == 0 { return nil } if err := json.Unmarshal(respBody, dest); err != nil { return fmt.Errorf("youtrack: decode: %w", err) } return nil } type rawIssue struct { IDReadable string `json:"idReadable"` Summary string `json:"summary"` Description string `json:"description"` Updated int64 `json:"updated"` CustomFields []rawCustomField `json:"customFields"` } type rawCustomField struct { ID string `json:"id"` Name string `json:"name"` Type string `json:"$type"` Value json.RawMessage `json:"value"` PossibleEvents []rawEvent `json:"possibleEvents"` } type rawEvent struct { ID string `json:"id"` Presentation string `json:"presentation"` Type string `json:"$type"` } func (r rawIssue) toIssue() Issue { iss := Issue{ IDReadable: r.IDReadable, Summary: r.Summary, Description: r.Description, } if r.Updated > 0 { iss.Updated = time.UnixMilli(r.Updated).UTC() } if sf := findStateField(r.CustomFields); sf != nil { iss.State = parseStateValue(sf.Value) } return iss } func parseStateValue(raw json.RawMessage) string { if len(raw) == 0 || string(raw) == "null" { return "" } var obj struct { Name string `json:"name"` Presentation string `json:"presentation"` } if err := json.Unmarshal(raw, &obj); err == nil { if obj.Name != "" { return obj.Name } return obj.Presentation } var s string if err := json.Unmarshal(raw, &s); err == nil { return s } return "" } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] + "…" }