// 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 }