Add YouTrack pull/push sync with project settings and time tracking.
CI / test-and-build (push) Failing after 1m19s
CI / demo (push) Skipped

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:
2026-10-24 00:17:51 +02:00
co-authored by Cursor
parent 19ae1f1b62
commit 98d2253f44
23 changed files with 2874 additions and 44 deletions
+256 -6
View File
@@ -1,6 +1,7 @@
package ui
import (
"context"
"fmt"
"os"
"strings"
@@ -18,6 +19,8 @@ import (
"task/internal/ticket"
"task/internal/ui/views"
"task/internal/version"
"task/internal/youtrack"
"task/internal/ytsync"
)
// timerTickMsg refreshes the header while a timer is running.
@@ -31,6 +34,15 @@ type editorFinishedMsg struct {
create bool // true: create ticket from edited file
}
// syncFinishedMsg is sent after a manual YouTrack pull or push completes.
type syncFinishedMsg struct {
projectID string
action views.SyncAction
pull ytsync.Result
push ytsync.Result
err error
}
// Mode is the active UI mode.
type Mode int
@@ -48,6 +60,8 @@ const (
ModeTimeEdit
ModeReport
ModeReportGroup
ModeEditProject
ModeSync
)
// Model is the root Bubble Tea model.
@@ -62,6 +76,8 @@ type Model struct {
tickets views.TicketList
projectList list.Model
statusMenu views.StatusMenu
syncMenu views.SyncMenu
syncProjectID string // project targeted by ModeSync
boardMenu views.BoardMenu
boardFilter ticket.BoardFilter
input textinput.Model
@@ -85,6 +101,9 @@ type Model struct {
creatingProject bool
pendingDeleteTitle string
pendingDeleteProject string // non-empty → confirm deletes a project, not a ticket
projectEdit views.ProjectEditForm
projectEditMeta project.Meta // snapshot when opening the form
syncing bool
}
// New creates the root UI model.
@@ -161,16 +180,44 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case editorFinishedMsg:
return m.handleEditorFinished(msg)
case syncFinishedMsg:
m.syncing = false
if msg.err != nil {
m.setError(msg.err)
return m, nil
}
if err := m.reloadTickets(); err != nil {
m.setError(err)
return m, nil
}
switch msg.action {
case views.SyncActionPush:
m.setStatus(fmt.Sprintf(
"pushed %s: +%d ~%d · time %d",
msg.projectID, msg.push.Created, msg.push.Updated, msg.push.TimePushed,
))
default:
m.setStatus(fmt.Sprintf(
"pulled %s: +%d ~%d · time %d",
msg.projectID, msg.pull.Created, msg.pull.Updated, msg.pull.TimePulled,
))
}
return m, nil
case tea.KeyMsg:
switch m.mode {
case ModeCreate, ModeSearch, ModeTimeManual, ModeTimeEdit:
return m.updateInput(msg)
case ModeEditProject:
return m.updateEditProject(msg)
case ModeProjects:
return m.updateProjects(msg)
case ModeMoveTicket:
return m.updateMoveTicket(msg)
case ModeStatus:
return m.updateStatus(msg)
case ModeSync:
return m.updateSync(msg)
case ModeBoard:
return m.updateBoard(msg)
case ModeConfirmDelete:
@@ -192,6 +239,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.projectList, cmd = m.projectList.Update(msg)
case ModeCreate, ModeSearch, ModeTimeManual, ModeTimeEdit:
m.input, cmd = m.input.Update(msg)
case ModeEditProject:
cmd = m.projectEdit.Update(msg)
}
return m, cmd
}
@@ -373,6 +422,11 @@ func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.mode = ModeReport
return m, nil
case key.Matches(msg, m.keys.Sync):
m.clearStatus()
m.openSyncMenu(m.cfg.ActiveProject)
return m, nil
case key.Matches(msg, m.keys.TimeEntries):
m.clearStatus()
if err := m.openTimeEntries(); err != nil {
@@ -594,7 +648,7 @@ func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.setStatus(fmt.Sprintf("created project %s", id))
return m, nil
}
if err := m.createTicket(value, ""); err != nil {
if _, err := m.createTicket(value, ""); err != nil {
m.setError(err)
return m, nil
}
@@ -749,6 +803,138 @@ func (m Model) updateReportGroup(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m Model) updateEditProject(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Escape):
m.mode = ModeProjects
m.clearStatus()
return m, nil
case key.Matches(msg, m.keys.Tab):
m.projectEdit.NextField()
return m, textinput.Blink
case key.Matches(msg, m.keys.ShiftTab):
m.projectEdit.PrevField()
return m, textinput.Blink
case msg.Type == tea.KeyDown:
m.projectEdit.NextField()
return m, textinput.Blink
case msg.Type == tea.KeyUp:
m.projectEdit.PrevField()
return m, textinput.Blink
case key.Matches(msg, m.keys.Enter):
if err := m.projectEdit.Validate(m.projectEditMeta); err != nil {
m.setError(err)
return m, nil
}
meta := m.projectEdit.Meta(m.projectEditMeta)
id := m.projectEdit.ProjectID
if err := m.projects.SaveMeta(id, meta); err != nil {
m.setError(err)
return m, nil
}
if err := m.openProjects(); err != nil {
m.setError(err)
return m, nil
}
m.mode = ModeProjects
if meta.YouTrack != nil && meta.YouTrack.Configured() {
m.setStatus(fmt.Sprintf("saved YouTrack settings for %s", id))
} else {
m.setStatus(fmt.Sprintf("cleared YouTrack settings for %s", id))
}
return m, nil
}
cmd := m.projectEdit.Update(msg)
return m, cmd
}
func (m *Model) openProjectEdit(projectID string) error {
meta, err := m.projects.LoadMeta(projectID)
if err != nil {
return err
}
m.projectEditMeta = meta
m.projectEdit = views.NewProjectEditForm(projectID, meta, m.width)
return nil
}
func (m *Model) openSyncMenu(projectID string) {
m.syncProjectID = projectID
m.syncMenu = views.NewSyncMenu(m.width, views.StatusMenuStyles{
Box: m.styles.StatusMenuBox,
Title: m.styles.StatusMenuTitle,
Selected: m.styles.StatusMenuSelected,
Normal: m.styles.StatusMenuNormal,
})
m.mode = ModeSync
}
func (m Model) updateSync(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Escape):
m.mode = ModeList
m.clearStatus()
return m, nil
case key.Matches(msg, m.keys.Enter):
action := m.syncMenu.Selected()
m.mode = ModeList
return m.startSyncAction(m.syncProjectID, action)
case key.Matches(msg, m.keys.Down):
m.syncMenu.MoveDown()
return m, nil
case key.Matches(msg, m.keys.Up):
m.syncMenu.MoveUp()
return m, nil
}
if msg.Type == tea.KeyRunes && len(msg.Runes) == 1 {
if m.syncMenu.JumpToShortcut(msg.Runes[0]) {
action := m.syncMenu.Selected()
m.mode = ModeList
return m.startSyncAction(m.syncProjectID, action)
}
}
return m, nil
}
func (m Model) startSyncAction(projectID string, action views.SyncAction) (tea.Model, tea.Cmd) {
if m.syncing {
m.setStatus("sync already running…")
return m, nil
}
m.clearStatus()
m.syncing = true
switch action {
case views.SyncActionPush:
m.setStatus(fmt.Sprintf("pushing %s…", projectID))
default:
m.setStatus(fmt.Sprintf("pulling %s…", projectID))
}
store := m.store
timeStore := m.timeStore
projects := m.projects
return m, func() tea.Msg {
var pull, push ytsync.Result
var err error
switch action {
case views.SyncActionPush:
push, err = ytsync.PushProject(context.Background(), projects, store, timeStore, projectID)
default:
pull, err = ytsync.PullProject(context.Background(), projects, store, timeStore, projectID)
}
return syncFinishedMsg{projectID: projectID, action: action, pull: pull, push: push, err: err}
}
}
func (m Model) updateMoveTicket(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Escape):
@@ -796,6 +982,30 @@ func (m Model) updateProjects(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.input.Focus()
return m, textinput.Blink
case key.Matches(msg, m.keys.Edit):
m.clearStatus()
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
if !ok {
m.setError(fmt.Errorf("no project selected"))
return m, nil
}
if err := m.openProjectEdit(item.Project.ID); err != nil {
m.setError(err)
return m, nil
}
m.mode = ModeEditProject
return m, textinput.Blink
case key.Matches(msg, m.keys.Sync):
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
if !ok {
m.setError(fmt.Errorf("no project selected"))
return m, nil
}
m.clearStatus()
m.openSyncMenu(item.Project.ID)
return m, nil
case key.Matches(msg, m.keys.DeleteForce):
m.clearStatus()
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
@@ -904,7 +1114,7 @@ func (m Model) handleEditorFinished(msg editorFinishedMsg) (tea.Model, tea.Cmd)
}
if msg.create {
if err := m.createTicket(title, description); err != nil {
if _, err := m.createTicket(title, description); err != nil {
m.setError(err)
return m, nil
}
@@ -941,7 +1151,7 @@ func (m Model) handleEditorFinished(msg editorFinishedMsg) (tea.Model, tea.Cmd)
return m, nil
}
func (m *Model) createTicket(title, description string) error {
func (m *Model) createTicket(title, description string) (ticket.Ticket, error) {
cmd := &command.CreateTicket{
Store: m.store,
Ticket: ticket.Ticket{
@@ -952,9 +1162,12 @@ func (m *Model) createTicket(title, description string) error {
},
}
if err := m.exec.Do(cmd); err != nil {
return err
return ticket.Ticket{}, err
}
return m.reloadTickets()
if err := m.reloadTickets(); err != nil {
return ticket.Ticket{}, err
}
return cmd.Ticket, nil
}
func (m *Model) deleteSelected() error {
@@ -1232,7 +1445,15 @@ func (m *Model) openProjects() error {
if bodyH < 1 {
bodyH = 1
}
m.projectList = views.NewProjectList(projects, m.cfg.ActiveProject, m.width, bodyH)
yt := make(map[string]bool, len(projects))
for _, p := range projects {
meta, err := m.projects.LoadMeta(p.ID)
if err != nil {
return err
}
yt[p.ID] = meta.YouTrack != nil && meta.YouTrack.Configured()
}
m.projectList = views.NewProjectListWithYT(projects, m.cfg.ActiveProject, yt, m.width, bodyH)
return nil
}
@@ -1424,6 +1645,13 @@ func (m *Model) deleteSelectedTimeEntry() error {
if !ok {
return fmt.Errorf("no entry selected")
}
// If synced to YouTrack, remove the remote work item first so the next
// pull does not resurrect it.
if e.ExternalID != "" {
if err := m.deleteRemoteWorkItem(e); err != nil {
return err
}
}
if err := m.exec.Do(&command.RemoveTimeEntry{Store: m.timeStore, Snapshot: e}); err != nil {
return err
}
@@ -1433,6 +1661,28 @@ func (m *Model) deleteSelectedTimeEntry() error {
return m.openTimeEntriesFor(m.timeEntriesTicket)
}
func (m *Model) deleteRemoteWorkItem(e ticket.TimeEntry) error {
tk, err := m.store.Get(e.TicketID)
if err != nil {
return fmt.Errorf("resolve ticket for work item: %w", err)
}
if tk.ExternalID == "" {
return nil
}
meta, err := m.projects.LoadMeta(tk.ProjectID)
if err != nil {
return err
}
if meta.YouTrack == nil || !meta.YouTrack.Configured() {
return nil
}
client := &youtrack.Client{BaseURL: meta.YouTrack.BaseURL, Token: meta.YouTrack.Token}
if err := client.DeleteWorkItem(context.Background(), tk.ExternalID, e.ExternalID); err != nil {
return fmt.Errorf("delete YouTrack work item: %w", err)
}
return nil
}
func (m *Model) saveEditedTime(raw string) error {
if m.timeStore == nil {
return fmt.Errorf("time store not configured")