ec0a1893b7
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>
230 lines
6.2 KiB
Go
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
|
|
}
|