A per-DataDir background process owns ticket JSON, history, and sync so multiple TUI instances stay consistent; MCP token APIs are ready for phase 2. Co-authored-by: Cursor <cursoragent@cursor.com>
180 lines
4.7 KiB
Go
180 lines
4.7 KiB
Go
package project
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
const metaFileName = "meta.json"
|
|
|
|
const (
|
|
BackendNone = "none"
|
|
BackendYouTrack = "youtrack"
|
|
BackendTaskMaster = "taskmaster"
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
|
|
// TaskMaster holds sync settings for a remote tm_server project.
|
|
type TaskMaster struct {
|
|
BaseURL string `json:"baseUrl"`
|
|
Token string `json:"token"`
|
|
ProjectID string `json:"projectId"`
|
|
}
|
|
|
|
// Configured reports whether task-master sync can run.
|
|
func (t TaskMaster) Configured() bool {
|
|
return strings.TrimSpace(t.BaseURL) != "" &&
|
|
strings.TrimSpace(t.Token) != "" &&
|
|
strings.TrimSpace(t.ProjectID) != ""
|
|
}
|
|
|
|
// McpAuth holds the hashed local MCP token for a project (plaintext never stored).
|
|
type McpAuth struct {
|
|
TokenHash string `json:"tokenHash"`
|
|
CreatedAt string `json:"createdAt,omitempty"`
|
|
}
|
|
|
|
// Meta is per-project metadata stored beside tickets.json.
|
|
type Meta struct {
|
|
Backend string `json:"backend,omitempty"` // none|youtrack|taskmaster
|
|
YouTrack *YouTrack `json:"youtrack,omitempty"`
|
|
TaskMaster *TaskMaster `json:"taskmaster,omitempty"`
|
|
McpAuth *McpAuth `json:"mcpAuth,omitempty"`
|
|
}
|
|
|
|
// EffectiveBackend returns the sync backend kind, with legacy youtrack inference.
|
|
func (m Meta) EffectiveBackend() string {
|
|
b := strings.TrimSpace(strings.ToLower(m.Backend))
|
|
switch b {
|
|
case BackendYouTrack, BackendTaskMaster, BackendNone:
|
|
return b
|
|
}
|
|
if m.YouTrack != nil && m.YouTrack.Configured() {
|
|
return BackendYouTrack
|
|
}
|
|
if m.TaskMaster != nil && m.TaskMaster.Configured() {
|
|
return BackendTaskMaster
|
|
}
|
|
if m.YouTrack != nil {
|
|
return BackendYouTrack
|
|
}
|
|
if m.TaskMaster != nil {
|
|
return BackendTaskMaster
|
|
}
|
|
return BackendNone
|
|
}
|
|
|
|
// SyncConfigured reports whether the active backend is fully set up.
|
|
func (m Meta) SyncConfigured() bool {
|
|
switch m.EffectiveBackend() {
|
|
case BackendYouTrack:
|
|
return m.YouTrack != nil && m.YouTrack.Configured()
|
|
case BackendTaskMaster:
|
|
return m.TaskMaster != nil && m.TaskMaster.Configured()
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
if meta.Backend == "" && meta.YouTrack != nil {
|
|
meta.Backend = BackendYouTrack
|
|
}
|
|
if meta.Backend == "" && meta.TaskMaster != nil {
|
|
meta.Backend = BackendTaskMaster
|
|
}
|
|
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
|
|
}
|
|
if meta.Backend == "" {
|
|
meta.Backend = meta.EffectiveBackend()
|
|
}
|
|
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
|
|
}
|