โ– humdrum codex / custard
license AGPL-3.0
5.2 KB raw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
// 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
}