package mapping import ( "regexp" "strings" "git.noodles.cam/claude-skills/marketplace/cli/internal/issue" ) // The id marker: the slug, kept tracker-side. // // Push deletes the local file once the tracker has confirmed the write, so the // slug — the issue's ONLY identity in the domain — cannot live only on this // machine any more. It rides up in the body as an HTML comment: // // // // Why the body and not a local number -> slug ledger: the ledger is a local // file, and "the local copy is not the record" is the whole point of deleting // one. A marker in the body survives a rename in the web UI, a lost ledger, a // fresh clone, and a second machine — none of which the ledger does. Why an // HTML comment: Gitea renders markdown, so it is invisible to a human reader, // and it comes back verbatim on every API read. // // WHERE: the first line of the tracker-side body, followed by one blank line. // First because it is the one position that does not depend on what sections // the issue happens to have, and because a human who does look at the raw // markdown finds it before the prose rather than buried in it. // // WHAT THE LOCAL FILE SEES: nothing. FromPayload strips every marker before the // body reaches the store, so `.kettle/issues/.md` holds exactly what the // author wrote — checkbox line numbers, `kettle check`, and diffs are all // unaffected, and the slug is already the file's name, so a copy of it in the // body would be duplicated state. // // WHY IT CANNOT ACCUMULATE: the two operations are strip-all and // strip-all-then-prepend-one. WithIDMarker never appends to what is there, and // StripIDMarker removes EVERY marker line, not the first. So a body that // somehow gained two (a hand-edit in the web UI, a copy-paste) is cleaned on // the next pull and goes back up with exactly one. There is no code path that // adds a marker to a body that has not just been stripped. // // WHY TWO SPELLINGS ARE READ AND ONE IS WRITTEN: this tool was called `tea` // and wrote ``. Issues pushed under that name are sitting in // the tracker right now, and their local files are gone — the marker is the // only copy of their slug there is. A rename that stopped reading the old // spelling would orphan every one of them: the pull would fall back to the // title, allocate a fresh slug, and every `depends:` pointing at the old one // would dangle. So the writer moved and the reader did not. var markerRe = regexp.MustCompile( `^[ \t]*[ \t]*$`) // IDMarker is the marker line for a slug. One place formats it, one regex // reads it — and what that regex accepts is deliberately wider than this. func IDMarker(id string) string { return "" } // IDInBody is the slug a tracker-side body claims, or "" when it claims none. // // The FIRST valid marker wins; a second one is ignored here and removed by // StripIDMarker on the way in. The captured text must be a slug by the domain's // own rule — a marker holding anything else is not a slug and is treated as if // it were not there, so a mangled comment falls back to the title instead of // naming a file after garbage. func IDInBody(body string) string { for _, line := range strings.Split(body, "\n") { if m := markerRe.FindStringSubmatch(strings.TrimSuffix(line, "\r")); m != nil { if issue.IsSlug(m[1]) { return m[1] } } } return "" } // StripIDMarker is body with every marker line removed, in either spelling. // Idempotent. // // A body that carries no marker is returned byte for byte — the common case (an // issue filed in the web UI) costs nothing and is not reformatted. When a marker // is removed from the top, the blank line it was written with goes with it, so // the round trip is exact: StripIDMarker(WithIDMarker(b, id)) == b. func StripIDMarker(body string) string { lines := strings.Split(body, "\n") found := false for _, line := range lines { if markerRe.MatchString(strings.TrimSuffix(line, "\r")) { found = true break } } if !found { return body } kept := make([]string, 0, len(lines)) for _, line := range lines { if !markerRe.MatchString(strings.TrimSuffix(line, "\r")) { kept = append(kept, line) } } return strings.TrimLeft(strings.Join(kept, "\n"), "\n") } // WithIDMarker is body with exactly one marker, as its first line. // // Strip-then-prepend, always — that is the guarantee that a body can never end // up with two, however many it arrived with, and it is what quietly rewrites a // `tea:id` marker into the current spelling the next time the issue is pushed. func WithIDMarker(body, id string) string { return IDMarker(id) + "\n\n" + StripIDMarker(body) }