Files
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

485 lines
13 KiB
Go

package ytsync
import (
"context"
"fmt"
"strings"
"time"
"task/internal/project"
"task/internal/ticket"
"task/internal/youtrack"
)
// Result summarizes a pull or push sync.
type Result struct {
Created int
Updated int
Skipped int
Total int
Pushed int // creates + updates sent to YouTrack
TimePushed int // work items created on YouTrack
TimePulled int // work items imported locally
}
// SyncResult is the outcome of a full bidirectional sync.
type SyncResult struct {
Push Result
Pull Result
}
// YouTrackSyncer syncs issues with the local ticket store.
type YouTrackSyncer struct {
Store ticket.Store
TimeStore ticket.TimeStore
Client *youtrack.Client
}
func (s *YouTrackSyncer) clientFor(yt project.YouTrack) *youtrack.Client {
client := s.Client
if client == nil {
return &youtrack.Client{BaseURL: yt.BaseURL, Token: yt.Token}
}
client.BaseURL = yt.BaseURL
client.Token = yt.Token
return client
}
// MapStatus converts a YouTrack State name to a local Status.
func MapStatus(state string) ticket.Status {
s := strings.ToLower(strings.TrimSpace(state))
switch {
case s == "":
return ticket.StatusTodo
case strings.Contains(s, "progress"), strings.Contains(s, "wip"),
s == "ongoing", s == "doing", s == "in progress",
strings.Contains(s, "bearbeitung"), strings.Contains(s, "arbeit"):
return ticket.StatusInProgress
case strings.Contains(s, "done"), strings.Contains(s, "fixed"),
strings.Contains(s, "complete"), strings.Contains(s, "resolved"),
strings.Contains(s, "obsolete"), strings.Contains(s, "closed"),
strings.Contains(s, "erledigt"), strings.Contains(s, "fertig"),
s == "verified", s == "duplicate", s == "won't fix", s == "cant reproduce":
return ticket.StatusDone
default:
return ticket.StatusTodo
}
}
// MapState converts a local Status to a YouTrack State name.
func MapState(st ticket.Status) string {
switch st {
case ticket.StatusInProgress:
return "In Progress"
case ticket.StatusDone:
return "Fixed"
default:
return "Open"
}
}
// NeedsPush reports whether a local ticket should be sent to YouTrack.
func NeedsPush(t ticket.Ticket) bool {
if t.ExternalID == "" {
// Local-only ticket in a YT-configured project → create remotely.
return true
}
if t.Source != ticket.SourceYouTrack {
return false
}
if t.SyncedAt.IsZero() {
return true
}
return t.UpdatedAt.After(t.SyncedAt)
}
// Pull syncs YouTrack issues into projectID using yt settings.
// Local tickets that still need a push (dirty) are not overwritten.
func (s *YouTrackSyncer) Pull(ctx context.Context, projectID string, yt project.YouTrack) (Result, error) {
if !yt.Configured() {
return Result{}, fmt.Errorf("youtrack not configured for project %s", projectID)
}
if s.Store == nil {
return Result{}, fmt.Errorf("ticket store required")
}
client := s.clientFor(yt)
issues, err := client.ListIssues(ctx, yt.IssueQuery())
if err != nil {
return Result{}, err
}
existing, err := s.Store.List(projectID)
if err != nil {
return Result{}, err
}
byExt := make(map[string]ticket.Ticket, len(existing))
for _, t := range existing {
if t.ExternalID != "" {
byExt[t.ExternalID] = t
}
}
var res Result
res.Total = len(issues)
for _, iss := range issues {
if iss.IDReadable == "" {
res.Skipped++
continue
}
status := MapStatus(iss.State)
issueURL := client.IssueURL(iss.IDReadable)
title := iss.Summary
if title == "" {
title = iss.IDReadable
}
if prev, ok := byExt[iss.IDReadable]; ok {
if NeedsPush(prev) {
res.Skipped++
continue
}
next := prev
next.Title = title
next.Description = iss.Description
// Only apply remote status when we actually parsed a state field.
// Empty state used to map to OPEN and wiped local WIP/DONE on every pull.
if iss.State != "" {
next.Status = status
}
next.Source = ticket.SourceYouTrack
next.ExternalURL = issueURL
if sameSyncedFields(prev, next) {
if err := s.Store.MarkSynced(prev.ID); err != nil {
return res, fmt.Errorf("touch %s: %w", iss.IDReadable, err)
}
res.Skipped++
continue
}
if err := s.Store.Update(next); err != nil {
return res, fmt.Errorf("update %s: %w", iss.IDReadable, err)
}
if err := s.Store.MarkSynced(next.ID); err != nil {
return res, fmt.Errorf("mark synced %s: %w", iss.IDReadable, err)
}
res.Updated++
continue
}
now := time.Now().UTC()
created, err := s.Store.Create(ticket.Ticket{
Title: title,
Description: iss.Description,
Status: status,
ProjectID: projectID,
Source: ticket.SourceYouTrack,
ExternalID: iss.IDReadable,
ExternalURL: issueURL,
CreatedAt: now,
UpdatedAt: now,
SyncedAt: now,
})
if err != nil {
return res, fmt.Errorf("create %s: %w", iss.IDReadable, err)
}
_ = created
res.Created++
}
if err := s.pullTimeEntries(ctx, client, projectID, &res); err != nil {
return res, err
}
return res, nil
}
// Push sends local tickets to YouTrack: creates for tickets without ExternalID,
// updates for dirty synced tickets, then pushes unsynced time entries.
func (s *YouTrackSyncer) Push(ctx context.Context, projectID string, yt project.YouTrack) (Result, error) {
if !yt.Configured() {
return Result{}, fmt.Errorf("youtrack not configured for project %s", projectID)
}
if s.Store == nil {
return Result{}, fmt.Errorf("ticket store required")
}
client := s.clientFor(yt)
existing, err := s.Store.List(projectID)
if err != nil {
return Result{}, err
}
var res Result
for _, t := range existing {
if !NeedsPush(t) {
res.Skipped++
continue
}
res.Total++
if t.ExternalID == "" {
if err := s.pushCreate(ctx, client, yt, t); err != nil {
return res, err
}
res.Created++
res.Pushed++
continue
}
if err := s.pushUpdate(ctx, client, t); err != nil {
return res, err
}
res.Updated++
res.Pushed++
}
if err := s.pushTimeEntries(ctx, client, projectID, &res); err != nil {
return res, err
}
return res, nil
}
func (s *YouTrackSyncer) pushTimeEntries(ctx context.Context, client *youtrack.Client, projectID string, res *Result) error {
if s.TimeStore == nil {
return nil
}
tickets, err := s.Store.List(projectID)
if err != nil {
return err
}
byID := make(map[string]ticket.Ticket, len(tickets))
for _, t := range tickets {
byID[t.ID] = t
}
entries, err := s.TimeStore.List()
if err != nil {
return err
}
for _, e := range entries {
if e.ProjectID != projectID || e.ExternalID != "" {
continue
}
t, ok := byID[e.TicketID]
if !ok || t.ExternalID == "" {
continue // ticket not linked to YouTrack yet
}
minutes := durationMinutes(e.Duration)
text := e.Note
if text == "" && e.Source != "" {
text = "logged via task-master (" + e.Source + ")"
}
wi, err := client.CreateWorkItem(ctx, t.ExternalID, minutes, e.Start, text)
if err != nil {
return fmt.Errorf("push time for %s: %w", t.ExternalID, err)
}
e.ExternalID = wi.ID
if err := s.TimeStore.Update(e); err != nil {
return fmt.Errorf("link time entry %s: %w", e.ID, err)
}
res.TimePushed++
}
return nil
}
func (s *YouTrackSyncer) pullTimeEntries(ctx context.Context, client *youtrack.Client, projectID string, res *Result) error {
if s.TimeStore == nil {
return nil
}
tickets, err := s.Store.List(projectID)
if err != nil {
return err
}
local, err := s.TimeStore.List()
if err != nil {
return err
}
haveExt := make(map[string]bool)
for _, e := range local {
if e.ExternalID != "" {
haveExt[e.ExternalID] = true
}
}
for _, t := range tickets {
if t.ExternalID == "" {
continue
}
items, err := client.ListWorkItems(ctx, t.ExternalID)
if err != nil {
return fmt.Errorf("list work items %s: %w", t.ExternalID, err)
}
for _, wi := range items {
if wi.ID == "" || haveExt[wi.ID] {
continue
}
dur := time.Duration(wi.Minutes) * time.Minute
if dur <= 0 {
continue
}
start := wi.Date
if start.IsZero() {
start = time.Now().UTC()
}
_, err := s.TimeStore.Create(ticket.TimeEntry{
TicketID: t.ID,
ProjectID: projectID,
Start: start,
End: start.Add(dur),
Duration: dur,
Source: ticket.SourceYouTrackTime,
Note: wi.Text,
ExternalID: wi.ID,
})
if err != nil {
return fmt.Errorf("import work item %s: %w", wi.ID, err)
}
haveExt[wi.ID] = true
res.TimePulled++
}
}
return nil
}
func durationMinutes(d time.Duration) int {
if d <= 0 {
return 1
}
m := int((d + time.Minute - 1) / time.Minute) // round up
if m < 1 {
return 1
}
return m
}
// PushTicket creates or updates a single ticket on YouTrack.
func (s *YouTrackSyncer) PushTicket(ctx context.Context, yt project.YouTrack, t ticket.Ticket) (ticket.Ticket, error) {
if !yt.Configured() {
return t, fmt.Errorf("youtrack not configured")
}
if s.Store == nil {
return t, fmt.Errorf("ticket store required")
}
if !NeedsPush(t) {
return t, nil
}
client := s.clientFor(yt)
if t.ExternalID == "" {
if err := s.pushCreate(ctx, client, yt, t); err != nil {
return t, err
}
} else {
if err := s.pushUpdate(ctx, client, t); err != nil {
return t, err
}
}
updated, err := s.Store.Get(t.ID)
if err != nil {
return t, err
}
return updated, nil
}
func (s *YouTrackSyncer) pushCreate(ctx context.Context, client *youtrack.Client, yt project.YouTrack, t ticket.Ticket) error {
iss, err := client.CreateIssue(ctx, yt.ProjectKey, t.Title, t.Description, MapState(t.Status))
if err != nil {
return fmt.Errorf("create remote for %s: %w", t.ID, err)
}
t.Source = ticket.SourceYouTrack
t.ExternalID = iss.IDReadable
t.ExternalURL = client.IssueURL(iss.IDReadable)
if err := s.Store.Update(t); err != nil {
return fmt.Errorf("link %s → %s: %w", t.ID, iss.IDReadable, err)
}
if err := s.Store.MarkSynced(t.ID); err != nil {
return fmt.Errorf("mark synced %s: %w", iss.IDReadable, err)
}
return nil
}
func (s *YouTrackSyncer) pushUpdate(ctx context.Context, client *youtrack.Client, t ticket.Ticket) error {
// Title/description only — state is applied separately when we can read the remote field.
_, err := client.UpdateIssue(ctx, t.ExternalID, t.Title, t.Description, "")
if err != nil {
return fmt.Errorf("update remote %s: %w", t.ExternalID, err)
}
if remote, err := client.GetIssue(ctx, t.ExternalID); err == nil && remote.State != "" {
if MapStatus(remote.State) != t.Status {
_ = client.SetState(ctx, t.ExternalID, MapState(t.Status))
}
}
t.Source = ticket.SourceYouTrack
t.ExternalURL = client.IssueURL(t.ExternalID)
if err := s.Store.Update(t); err != nil {
return fmt.Errorf("update local %s: %w", t.ExternalID, err)
}
if err := s.Store.MarkSynced(t.ID); err != nil {
return fmt.Errorf("mark synced %s: %w", t.ExternalID, err)
}
return nil
}
// Sync runs Push then Pull for a project.
func (s *YouTrackSyncer) Sync(ctx context.Context, projectID string, yt project.YouTrack) (SyncResult, error) {
var out SyncResult
var err error
out.Push, err = s.Push(ctx, projectID, yt)
if err != nil {
return out, err
}
out.Pull, err = s.Pull(ctx, projectID, yt)
return out, err
}
func sameSyncedFields(a, b ticket.Ticket) bool {
return a.Title == b.Title &&
a.Description == b.Description &&
a.Status == b.Status &&
a.ExternalURL == b.ExternalURL &&
a.Source == b.Source
}
// MetaLoader loads per-project YouTrack settings.
type MetaLoader interface {
LoadMeta(id string) (project.Meta, error)
}
func loadYT(projects MetaLoader, projectID string) (project.YouTrack, error) {
meta, err := projects.LoadMeta(projectID)
if err != nil {
return project.YouTrack{}, err
}
if meta.YouTrack == nil || !meta.YouTrack.Configured() {
return project.YouTrack{}, fmt.Errorf("project %s has no YouTrack config (edit project: URL, token, key)", projectID)
}
return *meta.YouTrack, nil
}
// SyncProject pushes local changes then pulls remote issues.
func SyncProject(ctx context.Context, projects MetaLoader, store ticket.Store, timeStore ticket.TimeStore, projectID string) (SyncResult, error) {
yt, err := loadYT(projects, projectID)
if err != nil {
return SyncResult{}, err
}
s := &YouTrackSyncer{Store: store, TimeStore: timeStore}
return s.Sync(ctx, projectID, yt)
}
// PullProject loads meta for projectID and runs Pull (no push).
func PullProject(ctx context.Context, projects MetaLoader, store ticket.Store, timeStore ticket.TimeStore, projectID string) (Result, error) {
yt, err := loadYT(projects, projectID)
if err != nil {
return Result{}, err
}
s := &YouTrackSyncer{Store: store, TimeStore: timeStore}
return s.Pull(ctx, projectID, yt)
}
// PushProject loads meta for projectID and runs Push (no pull).
func PushProject(ctx context.Context, projects MetaLoader, store ticket.Store, timeStore ticket.TimeStore, projectID string) (Result, error) {
yt, err := loadYT(projects, projectID)
if err != nil {
return Result{}, err
}
s := &YouTrackSyncer{Store: store, TimeStore: timeStore}
return s.Push(ctx, projectID, yt)
}