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
+199
View File
@@ -0,0 +1,199 @@
package views
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"task/internal/project"
)
// ProjectEdit field indices.
const (
FieldRemoteURL = iota
FieldToken
FieldProjectKey
ProjectEditFieldCount
)
// ProjectEditStyles styles the project edit form.
type ProjectEditStyles struct {
Label lipgloss.Style
Focused lipgloss.Style
Blurred lipgloss.Style
Hint lipgloss.Style
Title lipgloss.Style
}
// DefaultProjectEditStyles returns sensible defaults.
func DefaultProjectEditStyles() ProjectEditStyles {
return ProjectEditStyles{
Label: lipgloss.NewStyle().Foreground(lipgloss.Color("245")),
Focused: lipgloss.NewStyle().Foreground(lipgloss.Color("205")),
Blurred: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
Hint: lipgloss.NewStyle().Foreground(lipgloss.Color("241")),
Title: lipgloss.NewStyle().Bold(true),
}
}
// ProjectEditForm is a multi-field editor for YouTrack project settings.
type ProjectEditForm struct {
ProjectID string
Inputs []textinput.Model
Focus int
Styles ProjectEditStyles
Width int
hadToken bool
tokenChanged bool
}
// NewProjectEditForm builds a form prefilled from meta.
func NewProjectEditForm(projectID string, meta project.Meta, width int) ProjectEditForm {
styles := DefaultProjectEditStyles()
inputs := make([]textinput.Model, ProjectEditFieldCount)
mk := func(placeholder string, charLimit int, password bool) textinput.Model {
ti := textinput.New()
ti.Placeholder = placeholder
ti.CharLimit = charLimit
ti.Width = max(20, width-4)
if password {
ti.EchoMode = textinput.EchoPassword
ti.EchoCharacter = '•'
}
return ti
}
inputs[FieldRemoteURL] = mk("https://youtrack.example.com", 256, false)
inputs[FieldToken] = mk("permanent token", 512, true)
inputs[FieldProjectKey] = mk("PROJ", 64, false)
hadToken := false
if meta.YouTrack != nil {
yt := meta.YouTrack
inputs[FieldRemoteURL].SetValue(yt.BaseURL)
inputs[FieldProjectKey].SetValue(yt.ProjectKey)
if yt.Token != "" {
hadToken = true
inputs[FieldToken].Placeholder = "(unchanged — type to replace)"
}
}
f := ProjectEditForm{
ProjectID: projectID,
Inputs: inputs,
Focus: FieldRemoteURL,
Styles: styles,
Width: width,
hadToken: hadToken,
}
f.focusIndex(FieldRemoteURL)
return f
}
func (f *ProjectEditForm) focusIndex(i int) {
f.Focus = i
for idx := range f.Inputs {
if idx == i {
f.Inputs[idx].Focus()
f.Inputs[idx].PromptStyle = f.Styles.Focused
f.Inputs[idx].TextStyle = f.Styles.Focused
} else {
f.Inputs[idx].Blur()
f.Inputs[idx].PromptStyle = f.Styles.Blurred
f.Inputs[idx].TextStyle = f.Styles.Blurred
}
}
}
// NextField moves focus to the next field.
func (f *ProjectEditForm) NextField() {
f.focusIndex((f.Focus + 1) % ProjectEditFieldCount)
}
// PrevField moves focus to the previous field.
func (f *ProjectEditForm) PrevField() {
f.focusIndex((f.Focus - 1 + ProjectEditFieldCount) % ProjectEditFieldCount)
}
// Update forwards a message to the focused text input.
func (f *ProjectEditForm) Update(msg tea.Msg) tea.Cmd {
var cmd tea.Cmd
f.Inputs[f.Focus], cmd = f.Inputs[f.Focus].Update(msg)
if f.Focus == FieldToken && f.Inputs[FieldToken].Value() != "" {
f.tokenChanged = true
}
return cmd
}
// Meta builds the Meta to persist. Empty token keeps the previous token.
func (f ProjectEditForm) Meta(previous project.Meta) project.Meta {
baseURL := strings.TrimSpace(f.Inputs[FieldRemoteURL].Value())
token := strings.TrimSpace(f.Inputs[FieldToken].Value())
key := strings.TrimSpace(f.Inputs[FieldProjectKey].Value())
if baseURL == "" && token == "" && key == "" {
return project.Meta{YouTrack: nil}
}
yt := &project.YouTrack{
BaseURL: baseURL,
ProjectKey: key,
Token: token,
}
if token == "" && previous.YouTrack != nil {
yt.Token = previous.YouTrack.Token
}
if previous.YouTrack != nil && previous.YouTrack.Query != "" {
yt.Query = previous.YouTrack.Query
}
return project.Meta{YouTrack: yt}
}
// Validate ensures partial YouTrack configs are rejected.
func (f ProjectEditForm) Validate(previous project.Meta) error {
meta := f.Meta(previous)
if meta.YouTrack == nil {
return nil
}
yt := meta.YouTrack
any := yt.BaseURL != "" || yt.Token != "" || yt.ProjectKey != ""
if any && !yt.Configured() {
missing := []string{}
if yt.BaseURL == "" {
missing = append(missing, "remote URL")
}
if yt.Token == "" {
missing = append(missing, "token")
}
if yt.ProjectKey == "" {
missing = append(missing, "project key")
}
return fmt.Errorf("incomplete YouTrack config: missing %s", strings.Join(missing, ", "))
}
return nil
}
// View renders the form.
func (f ProjectEditForm) View() string {
labels := []string{"Remote URL", "Token", "Project key"}
var b strings.Builder
b.WriteString(f.Styles.Title.Render(fmt.Sprintf("Edit project %s", f.ProjectID)))
b.WriteString("\n")
b.WriteString(f.Styles.Hint.Render("YouTrack sync settings · tab/↑↓ fields · enter save · esc cancel"))
b.WriteString("\n\n")
for i, label := range labels {
b.WriteString(f.Styles.Label.Render(label + ":"))
b.WriteString("\n")
b.WriteString(f.Inputs[i].View())
b.WriteString("\n\n")
}
if f.hadToken && !f.tokenChanged {
b.WriteString(f.Styles.Hint.Render("Token is set; leave blank to keep."))
b.WriteString("\n")
}
return b.String()
}
+40
View File
@@ -0,0 +1,40 @@
package views_test
import (
"testing"
"task/internal/project"
"task/internal/ui/views"
)
func TestProjectEditMetaKeepsToken(t *testing.T) {
prev := project.Meta{
YouTrack: &project.YouTrack{
BaseURL: "https://yt.example",
Token: "secret",
ProjectKey: "ABC",
},
}
f := views.NewProjectEditForm("inbox", prev, 80)
// Leave token blank → keep previous.
f.Inputs[views.FieldRemoteURL].SetValue("https://yt.example")
f.Inputs[views.FieldProjectKey].SetValue("ABC")
f.Inputs[views.FieldToken].SetValue("")
meta := f.Meta(prev)
if meta.YouTrack == nil || meta.YouTrack.Token != "secret" {
t.Fatalf("expected kept token, got %+v", meta.YouTrack)
}
if err := f.Validate(prev); err != nil {
t.Fatalf("Validate: %v", err)
}
}
func TestProjectEditValidateIncomplete(t *testing.T) {
f := views.NewProjectEditForm("inbox", project.Meta{}, 80)
f.Inputs[views.FieldRemoteURL].SetValue("https://yt.example")
// missing token + key
if err := f.Validate(project.Meta{}); err == nil {
t.Fatal("expected incomplete error")
}
}
+26 -7
View File
@@ -10,25 +10,39 @@ import (
// ProjectItem wraps a project for bubbles/list.
type ProjectItem struct {
Project project.Project
Active bool
Project project.Project
Active bool
YouTrack bool // true when YouTrack sync is configured
}
func (i ProjectItem) Title() string {
if i.Active {
return i.Project.Name + " *"
name := i.Project.Name
if i.YouTrack {
name += " [YT]"
}
return i.Project.Name
if i.Active {
return name + " *"
}
return name
}
func (i ProjectItem) Description() string { return i.Project.ID }
func (i ProjectItem) FilterValue() string { return i.Project.Name }
// NewProjectList builds a list of projects.
func NewProjectList(projects []project.Project, activeID string, width, height int) list.Model {
return NewProjectListWithYT(projects, activeID, nil, width, height)
}
// NewProjectListWithYT builds a list, marking projects with YouTrack config.
func NewProjectListWithYT(projects []project.Project, activeID string, ytConfigured map[string]bool, width, height int) list.Model {
items := make([]list.Item, len(projects))
selected := 0
for i, p := range projects {
items[i] = ProjectItem{Project: p, Active: p.ID == activeID}
items[i] = ProjectItem{
Project: p,
Active: p.ID == activeID,
YouTrack: ytConfigured[p.ID],
}
if p.ID == activeID {
selected = i
}
@@ -77,9 +91,14 @@ func JumpProjectByLetter(l *list.Model, r rune) bool {
// ProjectsToItems converts projects to list items.
func ProjectsToItems(projects []project.Project, activeID string) []list.Item {
return ProjectsToItemsWithYT(projects, activeID, nil)
}
// ProjectsToItemsWithYT converts projects to list items with YouTrack flags.
func ProjectsToItemsWithYT(projects []project.Project, activeID string, ytConfigured map[string]bool) []list.Item {
items := make([]list.Item, len(projects))
for i, p := range projects {
items[i] = ProjectItem{Project: p, Active: p.ID == activeID}
items[i] = ProjectItem{Project: p, Active: p.ID == activeID, YouTrack: ytConfigured[p.ID]}
}
return items
}
+94
View File
@@ -0,0 +1,94 @@
package views
import (
"fmt"
"strings"
"unicode"
)
// SyncAction is a YouTrack sync menu choice.
type SyncAction string
const (
SyncActionPull SyncAction = "pull"
SyncActionPush SyncAction = "push"
)
// SyncOption is one row in the sync menu.
type SyncOption struct {
Action SyncAction
Label string
Shortcut rune
}
// AllSyncOptions is the ordered sync menu.
var AllSyncOptions = []SyncOption{
{Action: SyncActionPull, Label: "Pull from YouTrack", Shortcut: 'p'},
{Action: SyncActionPush, Label: "Push to YouTrack", Shortcut: 'u'},
}
// SyncMenu renders a pull/push picker overlay.
type SyncMenu struct {
Cursor int
Width int
Styles StatusMenuStyles
}
// NewSyncMenu creates a sync action menu.
func NewSyncMenu(width int, styles StatusMenuStyles) SyncMenu {
return SyncMenu{Cursor: 0, Width: width, Styles: styles}
}
// MoveDown moves the selection down.
func (m *SyncMenu) MoveDown() {
if m.Cursor < len(AllSyncOptions)-1 {
m.Cursor++
}
}
// MoveUp moves the selection up.
func (m *SyncMenu) MoveUp() {
if m.Cursor > 0 {
m.Cursor--
}
}
// JumpToShortcut selects the option whose shortcut matches r.
func (m *SyncMenu) JumpToShortcut(r rune) bool {
r = unicode.ToLower(r)
for i, opt := range AllSyncOptions {
if opt.Shortcut == r {
m.Cursor = i
return true
}
}
return false
}
// Selected returns the highlighted action.
func (m SyncMenu) Selected() SyncAction {
if m.Cursor < 0 || m.Cursor >= len(AllSyncOptions) {
return SyncActionPull
}
return AllSyncOptions[m.Cursor].Action
}
// View renders the sync menu.
func (m SyncMenu) View() string {
var lines []string
lines = append(lines, m.Styles.Title.Render("YouTrack"))
for i, opt := range AllSyncOptions {
entry := fmt.Sprintf("%c %s", opt.Shortcut, opt.Label)
if i == m.Cursor {
lines = append(lines, m.Styles.Selected.Render("▸ "+entry))
} else {
lines = append(lines, m.Styles.Normal.Render(" "+entry))
}
}
body := strings.Join(lines, "\n")
w := m.Width
if w < 20 {
w = 20
}
return m.Styles.Box.Width(min(44, w-2)).Render(body)
}
+6 -2
View File
@@ -176,8 +176,12 @@ func (l *TicketList) renderCard(t ticket.Ticket, selected bool) string {
innerWidth := max(10, l.Width-4)
badge := l.statusBadge(t.Status)
headline := truncateOneLine(plainHeadline(t.Title), max(8, innerWidth-lipgloss.Width(badge)-2))
headerLine := badge + " " + l.Styles.Headline.Render(headline)
ext := ""
if t.ExternalID != "" {
ext = lipgloss.NewStyle().Faint(true).Render(t.ExternalID) + " "
}
headline := truncateOneLine(plainHeadline(t.Title), max(8, innerWidth-lipgloss.Width(badge)-lipgloss.Width(ext)-2))
headerLine := badge + " " + ext + l.Styles.Headline.Render(headline)
var content string
if selected {