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: " 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//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//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 }