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>
This commit is contained in:
@@ -15,6 +15,7 @@ Offline-first Terminal-Taskmanager (`tm`) — Kanban-Board, Markdown-Tickets, Ch
|
||||
- Report mit Group-by (Ticket / Tag)
|
||||
- Undo / Redo über eine Command-History
|
||||
- Vim-Style Keybindings
|
||||
- YouTrack Pull/Push (manuell; Remote-URL, Token, Project-Key pro Projekt)
|
||||
|
||||
## Install
|
||||
|
||||
@@ -32,6 +33,9 @@ make build # → ./bin/tm
|
||||
|
||||
```bash
|
||||
tm # TUI starten
|
||||
tm sync pull # Issues von YouTrack holen
|
||||
tm sync push # lokale Änderungen nach YouTrack
|
||||
tm sync pull -p inbox # anderes Projekt
|
||||
tm version # Build-Version (Git-Hash)
|
||||
tm help
|
||||
```
|
||||
@@ -62,6 +66,8 @@ Daten landen standardmäßig unter `~/.local/share/task-master`, Config unter `~
|
||||
| `l` | Board-Filter |
|
||||
| `/` | Suche |
|
||||
| `p`/`P` | Projekt / Ticket verschieben |
|
||||
| `e` | Ticket bzw. Projekt editieren (in Projektliste: YouTrack) |
|
||||
| `Y` | YouTrack-Menü: Pull / Push |
|
||||
| `t`/`T` | Timer / Zeit buchen |
|
||||
| `m` | Zeiteinträge |
|
||||
| `r` | Report |
|
||||
@@ -71,6 +77,18 @@ Daten landen standardmäßig unter `~/.local/share/task-master`, Config unter `~
|
||||
|
||||
Checklisten im Preview: `J`/`K` + `Space`.
|
||||
|
||||
### YouTrack
|
||||
|
||||
1. `p` → Projekt wählen → `e`
|
||||
2. Remote-URL, Permanent Token und Project-Key setzen (gespeichert in `projects/<id>/meta.json`, Mode `0600`)
|
||||
3. Lokal arbeiten (Anlegen/Edit/Status bleiben lokal)
|
||||
4. `Y` → **Pull** (Issues + Work Items) oder **Push** (Tickets + Zeiteinträge)
|
||||
CLI: `tm sync pull` / `tm sync push`
|
||||
|
||||
Zeit: lokale Einträge ohne `externalId` werden als YouTrack-Work-Items gepusht; remote Work Items landen lokal beim Pull.
|
||||
|
||||
Status-Mapping Richtung YouTrack: OPEN→`Open`, WIP→`In Progress`, DONE→`Fixed` (Workflow-Namen können projektspezifisch abweichen).
|
||||
|
||||
## Entwicklung
|
||||
|
||||
```bash
|
||||
|
||||
+79
-4
@@ -1,8 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
@@ -12,6 +14,7 @@ import (
|
||||
"task/internal/ticket"
|
||||
"task/internal/ui"
|
||||
"task/internal/version"
|
||||
"task/internal/ytsync"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -23,6 +26,12 @@ func main() {
|
||||
case "help", "--help", "-h":
|
||||
printHelp()
|
||||
return
|
||||
case "sync":
|
||||
if err := runSync(os.Args[2:]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
|
||||
printHelp()
|
||||
@@ -61,17 +70,83 @@ func run() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSync(args []string) error {
|
||||
cfg, err := config.LoadOrCreate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: %w", err)
|
||||
}
|
||||
projectID := cfg.ActiveProject
|
||||
action := ""
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "pull", "push":
|
||||
if action != "" {
|
||||
return fmt.Errorf("specify only one of pull/push")
|
||||
}
|
||||
action = args[i]
|
||||
case "--project", "-p":
|
||||
if i+1 >= len(args) {
|
||||
return fmt.Errorf("%s requires a project id", args[i])
|
||||
}
|
||||
i++
|
||||
projectID = strings.TrimSpace(args[i])
|
||||
case "--help", "-h":
|
||||
fmt.Printf("Usage: %s sync <pull|push> [--project ID]\n", config.CliName)
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unknown sync argument: %s (use pull or push)", args[i])
|
||||
}
|
||||
}
|
||||
if action == "" {
|
||||
return fmt.Errorf("usage: %s sync <pull|push> [--project ID]", config.CliName)
|
||||
}
|
||||
if projectID == "" {
|
||||
return fmt.Errorf("project id required")
|
||||
}
|
||||
|
||||
store := ticket.NewJSONStore(cfg.DataDir)
|
||||
timeStore := ticket.NewJSONTimeStore(cfg.DataDir)
|
||||
projects := project.NewDirStore(cfg.DataDir)
|
||||
if err := projects.Ensure(projectID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "push":
|
||||
res, err := ytsync.PushProject(context.Background(), projects, store, timeStore, projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("pushed %s: tickets +%d ~%d, time %d\n",
|
||||
projectID, res.Created, res.Updated, res.TimePushed)
|
||||
default:
|
||||
res, err := ytsync.PullProject(context.Background(), projects, store, timeStore, projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("pulled %s: tickets +%d ~%d, time %d (%d issues)\n",
|
||||
projectID, res.Created, res.Updated, res.TimePulled, res.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printHelp() {
|
||||
fmt.Printf(`%s — offline-first terminal task manager
|
||||
|
||||
Usage:
|
||||
%s Start the TUI
|
||||
%s version Print build version
|
||||
%s help Show this help
|
||||
%s Start the TUI
|
||||
%s sync pull [-p ID] Pull issues from YouTrack
|
||||
%s sync push [-p ID] Push local changes to YouTrack
|
||||
%s version Print build version
|
||||
%s help Show this help
|
||||
|
||||
Env:
|
||||
TASK_MASTER_HOME Override home for config/data (dev/demo sandbox)
|
||||
TASK_MASTER_DATA_DIR Override data directory only
|
||||
|
||||
`, config.CliName, config.CliName, config.CliName, config.CliName)
|
||||
YouTrack:
|
||||
In the TUI: p → project → e to set remote URL, token, project key.
|
||||
Then Y → Pull or Push (manual only; edits stay local until you push).
|
||||
|
||||
`, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const metaFileName = "meta.json"
|
||||
|
||||
// YouTrack holds remote sync settings for a project.
|
||||
type YouTrack struct {
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Token string `json:"token"`
|
||||
ProjectKey string `json:"projectKey"`
|
||||
// Query overrides the default "project: <ProjectKey>" issue filter.
|
||||
Query string `json:"query,omitempty"`
|
||||
}
|
||||
|
||||
// Configured reports whether enough fields are set to sync.
|
||||
func (y YouTrack) Configured() bool {
|
||||
return strings.TrimSpace(y.BaseURL) != "" &&
|
||||
strings.TrimSpace(y.Token) != "" &&
|
||||
strings.TrimSpace(y.ProjectKey) != ""
|
||||
}
|
||||
|
||||
// IssueQuery returns the YouTrack search query used for sync.
|
||||
func (y YouTrack) IssueQuery() string {
|
||||
if q := strings.TrimSpace(y.Query); q != "" {
|
||||
return q
|
||||
}
|
||||
return "project: " + strings.TrimSpace(y.ProjectKey)
|
||||
}
|
||||
|
||||
// Meta is per-project metadata stored beside tickets.json.
|
||||
type Meta struct {
|
||||
YouTrack *YouTrack `json:"youtrack,omitempty"`
|
||||
}
|
||||
|
||||
func (s *DirStore) metaPath(id string) string {
|
||||
return filepath.Join(s.projectsRoot(), id, metaFileName)
|
||||
}
|
||||
|
||||
// LoadMeta reads projects/<id>/meta.json. Missing file yields empty Meta.
|
||||
func (s *DirStore) LoadMeta(id string) (Meta, error) {
|
||||
if id == "" {
|
||||
return Meta{}, fmt.Errorf("project id is required")
|
||||
}
|
||||
path := s.metaPath(id)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return Meta{}, nil
|
||||
}
|
||||
return Meta{}, fmt.Errorf("read meta %s: %w", id, err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return Meta{}, nil
|
||||
}
|
||||
var meta Meta
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
return Meta{}, fmt.Errorf("parse meta %s: %w", id, err)
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// SaveMeta writes projects/<id>/meta.json with mode 0600 (may contain tokens).
|
||||
func (s *DirStore) SaveMeta(id string, meta Meta) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("project id is required")
|
||||
}
|
||||
if err := s.Ensure(id); err != nil {
|
||||
return err
|
||||
}
|
||||
path := s.metaPath(id)
|
||||
data, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal meta: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "meta-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp meta: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer func() { _ = os.Remove(tmpName) }()
|
||||
|
||||
if err := tmp.Chmod(0o600); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("chmod temp meta: %w", err)
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("write temp meta: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temp meta: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("rename meta: %w", err)
|
||||
}
|
||||
_ = os.Chmod(path, 0o600)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package project_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"task/internal/project"
|
||||
)
|
||||
|
||||
func TestMetaSaveLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := project.NewDirStore(dir)
|
||||
if err := store.Ensure("inbox"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
meta := project.Meta{
|
||||
YouTrack: &project.YouTrack{
|
||||
BaseURL: "https://yt.example.com",
|
||||
Token: "perm:secret",
|
||||
ProjectKey: "PROJ",
|
||||
},
|
||||
}
|
||||
if err := store.SaveMeta("inbox", meta); err != nil {
|
||||
t.Fatalf("SaveMeta: %v", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, "projects", "inbox", "meta.json")
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if st.Mode().Perm()&0o077 != 0 {
|
||||
t.Fatalf("meta permissions too open: %o", st.Mode().Perm())
|
||||
}
|
||||
|
||||
got, err := store.LoadMeta("inbox")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMeta: %v", err)
|
||||
}
|
||||
if got.YouTrack == nil || got.YouTrack.Token != "perm:secret" || got.YouTrack.ProjectKey != "PROJ" {
|
||||
t.Fatalf("got %+v", got.YouTrack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMetaMissing(t *testing.T) {
|
||||
store := project.NewDirStore(t.TempDir())
|
||||
got, err := store.LoadMeta("missing")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMeta: %v", err)
|
||||
}
|
||||
if got.YouTrack != nil {
|
||||
t.Fatalf("expected empty meta, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestYouTrackConfiguredAndQuery(t *testing.T) {
|
||||
y := project.YouTrack{BaseURL: "https://x", Token: "t", ProjectKey: "ABC"}
|
||||
if !y.Configured() {
|
||||
t.Fatal("expected configured")
|
||||
}
|
||||
if y.IssueQuery() != "project: ABC" {
|
||||
t.Fatalf("query = %q", y.IssueQuery())
|
||||
}
|
||||
y.Query = "Assignee: me"
|
||||
if y.IssueQuery() != "Assignee: me" {
|
||||
t.Fatalf("query = %q", y.IssueQuery())
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ type Store interface {
|
||||
List() ([]Project, error)
|
||||
Ensure(id string) error
|
||||
Delete(id string) error
|
||||
LoadMeta(id string) (Meta, error)
|
||||
SaveMeta(id string, meta Meta) error
|
||||
}
|
||||
|
||||
// DirStore discovers projects as subdirectories of dataDir/projects.
|
||||
|
||||
@@ -134,6 +134,8 @@ func (s *JSONStore) Update(t Ticket) error {
|
||||
for i := range tickets {
|
||||
if tickets[i].ID == t.ID {
|
||||
t.CreatedAt = tickets[i].CreatedAt
|
||||
// Always bump UpdatedAt on local edits so NeedsPush sees dirty tickets.
|
||||
// Use MarkSynced after a successful remote sync to clear the dirty flag.
|
||||
t.UpdatedAt = time.Now().UTC()
|
||||
tickets[i] = t
|
||||
found = true
|
||||
@@ -146,6 +148,42 @@ func (s *JSONStore) Update(t Ticket) error {
|
||||
return s.writeUnlocked(t.ProjectID, tickets)
|
||||
}
|
||||
|
||||
// MarkSynced sets SyncedAt = UpdatedAt without changing UpdatedAt.
|
||||
func (s *JSONStore) MarkSynced(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if id == "" {
|
||||
return fmt.Errorf("ticket id is required")
|
||||
}
|
||||
|
||||
projects, err := os.ReadDir(filepath.Join(s.dataDir, "projects"))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("ticket %s: not found", id)
|
||||
}
|
||||
return fmt.Errorf("list projects: %w", err)
|
||||
}
|
||||
|
||||
for _, p := range projects {
|
||||
if !p.IsDir() {
|
||||
continue
|
||||
}
|
||||
tickets, err := s.readUnlocked(p.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range tickets {
|
||||
if tickets[i].ID != id {
|
||||
continue
|
||||
}
|
||||
tickets[i].SyncedAt = tickets[i].UpdatedAt
|
||||
return s.writeUnlocked(p.Name(), tickets)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("ticket %s: not found", id)
|
||||
}
|
||||
|
||||
// Move relocates a ticket to another project directory.
|
||||
func (s *JSONStore) Move(id, toProjectID string) (Ticket, error) {
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"task/internal/ticket"
|
||||
)
|
||||
@@ -65,6 +66,43 @@ func TestJSONStoreCreateListUpdate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAfterMarkSyncedIsDirty(t *testing.T) {
|
||||
store := ticket.NewJSONStore(t.TempDir())
|
||||
created, err := store.Create(ticket.Ticket{
|
||||
Title: "Synced", ProjectID: "inbox", Status: ticket.StatusTodo,
|
||||
Source: ticket.SourceYouTrack, ExternalID: "TAS-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.MarkSynced(created.ID); err != nil {
|
||||
t.Fatalf("MarkSynced: %v", err)
|
||||
}
|
||||
synced, err := store.Get(created.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if synced.UpdatedAt.After(synced.SyncedAt) {
|
||||
t.Fatalf("freshly marked synced should not be dirty: updated=%v syncedAt=%v",
|
||||
synced.UpdatedAt, synced.SyncedAt)
|
||||
}
|
||||
|
||||
// Simulate UI edit: copy ticket (SyncedAt == UpdatedAt) and change title.
|
||||
edited := synced
|
||||
edited.Title = "Edited locally"
|
||||
time.Sleep(time.Millisecond) // ensure clock can move
|
||||
if err := store.Update(edited); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := store.Get(created.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.UpdatedAt.After(got.SyncedAt) {
|
||||
t.Fatalf("edit must bump UpdatedAt past SyncedAt: %v vs %v", got.UpdatedAt, got.SyncedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONStoreMigratesLegacyTasksJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
projectDir := filepath.Join(dir, "projects", "inbox")
|
||||
|
||||
@@ -7,6 +7,9 @@ type Store interface {
|
||||
Get(id string) (Ticket, error)
|
||||
Create(t Ticket) (Ticket, error)
|
||||
Update(t Ticket) error
|
||||
// MarkSynced sets SyncedAt to the ticket's current UpdatedAt without
|
||||
// bumping UpdatedAt (so NeedsPush becomes false after a remote sync).
|
||||
MarkSynced(id string) error
|
||||
Move(id, toProjectID string) (Ticket, error)
|
||||
Delete(id string) error
|
||||
}
|
||||
|
||||
@@ -50,6 +50,11 @@ func (s Status) Index() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
const (
|
||||
// SourceYouTrack marks tickets imported from YouTrack.
|
||||
SourceYouTrack = "youtrack"
|
||||
)
|
||||
|
||||
// Ticket is the domain model for a work item.
|
||||
// Title (headline) and Description are Markdown.
|
||||
type Ticket struct {
|
||||
@@ -60,4 +65,10 @@ type Ticket struct {
|
||||
ProjectID string `json:"projectId"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
|
||||
// External sync (optional). Local UUID stays in ID.
|
||||
Source string `json:"source,omitempty"`
|
||||
ExternalID string `json:"externalId,omitempty"`
|
||||
ExternalURL string `json:"externalUrl,omitempty"`
|
||||
SyncedAt time.Time `json:"syncedAt,omitempty"`
|
||||
}
|
||||
|
||||
+35
-24
@@ -11,6 +11,11 @@ const (
|
||||
SourceManual = "manual"
|
||||
)
|
||||
|
||||
const (
|
||||
// SourceYouTrackTime marks time entries imported from YouTrack work items.
|
||||
SourceYouTrackTime = "youtrack"
|
||||
)
|
||||
|
||||
// TimeEntry is a recorded block of work on a ticket.
|
||||
type TimeEntry struct {
|
||||
ID string `json:"id"`
|
||||
@@ -21,43 +26,48 @@ type TimeEntry struct {
|
||||
Duration time.Duration `json:"duration"`
|
||||
Source string `json:"source"`
|
||||
Note string `json:"note,omitempty"`
|
||||
// ExternalID is the YouTrack work-item id when synced.
|
||||
ExternalID string `json:"externalId,omitempty"`
|
||||
}
|
||||
|
||||
// MarshalJSON encodes Duration as nanoseconds for stable JSON.
|
||||
func (e TimeEntry) MarshalJSON() ([]byte, error) {
|
||||
type alias struct {
|
||||
ID string `json:"id"`
|
||||
TicketID string `json:"ticketId"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
Duration int64 `json:"duration"`
|
||||
Source string `json:"source"`
|
||||
Note string `json:"note,omitempty"`
|
||||
ID string `json:"id"`
|
||||
TicketID string `json:"ticketId"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
Duration int64 `json:"duration"`
|
||||
Source string `json:"source"`
|
||||
Note string `json:"note,omitempty"`
|
||||
ExternalID string `json:"externalId,omitempty"`
|
||||
}
|
||||
return json.Marshal(alias{
|
||||
ID: e.ID,
|
||||
TicketID: e.TicketID,
|
||||
ProjectID: e.ProjectID,
|
||||
Start: e.Start,
|
||||
End: e.End,
|
||||
Duration: int64(e.Duration),
|
||||
Source: e.Source,
|
||||
Note: e.Note,
|
||||
ID: e.ID,
|
||||
TicketID: e.TicketID,
|
||||
ProjectID: e.ProjectID,
|
||||
Start: e.Start,
|
||||
End: e.End,
|
||||
Duration: int64(e.Duration),
|
||||
Source: e.Source,
|
||||
Note: e.Note,
|
||||
ExternalID: e.ExternalID,
|
||||
})
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes Duration from nanoseconds.
|
||||
func (e *TimeEntry) UnmarshalJSON(data []byte) error {
|
||||
type alias struct {
|
||||
ID string `json:"id"`
|
||||
TicketID string `json:"ticketId"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
Duration int64 `json:"duration"`
|
||||
Source string `json:"source"`
|
||||
Note string `json:"note,omitempty"`
|
||||
ID string `json:"id"`
|
||||
TicketID string `json:"ticketId"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
Duration int64 `json:"duration"`
|
||||
Source string `json:"source"`
|
||||
Note string `json:"note,omitempty"`
|
||||
ExternalID string `json:"externalId,omitempty"`
|
||||
}
|
||||
var a alias
|
||||
if err := json.Unmarshal(data, &a); err != nil {
|
||||
@@ -71,6 +81,7 @@ func (e *TimeEntry) UnmarshalJSON(data []byte) error {
|
||||
e.Duration = time.Duration(a.Duration)
|
||||
e.Source = a.Source
|
||||
e.Note = a.Note
|
||||
e.ExternalID = a.ExternalID
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+256
-6
@@ -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")
|
||||
|
||||
@@ -33,10 +33,18 @@ func (m Model) helpEntries() []helpEntry {
|
||||
{"a-z", "jump"},
|
||||
{"enter", "select"},
|
||||
{"n", "new project"},
|
||||
{"e", "edit"},
|
||||
{"Y", "YouTrack"},
|
||||
{"x", "delete"},
|
||||
{"X", "force delete"},
|
||||
{"esc", "cancel"},
|
||||
}
|
||||
case ModeEditProject:
|
||||
return []helpEntry{
|
||||
{"tab/↑↓", "fields"},
|
||||
{"enter", "save"},
|
||||
{"esc", "cancel"},
|
||||
}
|
||||
case ModeMoveTicket:
|
||||
return []helpEntry{
|
||||
{"j/k", "navigate"},
|
||||
@@ -51,6 +59,13 @@ func (m Model) helpEntries() []helpEntry {
|
||||
{"enter", "apply"},
|
||||
{"esc", "cancel"},
|
||||
}
|
||||
case ModeSync:
|
||||
return []helpEntry{
|
||||
{"j/k", "select"},
|
||||
{"p/u", "jump"},
|
||||
{"enter", "run"},
|
||||
{"esc", "cancel"},
|
||||
}
|
||||
case ModeBoard:
|
||||
return []helpEntry{
|
||||
{"j/k", "navigate"},
|
||||
@@ -106,6 +121,7 @@ func (m Model) helpEntries() []helpEntry {
|
||||
{"t/T", "timer/log"},
|
||||
{"m", "entries"},
|
||||
{"r", "report"},
|
||||
{"Y", "YouTrack"},
|
||||
{"u/U", "undo/redo"},
|
||||
{"/", "search"},
|
||||
{"p", "project"},
|
||||
|
||||
+16
-1
@@ -24,11 +24,14 @@ type KeyMap struct {
|
||||
TimeManual key.Binding // T — manual duration
|
||||
TimeEntries key.Binding // m — manage time entries
|
||||
Report key.Binding // r — time report
|
||||
Sync key.Binding // Y — pull YouTrack issues
|
||||
Undo key.Binding
|
||||
Redo key.Binding
|
||||
Quit key.Binding
|
||||
Enter key.Binding
|
||||
Escape key.Binding
|
||||
Tab key.Binding // tab — next form field
|
||||
ShiftTab key.Binding // shift+tab — previous form field
|
||||
}
|
||||
|
||||
// DefaultKeyMap returns the default key bindings.
|
||||
@@ -114,6 +117,10 @@ func DefaultKeyMap() KeyMap {
|
||||
key.WithKeys("r"),
|
||||
key.WithHelp("r", "report"),
|
||||
),
|
||||
Sync: key.NewBinding(
|
||||
key.WithKeys("Y"),
|
||||
key.WithHelp("Y", "YouTrack pull/push"),
|
||||
),
|
||||
Undo: key.NewBinding(
|
||||
key.WithKeys("u", "ctrl+z"),
|
||||
key.WithHelp("u", "undo"),
|
||||
@@ -134,12 +141,20 @@ func DefaultKeyMap() KeyMap {
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "cancel"),
|
||||
),
|
||||
Tab: key.NewBinding(
|
||||
key.WithKeys("tab"),
|
||||
key.WithHelp("tab", "next field"),
|
||||
),
|
||||
ShiftTab: key.NewBinding(
|
||||
key.WithKeys("shift+tab"),
|
||||
key.WithHelp("shift+tab", "prev field"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ShortHelp implements help.KeyMap.
|
||||
func (k KeyMap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{k.Up, k.New, k.Search, k.Project, k.MoveTicket, k.Board, k.Status, k.Edit, k.Timer, k.TimeManual, k.TimeEntries, k.Report, k.Delete, k.DeleteForce, k.Undo, k.Redo, k.Quit}
|
||||
return []key.Binding{k.Up, k.New, k.Search, k.Project, k.MoveTicket, k.Board, k.Status, k.Edit, k.Timer, k.TimeManual, k.TimeEntries, k.Report, k.Sync, k.Delete, k.DeleteForce, k.Undo, k.Redo, k.Quit}
|
||||
}
|
||||
|
||||
// FullHelp implements help.KeyMap.
|
||||
|
||||
@@ -80,10 +80,17 @@ func (m Model) renderBody(height int) string {
|
||||
m.projectList.SetSize(m.width, height)
|
||||
return m.projectList.View()
|
||||
|
||||
case ModeEditProject:
|
||||
return lipgloss.NewStyle().Height(height).Width(m.width).Render(m.projectEdit.View())
|
||||
|
||||
case ModeStatus:
|
||||
menu := m.statusMenu.View()
|
||||
return lipgloss.NewStyle().Height(height).Width(m.width).Render(menu)
|
||||
|
||||
case ModeSync:
|
||||
menu := m.syncMenu.View()
|
||||
return lipgloss.NewStyle().Height(height).Width(m.width).Render(menu)
|
||||
|
||||
case ModeBoard:
|
||||
menu := m.boardMenu.View()
|
||||
return lipgloss.NewStyle().Height(height).Width(m.width).Render(menu)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,719 @@
|
||||
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] + "…"
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package youtrack_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"task/internal/youtrack"
|
||||
)
|
||||
|
||||
func TestListIssues(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/issues" {
|
||||
t.Fatalf("path = %s", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer tok" {
|
||||
t.Fatalf("auth = %q", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[
|
||||
{
|
||||
"idReadable": "PROJ-1",
|
||||
"summary": "Fix bug",
|
||||
"description": "details",
|
||||
"updated": 1700000000000,
|
||||
"customFields": [
|
||||
{"name": "State", "$type": "StateIssueCustomField", "value": {"name": "In Progress", "$type": "StateBundleElement"}}
|
||||
]
|
||||
}
|
||||
]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &youtrack.Client{BaseURL: srv.URL, Token: "tok", HTTPClient: srv.Client()}
|
||||
issues, err := c.ListIssues(context.Background(), "project: PROJ")
|
||||
if err != nil {
|
||||
t.Fatalf("ListIssues: %v", err)
|
||||
}
|
||||
if len(issues) != 1 {
|
||||
t.Fatalf("len = %d", len(issues))
|
||||
}
|
||||
iss := issues[0]
|
||||
if iss.IDReadable != "PROJ-1" || iss.Summary != "Fix bug" || iss.State != "In Progress" {
|
||||
t.Fatalf("issue = %+v", iss)
|
||||
}
|
||||
if c.IssueURL("PROJ-1") != srv.URL+"/issue/PROJ-1" {
|
||||
t.Fatalf("url = %s", c.IssueURL("PROJ-1"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListIssuesHTTPError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "nope", http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &youtrack.Client{BaseURL: srv.URL, Token: "bad", HTTPClient: srv.Client()}
|
||||
_, err := c.ListIssues(context.Background(), "project: X")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAndUpdateIssueWithRenamedStateField(t *testing.T) {
|
||||
var statePosts []map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/admin/projects/PROJ":
|
||||
_, _ = w.Write([]byte(`{"id":"0-1","shortName":"PROJ"}`))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/issues":
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatalf("body: %v", err)
|
||||
}
|
||||
if _, hasCF := payload["customFields"]; hasCF {
|
||||
t.Fatal("create must not send customFields for State")
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"idReadable":"PROJ-9","summary":"New","description":"body"}`))
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/issues/"):
|
||||
// Field is named "Status", not "State".
|
||||
_, _ = w.Write([]byte(`{
|
||||
"idReadable":"PROJ-9","summary":"New","description":"body",
|
||||
"customFields":[{
|
||||
"id":"92-1","name":"Status","$type":"StateIssueCustomField",
|
||||
"value":{"name":"Submitted"}
|
||||
}]
|
||||
}`))
|
||||
case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/api/issues/"):
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var payload map[string]any
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
if cfs, ok := payload["customFields"].([]any); ok {
|
||||
statePosts = append(statePosts, cfs[0].(map[string]any))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
if payload["summary"] == "Updated" {
|
||||
_, _ = w.Write([]byte(`{"idReadable":"PROJ-9","summary":"Updated","description":"new"}`))
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected post body: %s", body)
|
||||
default:
|
||||
t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &youtrack.Client{BaseURL: srv.URL, Token: "tok", HTTPClient: srv.Client()}
|
||||
created, err := c.CreateIssue(context.Background(), "PROJ", "New", "body", "Open")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateIssue: %v", err)
|
||||
}
|
||||
if created.IDReadable != "PROJ-9" {
|
||||
t.Fatalf("created = %+v", created)
|
||||
}
|
||||
|
||||
updated, err := c.UpdateIssue(context.Background(), "PROJ-9", "Updated", "new", "In Progress")
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateIssue: %v", err)
|
||||
}
|
||||
if updated.Summary != "Updated" {
|
||||
t.Fatalf("updated = %+v", updated)
|
||||
}
|
||||
if len(statePosts) < 1 {
|
||||
t.Fatal("expected state field posts")
|
||||
}
|
||||
for _, p := range statePosts {
|
||||
if p["name"] != "Status" {
|
||||
t.Fatalf("expected field name Status, got %v", p["name"])
|
||||
}
|
||||
if p["$type"] != "StateIssueCustomField" {
|
||||
t.Fatalf("$type = %v", p["$type"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetStateMachineEvent(t *testing.T) {
|
||||
var posted map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet:
|
||||
_, _ = w.Write([]byte(`{
|
||||
"idReadable":"TAS-1","summary":"x",
|
||||
"customFields":[{
|
||||
"id":"82-11","name":"Stage","$type":"StateMachineIssueCustomField",
|
||||
"value":{"name":"Open"},
|
||||
"possibleEvents":[
|
||||
{"id":"start","presentation":"Start progress","$type":"Event"},
|
||||
{"id":"fix","presentation":"Fixed","$type":"Event"}
|
||||
]
|
||||
}]
|
||||
}`))
|
||||
case r.Method == http.MethodPost:
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(body, &posted)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &youtrack.Client{BaseURL: srv.URL, Token: "tok", HTTPClient: srv.Client()}
|
||||
if err := c.SetState(context.Background(), "TAS-1", "In Progress"); err != nil {
|
||||
t.Fatalf("SetState: %v", err)
|
||||
}
|
||||
cfs := posted["customFields"].([]any)
|
||||
cf := cfs[0].(map[string]any)
|
||||
if cf["name"] != "Stage" || cf["$type"] != "StateMachineIssueCustomField" {
|
||||
t.Fatalf("cf = %+v", cf)
|
||||
}
|
||||
ev := cf["event"].(map[string]any)
|
||||
if ev["id"] != "start" {
|
||||
t.Fatalf("event = %+v (want fuzzy match to Start progress)", ev)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToIssueReadsStatusField(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"idReadable":"TAS-1","summary":"x",
|
||||
"customFields":[{
|
||||
"id":"1","name":"Status","$type":"StateIssueCustomField",
|
||||
"value":{"name":"In Progress"}
|
||||
}]
|
||||
}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &youtrack.Client{BaseURL: srv.URL, Token: "tok", HTTPClient: srv.Client()}
|
||||
iss, err := c.GetIssue(context.Background(), "TAS-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if iss.State != "In Progress" {
|
||||
t.Fatalf("state = %q", iss.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAndListWorkItems(t *testing.T) {
|
||||
var created map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/timeTracking/workItems"):
|
||||
_, _ = w.Write([]byte(`[{"id":"wi-1","date":1700000000000,"text":"x","duration":{"minutes":30}}]`))
|
||||
case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/timeTracking/workItems"):
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(body, &created)
|
||||
_, _ = w.Write([]byte(`{"id":"wi-2","date":1700000000000,"text":"note","duration":{"minutes":45}}`))
|
||||
default:
|
||||
t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &youtrack.Client{BaseURL: srv.URL, Token: "tok", HTTPClient: srv.Client()}
|
||||
list, err := c.ListWorkItems(context.Background(), "TAS-1")
|
||||
if err != nil || len(list) != 1 || list[0].Minutes != 30 {
|
||||
t.Fatalf("list = %+v err=%v", list, err)
|
||||
}
|
||||
wi, err := c.CreateWorkItem(context.Background(), "TAS-1", 45, time.UnixMilli(1700000000000), "note")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wi.ID != "wi-2" {
|
||||
t.Fatalf("wi = %+v", wi)
|
||||
}
|
||||
dur := created["duration"].(map[string]any)
|
||||
if int(dur["minutes"].(float64)) != 45 {
|
||||
t.Fatalf("created = %+v", created)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCommand(t *testing.T) {
|
||||
var got string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/commands" {
|
||||
t.Fatalf("path %s", r.URL.Path)
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var payload map[string]any
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
got = fmt.Sprint(payload["query"])
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &youtrack.Client{BaseURL: srv.URL, Token: "tok", HTTPClient: srv.Client()}
|
||||
if err := c.ApplyCommand(context.Background(), "TAS-1", "Status Open"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "Status Open" {
|
||||
t.Fatalf("query = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package ytsync_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"task/internal/project"
|
||||
"task/internal/ticket"
|
||||
"task/internal/youtrack"
|
||||
"task/internal/ytsync"
|
||||
)
|
||||
|
||||
func TestMapStatus(t *testing.T) {
|
||||
cases := map[string]ticket.Status{
|
||||
"": ticket.StatusTodo,
|
||||
"Open": ticket.StatusTodo,
|
||||
"In Progress": ticket.StatusInProgress,
|
||||
"Fixed": ticket.StatusDone,
|
||||
"Done": ticket.StatusDone,
|
||||
"Obsolete": ticket.StatusDone,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := ytsync.MapStatus(in); got != want {
|
||||
t.Fatalf("MapStatus(%q) = %s, want %s", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapState(t *testing.T) {
|
||||
if ytsync.MapState(ticket.StatusTodo) != "Open" {
|
||||
t.Fatal("todo")
|
||||
}
|
||||
if ytsync.MapState(ticket.StatusInProgress) != "In Progress" {
|
||||
t.Fatal("wip")
|
||||
}
|
||||
if ytsync.MapState(ticket.StatusDone) != "Fixed" {
|
||||
t.Fatal("done")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsPush(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
local := ticket.Ticket{Title: "x"}
|
||||
if !ytsync.NeedsPush(local) {
|
||||
t.Fatal("local without external id should push")
|
||||
}
|
||||
synced := ticket.Ticket{
|
||||
Source: ticket.SourceYouTrack, ExternalID: "P-1",
|
||||
UpdatedAt: now, SyncedAt: now,
|
||||
}
|
||||
if ytsync.NeedsPush(synced) {
|
||||
t.Fatal("freshly synced should not push")
|
||||
}
|
||||
dirty := synced
|
||||
dirty.UpdatedAt = now.Add(time.Minute)
|
||||
if !ytsync.NeedsPush(dirty) {
|
||||
t.Fatal("dirty should push")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullCreateAndUpdate(t *testing.T) {
|
||||
var calls int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if calls == 1 {
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"idReadable":"P-1","summary":"One","description":"d1","updated":1,
|
||||
"customFields":[{"name":"State","value":{"name":"Open"}}]}
|
||||
]`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"idReadable":"P-1","summary":"One updated","description":"d2","updated":2,
|
||||
"customFields":[{"name":"State","value":{"name":"In Progress"}}]},
|
||||
{"idReadable":"P-2","summary":"Two","description":"","updated":2,
|
||||
"customFields":[{"name":"State","value":{"name":"Fixed"}}]}
|
||||
]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
store := ticket.NewJSONStore(t.TempDir())
|
||||
client := &youtrack.Client{BaseURL: srv.URL, Token: "t", HTTPClient: srv.Client()}
|
||||
syncer := &ytsync.YouTrackSyncer{Store: store, Client: client}
|
||||
yt := project.YouTrack{BaseURL: srv.URL, Token: "t", ProjectKey: "P"}
|
||||
|
||||
r1, err := syncer.Pull(context.Background(), "inbox", yt)
|
||||
if err != nil {
|
||||
t.Fatalf("pull1: %v", err)
|
||||
}
|
||||
if r1.Created != 1 || r1.Updated != 0 {
|
||||
t.Fatalf("r1 = %+v", r1)
|
||||
}
|
||||
|
||||
r2, err := syncer.Pull(context.Background(), "inbox", yt)
|
||||
if err != nil {
|
||||
t.Fatalf("pull2: %v", err)
|
||||
}
|
||||
if r2.Created != 1 || r2.Updated != 1 {
|
||||
t.Fatalf("r2 = %+v", r2)
|
||||
}
|
||||
|
||||
list, err := store.List("inbox")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("len = %d", len(list))
|
||||
}
|
||||
byExt := map[string]ticket.Ticket{}
|
||||
for _, tk := range list {
|
||||
byExt[tk.ExternalID] = tk
|
||||
}
|
||||
if byExt["P-1"].Title != "One updated" || byExt["P-1"].Status != ticket.StatusInProgress {
|
||||
t.Fatalf("P-1 = %+v", byExt["P-1"])
|
||||
}
|
||||
if byExt["P-2"].Status != ticket.StatusDone || byExt["P-2"].Source != ticket.SourceYouTrack {
|
||||
t.Fatalf("P-2 = %+v", byExt["P-2"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushCreateAndUpdate(t *testing.T) {
|
||||
var createdBody, updatedBody map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/admin/projects/P":
|
||||
_, _ = w.Write([]byte(`{"id":"0-1","shortName":"P"}`))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/issues":
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(b, &createdBody)
|
||||
_, _ = w.Write([]byte(`{"idReadable":"P-10","summary":"Local","description":"d"}`))
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/api/issues/P-10"):
|
||||
_, _ = w.Write([]byte(`{
|
||||
"idReadable":"P-10","summary":"Local","description":"d",
|
||||
"customFields":[{"id":"1","name":"State","$type":"StateIssueCustomField","value":{"name":"Open"}}]
|
||||
}`))
|
||||
case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/api/issues/P-10"):
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
var payload map[string]any
|
||||
_ = json.Unmarshal(b, &payload)
|
||||
if _, ok := payload["customFields"]; ok {
|
||||
// state set
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
_ = json.Unmarshal(b, &updatedBody)
|
||||
_, _ = w.Write([]byte(`{"idReadable":"P-10","summary":"Local2","description":"d2"}`))
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/issues":
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
default:
|
||||
t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
store := ticket.NewJSONStore(t.TempDir())
|
||||
local, err := store.Create(ticket.Ticket{
|
||||
Title: "Local", Description: "d", ProjectID: "inbox", Status: ticket.StatusTodo,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
client := &youtrack.Client{BaseURL: srv.URL, Token: "t", HTTPClient: srv.Client()}
|
||||
syncer := &ytsync.YouTrackSyncer{Store: store, Client: client}
|
||||
yt := project.YouTrack{BaseURL: srv.URL, Token: "t", ProjectKey: "P"}
|
||||
|
||||
push, err := syncer.Push(context.Background(), "inbox", yt)
|
||||
if err != nil {
|
||||
t.Fatalf("push: %v", err)
|
||||
}
|
||||
if push.Created != 1 {
|
||||
t.Fatalf("push = %+v", push)
|
||||
}
|
||||
if createdBody["summary"] != "Local" {
|
||||
t.Fatalf("created body = %+v", createdBody)
|
||||
}
|
||||
|
||||
got, err := store.Get(local.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ExternalID != "P-10" || got.Source != ticket.SourceYouTrack {
|
||||
t.Fatalf("linked = %+v", got)
|
||||
}
|
||||
if ytsync.NeedsPush(got) {
|
||||
t.Fatalf("should not need push after create: updated=%v synced=%v", got.UpdatedAt, got.SyncedAt)
|
||||
}
|
||||
|
||||
got.Title = "Local2"
|
||||
got.Status = ticket.StatusInProgress
|
||||
got.UpdatedAt = time.Now().UTC().Add(time.Minute)
|
||||
if err := store.Update(got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ = store.Get(local.ID)
|
||||
if !ytsync.NeedsPush(got) {
|
||||
t.Fatal("expected dirty after local edit")
|
||||
}
|
||||
|
||||
push2, err := syncer.Push(context.Background(), "inbox", yt)
|
||||
if err != nil {
|
||||
t.Fatalf("push2: %v", err)
|
||||
}
|
||||
if push2.Updated != 1 {
|
||||
t.Fatalf("push2 = %+v", push2)
|
||||
}
|
||||
if updatedBody["summary"] != "Local2" {
|
||||
t.Fatalf("updated body = %+v", updatedBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushTimeEntries(t *testing.T) {
|
||||
var workPosts int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/timeTracking/workItems"):
|
||||
workPosts++
|
||||
_, _ = w.Write([]byte(`{"id":"wi-9","date":1,"duration":{"minutes":15},"text":"n"}`))
|
||||
default:
|
||||
t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
store := ticket.NewJSONStore(dir)
|
||||
timeStore := ticket.NewJSONTimeStore(dir)
|
||||
tk, err := store.Create(ticket.Ticket{
|
||||
Title: "T", ProjectID: "inbox", Status: ticket.StatusTodo,
|
||||
Source: ticket.SourceYouTrack, ExternalID: "TAS-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.MarkSynced(tk.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = timeStore.Create(ticket.TimeEntry{
|
||||
TicketID: tk.ID, ProjectID: "inbox",
|
||||
Duration: 15 * time.Minute, Source: ticket.SourceManual, Note: "n",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
client := &youtrack.Client{BaseURL: srv.URL, Token: "t", HTTPClient: srv.Client()}
|
||||
syncer := &ytsync.YouTrackSyncer{Store: store, TimeStore: timeStore, Client: client}
|
||||
yt := project.YouTrack{BaseURL: srv.URL, Token: "t", ProjectKey: "TAS"}
|
||||
res, err := syncer.Push(context.Background(), "inbox", yt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.TimePushed != 1 || workPosts != 1 {
|
||||
t.Fatalf("res=%+v posts=%d", res, workPosts)
|
||||
}
|
||||
entries, _ := timeStore.ListByTicket(tk.ID)
|
||||
if entries[0].ExternalID != "wi-9" {
|
||||
t.Fatalf("entry = %+v", entries[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullKeepsStatusWhenRemoteStateEmpty(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if strings.Contains(r.URL.Path, "/timeTracking/") {
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
return
|
||||
}
|
||||
// No State custom field → empty remote state must not wipe local WIP.
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"idReadable":"P-1","summary":"One","description":"d","updated":2,"customFields":[]}
|
||||
]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
store := ticket.NewJSONStore(t.TempDir())
|
||||
now := time.Now().UTC()
|
||||
_, err := store.Create(ticket.Ticket{
|
||||
Title: "One", ProjectID: "inbox", Status: ticket.StatusInProgress,
|
||||
Source: ticket.SourceYouTrack, ExternalID: "P-1",
|
||||
UpdatedAt: now, SyncedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
client := &youtrack.Client{BaseURL: srv.URL, Token: "t", HTTPClient: srv.Client()}
|
||||
syncer := &ytsync.YouTrackSyncer{Store: store, Client: client}
|
||||
yt := project.YouTrack{BaseURL: srv.URL, Token: "t", ProjectKey: "P"}
|
||||
if _, err := syncer.Pull(context.Background(), "inbox", yt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, _ := store.List("inbox")
|
||||
if list[0].Status != ticket.StatusInProgress {
|
||||
t.Fatalf("status = %s, want in_progress", list[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullSkipsDirtyLocal(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"idReadable":"P-1","summary":"Remote","description":"","updated":2,
|
||||
"customFields":[{"name":"State","value":{"name":"Open"}}]}
|
||||
]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
store := ticket.NewJSONStore(t.TempDir())
|
||||
now := time.Now().UTC()
|
||||
_, err := store.Create(ticket.Ticket{
|
||||
Title: "Local edit", ProjectID: "inbox", Status: ticket.StatusTodo,
|
||||
Source: ticket.SourceYouTrack, ExternalID: "P-1",
|
||||
UpdatedAt: now.Add(time.Hour), SyncedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
client := &youtrack.Client{BaseURL: srv.URL, Token: "t", HTTPClient: srv.Client()}
|
||||
syncer := &ytsync.YouTrackSyncer{Store: store, Client: client}
|
||||
yt := project.YouTrack{BaseURL: srv.URL, Token: "t", ProjectKey: "P"}
|
||||
|
||||
res, err := syncer.Pull(context.Background(), "inbox", yt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Skipped != 1 || res.Updated != 0 {
|
||||
t.Fatalf("res = %+v", res)
|
||||
}
|
||||
list, _ := store.List("inbox")
|
||||
if list[0].Title != "Local edit" {
|
||||
t.Fatalf("should keep local title, got %q", list[0].Title)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user