feat: add the kettle CLI, replacing the plugin's Python scripts
The plugin resolved its issue store from `__file__`, which put it inside a versioned plugin cache: issues written from one project were invisible from the next, and `origin: local` files — the only copy of that work by definition — were stranded a version bump at a time. The walk that answers "which directory is the project" was written three times over, and in a linked worktree the three disagreed. Both are runtime failures rather than logic ones, so the fix is a compiled binary: one walk, imported rather than re-derived, and a layering rule the build graph enforces instead of a grep. Seven packages, knowledge flowing one way. `project` answers which directory is the project and depends on nothing. `issue` is the domain — format, taxonomy, validation, checkboxes, dependency graph, the store, eviction — offline, with no tracker in it. `wire` holds the protocol shapes. `gitea` is the transport, `mapping` the bridge, `config` the credentials, `cmd` the command tree. Four tests hold the boundaries, each failing on a real mistake rather than a naming convention. The marker moves to `.kettle/` and the login pin moves out of the harness's settings file into `.kettle/config.yaml`, which pins a login by NAME; the tokens live in one file per machine, mode 0600, outside every working tree. That retires the PreToolUse guard hook entirely — the binary holds its own credentials, so a command running under a login nobody chose is not expressible rather than caught. `kettle init` migrates an older `tmp/issues` or `.tea/issues` store in, as a move: a store left behind at an old path is one somebody edits by accident months later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
package issue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A checkbox is the one part of a body that is *state* and not prose, so the
|
||||
// format gives it markup of its own. It is item markup, not a property of one
|
||||
// section: `## Acceptance criteria` is the usual home, but a type/feature
|
||||
// keeps its children as checkboxes under `## Issues`. The scan is therefore
|
||||
// over the whole text and the heading is only recorded, never required.
|
||||
var (
|
||||
// The trailing group stands in for a lookahead RE2 does not have: after
|
||||
// the bracket there is either whitespace and then anything, or end of line.
|
||||
checkboxRe = regexp.MustCompile(
|
||||
`^(?P<indent>[ \t]*)(?P<marker>[-*+]|\d+[.)])[ \t]+` +
|
||||
`\[(?P<box>[ xX])\](?P<text>[ \t].*|)$`)
|
||||
// Any list item — a sibling ends the item above it, checkbox or not.
|
||||
listItemRe = regexp.MustCompile(`^[ \t]*([-*+]|\d+[.)])([ \t]|$)`)
|
||||
fenceRe = regexp.MustCompile("^[ \t]{0,3}(`{3,}|~{3,})")
|
||||
)
|
||||
|
||||
// Checkbox is one checkbox item found in a text.
|
||||
type Checkbox struct {
|
||||
// Index is the 1-based position in the list — what a user types to pick it.
|
||||
Index int
|
||||
// Line is the 1-based line of the `- [ ]` marker, in the text given.
|
||||
Line int
|
||||
// EndLine is the 1-based last line of the item, continuations included.
|
||||
EndLine int
|
||||
// Checked is true for [x] / [X].
|
||||
Checked bool
|
||||
// Text is the item's text; continuation lines joined with one space.
|
||||
Text string
|
||||
// Section is the nearest preceding `## ` heading, "" above the first one.
|
||||
Section string
|
||||
}
|
||||
|
||||
// Checkboxes returns every checkbox item in text, in document order.
|
||||
//
|
||||
// A pure function of the string it is given — no I/O, no store, no tracker.
|
||||
// Pass an issue body to get body-relative line numbers, or a whole file to get
|
||||
// file-relative ones; nothing else changes.
|
||||
//
|
||||
// Rules:
|
||||
//
|
||||
// - Only a line matching checkboxRe opens an item. A wrapped ("continuation")
|
||||
// line is part of the item above it, never an item of its own; the item runs
|
||||
// to the next blank line, heading, code fence, or list marker.
|
||||
// - Fenced code blocks are skipped whole: `- [ ]` inside a fence is an example
|
||||
// of the markup, not a box anybody may tick.
|
||||
// - `-`, `*`, `+` and `1.` markers all count, at any indentation, so nested
|
||||
// lists are seen too.
|
||||
func Checkboxes(text string) []Checkbox {
|
||||
lines := splitLines(text)
|
||||
var items []Checkbox
|
||||
section, fence := "", ""
|
||||
|
||||
for n, line := range lines {
|
||||
if m := fenceRe.FindStringSubmatch(line); m != nil {
|
||||
tok := m[1]
|
||||
switch {
|
||||
case fence == "":
|
||||
fence = tok
|
||||
case tok[0] == fence[0] && len(tok) >= len(fence):
|
||||
fence = ""
|
||||
}
|
||||
continue
|
||||
}
|
||||
if fence != "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "## ") {
|
||||
section = strings.TrimSpace(line)
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "# ") {
|
||||
section = ""
|
||||
continue
|
||||
}
|
||||
m := checkboxRe.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
end := n + 1
|
||||
parts := []string{strings.TrimSpace(m[4])}
|
||||
for k := n + 1; k < len(lines); k++ {
|
||||
next := lines[k]
|
||||
if strings.TrimSpace(next) == "" || strings.HasPrefix(next, "#") ||
|
||||
fenceRe.MatchString(next) || listItemRe.MatchString(next) {
|
||||
break
|
||||
}
|
||||
end = k + 1
|
||||
parts = append(parts, strings.TrimSpace(next))
|
||||
}
|
||||
var kept []string
|
||||
for _, p := range parts {
|
||||
if p != "" {
|
||||
kept = append(kept, p)
|
||||
}
|
||||
}
|
||||
items = append(items, Checkbox{
|
||||
Index: len(items) + 1,
|
||||
Line: n + 1,
|
||||
EndLine: end,
|
||||
Checked: m[3] != " ",
|
||||
Text: strings.Join(kept, " "),
|
||||
Section: section,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// SetCheckbox returns text with the checkbox on the given 1-based line set to
|
||||
// checked.
|
||||
//
|
||||
// Pure, and deliberately surgical: exactly one byte of the input changes — the
|
||||
// one between the brackets. Everything else, including trailing whitespace and
|
||||
// the item's own wording, comes back byte for byte. That is the whole point:
|
||||
// ticking a box must not produce a diff wider than the state that changed.
|
||||
//
|
||||
// Already in the requested state is a no-op — text comes back unchanged, and
|
||||
// an existing [X] keeps its capital.
|
||||
func SetCheckbox(text string, line int, checked bool) (string, error) {
|
||||
off := 0
|
||||
for n := 1; off <= len(text); n++ {
|
||||
nl := strings.IndexByte(text[off:], '\n')
|
||||
var raw string
|
||||
if nl == -1 {
|
||||
raw = text[off:]
|
||||
} else {
|
||||
raw = text[off : off+nl]
|
||||
}
|
||||
if n == line {
|
||||
m := checkboxRe.FindStringSubmatchIndex(strings.TrimRight(raw, "\r"))
|
||||
if m == nil {
|
||||
return "", fmt.Errorf("line %d is not a checkbox item", line)
|
||||
}
|
||||
box := off + m[6] // group 3: box
|
||||
if (text[box] != ' ') == checked {
|
||||
return text, nil
|
||||
}
|
||||
c := byte(' ')
|
||||
if checked {
|
||||
c = 'x'
|
||||
}
|
||||
return text[:box] + string(c) + text[box+1:], nil
|
||||
}
|
||||
if nl == -1 {
|
||||
break
|
||||
}
|
||||
off += nl + 1
|
||||
}
|
||||
return "", fmt.Errorf("line %d is past the end of the text", line)
|
||||
}
|
||||
|
||||
// CheckboxProgress is (done, total) over every checkbox in text; (0, 0) when
|
||||
// it has none.
|
||||
//
|
||||
// Computed on the fly, on purpose. Progress is not a metadata field: it is the
|
||||
// body read back, and the body is the only place the state lives.
|
||||
func CheckboxProgress(text string) (done, total int) {
|
||||
items := Checkboxes(text)
|
||||
for _, c := range items {
|
||||
if c.Checked {
|
||||
done++
|
||||
}
|
||||
}
|
||||
return done, len(items)
|
||||
}
|
||||
|
||||
// splitLines is strings.Split minus the phantom final element a trailing
|
||||
// newline produces, matching Python's str.splitlines().
|
||||
func splitLines(text string) []string {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
|
||||
if last := len(lines) - 1; lines[last] == "" {
|
||||
lines = lines[:last]
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package issue
|
||||
|
||||
import "testing"
|
||||
|
||||
const boxes = `## Acceptance criteria
|
||||
- [ ] первый пункт
|
||||
- [x] второй пункт, который
|
||||
переносится на вторую строку
|
||||
* [X] третий
|
||||
1. [ ] четвёртый
|
||||
|
||||
## Notes
|
||||
` + "```" + `
|
||||
- [ ] это пример разметки, а не состояние
|
||||
` + "```" + `
|
||||
`
|
||||
|
||||
func TestCheckboxesReadTheWholeBody(t *testing.T) {
|
||||
items := Checkboxes(boxes)
|
||||
if len(items) != 4 {
|
||||
t.Fatalf("found %d items, want 4: %+v", len(items), items)
|
||||
}
|
||||
if items[1].Text != "второй пункт, который переносится на вторую строку" {
|
||||
t.Errorf("continuation not joined: %q", items[1].Text)
|
||||
}
|
||||
if items[1].EndLine != 4 {
|
||||
t.Errorf("end line = %d, want 4", items[1].EndLine)
|
||||
}
|
||||
if !items[2].Checked {
|
||||
t.Error("[X] must read as checked")
|
||||
}
|
||||
if items[3].Section != ACSection {
|
||||
t.Errorf("section = %q", items[3].Section)
|
||||
}
|
||||
for _, c := range items {
|
||||
if c.Section == "## Notes" {
|
||||
t.Error("a checkbox inside a code fence was counted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckboxProgressIsCountedOffTheBody(t *testing.T) {
|
||||
done, total := CheckboxProgress(boxes)
|
||||
if done != 2 || total != 4 {
|
||||
t.Errorf("progress = %d/%d, want 2/4", done, total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCheckboxChangesExactlyOneByte(t *testing.T) {
|
||||
items := Checkboxes(boxes)
|
||||
got, err := SetCheckbox(boxes, items[0].Line, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != len(boxes) {
|
||||
t.Fatalf("length changed: %d -> %d", len(boxes), len(got))
|
||||
}
|
||||
diff := 0
|
||||
for i := range got {
|
||||
if got[i] != boxes[i] {
|
||||
diff++
|
||||
}
|
||||
}
|
||||
if diff != 1 {
|
||||
t.Errorf("%d bytes changed, want 1", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCheckboxIsANoOpWhenAlreadyInState(t *testing.T) {
|
||||
items := Checkboxes(boxes)
|
||||
// [X] keeps its capital: the state already matches, so nothing is rewritten.
|
||||
got, err := SetCheckbox(boxes, items[2].Line, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != boxes {
|
||||
t.Error("an already-checked box was rewritten")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCheckboxRefusesALineThatIsNotOne(t *testing.T) {
|
||||
if _, err := SetCheckbox(boxes, 1, true); err == nil {
|
||||
t.Error("ticking a heading must fail")
|
||||
}
|
||||
if _, err := SetCheckbox(boxes, 9999, true); err == nil {
|
||||
t.Error("ticking past the end must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBodyDepRefsOnlyReadTheDepSections(t *testing.T) {
|
||||
body := `## Summary
|
||||
смотри также some-other-issue, который не зависимость
|
||||
|
||||
## Depends on
|
||||
- migrate-schema — нужна схема
|
||||
- add-pool-cfg
|
||||
|
||||
## Issues
|
||||
- [ ] wire-sqlc-appclick — часть
|
||||
- [ ] #42
|
||||
`
|
||||
got := BodyDepRefs(body)
|
||||
want := []DepRef{
|
||||
{DependsSection, "migrate-schema"},
|
||||
{DependsSection, "add-pool-cfg"},
|
||||
{IssuesSection, "wire-sqlc-appclick"},
|
||||
{IssuesSection, "#42"},
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %+v, want %+v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("ref %d = %+v, want %+v", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package issue
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A reference is a slug, or `#N` on an issue that came from a tracker.
|
||||
var depRefRe = regexp.MustCompile(`#(\d+)|\b([a-z0-9]+(?:-[a-z0-9]+)+)\b`)
|
||||
|
||||
// DepRef is one dependency reference written in the body prose, carried out
|
||||
// with the section it was found in.
|
||||
//
|
||||
// The section travels with the reference so a caller can name the one the
|
||||
// reader actually has in front of them: a container's children come from
|
||||
// `## Issues`, and pointing at `## Depends on` would name a section that is not
|
||||
// in the file.
|
||||
type DepRef struct {
|
||||
Section string
|
||||
Ref string
|
||||
}
|
||||
|
||||
// BodyDepRefs returns every reference under one of DepSections, deduplicated
|
||||
// on first sight, in order of first appearance.
|
||||
//
|
||||
// Never from prose elsewhere, or a graph walk would drag in half the backlog.
|
||||
func BodyDepRefs(body string) []DepRef {
|
||||
var out []DepRef
|
||||
seen := map[string]bool{}
|
||||
section := ""
|
||||
|
||||
for _, line := range splitLines(body) {
|
||||
if strings.HasPrefix(line, "## ") {
|
||||
head := strings.TrimSpace(line)
|
||||
section = ""
|
||||
for _, s := range DepSections {
|
||||
if head == s {
|
||||
section = head
|
||||
break
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if section == "" {
|
||||
continue
|
||||
}
|
||||
for _, tok := range depRefRe.FindAllStringSubmatch(line, -1) {
|
||||
ref := tok[2]
|
||||
if tok[1] != "" {
|
||||
ref = "#" + tok[1]
|
||||
}
|
||||
if !seen[ref] {
|
||||
seen[ref] = true
|
||||
out = append(out, DepRef{Section: section, Ref: ref})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package issue
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Closed issues leave the store. The store is a working set, not an archive.
|
||||
//
|
||||
// WHAT IS EVICTED, and it is two conditions, both read off the file:
|
||||
//
|
||||
// state: closed the work is done
|
||||
// origin: <tracker> the work is somewhere else too
|
||||
//
|
||||
// THE SECOND CONDITION IS THE WHOLE SAFETY ARGUMENT. `origin: local` means this
|
||||
// file IS the issue — there is no other copy and deleting it deletes the work.
|
||||
// It is therefore never evicted, in any state, not even when named explicitly:
|
||||
// a closed local issue is reported and kept. The only files that go are ones
|
||||
// whose own metadata says the work can be fetched back, which is the same trade
|
||||
// a push makes when it drops a file the tracker has just confirmed.
|
||||
//
|
||||
// That parallel is exact except for where the confirmation comes from. Push has
|
||||
// to ask the tracker, because it is the tracker that just changed. Eviction asks
|
||||
// the file, because state and origin are domain fields and the answer is already
|
||||
// in the store — which is why this lives in the domain and needs no network, no
|
||||
// login, and no tracker. The sync layer's variant refreshes state from the
|
||||
// tracker first and then calls Evict, so there is exactly one implementation of
|
||||
// "what may be evicted" and it is this one.
|
||||
//
|
||||
// NOT A ONE-OFF MIGRATION. A pull by number fetches an issue in any state — a
|
||||
// number is an address, not a query — so a closed issue pulled after an eviction
|
||||
// lands on disk again. That is the tracker being asked a direct question, not a
|
||||
// regression; evict it again when you are done with it.
|
||||
//
|
||||
// `.remote.json` is deliberately NOT pruned. It is the local number -> slug
|
||||
// ledger, its entries outlive the files they name, and an evicted issue is in
|
||||
// exactly that state. INDEX.md is rebuilt, because it IS a view of the
|
||||
// directory.
|
||||
|
||||
const closed = "closed"
|
||||
|
||||
// LocalReason is printed whether or not the issue was named, because "this
|
||||
// closed thing is still here" needs an answer every time.
|
||||
const LocalReason = "origin: " + Local + " — this file IS the issue"
|
||||
|
||||
// Evicted is one issue that left the store, with every file that went with it.
|
||||
type Evicted struct {
|
||||
ID string
|
||||
Paths []string
|
||||
}
|
||||
|
||||
// Kept is one issue that was considered and stayed, with the reason.
|
||||
type Kept struct {
|
||||
ID string
|
||||
Why string
|
||||
Open bool // true when it is simply not closed yet — the normal case
|
||||
}
|
||||
|
||||
// EvictReport is what a run did, or would have done.
|
||||
type EvictReport struct {
|
||||
Evicted []Evicted
|
||||
Kept []Kept
|
||||
DryRun bool
|
||||
IndexPath string
|
||||
IndexCount int
|
||||
}
|
||||
|
||||
// Classify splits the store into what may be evicted, what is protected, and
|
||||
// what is still open.
|
||||
//
|
||||
// Pure — it reads the loaded issues and decides; nothing here touches disk.
|
||||
// ids restricts the question to those issues; empty considers the whole store.
|
||||
// A protected issue is returned as such even when it was named explicitly:
|
||||
// naming a file does not make deleting it safe.
|
||||
func Classify(issues map[string]*Issue, ids []string) (evict, protected, stillOpen []string) {
|
||||
chosen := ids
|
||||
if len(chosen) == 0 {
|
||||
for id := range issues {
|
||||
chosen = append(chosen, id)
|
||||
}
|
||||
sort.Strings(chosen)
|
||||
}
|
||||
for _, id := range chosen {
|
||||
i, ok := issues[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case i.State != closed:
|
||||
stillOpen = append(stillOpen, id)
|
||||
case i.IsLocal():
|
||||
protected = append(protected, id)
|
||||
default:
|
||||
evict = append(evict, id)
|
||||
}
|
||||
}
|
||||
return evict, protected, stillOpen
|
||||
}
|
||||
|
||||
// Remove deletes everything the store holds under one slug and returns the
|
||||
// paths that went.
|
||||
//
|
||||
// Deliberately dumb: it takes an id, not a decision. Whether an issue may go is
|
||||
// settled by Classify before this is reached, so the dangerous half of the
|
||||
// operation has no branches in it at all.
|
||||
func Remove(root, id string) ([]string, error) {
|
||||
var gone []string
|
||||
for _, p := range SlugFiles(root, id) {
|
||||
if err := os.Remove(p); err != nil {
|
||||
return gone, err
|
||||
}
|
||||
gone = append(gone, p)
|
||||
}
|
||||
return gone, nil
|
||||
}
|
||||
|
||||
// Evict classifies, removes, and rebuilds the index. The one implementation,
|
||||
// called both by the offline command and by the sync layer — which does nothing
|
||||
// to this decision except hand over issues whose state it has just refreshed
|
||||
// from the tracker.
|
||||
func Evict(root string, issues map[string]*Issue, ids []string, dryRun bool) (*EvictReport, error) {
|
||||
evict, protected, stillOpen := Classify(issues, ids)
|
||||
rep := &EvictReport{DryRun: dryRun}
|
||||
|
||||
for _, id := range evict {
|
||||
var paths []string
|
||||
if dryRun {
|
||||
paths = SlugFiles(root, id)
|
||||
} else {
|
||||
var err error
|
||||
if paths, err = Remove(root, id); err != nil {
|
||||
return rep, err
|
||||
}
|
||||
}
|
||||
rep.Evicted = append(rep.Evicted, Evicted{ID: id, Paths: paths})
|
||||
}
|
||||
for _, id := range protected {
|
||||
rep.Kept = append(rep.Kept, Kept{ID: id, Why: LocalReason})
|
||||
}
|
||||
for _, id := range stillOpen {
|
||||
rep.Kept = append(rep.Kept, Kept{ID: id, Why: "state: " + issues[id].State, Open: true})
|
||||
}
|
||||
|
||||
// Only when something actually went: the index is a view of the directory,
|
||||
// and rewriting it after a run that changed nothing is a write nobody asked
|
||||
// for.
|
||||
if !dryRun && len(rep.Evicted) > 0 {
|
||||
path, n, err := BuildIndex(root)
|
||||
if err != nil {
|
||||
return rep, err
|
||||
}
|
||||
rep.IndexPath, rep.IndexCount = path, n
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package issue
|
||||
|
||||
import "sort"
|
||||
|
||||
// Graph is the edge list read off the `depends:` metadata — the authoritative
|
||||
// one. Body prose is never walked.
|
||||
func Graph(issues map[string]*Issue) map[string][]string {
|
||||
out := make(map[string][]string, len(issues))
|
||||
for id, i := range issues {
|
||||
out[id] = append([]string{}, i.Depends...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Dependents lists who depends on id — the upward direction.
|
||||
func Dependents(issues map[string]*Issue, id string) []string {
|
||||
var out []string
|
||||
for other, i := range issues {
|
||||
if contains(i.Depends, id) {
|
||||
out = append(out, other)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// TopoOrder puts dependencies first.
|
||||
//
|
||||
// Cycles are broken deterministically rather than raising: a cycle is a data
|
||||
// problem for the caller to report, not a reason to refuse to order the rest.
|
||||
func TopoOrder(ids []string, edges map[string][]string) []string {
|
||||
const (
|
||||
open = 1
|
||||
done = 2
|
||||
)
|
||||
state := map[string]int{}
|
||||
var order []string
|
||||
|
||||
var visit func(string)
|
||||
visit = func(n string) {
|
||||
switch state[n] {
|
||||
case done, open: // open = a back edge; leave it unresolved
|
||||
return
|
||||
}
|
||||
state[n] = open
|
||||
for _, d := range edges[n] {
|
||||
if _, ok := edges[d]; ok {
|
||||
visit(d)
|
||||
}
|
||||
}
|
||||
state[n] = done
|
||||
order = append(order, n)
|
||||
}
|
||||
|
||||
for _, n := range ids {
|
||||
visit(n)
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
// FindCycles returns one id list per cycle. Empty when the graph is a DAG.
|
||||
func FindCycles(edges map[string][]string) [][]string {
|
||||
const (
|
||||
open = 1
|
||||
done = 2
|
||||
)
|
||||
state := map[string]int{}
|
||||
var stack []string
|
||||
var cycles [][]string
|
||||
|
||||
var visit func(string)
|
||||
visit = func(n string) {
|
||||
state[n] = open
|
||||
stack = append(stack, n)
|
||||
for _, d := range edges[n] {
|
||||
if _, ok := edges[d]; !ok {
|
||||
continue
|
||||
}
|
||||
if state[d] == open {
|
||||
for i, s := range stack {
|
||||
if s == d {
|
||||
cycles = append(cycles, append(append([]string{}, stack[i:]...), d))
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if state[d] == 0 {
|
||||
visit(d)
|
||||
}
|
||||
}
|
||||
stack = stack[:len(stack)-1]
|
||||
state[n] = done
|
||||
}
|
||||
|
||||
// Sorted so the report is the same on every run; Go map order is not.
|
||||
ids := make([]string, 0, len(edges))
|
||||
for n := range edges {
|
||||
ids = append(ids, n)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
for _, n := range ids {
|
||||
if state[n] == 0 {
|
||||
visit(n)
|
||||
}
|
||||
}
|
||||
return cycles
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package issue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var treeFileRe = regexp.MustCompile(`^tree-.+\.md$`)
|
||||
|
||||
const indexPreamble = "Every issue this project knows about. `origin: local` means it " +
|
||||
"exists nowhere else — a complete state, not a pending one. Any other value " +
|
||||
"names the tracker it also lives in; the handle is in the file. `progress` " +
|
||||
"counts the body's checkboxes, ticked over total, and is blank for an issue " +
|
||||
"that has none — read off the body at build time, stored nowhere. Rebuild " +
|
||||
"with `kettle index`; tick a box with `kettle ac`."
|
||||
|
||||
// BuildIndex rewrites INDEX.md from what is on disk and returns its path and
|
||||
// the number of issues in it.
|
||||
//
|
||||
// An index of a store that is not there is not an empty index, it is a bad
|
||||
// path: failing beats writing INDEX.md into a directory nobody asked for. An
|
||||
// existing store with nothing in it is a legitimate thing to index and gets an
|
||||
// "_empty_" table.
|
||||
func BuildIndex(root string) (string, int, error) {
|
||||
if err := RequireStore(root); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
issues, err := LoadAll(root)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(issues))
|
||||
for id := range issues {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
|
||||
out := []string{"# Issue store", "", indexPreamble, ""}
|
||||
if len(ids) > 0 {
|
||||
out = append(out,
|
||||
"| id | state | progress | type | labels | title | milestone | depends | origin |",
|
||||
"|---|---|---|---|---|---|---|---|---|")
|
||||
for _, id := range ids {
|
||||
i := issues[id]
|
||||
var rest []string
|
||||
for _, l := range i.Labels {
|
||||
if !strings.HasPrefix(l, "type/") {
|
||||
rest = append(rest, l)
|
||||
}
|
||||
}
|
||||
out = append(out, fmt.Sprintf("| [%s](%s.md) | %s | %s | %s | %s | %s | %s | %s | %s |",
|
||||
id, id, cell(i.State), progress(i.Body), cell(i.Type()),
|
||||
cellList(rest), cell(i.Title), cell(i.Milestone),
|
||||
cellList(i.Depends), cell(i.Origin)))
|
||||
}
|
||||
} else {
|
||||
out = append(out, "_empty_")
|
||||
}
|
||||
|
||||
if trees := treeFiles(root); len(trees) > 0 {
|
||||
out = append(out, "", "## Dependency trees", "")
|
||||
for _, t := range trees {
|
||||
out = append(out, fmt.Sprintf("- [%s](%s)", t, t))
|
||||
}
|
||||
}
|
||||
|
||||
if cycles := FindCycles(Graph(issues)); len(cycles) > 0 {
|
||||
out = append(out, "", "## Dependency cycles", "")
|
||||
for _, c := range cycles {
|
||||
out = append(out, "- "+strings.Join(c, " -> "))
|
||||
}
|
||||
}
|
||||
out = append(out, "")
|
||||
|
||||
path := filepath.Join(root, "INDEX.md")
|
||||
if err := os.WriteFile(path, []byte(strings.Join(out, "\n")), 0o644); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return path, len(ids), nil
|
||||
}
|
||||
|
||||
// progress is `3/7` for a body with checkboxes, "" for one without.
|
||||
//
|
||||
// Counted from the body every time the index is built and stored nowhere — the
|
||||
// boxes are the state, and a second copy of it in a metadata field would be
|
||||
// wrong by the next edit.
|
||||
func progress(body string) string {
|
||||
done, total := CheckboxProgress(body)
|
||||
if total == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d/%d", done, total)
|
||||
}
|
||||
|
||||
func cell(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return "—"
|
||||
}
|
||||
return strings.ReplaceAll(v, "|", `\|`)
|
||||
}
|
||||
|
||||
func cellList(xs []string) string {
|
||||
if len(xs) == 0 {
|
||||
return "—"
|
||||
}
|
||||
return strings.Join(xs, ", ")
|
||||
}
|
||||
|
||||
func treeFiles(root string) []string {
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if treeFileRe.MatchString(e.Name()) {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// Package issue is what an issue IS. The domain layer.
|
||||
//
|
||||
// It knows the canonical markdown format, the label taxonomy, validation, and
|
||||
// the dependency graph. It knows NOTHING about any tracker: no Gitea, no
|
||||
// logins, no HTTP, no issue numbers. The layering rule is mechanically checked
|
||||
// — see TestDomainImportsNothing, which walks this package's transitive
|
||||
// dependencies and fails on anything outside the standard library and
|
||||
// internal/project.
|
||||
//
|
||||
// Delete the transport entirely and this layer keeps working: issues that live
|
||||
// only on this machine are first-class, not drafts on their way somewhere.
|
||||
//
|
||||
// Identity is a slug derived from the title, and it is the only identity the
|
||||
// domain has. The file name is the id:
|
||||
//
|
||||
// .tea/issues/wire-sqlc-appclick.md
|
||||
//
|
||||
// ---
|
||||
// id: wire-sqlc-appclick
|
||||
// state: open
|
||||
// labels: [type/task, tech/sql]
|
||||
// assignees: [naudachu]
|
||||
// milestone: v0.2
|
||||
// depends: [migrate-schema]
|
||||
// origin: gitea
|
||||
// gitea: owner/repo#42
|
||||
// synced: 2026-08-07T18:40:00Z
|
||||
// ---
|
||||
// # Wire sqlc into the appclick repo layer
|
||||
//
|
||||
// ## Summary
|
||||
// ...
|
||||
//
|
||||
// Keys down to origin are owned here. Everything below is written by the sync
|
||||
// layer; this package carries those keys through load/save verbatim in Extra
|
||||
// and never reads them. That passthrough is what lets one file represent both
|
||||
// a local issue and a synced one without the domain learning a second
|
||||
// vocabulary.
|
||||
//
|
||||
// Every metadata field is one line and lists are inline, so plain grep works
|
||||
// without a parser:
|
||||
//
|
||||
// grep -l 'labels:.*type/bug' .tea/issues/*.md
|
||||
// grep -ln 'depends:.*migrate-schema' .tea/issues/*.md # who depends on it
|
||||
package issue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Origin is "does this issue exist anywhere but here" — a fact about the work,
|
||||
// so it is owned here. Its value is Local or a tracker's name; what that name
|
||||
// means, and the handle that goes with it (gitea: owner/repo#42), stay foreign
|
||||
// keys this layer carries but never reads.
|
||||
const Local = "local"
|
||||
|
||||
// DomainKeys are the metadata fields this layer owns, in render order. Foreign
|
||||
// keys render after these, sorted, so the sync layer can add fields without
|
||||
// touching this list.
|
||||
var DomainKeys = []string{"id", "state", "labels", "assignees", "milestone",
|
||||
"depends", "origin"}
|
||||
|
||||
var listKeys = map[string]bool{"labels": true, "assignees": true, "depends": true}
|
||||
|
||||
// States an issue may be in.
|
||||
var States = []string{"open", "closed"}
|
||||
|
||||
var slugOK = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
|
||||
|
||||
var slugPunct = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// Issue is one unit of work. Extra holds metadata this layer does not own.
|
||||
type Issue struct {
|
||||
ID string
|
||||
Title string
|
||||
Body string
|
||||
State string
|
||||
Labels []string
|
||||
Assignees []string
|
||||
Milestone string
|
||||
Depends []string
|
||||
Origin string
|
||||
Extra map[string]string
|
||||
}
|
||||
|
||||
// IsLocal reports whether this issue exists nowhere but here.
|
||||
//
|
||||
// A complete state, not a pending one — and the state in which this file is
|
||||
// the only copy of the work. An issue whose Origin names somewhere else can be
|
||||
// fetched from there again; this one cannot.
|
||||
func (i *Issue) IsLocal() bool { return i.Origin == Local }
|
||||
|
||||
// Type is the value of the mandatory, exclusive type/* label.
|
||||
func (i *Issue) Type() string { return i.namespaced("type/") }
|
||||
|
||||
// Severity is the value of the optional, exclusive severity/* label.
|
||||
func (i *Issue) Severity() string { return i.namespaced("severity/") }
|
||||
|
||||
func (i *Issue) namespaced(prefix string) string {
|
||||
for _, l := range i.Labels {
|
||||
if v, ok := strings.CutPrefix(l, prefix); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// FromText parses a stored issue. A non-empty id overrides the one in the
|
||||
// metadata block, which is how the store makes the file name authoritative.
|
||||
func FromText(text, id string) *Issue {
|
||||
meta, title, body := ParseMeta(text)
|
||||
|
||||
extra := map[string]string{}
|
||||
for k, v := range meta {
|
||||
if !isDomainKey(k) {
|
||||
extra[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
if id == "" {
|
||||
id = meta["id"]
|
||||
}
|
||||
milestone := meta["milestone"]
|
||||
if milestone == "none" {
|
||||
milestone = ""
|
||||
}
|
||||
state := meta["state"]
|
||||
if state == "" {
|
||||
state = "open"
|
||||
}
|
||||
origin := meta["origin"]
|
||||
if origin == "" {
|
||||
origin = Local
|
||||
}
|
||||
|
||||
return &Issue{
|
||||
ID: id,
|
||||
Title: title,
|
||||
Body: strings.TrimSpace(body),
|
||||
State: state,
|
||||
Labels: splitList(meta["labels"]),
|
||||
Assignees: splitList(meta["assignees"]),
|
||||
Milestone: milestone,
|
||||
Depends: splitList(meta["depends"]),
|
||||
Origin: origin,
|
||||
Extra: extra,
|
||||
}
|
||||
}
|
||||
|
||||
// Text renders the issue back to its canonical file form.
|
||||
func (i *Issue) Text() string {
|
||||
meta := map[string]string{}
|
||||
for k, v := range i.Extra {
|
||||
meta[k] = v
|
||||
}
|
||||
milestone := i.Milestone
|
||||
if milestone == "" {
|
||||
milestone = "none"
|
||||
}
|
||||
meta["id"] = i.ID
|
||||
meta["state"] = i.State
|
||||
meta["labels"] = renderList(i.Labels)
|
||||
meta["assignees"] = renderList(i.Assignees)
|
||||
meta["milestone"] = milestone
|
||||
meta["depends"] = renderList(i.Depends)
|
||||
meta["origin"] = i.Origin
|
||||
|
||||
body := strings.TrimSpace(i.Body)
|
||||
if body == "" {
|
||||
body = "(no body)"
|
||||
}
|
||||
return fmt.Sprintf("%s\n# %s\n\n%s\n", RenderMeta(meta), i.Title, body)
|
||||
}
|
||||
|
||||
// Slugify turns a title into an id. Titles are English by format rule, so
|
||||
// ASCII is enough; anything else is dropped rather than transliterated.
|
||||
func Slugify(text string, maxLen int) string {
|
||||
if maxLen <= 0 {
|
||||
maxLen = 48
|
||||
}
|
||||
s := strings.Trim(slugPunct.ReplaceAllString(strings.ToLower(text), "-"), "-")
|
||||
if len(s) > maxLen {
|
||||
cut := s[:maxLen]
|
||||
if i := strings.LastIndex(cut, "-"); i > 0 {
|
||||
cut = cut[:i]
|
||||
}
|
||||
s = cut
|
||||
}
|
||||
s = strings.Trim(s, "-")
|
||||
if s == "" {
|
||||
return "issue"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// IsSlug reports whether id is a well-formed identity.
|
||||
func IsSlug(id string) bool { return slugOK.MatchString(id) }
|
||||
|
||||
// UniqueID is base, or base-2, base-3… when the slug is already used.
|
||||
func UniqueID(root, base string, taken []string) (string, error) {
|
||||
used := map[string]bool{}
|
||||
for _, t := range taken {
|
||||
used[t] = true
|
||||
}
|
||||
for _, t := range AllIDs(root) {
|
||||
used[t] = true
|
||||
}
|
||||
if !used[base] {
|
||||
return base, nil
|
||||
}
|
||||
for n := 2; n < 1000; n++ {
|
||||
cand := fmt.Sprintf("%s-%d", base, n)
|
||||
if !used[cand] {
|
||||
return cand, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("cannot allocate an id for %q", base)
|
||||
}
|
||||
|
||||
func isDomainKey(k string) bool {
|
||||
for _, d := range DomainKeys {
|
||||
if d == k {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package issue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const sample = `---
|
||||
id: wire-sqlc-appclick
|
||||
state: open
|
||||
labels: [type/task, tech/sql]
|
||||
assignees: [naudachu]
|
||||
milestone: v0.2
|
||||
depends: [migrate-schema]
|
||||
origin: gitea
|
||||
gitea: claude-skills/tea#42
|
||||
synced: 2026-08-09T18:40:00Z
|
||||
---
|
||||
# Wire sqlc into the appclick repo layer
|
||||
|
||||
## Summary
|
||||
Проводка sqlc.
|
||||
|
||||
## Spec
|
||||
none
|
||||
|
||||
## Motivation
|
||||
Ручной SQL расходится со схемой.
|
||||
|
||||
## Acceptance criteria
|
||||
- [x] сгенерирован код
|
||||
- [ ] тесты зелёные
|
||||
`
|
||||
|
||||
func TestFromTextReadsTheDomainAndCarriesTheRest(t *testing.T) {
|
||||
i := FromText(sample, "")
|
||||
|
||||
if i.ID != "wire-sqlc-appclick" {
|
||||
t.Errorf("id = %q", i.ID)
|
||||
}
|
||||
if i.Title != "Wire sqlc into the appclick repo layer" {
|
||||
t.Errorf("title = %q", i.Title)
|
||||
}
|
||||
if want := []string{"type/task", "tech/sql"}; !reflect.DeepEqual(i.Labels, want) {
|
||||
t.Errorf("labels = %v, want %v", i.Labels, want)
|
||||
}
|
||||
if i.Type() != "task" {
|
||||
t.Errorf("type = %q", i.Type())
|
||||
}
|
||||
if i.IsLocal() {
|
||||
t.Error("origin gitea must not read as local")
|
||||
}
|
||||
if i.Extra["gitea"] != "claude-skills/tea#42" {
|
||||
t.Errorf("foreign key lost: %v", i.Extra)
|
||||
}
|
||||
// The domain carries foreign keys; it must not learn to read them.
|
||||
if _, ok := i.Extra["labels"]; ok {
|
||||
t.Error("a domain key leaked into Extra")
|
||||
}
|
||||
if strings.Contains(i.Body, "# "+i.Title) {
|
||||
t.Error("the title heading was left in the body")
|
||||
}
|
||||
if !strings.HasPrefix(i.Body, SummarySection) {
|
||||
t.Errorf("body does not start at ## Summary: %q", head(i.Body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextRoundTripsByteForByte(t *testing.T) {
|
||||
if got := FromText(sample, "").Text(); got != sample {
|
||||
t.Errorf("round trip changed the file:\n--- got ---\n%s\n--- want ---\n%s", got, sample)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMilestoneNoneIsTheEmptyMilestone(t *testing.T) {
|
||||
i := FromText("---\nid: x\nmilestone: none\n---\n# T\n\nbody\n", "")
|
||||
if i.Milestone != "" {
|
||||
t.Errorf("milestone = %q, want empty", i.Milestone)
|
||||
}
|
||||
if !strings.Contains(i.Text(), "milestone: none") {
|
||||
t.Error("an empty milestone must render back as none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBareListValueIsTheSameStatementAsABracketedOne(t *testing.T) {
|
||||
i := FromText("---\nid: x\nlabels: type/bug\n---\n# T\n\nbody\n", "")
|
||||
if want := []string{"type/bug"}; !reflect.DeepEqual(i.Labels, want) {
|
||||
t.Errorf("labels = %v, want %v", i.Labels, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileNameWinsOverTheMetadataID(t *testing.T) {
|
||||
// The store names the file after the slug, so a hand-edited `id:` that
|
||||
// disagrees with it is the one that is wrong.
|
||||
if got := FromText(sample, "renamed-by-hand").ID; got != "renamed-by-hand" {
|
||||
t.Errorf("id = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlugify(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
// Truncation cuts back to the last dash, so a slug never ends in half
|
||||
// a word — even when the limit happened to land on a boundary.
|
||||
"Wire sqlc into the appclick repo layer": "wire-sqlc-into-the-appclick",
|
||||
"Fix tea-guard crash": "fix-tea-guard-crash",
|
||||
" Trailing --- dashes ": "trailing-dashes",
|
||||
// Titles are English by format rule; anything else is dropped rather
|
||||
// than transliterated, and an empty result is not an id.
|
||||
"Крашится гвард": "issue",
|
||||
"": "issue",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := Slugify(in, 32); got != want {
|
||||
t.Errorf("Slugify(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func head(s string) string {
|
||||
if len(s) > 40 {
|
||||
return s[:40]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestSectionBodyStopsAtTheNextHeading(t *testing.T) {
|
||||
body := "## Summary\nодин\nдва\n\n## Spec\nnone\n"
|
||||
if got := SectionBody(body, "## Summary"); got != "один\nдва" {
|
||||
t.Errorf("Summary = %q", got)
|
||||
}
|
||||
if got := SectionBody(body, SpecSection); got != "none" {
|
||||
t.Errorf("Spec = %q", got)
|
||||
}
|
||||
if got := SectionBody(body, "## Missing"); got != "" {
|
||||
t.Errorf("missing section = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package issue
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The domain must depend on nothing but the standard library and the one
|
||||
// package that answers "which directory is the project".
|
||||
//
|
||||
// In Python this rule was a grep in a document and a habit; here it is a build
|
||||
// graph, and the test fails the moment a tracker concept — an HTTP client, a
|
||||
// JSON payload, a login — is imported into the layer that must not know a
|
||||
// tracker exists.
|
||||
func TestDomainDependsOnNothing(t *testing.T) {
|
||||
const allowed = "git.noodles.cam/claude-skills/marketplace/cli/internal/project"
|
||||
|
||||
out, err := exec.Command("go", "list", "-deps", ".").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("go list: %v", err)
|
||||
}
|
||||
for _, dep := range strings.Fields(string(out)) {
|
||||
if dep == allowed || dep == "git.noodles.cam/claude-skills/marketplace/cli/internal/issue" {
|
||||
continue
|
||||
}
|
||||
// A standard-library import path has no dot in its first element,
|
||||
// because it has no domain name in front of it.
|
||||
first, _, _ := strings.Cut(dep, "/")
|
||||
if strings.Contains(first, ".") {
|
||||
t.Errorf("the domain imports %s — a tracker concept in the layer that must not know one exists", dep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of the same rule: net/http and its friends are standard
|
||||
// library, so "no third-party imports" would not catch a transport written by
|
||||
// hand. Name them.
|
||||
func TestDomainDoesNotReachTheNetworkOrTheShell(t *testing.T) {
|
||||
forbidden := []string{"net/http", "net", "os/exec", "encoding/json"}
|
||||
|
||||
out, err := exec.Command("go", "list", "-deps", ".").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("go list: %v", err)
|
||||
}
|
||||
deps := map[string]bool{}
|
||||
for _, d := range strings.Fields(string(out)) {
|
||||
deps[d] = true
|
||||
}
|
||||
for _, f := range forbidden {
|
||||
if deps[f] {
|
||||
t.Errorf("the domain reaches %s — that belongs in the transport", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package issue
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var titleRe = regexp.MustCompile(`^#[ \t]+(.+?)[ \t]*\n`)
|
||||
|
||||
// ParseMeta splits a file into its metadata block, title, and body.
|
||||
//
|
||||
// Values come back as the raw text that followed the colon. Lists are not
|
||||
// unpacked here: a foreign key that happens to look like a list must round
|
||||
// trip byte for byte, and the domain's own lists are unpacked by their
|
||||
// accessors. The title is the first `# ` heading below the block and is
|
||||
// stripped out of the body.
|
||||
func ParseMeta(text string) (meta map[string]string, title, body string) {
|
||||
meta = map[string]string{}
|
||||
rest := text
|
||||
if strings.HasPrefix(text, "---") {
|
||||
if end := strings.Index(text[3:], "\n---"); end != -1 {
|
||||
end += 3
|
||||
for _, line := range strings.Split(strings.TrimSpace(text[3:end]), "\n") {
|
||||
k, v, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
meta[strings.TrimSpace(k)] = strings.TrimSpace(v)
|
||||
}
|
||||
rest = text[end+4:]
|
||||
}
|
||||
}
|
||||
rest = strings.TrimLeft(rest, "\n")
|
||||
|
||||
if m := titleRe.FindStringSubmatchIndex(rest); m != nil {
|
||||
title = strings.TrimSpace(rest[m[2]:m[3]])
|
||||
rest = strings.TrimLeft(rest[m[1]:], "\n")
|
||||
}
|
||||
return meta, title, rest
|
||||
}
|
||||
|
||||
// RenderMeta writes the block back: domain keys in DomainKeys order, foreign
|
||||
// keys after them, sorted. Lists stay on one line so grep sees them whole.
|
||||
func RenderMeta(meta map[string]string) string {
|
||||
var foreign []string
|
||||
for k := range meta {
|
||||
if !isDomainKey(k) {
|
||||
foreign = append(foreign, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(foreign)
|
||||
|
||||
lines := []string{"---"}
|
||||
for _, k := range append(append([]string{}, DomainKeys...), foreign...) {
|
||||
if v, ok := meta[k]; ok {
|
||||
lines = append(lines, k+": "+v)
|
||||
}
|
||||
}
|
||||
return strings.Join(append(lines, "---"), "\n")
|
||||
}
|
||||
|
||||
// splitList unpacks the inline `[a, b]` form, and a bare comma-separated value
|
||||
// too: a hand-written `labels: type/bug` is the same statement as
|
||||
// `labels: [type/bug]` and the format does not make an operator care.
|
||||
func splitList(v string) []string {
|
||||
v = strings.TrimSpace(v)
|
||||
if strings.HasPrefix(v, "[") && strings.HasSuffix(v, "]") {
|
||||
v = v[1 : len(v)-1]
|
||||
}
|
||||
var out []string
|
||||
for _, part := range strings.Split(v, ",") {
|
||||
if part = strings.TrimSpace(part); part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func renderList(xs []string) string { return "[" + strings.Join(xs, ", ") + "]" }
|
||||
@@ -0,0 +1,209 @@
|
||||
package issue
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
|
||||
)
|
||||
|
||||
// The store holds two kinds of file, and only one of them is a store.
|
||||
//
|
||||
// An issue whose origin is Local lives here and nowhere else — that file IS the
|
||||
// issue, and losing it loses the work. Anything with a tracker origin is a
|
||||
// cache: the tracker has it, this copy is a working copy, and it is deleted the
|
||||
// moment a push confirms the tracker is up to date.
|
||||
|
||||
// Root resolves the issue store for the current project. An explicit out
|
||||
// overrides it and is used exactly as typed: a relative out stays relative to
|
||||
// the working directory, because that is what the operator asked for.
|
||||
func Root(out string) string {
|
||||
if out != "" {
|
||||
return out
|
||||
}
|
||||
return project.StoreRoot("")
|
||||
}
|
||||
|
||||
// ErrStoreMissing marks the "the store directory is not there" failure.
|
||||
//
|
||||
// Deliberately a different answer from "the store is empty". One is a path that
|
||||
// does not exist, the other is a repository with no issues filed yet, and
|
||||
// conflating the two is exactly what made a missed directory look like an empty
|
||||
// backlog.
|
||||
var ErrStoreMissing = errors.New("store missing")
|
||||
|
||||
// StoreExists reports whether root is a directory that can be read as a store.
|
||||
func StoreExists(root string) bool {
|
||||
if root == "" {
|
||||
return false
|
||||
}
|
||||
fi, err := os.Stat(root)
|
||||
return err == nil && fi.IsDir()
|
||||
}
|
||||
|
||||
// RequireStore asserts the store is there before reading or writing it.
|
||||
//
|
||||
// An empty root means no project was found at all — a different failure from a
|
||||
// project whose store has not been created yet, and the message says so.
|
||||
func RequireStore(root string) error {
|
||||
if root == "" {
|
||||
return fmt.Errorf("%w: %s", ErrStoreMissing, project.NotFoundError(""))
|
||||
}
|
||||
if !StoreExists(root) {
|
||||
return fmt.Errorf("%w: store %s does not exist", ErrStoreMissing, root)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateStore creates the store, reporting whether it made the directory.
|
||||
//
|
||||
// Only the commands that legitimately bootstrap a store call this — `new` and
|
||||
// `pull` — and both announce it. Nothing creates a store as a side effect of a
|
||||
// write: a missing directory is something to report, not something to conjure.
|
||||
// An unresolved root is never conjured either — without a marker there is no
|
||||
// project to create a store IN, and guessing one is how a store once ended up
|
||||
// inside the plugin.
|
||||
func CreateStore(root string) (bool, error) {
|
||||
if root == "" {
|
||||
return false, fmt.Errorf("%w: %s", ErrStoreMissing, project.NotFoundError(""))
|
||||
}
|
||||
if StoreExists(root) {
|
||||
return false, nil
|
||||
}
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// StoreError says why root cannot be read as a store, or nil when it holds
|
||||
// issues.
|
||||
//
|
||||
// The three messages are distinct on purpose — no project at all, a project
|
||||
// with no store, and a store with nothing in it are three different things to
|
||||
// do next.
|
||||
func StoreError(root string) error {
|
||||
switch {
|
||||
case root == "":
|
||||
return project.NotFoundError("")
|
||||
case !StoreExists(root):
|
||||
return fmt.Errorf("store %s does not exist — nothing was created; pass --out to point elsewhere", root)
|
||||
case len(AllIDs(root)) == 0:
|
||||
return fmt.Errorf("store %s exists but is empty", root)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PathOf is where the issue with this id lives.
|
||||
func PathOf(root, id string) string { return filepath.Join(root, id+".md") }
|
||||
|
||||
// AllIDs lists every issue in the store, by slug.
|
||||
//
|
||||
// An issue file is named by its slug and a slug has no dot in it, so
|
||||
// `<id>.comments.md` — the thread the sync layer parks beside an issue — is not
|
||||
// one, and neither is anything else that grew a second extension. Without that
|
||||
// rule `wire-sqlc.comments` reads as an issue called `wire-sqlc.comments`, and
|
||||
// a bare push tries to file the comment thread as a unit of work.
|
||||
func AllIDs(root string) []string {
|
||||
if !StoreExists(root) {
|
||||
return nil
|
||||
}
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(name, ".md") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "INDEX") ||
|
||||
strings.HasPrefix(name, "tree-") {
|
||||
continue
|
||||
}
|
||||
id := name[:len(name)-3]
|
||||
if strings.Contains(id, ".") {
|
||||
continue
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// SlugFiles lists every file the store holds under one slug — the issue and its
|
||||
// sidecars.
|
||||
//
|
||||
// `<id>.md` is the issue. Anything named `<id>.<something>` beside it is a
|
||||
// companion another layer parked there (`<id>.comments.md` is the one that
|
||||
// exists today). AllIDs already refuses to read those as issues because a slug
|
||||
// has no dot in it; this is the same rule read the other way round.
|
||||
//
|
||||
// Which is how the domain can remove an issue completely without learning what
|
||||
// any of those companions are: it does not need to know that a comment thread
|
||||
// exists to know that a file named after this issue belongs to it and goes when
|
||||
// it goes. The issue's own file comes first — it is the headline of any receipt
|
||||
// printed from this list.
|
||||
//
|
||||
// A missing store is an empty list, not an error: nothing is there to remove.
|
||||
func SlugFiles(root, id string) []string {
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
prefix, own := id+".", id+".md"
|
||||
var self, sidecars []string
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !strings.HasPrefix(name, prefix) || e.IsDir() {
|
||||
continue
|
||||
}
|
||||
p := filepath.Join(root, name)
|
||||
if name == own {
|
||||
self = append(self, p)
|
||||
} else {
|
||||
sidecars = append(sidecars, p)
|
||||
}
|
||||
}
|
||||
sort.Strings(sidecars)
|
||||
return append(self, sidecars...)
|
||||
}
|
||||
|
||||
// Load reads one issue. The file name wins over the id in the metadata block.
|
||||
func Load(root, id string) (*Issue, error) {
|
||||
raw, err := os.ReadFile(PathOf(root, id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return FromText(string(raw), id), nil
|
||||
}
|
||||
|
||||
// LoadAll reads the whole store.
|
||||
func LoadAll(root string) (map[string]*Issue, error) {
|
||||
out := map[string]*Issue{}
|
||||
for _, id := range AllIDs(root) {
|
||||
i, err := Load(root, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id] = i
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Save writes an issue to the store, which must already exist.
|
||||
func Save(root string, i *Issue) (string, error) {
|
||||
if err := RequireStore(root); err != nil {
|
||||
return "", err
|
||||
}
|
||||
p := PathOf(root, i.ID)
|
||||
if err := os.WriteFile(p, []byte(i.Text()), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package issue
|
||||
|
||||
import "strings"
|
||||
|
||||
// Four namespaces classify an issue. type/* is mandatory and exclusive,
|
||||
// severity/* is optional and exclusive, tech/* and comp/* are free-form.
|
||||
//
|
||||
// Colors are NOT here — a hex code is how a tracker paints a chip, which makes
|
||||
// it the sync layer's business.
|
||||
|
||||
// Types are the kinds of work, and the order is the order they are offered in.
|
||||
var Types = []struct{ Name, Meaning string }{
|
||||
{"bug", "Something behaves incorrectly in existing code"},
|
||||
{"task", "Implementation of new functionality"},
|
||||
{"refactor", "Internal restructuring; behavior must not change"},
|
||||
{"test", "Writing or fixing tests"},
|
||||
{"feature", "Container: several issues delivering one unit of business value"},
|
||||
{"draft", "Idea captured for later; not ready for work"},
|
||||
}
|
||||
|
||||
// Severities are the business-impact levels, ascending.
|
||||
var Severities = []string{"low", "medium", "high", "showstopper", "critical"}
|
||||
|
||||
// Section headers are fixed English literals in a fixed order; only body prose
|
||||
// is Russian.
|
||||
const (
|
||||
SummarySection = "## Summary"
|
||||
SpecSection = "## Spec"
|
||||
ACSection = "## Acceptance criteria"
|
||||
DependsSection = "## Depends on"
|
||||
IssuesSection = "## Issues"
|
||||
)
|
||||
|
||||
// RequiredSections must be present in every type. type/draft is exempt from
|
||||
// acceptance criteria and only from that.
|
||||
var RequiredSections = []string{SummarySection, SpecSection}
|
||||
|
||||
// DepSections both name what an issue depends on, so both are edge sources and
|
||||
// both point the same way. In a type/feature that reads container -> child:
|
||||
// "the container is closed when its children are closed" IS a dependency.
|
||||
// "a child belongs to a feature" is membership, and membership has no place in
|
||||
// a dependency graph — which is why a child never names its container back.
|
||||
var DepSections = []string{DependsSection, IssuesSection}
|
||||
|
||||
// ExpectedSections are the per-type sections from the templates. Absence is a
|
||||
// warning, not a stop.
|
||||
var ExpectedSections = map[string][]string{
|
||||
"bug": {"## Steps to reproduce", "## Expected", "## Actual", "## Environment"},
|
||||
"task": {"## Motivation"},
|
||||
"refactor": {"## Motivation", "## Invariants"},
|
||||
"test": {"## Motivation", "## Test cases"},
|
||||
"feature": {"## Motivation", IssuesSection},
|
||||
"draft": {"## Notes"},
|
||||
}
|
||||
|
||||
// KnownType reports whether name is one of Types.
|
||||
func KnownType(name string) bool {
|
||||
for _, t := range Types {
|
||||
if t.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// KnownSeverity reports whether name is one of Severities.
|
||||
func KnownSeverity(name string) bool {
|
||||
for _, s := range Severities {
|
||||
if s == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TypeNames lists the type slugs, for error messages and completion.
|
||||
func TypeNames() []string {
|
||||
out := make([]string, len(Types))
|
||||
for i, t := range Types {
|
||||
out[i] = t.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CanonicalLabels is the label set a tracker needs before a push can attach
|
||||
// anything: the two exclusive namespaces in full. tech/* and comp/* are
|
||||
// project-specific and have no preset.
|
||||
func CanonicalLabels() []string {
|
||||
out := make([]string, 0, len(Types)+len(Severities))
|
||||
for _, t := range Types {
|
||||
out = append(out, "type/"+t.Name)
|
||||
}
|
||||
for _, s := range Severities {
|
||||
out = append(out, "severity/"+s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SectionBody is the text under header, up to the next `## ` heading.
|
||||
func SectionBody(body, header string) string {
|
||||
var out []string
|
||||
active := false
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
if strings.HasPrefix(line, "## ") {
|
||||
if active {
|
||||
break
|
||||
}
|
||||
active = strings.TrimSpace(line) == header
|
||||
continue
|
||||
}
|
||||
if active {
|
||||
out = append(out, line)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(out, "\n"))
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package issue
|
||||
|
||||
import "strings"
|
||||
|
||||
// The type templates, verbatim from references/format.md.
|
||||
//
|
||||
// Section headers are fixed English literals in a fixed order; body prose is
|
||||
// Russian. Both halves of that rule are in the strings below, and the format
|
||||
// document is the source of truth for them.
|
||||
|
||||
const specSection = "## Spec\nnone\n"
|
||||
|
||||
var templates = map[string]string{
|
||||
"bug": `## Summary
|
||||
Что сломано и где проявляется, одно-два предложения.
|
||||
|
||||
` + specSection + `
|
||||
## Steps to reproduce
|
||||
1. …
|
||||
2. …
|
||||
|
||||
## Expected
|
||||
Что должно было произойти.
|
||||
|
||||
## Actual
|
||||
Что происходит на самом деле: вывод команды, лог.
|
||||
|
||||
## Environment
|
||||
Только релевантное: версии, ОС, конфигурация.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] баг не воспроизводится по шагам выше
|
||||
- [ ] добавлена проверка на регрессию (если применимо)
|
||||
`,
|
||||
"task": `## Summary
|
||||
Что нужно сделать, одно-два предложения.
|
||||
|
||||
` + specSection + `
|
||||
## Motivation
|
||||
Какую проблему пользователя/системы это решает.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] проверяемое условие
|
||||
- [ ] …
|
||||
`,
|
||||
"refactor": `## Summary
|
||||
Что перестраиваем и в каких файлах (` + "`path/file:line`" + `).
|
||||
|
||||
` + specSection + `
|
||||
## Motivation
|
||||
Чем плохо текущее состояние: дублирование, связность, читаемость.
|
||||
|
||||
## Invariants
|
||||
Что НЕ должно измениться: поведение, публичные API, форматы данных.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] проверяемое условие (тесты зелёные, старый путь удалён, …)
|
||||
`,
|
||||
"test": `## Summary
|
||||
Что покрываем тестами и где (` + "`path/file:line`" + `).
|
||||
|
||||
` + specSection + `
|
||||
## Motivation
|
||||
Зачем: регрессия после бага, пробел в покрытии, флаки-тест.
|
||||
|
||||
## Test cases
|
||||
- сценарий → ожидаемый результат
|
||||
- …
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] перечисленные кейсы покрыты и зелёные
|
||||
- [ ] тесты проходят в CI
|
||||
`,
|
||||
"feature": `## Summary
|
||||
Бизнес-ценность одним-двумя предложениями.
|
||||
|
||||
` + specSection + `
|
||||
## Motivation
|
||||
Какую проблему пользователя/системы это решает.
|
||||
|
||||
## Issues
|
||||
- [ ] slug-дочернего-issue — краткое описание части
|
||||
- [ ] …
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] все дочерние issues закрыты
|
||||
- [ ] проверяемое условие уровня фичи
|
||||
`,
|
||||
"draft": `## Summary
|
||||
Идея одним-двумя предложениями.
|
||||
|
||||
` + specSection + `
|
||||
## Notes
|
||||
Свободные заметки: что известно, открытые вопросы, варианты.
|
||||
`,
|
||||
}
|
||||
|
||||
// Template is the prefilled body for a type, with `## Depends on` inserted
|
||||
// right after `## Spec` when the issue has dependencies.
|
||||
func Template(typ string, depends []string) string {
|
||||
return withDepends(templates[typ], depends)
|
||||
}
|
||||
|
||||
// withDepends places the section where the format says it goes: after
|
||||
// `## Spec`, before everything else. Appended at the end only when the
|
||||
// template has no third section to sit in front of.
|
||||
func withDepends(body string, depends []string) string {
|
||||
if len(depends) == 0 {
|
||||
return body
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("## Depends on\n")
|
||||
for _, d := range depends {
|
||||
b.WriteString("- " + d + "\n")
|
||||
}
|
||||
block := b.String()
|
||||
|
||||
var out []string
|
||||
placed := false
|
||||
for _, line := range strings.SplitAfter(body, "\n") {
|
||||
if !placed && len(out) > 0 && strings.HasPrefix(line, "## ") &&
|
||||
!strings.HasPrefix(line, SummarySection) && !strings.HasPrefix(line, SpecSection) {
|
||||
out = append(out, block+"\n")
|
||||
placed = true
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
if !placed {
|
||||
out = append(out, "\n"+block)
|
||||
}
|
||||
return strings.Join(out, "")
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package issue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
titlePrefixRe = regexp.MustCompile(
|
||||
`(?i)^\s*(\[[^\]]+\]|(fix|feat|feature|bug|task|test|chore|refactor)\s*:)`)
|
||||
cyrillicRe = regexp.MustCompile(`(?i)[а-яё]`)
|
||||
)
|
||||
|
||||
// Validate reports what is wrong with an issue.
|
||||
//
|
||||
// Errors mean the issue is not well-formed in the canonical format; warnings
|
||||
// mean it deviates from its type template. Pass knownIDs to have dependencies
|
||||
// resolved against a store; pass nil to skip that check.
|
||||
func Validate(i *Issue, knownIDs map[string]bool) (errs, warns []string) {
|
||||
switch {
|
||||
case i.ID == "":
|
||||
errs = append(errs, "no `id:` — the slug is the issue's identity")
|
||||
case !IsSlug(i.ID):
|
||||
errs = append(errs, fmt.Sprintf("id %q is not a slug (lowercase, digits, single dashes)", i.ID))
|
||||
}
|
||||
|
||||
if !contains(States, i.State) {
|
||||
errs = append(errs, fmt.Sprintf("state %q must be one of: %s",
|
||||
i.State, strings.Join(States, ", ")))
|
||||
}
|
||||
|
||||
var types []string
|
||||
severities := 0
|
||||
for _, l := range i.Labels {
|
||||
if strings.HasPrefix(l, "type/") {
|
||||
types = append(types, l)
|
||||
}
|
||||
if strings.HasPrefix(l, "severity/") {
|
||||
severities++
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(types) != 1:
|
||||
found := strings.Join(types, ", ")
|
||||
if found == "" {
|
||||
found = "none"
|
||||
}
|
||||
errs = append(errs, fmt.Sprintf("need exactly one type/* label, found %d: %s",
|
||||
len(types), found))
|
||||
case !KnownType(i.Type()):
|
||||
errs = append(errs, fmt.Sprintf("unknown type %q — known: %s",
|
||||
i.Type(), strings.Join(TypeNames(), ", ")))
|
||||
}
|
||||
if severities > 1 {
|
||||
errs = append(errs, "at most one severity/* label")
|
||||
}
|
||||
if s := i.Severity(); s != "" && !KnownSeverity(s) {
|
||||
warns = append(warns, fmt.Sprintf("unknown severity %q", s))
|
||||
}
|
||||
|
||||
if i.Title == "" {
|
||||
errs = append(errs, "no `# Title` heading below the metadata block")
|
||||
} else {
|
||||
if titlePrefixRe.MatchString(i.Title) {
|
||||
head := i.Title
|
||||
if len(head) > 24 {
|
||||
head = head[:24]
|
||||
}
|
||||
errs = append(errs, fmt.Sprintf(
|
||||
"title carries a type prefix (%q) — the type lives in the label", head))
|
||||
}
|
||||
if cyrillicRe.MatchString(i.Title) {
|
||||
errs = append(errs, "title must be English, imperative mood (prose stays Russian)")
|
||||
}
|
||||
}
|
||||
|
||||
for _, h := range RequiredSections {
|
||||
if !strings.Contains(i.Body, h) {
|
||||
errs = append(errs, "missing section "+h)
|
||||
}
|
||||
}
|
||||
if i.Type() != "draft" && !strings.Contains(i.Body, ACSection) {
|
||||
errs = append(errs, "missing section "+ACSection)
|
||||
}
|
||||
if strings.Contains(i.Body, SpecSection) && SectionBody(i.Body, SpecSection) == "" {
|
||||
errs = append(errs, "## Spec is empty — put a repo path, a URL, or the literal `none`")
|
||||
}
|
||||
|
||||
for _, h := range ExpectedSections[i.Type()] {
|
||||
if !strings.Contains(i.Body, h) {
|
||||
warns = append(warns, fmt.Sprintf("type/%s template usually has %s", i.Type(), h))
|
||||
}
|
||||
}
|
||||
|
||||
if contains(i.Depends, i.ID) {
|
||||
errs = append(errs, "depends on itself")
|
||||
}
|
||||
if knownIDs != nil {
|
||||
for _, d := range i.Depends {
|
||||
if !knownIDs[d] {
|
||||
warns = append(warns, fmt.Sprintf("depends on %q, which is not in the store", d))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `depends:` is the machine-readable graph; the body section is prose for
|
||||
// humans. They drift silently unless something says so. Name the section
|
||||
// the reference actually came from — for a container that is `## Issues`.
|
||||
for _, r := range BodyDepRefs(i.Body) {
|
||||
if !strings.HasPrefix(r.Ref, "#") && !contains(i.Depends, r.Ref) {
|
||||
warns = append(warns, fmt.Sprintf(
|
||||
"%s mentions %q but `depends:` does not list it", r.Section, r.Ref))
|
||||
}
|
||||
}
|
||||
|
||||
// An unticked checkbox is never a finding — neither an error nor a warning.
|
||||
// `- [ ]` is work not done yet, which is the normal state of a perfectly
|
||||
// well-formed issue. Reading that state is the `ac` command's job.
|
||||
|
||||
return errs, warns
|
||||
}
|
||||
|
||||
func contains(xs []string, x string) bool {
|
||||
for _, v := range xs {
|
||||
if v == x {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user