Files
marketplace/cli/internal/issue/validate.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

132 lines
3.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}