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>
485 lines
13 KiB
Go
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)
|
|
}
|