Files
marketplace/cli/internal/issue/issue.go
T
naudachu ec0a1893b7 docs: repoint the doc comments at the marker they describe
The store moved to `.kettle/` when the binary replaced the plugin's scripts, but
six doc comments in the domain and the project layer still showed `.tea/` paths
in their examples. The code was right and the prose was a version behind, which
is the failure mode worth catching early: these are the comments somebody reads
to learn where the store IS.

The `.tea/` that remain are the ones that mean it — the legacy migration list
and the tests that exercise it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 00:20:20 +05:00

230 lines
6.2 KiB
Go

// 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:
//
// .kettle/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' .kettle/issues/*.md
// grep -ln 'depends:.*migrate-schema' .kettle/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
}