// Package backlog reads a repository's in-repo Backlog.md tasks // (backlog/tasks/*.md) into Task values for the GitHub-issues-style views. The // task files are the only data source — there is no separate issue store, and // these views are read-only (create/edit happens through the backlog CLI). package backlog import ( "sort" "strings" "custard/internal/gitread" "gopkg.in/yaml.v3" ) // Dir is the in-repo path Backlog.md stores tasks under. const Dir = "backlog/tasks" // configPath is the in-repo Backlog.md config file. const configPath = "backlog/config.yml" // DefaultStatuses is the fallback status order when a repo has no config.yml. var DefaultStatuses = []string{"To Do", "In Progress", "Done"} // Config is the subset of backlog/config.yml that custard cares about: the // status set (which drives issue column order) and the default status. type Config struct { Statuses []string `yaml:"statuses"` DefaultStatus string `yaml:"default_status"` } // ReadConfig parses the repo's backlog/config.yml at ref. It always returns a // usable Config: missing or unparseable config falls back to DefaultStatuses. func ReadConfig(s *gitread.Store, repo, ref string) Config { cfg := Config{Statuses: DefaultStatuses, DefaultStatus: DefaultStatuses[0]} data, err := s.Blob(repo, ref, configPath) if err != nil { return cfg } var parsed Config if err := yaml.Unmarshal(data, &parsed); err != nil { return cfg } if len(parsed.Statuses) > 0 { cfg.Statuses = parsed.Statuses } if parsed.DefaultStatus != "" { cfg.DefaultStatus = parsed.DefaultStatus } return cfg } // Task is one backlog task: parsed frontmatter plus the Markdown body. type Task struct { ID string `yaml:"id"` Title string `yaml:"title"` Status string `yaml:"status"` Priority string `yaml:"priority"` Labels []string `yaml:"labels"` Assignee []string `yaml:"assignee"` Deps []string `yaml:"dependencies"` Ordinal int `yaml:"ordinal"` Created string `yaml:"created_date"` Updated string `yaml:"updated_date"` Body string `yaml:"-"` // Markdown after the frontmatter Slug string `yaml:"-"` // source filename without extension } // Key is the lowercased task id used in URLs (e.g. "task-001"). func (t Task) Key() string { return strings.ToLower(t.ID) } // Types is the canonical label vocabulary for a task's primary type. var Types = []string{"feature", "bug", "chore", "docs", "refactor"} // Type is the task's primary type: its first label, lowercased ("" if none). func (t Task) Type() string { if len(t.Labels) == 0 { return "" } return strings.ToLower(t.Labels[0]) } // OtherLabels are the labels after the primary type. func (t Task) OtherLabels() []string { if len(t.Labels) <= 1 { return nil } return t.Labels[1:] } // Has reports whether the repo carries any backlog tasks at ref. func Has(s *gitread.Store, repo, ref string) bool { entries, err := s.Tree(repo, ref, Dir) if err != nil { return false } for _, e := range entries { if !e.IsDir && strings.HasSuffix(e.Name, ".md") { return true } } return false } // List reads and parses every task at ref, sorted by ordinal then id. func List(s *gitread.Store, repo, ref string) ([]Task, error) { entries, err := s.Tree(repo, ref, Dir) if err != nil { return nil, err } var tasks []Task for _, e := range entries { if e.IsDir || !strings.HasSuffix(e.Name, ".md") { continue } data, err := s.Blob(repo, ref, e.Path) if err != nil { continue } t, err := ParseTask(strings.TrimSuffix(e.Name, ".md"), data) if err != nil { continue } tasks = append(tasks, t) } sort.Slice(tasks, func(i, j int) bool { if tasks[i].Ordinal != tasks[j].Ordinal { return tasks[i].Ordinal < tasks[j].Ordinal } return tasks[i].ID < tasks[j].ID }) return tasks, nil } // Get returns the task whose Key matches id (case-insensitive), or ok=false. func Get(s *gitread.Store, repo, ref, id string) (Task, bool) { tasks, err := List(s, repo, ref) if err != nil { return Task{}, false } id = strings.ToLower(id) for _, t := range tasks { if t.Key() == id { return t, true } } return Task{}, false } // ParseTask splits YAML frontmatter from the Markdown body and unmarshals it. // The id falls back to the slug when frontmatter omits it. func ParseTask(slug string, raw []byte) (Task, error) { fm, body := splitFrontmatter(raw) var t Task if len(fm) > 0 { if err := yaml.Unmarshal(fm, &t); err != nil { return Task{}, err } } t.Slug = slug t.Body = strings.TrimSpace(string(body)) if t.ID == "" { t.ID = slug } if t.Title == "" { t.Title = slug } if t.Status == "" { t.Status = "To Do" } return t, nil } // splitFrontmatter separates a leading `---`-delimited YAML block from the rest. // If no frontmatter is present, fm is nil and body is the whole input. func splitFrontmatter(raw []byte) (fm, body []byte) { s := string(raw) if !strings.HasPrefix(s, "---") { return nil, raw } nl := strings.IndexByte(s, '\n') if nl < 0 { return nil, raw } rest := s[nl+1:] end := strings.Index(rest, "\n---") if end < 0 { return nil, raw } fm = []byte(rest[:end]) after := rest[end+1:] // begins at the closing "---" if nl2 := strings.IndexByte(after, '\n'); nl2 >= 0 { body = []byte(after[nl2+1:]) } return fm, body }