Files
task-master/internal/youtrack/client.go
T
skrueckenandCursor 98d2253f44
CI / test-and-build (push) Failing after 1m19s
CI / demo (push) Skipped
Add YouTrack pull/push sync with project settings and time tracking.
Wire manual Y menu and CLI sync for issues and work items, persist
per-project credentials, and keep local edits until an explicit push.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-24 00:17:51 +02:00

720 lines
20 KiB
Go

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
}
// 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] + "…"
}