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>
108 lines
2.7 KiB
Go
108 lines
2.7 KiB
Go
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
|
|
}
|