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>
This commit is contained in:
@@ -0,0 +1,719 @@
|
||||
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] + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user