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

Wire manual Y menu and CLI sync for issues and work items, persist
per-project credentials, and keep local edits until an explicit push.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-24 00:17:51 +02:00
co-authored by Cursor
parent 19ae1f1b62
commit 98d2253f44
23 changed files with 2874 additions and 44 deletions
+107
View File
@@ -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
}
+70
View File
@@ -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())
}
}
+2
View File
@@ -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.