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>
720 lines
20 KiB
Go
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] + "…"
|
|
}
|