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:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user