Files
marketplace/cli/internal/issue/checkbox.go
T
naudachu 9480e48312 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>
2026-08-11 19:05:39 +05:00

186 lines
5.5 KiB
Go

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
}