feat: publish releases with this repository's own SDK code

There is no CI: the instance has no act_runner and none is planned, so releases
are cut by hand. That makes `make check` the only thing standing between a
mistake and the tracker, and it is one command: gofmt, vet, the suite with the
cache defeated, `go mod verify`, a vendored build, and `kettle gen skills
--check`. The last one is the invariant worth having — the plugin's SKILL.md
command reference is generated from the binary's registry, so a flag that
changed cannot ship with documentation that recommends the old one.

`cli/cmd/release` publishes to Gitea using the same SDK the binary already
vendors, which is a pleasing thing to be able to say: nothing third-party
handles the artifacts. It is a second binary rather than a `kettle` subcommand
on purpose — `kettle`'s command tree is what generates the plugin's skills, so a
verb there ships to every operator, and publishing a release is build
infrastructure. It is idempotent end to end: an existing release for the tag is
reused, an asset of the same name is replaced rather than doubled, and a retried
run converges instead of duplicating.

`make release` refuses three things, each with its own message: a dirty working
tree, a TAG that is not what `git describe` reports, and a tag the remote does
not have. A release built from uncommitted code is unreproducible and nobody
finds out until they need to reproduce it.

`kettle version` reports the stamp, the toolchain and the VCS revision. The
default is `dev`, and a hand build says so and means it — a binary out of
somebody's working tree is not a release and must not claim to be one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
naudachu
2026-08-12 01:01:27 +05:00
parent ec0a1893b7
commit 01fb5a2703
27 changed files with 3385 additions and 266 deletions
+87
View File
@@ -0,0 +1,87 @@
# AGENTS.md — internal/, and the boundaries between the packages in it
Seven packages, one direction of knowledge. The diagram is in
[`cli/AGENTS.md`](../AGENTS.md); **this file owns the rules that hold it and the
tests that fail when one is broken.** Each package's own document owns what is
inside it.
The rule in one sentence: **read the diagram bottom-up and each layer knows
strictly less about trackers than the one above it.** A tracker concept — an issue
number, a login, an HTTP call, a label colour — that shows up in
[`issue`](issue/AGENTS.md) is in the wrong place, and a domain concept — a
section, an acceptance criterion, a type taxonomy — that shows up in
[`gitea`](gitea/AGENTS.md) is in the wrong place too.
## Four rules, seven tests
Each test fails on a real mistake rather than on a naming convention.
| rule | enforced by |
|---|---|
| [`issue`](issue/AGENTS.md) may import [`project`](project/AGENTS.md) and the standard library, and **nothing else** | `TestDomainDependsOnNothing` walks `go list -deps` and fails on any import path with a dot in its first element — which is what keeps yaml *and* the SDK out of the domain; `TestDomainDoesNotReachTheNetworkOrTheShell` names `net/http`, `net`, `os/exec` and `encoding/json`, standard library the first test cannot catch |
| [`wire`](wire/AGENTS.md) imports **only** the standard library | `TestWireDependsOnNothing` and `TestWireReachesNeitherTheNetworkNorTheDisk`, the same two checks |
| [`gitea`](gitea/AGENTS.md) must not import [`issue`](issue/AGENTS.md) **or** [`mapping`](mapping/AGENTS.md) | `TestTransportDoesNotImportTheDomain` — the transport knows numbers, logins, HTTP and JSON, and none of what they mean |
| [`mapping`](mapping/AGENTS.md) reaches for nothing but the domain, `wire` and the SDK, and does no I/O | `TestTheBridgeTranslatesAndNothingElse` on its **direct** imports, with `os`, `net/http`, `internal/gitea`, `internal/config` and `internal/project` named; `TestTheBridgeHasNoClock` greps its sources for `time.Now` |
The domain's two tests were **untouched by the migration to the Gitea SDK, and
that is the point: the domain did not notice it happened.**
## The one rule that got weaker, and why the trade was taken
The payload shapes used to live in `wire`, a package that imported the standard
library and nothing else, so "the bridge cannot reach a transport" was a fact
about the import graph: there was nothing in its dependency closure that could
open a socket. `code.gitea.io/sdk/gitea` is a client and a set of types in one
package, so importing the types imports the client, and a test that walked the
closure would now be asserting something false.
What is still true, and what the test now says, is that **mapping performs no
I/O** — no `os`, no `net/http`, no transport, no configuration, no clock. Note
the deliberate asymmetry with the domain's test: this one checks **direct**
imports, because the domain reaches `os` *through* `project` and that is the
domain's business. `time` is allowed here where it was not, because the SDK hands
over a `time.Time` and somebody has to format it back into the string an issue
file holds; the clock itself is still the caller's, and the grep for `time.Now`
is what says so.
## Why `wire` still exists
It existed because Go needs the JSON shapes to be one type — the transport and
the bridge were written in parallel and each invented its own `Issue`, `Label`,
`Milestone` and `Comment`. The SDK settles that argument for the shapes.
**What survives is addressing**, which the SDK has no answer for at all: it takes
an owner, a name and an `int64`, and never parses. `42`, `#42`, `owner/repo#42`
and an issue URL are four spellings of one address, all four are what somebody
has in hand, and `wire.Key` is what the ledger is keyed by and what the `gitea:`
metadata field holds. So `wire` keeps `Repo`, `Key`, their parsing and their
tests, and lost the payloads.
## Adding a package here
Three questions, in order:
1. **What does it know that its neighbours must not?** A package that cannot
answer this is a file in an existing one.
2. **Which direction does it import?** Draw it into the diagram in
[`cli/AGENTS.md`](../AGENTS.md) before writing code; an arrow that has to point
both ways means the split is in the wrong place.
3. **What test fails when the boundary is crossed?** Write it with the package,
not after. Every rule above has one, and each of them exists because the
equivalent convention in the Python version was a grep somebody eventually
forgot to run.
Then give it an `AGENTS.md`, add it to the table in [`cli/AGENTS.md`](../AGENTS.md),
and add its rule to the table above.
## Keeping this file true
- **Scope:** the boundaries *between* the packages under `internal/` — the four
rules, the seven tests that hold them, and the history of the one that changed.
Files: every `layering_test.go`, plus `TestTransportDoesNotImportTheDomain` in
`gitea/client_test.go`.
- **Update it when** a layering test is added, renamed, removed or weakened; when
a package is added or removed; or when an import that was forbidden becomes
allowed — that last one always comes with a reason, and the reason is what this
file is for.
- **Do not** restate what a package does. The table links to the file that says so.
+187
View File
@@ -0,0 +1,187 @@
# AGENTS.md — internal/cmd
**The command tree: flags, receipts, exit codes.** The only package that may import
every layer below it, and the only one that prints.
`cmd/kettle` is four lines around `cmd.Main(os.Args[1:])` — everything a `main`
usually accumulates lives here instead, because a `main` package cannot be imported
and therefore cannot be tested.
## Commands are values
Each command is one `register(&Command{…})` in an `init()`, carrying the metadata a
human needs — `Short`, `Long`, `Examples`, `Args`, `Group` — **in the same struct
that carries the code**. That is what lets the plugin's SKILL.md files be generated
from this list: a command whose flags changed cannot ship with documentation that
says otherwise.
```go
func init() {
register(&Command{
Name: "tree",
Group: GroupIssue,
Args: "[<id>…]",
Short: "draw the dependency graph of the local store",
Long: ``,
Examples: []Example{{"kettle tree", "every root (nothing depends on it)"}},
Setup: func(fs *flag.FlagSet) func([]string) error {
out := storeFlag(fs)
depth := fs.Int("depth", 6, "maximum depth")
return func(args []string) error { }
},
})
}
```
**`Setup` registers flags and returns the runner**, closing over them. Splitting it
that way is what lets `Command.Flags()` walk a command's flags without running
anything — which is how the doc generator reads them.
**The tree is flat.** `kettle new`, not `kettle issue new`: an agent pays for every
token of every invocation, and the grouping that matters for reading is carried in
`Group` and only shows up in the docs. Three groups, in presentation order:
`project`, `issue`, `sync`.
| file | what is in it |
|---|---|
| `command.go` | `Command`, the registry, `Main`, help rendering, `SilentError`, `Fail`, and `permute` |
| `flags.go` | `storeFlag`/`storeRoot`, `wasSet`, the repeatable `stringList` |
| `sync.go` | `syncStart`/`syncStartExisting`, `commentsSidecarPath` — the shared opening of every tracker command |
| `gen.go` | `kettle gen skills`: the generated region in the plugin's SKILL.md files |
| `init.go` `auth.go` `config.go` `version.go` | group `project`. `version.go` also holds `Version`, the string a release build stamps in with `-ldflags -X` |
| `new.go` `check.go` `ac.go` `tree.go` `index.go` `evict.go` | group `issue` — no network in any of them |
| `pull.go` `push.go` `remote.go` `comment.go` `close.go` `labels.go` `evict_sync.go` | group `sync` |
| `cli_test.go` | builds the binary in `TestMain`, runs it as a subprocess |
| `sync_pull_test.go` `sync_write_test.go` | the tracker halves, against fake servers |
| `gen_test.go` | the generator: determinism, the region splice, the missing-marker refusal |
## Three conventions every command follows
**Flags may come after positionals.** The standard `flag` package stops parsing at
the first non-flag argument, so `kettle ac <id> --check 3` would hand `--check` to
the command as a positional and tick nothing. `permute` moves flags forward, using
the `FlagSet` to know whether a flag swallows the next argument; `--` ends the
permutation. Every other CLI an operator uses interleaves the two, and a tool that
silently ignores a flag because of where it was typed is worse than one that rejects
it.
**Exit codes are three.** `0` fine, `2` for a usage problem (unknown command,
unparseable flags), `1` for an ordinary failure — printed as `kettle <name>: <err>`
by `Main`, which is why no command prefixes its own errors. `SilentError{Code: 1}`
is for a command that has already said everything it has to say: `check` and
`gen --check` use it, because findings went to stdout and a second copy on stderr
would be noise.
**The store is resolved before a socket is opened.** `syncStart` does that in one
place: a command that dialled first would report a network problem for a project
that was never initialized, and an operator would go looking at the wrong thing.
`syncStartExisting` adds `RequireStore` for the commands that read the store rather
than create it — `push`, `comment`, `close`, `sync-evict` — because a missing store
is a mistake to report, not a directory to conjure.
**There is no `--login` and no `--repo`** on any sync command bar `labels`. Which
login a project runs under is a fact about the project, stated once by
`kettle init`. That the two could disagree is what the Python version needed a
`PreToolUse` hook to police.
`--out` is the one flag almost every command has, and an explicit one is used
**exactly as typed**: a relative `--out` stays relative to the working directory,
because that is what the operator asked for.
## Usage
```bash
kettle help # the tree, grouped
kettle help push # one command in full: flags, defaults, examples
kettle init --login noodles --repo owner/name
kettle new --type task --title "Wire sqlc into the appclick repo layer"
kettle ac wire-sqlc-appclick --check 3
kettle check --strict # exit 1 on any error; --strict counts warnings too
kettle pull 42 # the issue and everything blocking it, any state
kettle push --update wire-sqlc-appclick
kettle sync-evict --dry-run
```
Every command's own `Long` text is the reference — it is what
`kettle help <name>` prints and what the generator writes into the plugin. **Do not
restate a flag table here**; it would be a third copy of something already in two
places, one of them mechanically checked.
## push and pull, the two halves of one rule
The rule is that **the store holds what has not left this machine.** Both halves are
worth reading in full before either file is touched.
`push` (`push.go`) deletes `<id>.md` and every sidecar under that slug — on create
and on `--update` alike, one rule with no exception, because a `PATCH` is a push and
two rules would put back exactly the question this removes ("is my copy the fresh
one?"). The deletion is the **last** thing that happens, and only after all three of:
1. the call came back without an error and with a 2xx,
2. the answer carries a plausible number — on `--update`, the very number that was
`PATCH`ed,
3. the number → slug ledger has been written.
Network down, non-2xx, an answer that does not confirm the write: the file stays and
the run stops. Get the ordering wrong and a slug is lost at exactly the moment the
local copy stops being the record, which is why the ledger is written *before*
anything is deleted. A never-pushed `origin: local` issue is never touched by any of
it.
Dependencies go first, in topological order, so a blocker has its number before the
issue that names it. An `--update` can take one extra request with it, because
Gitea's edit endpoint carries no labels — when the answer's label set and the
issue's disagree the whole set goes up in a `PUT`, and a warning on stderr says
which names moved.
`pull` (`pull.go`) is how a pushed issue comes back. Three sources answer "what is
this issue called here", in this order: the ledger (the only one that knows what is
on disk *right now*, so it wins), the `<!-- kettle:id … -->` marker in the
tracker-side body, then the slugified title. A marker is taken at its word only when
the slug is free; a name already in use is a collision, not an identity, and is
uniquified.
Two ways to name what to pull, and they are **not the same operation**: a key is an
*address* and fetches an issue in any state, while a filter is a *query* and leaves
closed issues out. `--limit` is on the **write**, not the selection — it counts what
lands in the store, which is why a filtered pull can enumerate far more than it
keeps and says so. Blockers come down too, recursively to `--depth`, and are outside
the limit: a blocker is followed because a stored issue named it. A pull **overwrites
the body** — it is a fetch, not a merge — with checkbox state the one exception.
## The generator
`gen.go` writes the plugin's SKILL.md command reference from this registry.
**It owns a region, not a file.** Everything between `<!-- kettle:gen -->` and
`<!-- /kettle:gen -->` is replaced on every run; every byte outside comes back
exactly as it was, which matters most for `description:`, the prose that decides
whether an agent loads the skill at all and the one thing here no generator can
write. A file with **no** markers is reported and left alone, never overwritten —
clobbering somebody's prose because they forgot a marker is the failure this design
exists to prevent.
The output is deterministic to the byte — no timestamps, no map iteration — so
regenerating something unchanged produces no diff. `--check` is that property made
useful: it writes nothing and exits 1 when anything on disk differs, which is what a
pre-commit hook or a CI step calls, and it wins over `--dry-run`.
One file per **group**, so adding a group here adds a skill directory over there;
name one only when it is a subject somebody would load on its own. A command with no
`Group` is in no skill and the run says so. A `Long` or `Example` that spells a
region marker out in full is a hard error — the generated block would end inside
itself.
## Keeping this file true
- **Scope:** the shape of the command tree — the registry, the shared helpers, the
three conventions, the round trip, the generator. The file table names every
source file in this directory.
- **Update it when** a command file is added or removed, a group is added, a shared
helper in `flags.go`/`sync.go` changes, an exit code gains a meaning, or the
push/pull ordering guarantees change.
- **Do not** copy a flag list or a command's `Long` text here. `kettle help <name>`
and the generated SKILL.md blocks are the two places that exist for it, and a
third would be the one that drifts.
+38
View File
@@ -491,6 +491,44 @@ func TestAMissingLoginIsExplained(t *testing.T) {
}
}
// The version is "dev" until a build stamps it, and the STAMPING is what is
// tested here rather than the printing.
//
// A `-X` whose symbol path is one character wrong is not an error: the linker
// ignores it and the binary goes on reporting "dev" for the rest of its life,
// which is discovered by an operator holding a release that will not say what
// it is. So this builds with the flag the Makefile uses and reads the answer
// back out of the binary.
func TestVersionSaysDevUntilABuildStampsIt(t *testing.T) {
dir := t.TempDir()
r := mustRun(t, dir, "version")
if !strings.Contains(r.stdout, "dev") || !strings.Contains(r.stdout, "built") {
t.Errorf("a build from source must say what it is:\n%s", r.out())
}
// A version needs no project: it is a fact about the binary, and the
// question is asked most often by somebody whose project is not resolving.
if short := mustRun(t, dir, "version", "--short"); strings.TrimSpace(short.stdout) != "dev" {
t.Errorf("--short printed %q, want dev", short.stdout)
}
const stamp = "v9.9.9-from-the-test"
stamped := filepath.Join(t.TempDir(), "kettle")
build := exec.Command("go", "build",
"-ldflags", "-X git.noodles.cam/claude-skills/marketplace/cli/internal/cmd.Version="+stamp,
"-o", stamped, "../../cmd/kettle")
if out, err := build.CombinedOutput(); err != nil {
t.Fatalf("building a stamped binary: %v\n%s", err, out)
}
out, err := exec.Command(stamped, "version", "--short").Output()
if err != nil {
t.Fatalf("running the stamped binary: %v", err)
}
if got := strings.TrimSpace(string(out)); got != stamp {
t.Errorf("the stamped binary reports %q, want %q — the -X symbol path is wrong", got, stamp)
}
}
func closeIssue(t *testing.T, path string) {
t.Helper()
setField(t, path, "state", "closed")
+93
View File
@@ -0,0 +1,93 @@
package cmd
import (
"flag"
"fmt"
"runtime"
"runtime/debug"
)
// Version is what this binary was built as. It is stamped at link time and
// defaults to something honest.
//
// "dev" is the truth for a build from a working tree: a binary somebody built
// out of a checkout is not a release and must not claim to be one. A release
// build says otherwise by naming this variable:
//
// go build -ldflags "-X git.noodles.cam/claude-skills/marketplace/cli/internal/cmd.Version=v1.2.3" ./cmd/kettle
//
// which is what `make build`, `make dist` and `make release` do. The path is
// exercised by a test that builds with the flag and reads the answer back,
// because a -X whose symbol path is one character wrong is silently ignored and
// the binary goes on reporting "dev".
var Version = "dev"
func init() {
register(&Command{
Name: "version",
Group: GroupProject,
Short: "print the version this binary was built as",
Long: `A binary that cannot say what it is, is a support problem: an operator with an
old copy on PATH and a skill written against a newer one has no way to see the
mismatch, and neither does anybody reading their transcript.
The version is stamped at link time. A build from source says "dev" and means
it — that is not a placeholder to be edited, it is the answer for a binary that
came out of somebody's working tree rather than off a tag.
The commit is reported when the build recorded one, which ` + "`go build`" + ` does from
git and a build from an unpacked tarball cannot. A tree with uncommitted
changes in it says so beside the commit.`,
Examples: []Example{
{"kettle version", "the version, the toolchain and the commit"},
{"kettle version --short", "just the version, for a script"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
short := fs.Bool("short", false, "print the version alone, with nothing around it")
return func(args []string) error {
if len(args) > 0 {
return Fail("version takes no arguments")
}
if *short {
fmt.Println(Version)
return nil
}
fmt.Printf("kettle %s\n", Version)
fmt.Printf("built %s %s/%s\n", runtime.Version(), runtime.GOOS, runtime.GOARCH)
if rev := revision(); rev != "" {
fmt.Printf("commit %s\n", rev)
}
return nil
}
},
})
}
// revision is the commit this binary was built from, or "" when the build
// recorded none.
//
// `go build` stamps it out of git; a build from an unpacked tarball has no
// repository to ask, and there is nothing to report rather than something to
// invent. A dirty tree is named as one: the commit is then a lower bound on
// what is in the binary and not a description of it.
func revision() string {
info, ok := debug.ReadBuildInfo()
if !ok {
return ""
}
var rev string
var dirty bool
for _, s := range info.Settings {
switch s.Key {
case "vcs.revision":
rev = s.Value
case "vcs.modified":
dirty = s.Value == "true"
}
}
if rev != "" && dirty {
rev += " (with uncommitted changes)"
}
return rev
}
+101
View File
@@ -0,0 +1,101 @@
# AGENTS.md — internal/config
**Two files: what this project is, and who this machine is.** The only package in
the tree that imports yaml.
| file | what is in it |
|---|---|
| `config.go` | `Project` and `Logins` (the two files), `Resolve`/`ResolveOutsideAProject`/`Require`, `Resolved` with `Complete` and `Redacted`, the `KETTLE_*` overrides, and the 0600 write |
## The split is the whole design
```
<project>/.kettle/config.yaml login: noodles a NAME, never a token
repo: owner/name
~/.config/kettle/logins.yaml logins: [{name, url, user, token}] mode 0600
```
**A token in a file inside a working tree ends up in a commit.** Not always, not
immediately, and not by anyone careless — but a project config is exactly the file
somebody eventually decides to share, and a secret that has ever been pushed has to
be rotated. So the project pins a login by **name**, and the name is worth nothing
on its own, which is what makes it safe to keep in a repository.
Which tokens this computer holds is a fact about the computer, the way which issues
a tree holds is a fact about the tree. `SaveLogins` writes 0600 into a 0700
directory; nothing else on the machine has any business reading it. `$KETTLE_CONFIG_HOME`
relocates it — the test suite sets it, so a run can neither read nor overwrite the
developer's own tokens — and `$XDG_CONFIG_HOME` is honoured too.
**Nothing prints a token.** `Redacted` is what a receipt gets; `kettle config` shows
`(set)`.
## Resolution, and why it fails early
`Resolve` merges three sources — the project config, the machine's login file, and
the environment — into `Resolved`, which is everything the transport needs.
**Every failure names the file it read and the command that fixes it.**
"401 Unauthorized" is what happens when this function is allowed to return a
half-filled struct, and a 401 names nothing an operator can act on.
The same discipline splits the two "missing" answers: a missing `config.yaml` is
`ErrNoConfig`, not an empty config, because "this project has not been told which
tracker it belongs to" and "it belongs to no tracker" are different answers and only
one is fixed by running `init`. A missing login file, by contrast, **is** an empty
list — a machine with no logins yet is an ordinary machine.
`Complete` is that assertion on its own, as a method, because the two questions are
different: `kettle config` wants to **show** a half-filled configuration and
everything that dials wants to **refuse** one. `Require` is `Resolve` plus
`Complete`; [`gitea.New`](../gitea/AGENTS.md) and `cmd/release` call `Complete`
themselves, so a client can never be built from a struct that is missing a field.
`ResolveOutsideAProject` is for the one caller that legitimately stands nowhere near
a project: [`cmd/release`](../../cmd/release/AGENTS.md), run from a fresh clone. The
marker is gitignored, so a clone has none and a build tool must not create one — and
with no marker there is nothing to merge, so the **environment is** the
configuration. A marker that is there is read as always, so the same command run
from a maintainer's own checkout picks up the login pinned in it. Every other caller
wants `Resolve`, where "no project" is the answer rather than a state to work
around: a push that quietly ran against whatever was in the environment would be a
push into somebody else's repository.
`ReadProjectFile` exists for exactly one caller: `kettle init`, which is creating
the marker `LoadProject` walks for, and on a dry run may not have created it at all.
## Overrides
| variable | shadows |
|---|---|
| `KETTLE_LOGIN` | `login:` in the project config |
| `KETTLE_REPO` | `repo:` in the project config |
| `KETTLE_URL` | the login's `url` |
| `KETTLE_TOKEN` | the login's `token` |
| `KETTLE_CONFIG_HOME` | the directory holding `logins.yaml` |
Each wins over the file it shadows. They exist for CI, for a one-off run against
another instance, and for anyone who would rather not have a token on disk at all.
## Unknown keys are an error
Not a silent drop. An older binary reading a newer config would otherwise delete the
setting it did not recognize the next time it wrote the file — which is a data-loss
bug that only shows up on the machine running the older build.
## What does not belong here
A request, a store path, an issue. This package reads and writes two files and
answers "who am I and where am I pointed"; [`gitea`](../gitea/AGENTS.md) takes the
answer and dials, and the paths themselves come from
[`project`](../project/AGENTS.md).
## Keeping this file true
- **Scope:** `config.go` — the two files, their fields, the overrides, the file modes.
- **Update it when** a field is added to either file (both tables above are the
contract), an override is added or renamed, the location or mode of the login file
changes, or the unknown-key policy changes.
- **Do not** move a credential into the project file, and if that ever changes, the
argument above is what has to be answered first.
+44 -2
View File
@@ -243,7 +243,34 @@ func Resolve(start string) (*Resolved, error) {
} else if !errors.Is(err, ErrNoConfig) {
return nil, err
}
return merge(p)
}
// ResolveOutsideAProject is Resolve for a caller that legitimately has no
// project to stand in.
//
// `cmd/release` is the one, and it is not an exception being carved out: the
// marker is gitignored, so a fresh clone has none, and a tool that publishes a
// tag must not create one on its way past. With no marker there is nothing to
// merge and the ENVIRONMENT IS the configuration — KETTLE_URL, KETTLE_TOKEN and
// KETTLE_REPO, which is exactly what somebody exports before cutting a release.
//
// A marker that IS there is read as always, overrides and all, so the same
// command run from a maintainer's own checkout picks up the login pinned in it
// and needs no token in the shell.
//
// Every other caller wants Resolve: for `kettle`, "no project" is the answer,
// not a state to work around. A push that quietly ran against whatever was in
// the environment would be a push into somebody else's repository.
func ResolveOutsideAProject(start string) (*Resolved, error) {
if ProjectPath(start) == "" {
return merge(Project{})
}
return Resolve(start)
}
// merge applies the login file and the environment to a project's settings.
func merge(p Project) (*Resolved, error) {
out := &Resolved{Login: p.Login}
if v := os.Getenv(EnvLogin); v != "" {
out.Login = v
@@ -295,6 +322,21 @@ func Require(start string) (*Resolved, error) {
if err != nil {
return nil, err
}
if err := r.Complete(); err != nil {
return nil, err
}
return r, nil
}
// Complete reports what a resolved configuration is still missing, naming the
// one command or the one variable that supplies each.
//
// A half-filled struct allowed through is a 401 three calls later, and "401
// Unauthorized" names nothing an operator can act on. It is a method rather
// than part of Resolve because the two questions are different: `kettle config`
// wants to SHOW a half-filled configuration, and everything that dials wants to
// refuse one.
func (r *Resolved) Complete() error {
var missing []string
if r.URL == "" {
missing = append(missing, "a URL (pin a login with `kettle init --login`, or set "+EnvURL+")")
@@ -306,9 +348,9 @@ func Require(start string) (*Resolved, error) {
missing = append(missing, "a repository (`kettle init --repo owner/name`, or set "+EnvRepo+")")
}
if len(missing) > 0 {
return nil, fmt.Errorf("this project has no %s", strings.Join(missing, ", and no "))
return fmt.Errorf("this project has no %s", strings.Join(missing, ", and no "))
}
return r, nil
return nil
}
// strictUnmarshal refuses keys the struct does not know.
+179
View File
@@ -0,0 +1,179 @@
# AGENTS.md — internal/gitea (TRANSPORT)
**Everything that talks to a tracker, and nothing else.** Numbers, logins, HTTP
verbs, pagination, JSON.
It does not know what an issue *is* — no sections, no acceptance criteria, no type
taxonomy — and the import graph says so in **both** directions: this package may
not reach into [`issue`](../issue/AGENTS.md), and `issue` may not reach in here.
[`mapping`](../mapping/AGENTS.md) is not imported either: it sits *above* this
package, not beside it. `TestTransportDoesNotImportTheDomain` is the check.
| file | what is in it |
|---|---|
| `client.go` | `New`, `For`, the payload-dumping `RoundTripper`, pagination, `APIError` and `Fail`, `ListIssues` and its budget |
| `issues.go` | `GetIssue`, `CreateIssue`, `EditIssue`, `SetLabels`, comments, milestones, dependencies |
| `labels.go` | `ListLabels`, `CreateLabel`, `EditLabel` |
| `remotemap.go` | `RemoteMap` — the number → slug ledger, and why nothing prunes it |
| `client_test.go` | pagination, error bodies, the scratchpad, the page budget, the version gate |
| `remotemap_test.go` | load, merge, save |
## What this package is, now that the SDK exists
The one place that holds the **credentials, the scratchpad and the repository this
project points at**, so no command has to. Every method is a thin wrapper, and the
wrapping is for the three things the SDK does not do:
- **every request body is filed under `.kettle/payload/`** by a `RoundTripper`, so a
retry or a post-mortem has the bytes that went out;
- **every failure comes back as `*APIError`** carrying the status *and* what the
server said, because "500" on its own has never helped anybody. Gitea answers 422
for a label that already exists, for a milestone id belonging to another
repository, and for a body missing a field — the three are told apart only by the
message, so the body travels with the code, always;
- **a listing stops when the caller has what it asked for**, which a client that
fetches whole pages into a slice cannot do.
The payload shapes are the SDK's, aliased `sdk` everywhere. The issue **keys** are
still [`wire`](../wire/AGENTS.md)'s — the SDK addresses an issue as
`(owner, repo, int64)` and never parses `owner/repo#42` out of anything.
`Fail` builds an `*APIError` out of an SDK `(response, error)` pair and is exported
for [`cmd/release`](../../cmd/release/AGENTS.md), the one caller outside this package
that builds its own client — so "the tracker said no" has one spelling in the tree.
## Building a client dials
`New` refuses a half-filled configuration **before** anything else, because building
the client dials: the SDK asks the instance for its version before it hands one
back. A missing token reported as a connection failure sends the operator to the
wrong place. Every field it checks has exactly one command that supplies it.
That handshake is also what pays for the dependency gate below, and it is why every
fake tracker in the test suite answers `/api/v1/version`.
`For(repo)` returns a copy pointed at another repository — **bookkeeping, not a
second connection**, since the SDK takes the owner and name per call. Credentials,
the negotiated version and the scratchpad are shared, which is what makes
`kettle pull owner/repo#42` cost nothing extra.
## The scratchpad
`.kettle/payload/` is a **sibling of the store, never a child**: request bodies are
debris of the transport, and a scratchpad inside a store makes `ls .kettle/issues`
lie about what exists. It is written by the `RoundTripper`, so **every** request
with a body is filed and not only the ones a call site remembered to name — a name
only decides what the file is called.
A run that sends nothing, which includes every read-only command, leaves no
directory at all: the first write creates it. The dump is the same JSON the wire
carried, re-indented and with `<`, `>` and `&` left alone, because the SDK marshals
with `encoding/json`'s escaping and a dump nobody can read is a dump nobody reads.
## Listings, and the two boundaries
`ListIssues` makes one request per page, and a payload already carries the issue
body — a whole milestone costs one call per page, not one per issue.
`IssueFilter.Keep` decides whether a payload counts against `Limit`. **What Keep
means is the caller's business; this package only counts.** Two boundaries hold
whatever it decides:
- **stop at the limit** — the page after the one that completed the budget is never
requested;
- **stop at the page budget** — a filtered read scans at most `PageSlack` (4) times
the pages `Limit` would need if every payload counted. A predicate that rejects
everything must not turn a bounded read into a walk of the whole tracker. Hitting
the budget unfilled sets `IssueListing.Warning` rather than answering short in
silence — **returned rather than printed**, because the transport does not own the
operator's terminal.
`ResolveMilestone` fails **loudly**, and that is the whole point of resolving before
filtering: Gitea silently ignores a `milestones=` filter it cannot resolve and
answers with the entire backlog, so a typo would read as "your milestone has 300
issues in it". It resolves against the whole listing rather than the SDK's
`GetMilestoneByName`, which matches case-insensitively and would fold two different
milestones into one. `FindMilestone` is its quiet counterpart for a push, where a
milestone the tracker does not have means "filed without one".
`ListMilestones` returns both states, always: a milestone is closed the moment its
work is done, and a listing that hid those would fail to resolve exactly the filter
somebody types when they want to see what was in it. `ListLabels` is read from the
repository and never from a cache — a cache answers "what did we create last time",
and the question is "what does this repository have right now".
## The ledger
`.remote.json`, **inside the issue store, beside the issues it indexes** — it is
bookkeeping about issues and belongs where they are, not in the scratchpad.
**Its entries outlive the files they name, and that is deliberate.** A push deletes
an issue's file the moment the tracker confirms the write, and the entry left behind
is what makes the next pull of that number land on the same slug — so every
`depends:` that pointed at it still resolves. Nothing prunes them, not push and not
eviction, because "no file" no longer means "no such issue". A stale entry costs one
line of JSON and is corrected the next time that number is pulled.
It is a **cache, not a record**. The order of authority:
```
the tracker the issue, and the marker naming its slug
.remote.json a local number -> slug ledger, a cache of that marker
the store whatever happens to be checked out right now
```
Which is why `LoadRemoteMap` never fails — a missing, unreadable or malformed file
is an empty ledger, since refusing to run would block the very pull that would
rebuild it — and why a rebuild is a **merge and never a replacement**: the store is
a subset of what the ledger knows, so starting from the files alone would throw away
every entry it cannot see. Load, add what the files say, save.
`Save` is the one write here allowed to create the store, and only because of when
it happens: the ledger is written the instant the tracker confirms a push and
**before** the local file is deleted, so failing it over a missing directory would
lose the slug at exactly the moment the local copy stops being the record.
## Issue dependencies — the one endpoint with a story
`depends:` becomes a native Gitea link, which is what makes the tracker show the
blocking panel and refuse to close a blocked issue first.
- **Reading** goes through the SDK (`ListIssueDependencies`).
- **Writing does not.** Gitea's own `IssueMeta` is `{index, owner, repo}` and has
been since the endpoint existed; the SDK's is `{index}`, which can only link
inside one repository, and a `depends:` entry is allowed to live somewhere else.
So one hand-rolled `POST` survives, through the same HTTP client as everything
else — same payload dump, same `*APIError`. The URL names the blocked issue and
the body the blocker, which is the direction `Dependencies` reads back.
- **The version gates both.** The routes are absent from Gitea 1.19 and present in
1.20, checked against the release tags themselves, so an older instance is
answered from the version it already gave us rather than from a 404 — which on an
old server is also what "no such issue" looks like.
- **A tracker that answers with a status still means "no dependencies here"**,
because an instance that has the endpoint can still have them turned off for a
repository, and a pull must bring the issue back either way. **A dead connection
is not that answer** — the Python version swallowed both, and "the server said no"
and "there was no server" are different answers.
A link that already exists answers 409, so callers pre-filter with `DependencyKeys`
and treat a failure here as a note rather than an abort: one missing cross-link must
not undo a push that has already created issues.
## Two Gitea quirks worth knowing before touching anything
- **`EditIssue` carries no labels.** Gitea's edit endpoint takes none and neither
does the SDK's option struct, so an issue whose labels changed needs `SetLabels`
after it — `push` makes that call and says which names moved.
- **A create can silently drop labels handed to it.** `SetLabels` re-applies them
rather than trusting the echo.
## Keeping this file true
- **Scope:** every `.go` file here — the client, the endpoints it wraps, the
scratchpad, the ledger, and the quirks that shape them.
- **Update it when** a method is added or removed, a request stops going through the
SDK or starts to, the page budget or the version gate changes, the ledger's format
or location changes, or a new Gitea quirk is worked around — a workaround with no
written reason is a workaround somebody deletes.
- **Do not** explain what a field *means* to an issue. That is
[`mapping`](../mapping/AGENTS.md)'s and [`issue`](../issue/AGENTS.md)'s.
+8
View File
@@ -197,6 +197,14 @@ func StatusIs(err error, status int) bool {
return errors.As(err, &apiErr) && apiErr.Status == status
}
// Fail is fail, exported for the one caller outside this package that needs it.
//
// `cmd/release` builds its own SDK client — see its package doc for why a build
// tool must not use this one — but a failure it reports has to name a status
// and quote what the server said in the same words a push does. One function,
// so the two spellings of "the tracker said no" cannot drift apart.
func Fail(resp *sdk.Response, err error) error { return fail(resp, err) }
// fail turns one SDK call's (response, error) pair into this package's error.
//
// BOTH HALVES OR NEITHER. The SDK reads the response body to build its error
+173
View File
@@ -0,0 +1,173 @@
# AGENTS.md — internal/issue (DOMAIN)
**What an issue IS.** The canonical markdown format, the label taxonomy,
validation, checkboxes, the dependency graph, the store, and eviction.
It knows **nothing** about any tracker: no Gitea, no logins, no HTTP, no issue
numbers. 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.
Imports [`project`](../project/AGENTS.md) and the standard library, and nothing
else; two tests hold that, see [`internal/AGENTS.md`](../AGENTS.md).
| file | what is in it |
|---|---|
| `issue.go` | the `Issue` type, `FromText`/`Text`, `Slugify`, `IsSlug`, `UniqueID`, `DomainKeys` — and the package comment with the annotated file format |
| `meta.go` | `ParseMeta`/`RenderMeta`: the metadata block, one field per line |
| `taxonomy.go` | `Types`, `Severities`, the section headers, `RequiredSections`, `ExpectedSections`, `CanonicalLabels`, `SectionBody` |
| `template.go` | `Template`: the prefilled body per type |
| `validate.go` | `Validate`: errors mean malformed, warnings mean it deviates from its template |
| `checkbox.go` | `Checkboxes`, `SetCheckbox`, `CheckboxProgress` — pure functions over a string |
| `graph.go` | `Graph`, `Dependents`, `TopoOrder`, `FindCycles` over `depends:` |
| `depsection.go` | `BodyDepRefs`: references written in `## Depends on` / `## Issues` prose |
| `store.go` | `Root`, `AllIDs`, `SlugFiles`, `Load`/`LoadAll`/`Save`, `RequireStore`, `CreateStore`, `StoreError` |
| `index.go` | `BuildIndex`: INDEX.md, a view of the directory |
| `evict.go` | `Classify`, `Evict`, `Remove`, and the report types |
| `layering_test.go` | the two tests that keep a tracker out of this package |
## Identity
A slug derived from the title, and **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 foreign**: written by
the sync layer, carried through load and save verbatim in `Issue.Extra`, never
read. 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:
```bash
grep -l 'labels:.*type/bug' .kettle/issues/*.md
grep -ln 'depends:.*migrate-schema' .kettle/issues/*.md # who depends on it
```
`FromText` takes an id that **overrides** the one in the block, which is how the
store makes the file name authoritative.
## Origin is the safety argument
`origin: local` means **this file IS the issue** — there is no other copy, and
deleting it deletes the work. It is a complete state, not a pending one. Anything
with a tracker origin can be fetched again, which is what makes it safe to remove.
Every dangerous operation in this package turns on that one field:
- `Classify` splits the store into evictable, protected and still-open. It is
**pure** — it reads loaded issues and decides, touching no disk — and a protected
issue comes back as protected **even when it was named explicitly**: naming a
file does not make deleting it safe.
- `Evict` classifies, removes, and rebuilds the index. One implementation, called
both by the offline command and by the sync layer — which does nothing to this
decision except hand over issues whose `state:` it has just refreshed.
- `Remove` is deliberately dumb: it takes an id, not a decision. Whether an issue
may go is settled by `Classify` before this is reached, so the dangerous half of
the operation has no branches in it at all.
The store is a **working set, not an archive**: a closed issue with a tracker origin
is evicted, and eviction is not a one-off migration — a pull by number fetches an
issue in any state, so a closed issue pulled after an eviction lands on disk again.
## The store, and the three ways it can be missing
`AllIDs` reads `<slug>.md` and nothing else. **A slug has no dot in it**, so
`wire-sqlc.comments.md` is not an issue; without that rule a bare push tries to
file a comment thread as a unit of work. `SlugFiles` is the same rule read the
other way round — everything named `<id>.<something>` belongs to that issue and
goes when it goes, which is how the domain removes an issue completely without
learning what a comment thread is.
Three failures, three messages, because they are three different things to do next:
| answer | means |
|---|---|
| `project.NotFoundError` | no project at all — run `kettle init` |
| `store … does not exist` | a project whose store was never created |
| `store … exists but is empty` | a store with nothing filed in it yet |
`ErrStoreMissing` marks the first two. Conflating "empty" with "not there" is
exactly what once made a missed directory look like an empty backlog. **Nothing
creates a store as a side effect of a write** — only `new` and `pull` call
`CreateStore`, and both announce it.
## Sections, and what a checkbox is
Section headers are fixed English literals in a fixed order; **only body prose is
Russian**. `RequiredSections` (`## Summary`, `## Spec`) must be present in every
type; `ExpectedSections` are the per-type ones and their absence is a warning.
`DepSections``## Depends on` and `## Issues` — both name what an issue depends
on, so both are edge sources pointing the same way. In a `type/feature` that reads
container → child: "the container is closed when its children are closed" *is* a
dependency, while "a child belongs to a feature" is membership, and membership has
no place in a dependency graph. Which is why a child never names its container back.
**`depends:` is the authoritative edge list; body prose is never walked by
`Graph`.** `BodyDepRefs` exists so a command can *report* what the prose claims,
and never so the graph can be built from it.
A checkbox is the one part of a body that is **state** and not prose. `SetCheckbox`
is surgical: exactly one byte of the input changes, and everything else — trailing
whitespace, the item's own wording, an existing `[X]`'s capital — comes back byte
for byte. Ticking a box must not produce a diff wider than the state that changed.
Fenced code blocks are skipped whole: `- [ ]` inside a fence is an example of the
markup, not a box anybody may tick.
`CheckboxProgress` is computed on the fly. Progress is not a metadata field — a
second copy of that state would be wrong by the next edit.
## Usage
```go
root := issue.Root(out) // out overrides; "" resolves the project
if err := issue.RequireStore(root); err != nil { return err }
issues, err := issue.LoadAll(root)
order := issue.TopoOrder(ids, issue.Graph(issues)) // dependencies first
errs, warns := issue.Validate(issues[id], knownIDs)
```
`TopoOrder` breaks cycles deterministically rather than raising: a cycle is a data
problem for the caller to report (`FindCycles` finds them), not a reason to refuse
to order the rest.
## What does not belong here
An issue number, a login, an HTTP call, a label colour, a hex code, a JSON tag, a
yaml tag. If one appears in this package it is in the wrong place — colours are
[`mapping`](../mapping/AGENTS.md)'s, because a hex code is how a tracker paints a
chip and not what an issue is.
## Keeping this file true
- **Scope:** every `.go` file in this directory — the format, the taxonomy, the
store, the graph, checkboxes, eviction.
- **Update it when** a metadata field is added to `DomainKeys`, a type or severity
is added to the taxonomy, a required or expected section changes, a file appears
or goes in the table above, or any rule about what may be deleted changes. The
format's operator-facing statement of intent lives in the plugin
(`plugins/kettle/skills/issue/references/format.md`) — when the taxonomy moves,
both change.
- **Do not** document how any of this reaches a tracker.
+125
View File
@@ -0,0 +1,125 @@
# AGENTS.md — internal/mapping (BRIDGE)
**md ↔ Gitea's payloads. The whole translation, and only the translation.**
Pure functions: no network, no filesystem, no flags, no clock. Give it a payload
and it hands back a domain issue; give it an issue and it hands back a request
body. That purity is the point — it can be reasoned about and tested without a
Gitea anywhere, and it is the one package to open when the two representations
disagree.
Imports [`issue`](../issue/AGENTS.md), [`wire`](../wire/AGENTS.md) and the SDK.
Nothing imports it but [`cmd`](../cmd/AGENTS.md) — not the domain, not the
transport. Both sides speak the SDK's shapes, which is what lets the two meet
without either reaching into the other.
| file | what is in it |
|---|---|
| `mapping.go` | the sync-owned metadata keys (`gitea`, `url`, `synced`, `remote-updated`, `comments`, `branch`), `Origin`, `ApplyRemote` |
| `frompayload.go` | `FromPayload` and the accessors around it: `LabelNames`, `AssigneeLogins`, `MergeCheckboxState` |
| `torequest.go` | the create/edit request bodies, `LabelIDsFor` |
| `labels.go` | `LabelColor`, `LabelSpecs`, `CanonicalLabelSpecs`, `DefaultColor` |
| `marker.go` | `IDMarker`, `IDInBody`, and the strip on the way in |
| `layering_test.go` | the two tests that keep this package pure |
## What crosses the boundary, and what does not
```
domain Gitea note
----------------------------------------------------------------------
id (slug) body marker <!-- kettle:id … -->, first line of the
tracker-side body; stripped out of the
local copy — see marker.go
title title verbatim, both ways
body body verbatim up, verbatim down except the
marker and checkbox state
state state open/closed, the same vocabulary
labels labels[] names both ways; ids only on write
assignees assignees[] logins
milestone milestone.title resolved to an id on write
depends — slugs; #N is translated at this edge
— number, html_url lands in Extra as gitea:/url:
— ref Extra as branch:; push fills it from git
```
Only the **login** of a Gitea user crosses: it is what `assignees:` holds, and a
display name is not an identity anything can be pushed against. Label and
assignee lists are appended into a nil slice, so an issue with no labels is the
same value as one loaded from a file — two spellings of "none" is a comparison bug
waiting to happen.
`depends:` is the authoritative graph and is always slugs. The body's
`## Depends on` prose is passed through **unchanged in both directions**: a pull
seeds `depends:` from the `#N` it finds there, and a push never rewrites what the
author wrote. Deliberate — a translator that edits prose churns the body on every
round trip.
## The id marker
The **one** thing this package adds to a body, and it adds it because the slug has
to survive a push: push deletes the local file, so the tracker has to be the thing
that remembers what the issue was called here.
- one place formats it (`IDMarker`), one regex reads it — and the regex accepts
more than the formatter writes, including the older `<!-- tea:id … -->`, because
issues pushed before the rename are still in the tracker;
- the **first** valid marker wins; a second is ignored and removed on the way in;
- the captured text must be a slug by the domain's own rule, so a mangled comment
falls back to the title instead of naming a file after garbage;
- it is stripped before anything else looks at the body, so checkboxes, `#N`
references and what lands on disk all see the body the author wrote.
## The checkbox merge
`MergeCheckboxState` is the one exception to "a pull overwrites the body", and
deliberately the narrowest one that works. **A tick is monotone** — an item only
travels `[ ]``[x]` — so the two sides are joined by a **set union**: no base
version, no drift tracking, no conflict to resolve. An item comes out ticked when
either side has it ticked; everything else in the body is still the remote's word.
Matching is on `Checkbox.Text`, which the domain parser has already stripped and
rejoined with single spaces, so rewrapping a long item does not cost it its tick.
It is otherwise literal: reword an item and it is a different item.
The same text more than once is read as the rule says, as a set — one ticked local
item ticks every remote item with that text. Pairing duplicates up by order is the
reading that can still drop a tick, and dropping a tick is the bug this exists to
fix.
## Labels, and the two write paths
Colours live here, not in the domain: a hex code is how a tracker paints a chip and
not what an issue is. `CanonicalLabelSpecs` is derived from the domain's own list
rather than restated, so adding a type over in the taxonomy creates it on the next
bootstrap with no line changing here but the colour. `DefaultColor` paints
everything outside the canonical set, because `tech/*` and `comp/*` are
project-specific and guessing a colour for one invents a meaning it does not have.
`LabelIDsFor` is exported so that a create and a repair cannot derive the answer
differently: **Gitea's edit endpoint carries no labels**, so an issue that already
exists gets its label set through a `PUT`, and a `PUT` that disagreed with what a
create would have sent would make a pushed issue and a re-pushed one two different
things. `nil` means "resolved no ids"; an **empty, non-nil** list means "resolved
some and matched none", which is a statement to the tracker — `[]` clears every
label on the issue.
## Purity, and the one weakening
`layering_test.go` checks **direct** imports and fails on `os`, `net/http`,
`os/exec`, `internal/gitea`, `internal/config` and `internal/project`; a second
test greps the sources for `time.Now`.
It does not walk the dependency closure, and it cannot: the SDK's types come with
the SDK's client attached, so the graph contains an HTTP client whatever this
package does with it. The full reasoning — and why `time` is allowed where it once
was not — is in [`internal/AGENTS.md`](../AGENTS.md).
## Keeping this file true
- **Scope:** every `.go` file here — the field table, the marker, the merge, the
colours.
- **Update it when** a field starts or stops crossing the boundary (the table is
the contract), a sync-owned metadata key is added, the marker spelling changes or
an older one stops being read, or the purity test is loosened.
- **Do not** put a request here that anything else could make. This package returns
values; [`gitea`](../gitea/AGENTS.md) sends them.
+107
View File
@@ -0,0 +1,107 @@
# AGENTS.md — internal/project (ROOT)
**One question: which directory is the project.** Everything that is a fact about
a project — the issue store, the request-payload scratchpad, the tracker config —
is resolved from the answer, and the answer is found by one walk written once.
This package **depends on nothing** but the standard library, and it is the only
one in the tree with no other package below it.
| file | what is in it |
|---|---|
| `project.go` | `Marker`, `Anchors`, `Parents`, `GitDirOf`, `MainWorktree`, `Root`, and the paths resolved from it — `StoreRoot`, `PayloadRoot`, `ConfigPath` — plus `NotFoundError` |
| `init.go` | `Init`: creates the marker, migrates an older layout in, gitignores `.kettle/`. `ClashError` is its refusal |
| `project_test.go` | the walk, including the worktree hop and the "no marker anywhere" answer |
## The walk
Anchors, first hit wins: `$CLAUDE_PROJECT_DIR`, then the working directory. Each
is searched up its parent chain for a `.kettle/` marker, and then — **only if that
found nothing** — up the parent chain of the **main working tree of any linked
worktree** met on the way, reached by reading `gitdir:` out of a `.git` *file* and
following `commondir`.
A marker, not a fixed number of `..` hops: how deep a caller sits below the root
is an implementation detail of the layout, and the layout is not a promise. Walking
up means every command sees one store from anywhere inside the project — including
from inside the store itself — while a `cd` into a *different* project correctly
answers with that project's store.
The worktree hop is one level of indirection, never two: a main checkout is not
itself a linked worktree, so it cannot chain and cannot cycle. Only a `.git` *file*
is a pointer; in an ordinary clone `.git` is a directory and there is nothing to
follow. A submodule's `.git` is a pointer too, but it points into
`<super>/.git/modules/…`, and `MainWorktree` refuses it on the `.git` basename
check — the tree it belongs to is already on the parent chain.
## Two rules that are not negotiable
**Nothing here resolves from the executable's own location.** Where an installation
keeps its files is a fact about the installation; whose issues a tree has is a fact
about the tree, and a binary installed in one place and pointed at another must
answer from the one it was pointed at. This is the whole reason the package exists
— the Python version resolved its store from `__file__` and wrote issues into a
versioned plugin cache.
**The marker is created by `kettle init`, never inferred.** `.git` was tried and is
in every clone, including this repository's own, which is how a plugin came to
resolve its store inside itself. No marker anywhere is an *answer*, not a fallback:
`NotFoundError` names the anchors the search began from — not the whole chain,
because an operator who sees the two places it started knows immediately whether it
started where they meant it to.
## Init, and the migration
`Init` is idempotent and every step announces itself, so `--dry-run` is the same
code path with the writes turned off:
- creates `.kettle/issues/` and `.kettle/payload/`;
- migrates an older store in, oldest layout first — `tmp/issues`, then
`.tea/issues`, and the same pair for `payload` — so a tree that skipped a
generation still lands in one place;
- adds `.kettle/` to `.gitignore`, unless some line already ignores it.
**Each migration is a move, never a copy.** Two stores is the state the marker
exists to prevent, and a store left behind at an old path is a store somebody will
edit by accident months later. When both sides hold a file of the same name it
stops with a `ClashError` naming up to five of them and changes nothing: two
versions of one issue, and which survives is not a decision a migration makes
quietly. The old `.tea` marker is removed only when the migration emptied it —
anything else parked in there is somebody's.
`.kettle/` is gitignored because an `origin: local` issue is the only copy of that
work and what goes into a shared history is the operator's call. Committing the
store is a legitimate choice; drop the line if the team makes it.
## Usage
```go
root := project.Root("") // "" when there is no project
store := project.StoreRoot("") // <root>/.kettle/issues
if store == "" {
return project.NotFoundError("") // names the directories it searched
}
```
A non-empty `start` overrides both anchors and exists so resolution can be
exercised against a scratch tree — which is what the test suite does, and why
every fixture also strips `CLAUDE_PROJECT_DIR`.
## What does not belong here
Anything that reads or writes an issue, a config file or a socket. This package
hands out **paths** and one answer about directories; the store is
[`issue`](../issue/AGENTS.md)'s, the config is
[`config`](../config/AGENTS.md)'s, and the scratchpad is filled by
[`gitea`](../gitea/AGENTS.md).
## Keeping this file true
- **Scope:** `project.go`, `init.go`, `project_test.go` — the walk, the marker, the
paths derived from it, and the migration.
- **Update it when** an anchor is added or reordered, the marker name changes, a
new path is resolved under the marker (the file table and the walk section both
name them), a legacy layout is added to or dropped from the migration list, or
the worktree rule changes.
- **Do not** document what any resolved path is *used for*; that belongs to the
package that uses it.
+69
View File
@@ -0,0 +1,69 @@
# AGENTS.md — internal/wire (ADDRESSES)
**How this project addresses one repository and one issue, and nothing else.**
Two types, `Repo` and `Key`, and the parsing that reads them.
**Imports the standard library and nothing else** — no HTTP, no filesystem, no
configuration, no SDK, and above all not [`issue`](../issue/AGENTS.md). An
identifier that reached for any of those would drag every user of it into that
layer. Two tests hold it, see [`internal/AGENTS.md`](../AGENTS.md).
| file | what is in it |
|---|---|
| `key.go` | `Repo`, `Key`, `ParseRepo`, `ParseKey`, `Key.In`, and the `String` methods |
| `key_test.go` | every spelling above, and what a malformed one answers |
| `layering_test.go` | the two tests that keep this package at the bottom |
## Four spellings, one address
```go
wire.ParseKey("42") // Key{Number: 42} — repo zero: "this project's"
wire.ParseKey("#42") // the same, copied out of a body
wire.ParseKey("owner/repo#42") // qualified, out of the ledger
wire.ParseKey("https://git.example.com/owner/repo/issues/42")
```
All four because all four are what somebody has in hand — a number from a receipt,
a `#42` copied out of an issue body, a qualified key out of the ledger, a URL pasted
from a browser. Refusing three of them buys nothing.
**`Key.Repo` is zero when the caller named a number and nothing else**, which is the
common case on a command line: `42` means "42 in this project's repository", and
which repository that is, is the client's business. `Key.In(repo)` fills it in.
`Repo.Zero()` requires both halves — half a name addresses nothing.
## Why a key is not a bare number
`Key` has to survive being written to a file and read back: it is what the
number → slug ledger is keyed by and what the `gitea:` metadata field holds. **A
number is ambiguous the moment a dependency lives in another repository, and
dependencies are allowed to.** So a key is a repository and a number, always, and
`String()` spells it `owner/repo#42`.
## Why this package still exists after the SDK
The JSON shapes used to live here too, because the transport and the bridge both
had to name a Gitea issue and neither may import the other. They are
`code.gitea.io/sdk/gitea`'s now.
**What the SDK has no answer for is addressing.** It takes an owner, a name and an
`int64`, and never parses. So the parsing stays, and so does the pair of types it
produces — the values that go into the ledger, into the `gitea:` field, and into
every receipt. The longer version of that history is in
[`internal/AGENTS.md`](../AGENTS.md).
## What does not belong here
Anything that *does* something with an address: fetching, storing, resolving a
repository from configuration. This package parses and prints. Callers are
[`gitea`](../gitea/AGENTS.md), [`mapping`](../mapping/AGENTS.md) and
[`cmd`](../cmd/AGENTS.md).
## Keeping this file true
- **Scope:** `key.go` and its tests — the two types and the spellings they accept.
- **Update it when** a spelling is added or dropped, a type gains a field, or the
zero-value meaning of `Key.Repo` changes.
- **Do not** add a third type here without an argument for why it is an *address*.
Anything that is a payload belongs to the SDK; anything that is a fact about work
belongs to the domain.