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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user