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:
naudachu
2026-08-11 19:05:39 +05:00
parent fb5445915f
commit 9480e48312
83 changed files with 23894 additions and 0 deletions
+185
View File
@@ -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
}
+117
View File
@@ -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])
}
}
}
+59
View File
@@ -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
}
+155
View File
@@ -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
}
+106
View File
@@ -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
}
+128
View File
@@ -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
}
+229
View File
@@ -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
}
+137
View File
@@ -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)
}
}
+55
View File
@@ -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)
}
}
}
+80
View File
@@ -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, ", ") + "]" }
+209
View File
@@ -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
}
+116
View File
@@ -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"))
}
+132
View File
@@ -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, "")
}
+131
View File
@@ -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
}