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