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:
+145
-239
@@ -1,11 +1,15 @@
|
||||
# AGENTS.md — the kettle CLI
|
||||
|
||||
`kettle` is a globally installed binary. It owns everything that used to be a
|
||||
Python script under `plugins/tea/skills/*/scripts/`: what an issue is, where the
|
||||
store lives, who this machine is, and how issues move to and from Gitea.
|
||||
`kettle` is a globally installed binary. It owns everything mechanical about
|
||||
issues: what an issue is, where the store lives, who this machine is, and how
|
||||
issues move to and from Gitea. It replaced a set of Python scripts that used to
|
||||
ship inside the plugin.
|
||||
|
||||
The plugin keeps what only a plugin can carry — the rules an operator states and
|
||||
a binary cannot enforce. Everything mechanical is here.
|
||||
a binary cannot enforce. Everything else is here.
|
||||
|
||||
**This file is the binary's map.** Each package documents its own rules in its own
|
||||
directory; nothing below is repeated there and nothing there is repeated here.
|
||||
|
||||
## Why a binary
|
||||
|
||||
@@ -20,47 +24,17 @@ Three failures in the Python version were failures of *runtime*, not of logic:
|
||||
convention checked by grep.
|
||||
|
||||
A compiled binary answers all three by construction. There is one walk
|
||||
(`internal/project`), it is imported rather than re-derived, and the layering
|
||||
rule is a build graph a test walks.
|
||||
|
||||
## Two dependencies
|
||||
|
||||
`gopkg.in/yaml.v3` and `code.gitea.io/sdk/gitea`, both vendored, and that is the
|
||||
whole list — eight modules once the SDK's own are counted, 2.3 MB of `vendor/`.
|
||||
The CLI still has no cobra: commands are values in a registry, which is what
|
||||
lets the plugin's SKILL.md files be generated from the same struct that holds
|
||||
the code.
|
||||
|
||||
`vendor/` is committed, so a build needs no network. **`go.mod` says `go 1.26`**,
|
||||
which the SDK requires; that is now the minimum for anybody building this binary.
|
||||
|
||||
The transport was hand-rolled `net/http` against the REST API and is now the
|
||||
official SDK. What that bought:
|
||||
|
||||
- **the payload shapes are one vocabulary, maintained by the people who maintain
|
||||
the server.** They used to be ours, in `internal/wire`, and every field Gitea
|
||||
learned was a field somebody here had to notice.
|
||||
- **the server's version, for free.** The SDK negotiates it when a client is
|
||||
built, which is what lets the transport answer "does this instance have issue
|
||||
dependencies?" from the version instead of guessing from a status code — see
|
||||
`dependenciesSince`.
|
||||
|
||||
What it cost is written down where it happened: the shapes come with an HTTP
|
||||
client attached (see the layering note below), a run makes one extra request
|
||||
before it does anything (the version handshake), and Gitea's issue **edit**
|
||||
endpoint carries no labels, so an issue whose labels changed needs a second call
|
||||
— `push` makes it and says so.
|
||||
|
||||
The SDK is imported as `sdk` everywhere, so one type has one spelling across the
|
||||
tree. `internal/gitea` is the package named `gitea` and the SDK is `sdk` inside
|
||||
it; the same alias holds in `internal/mapping` and `internal/cmd`.
|
||||
([`internal/project`](internal/project/AGENTS.md)), it is imported rather than
|
||||
re-derived, and the layering rule is a build graph a test walks.
|
||||
|
||||
## Layers
|
||||
|
||||
Knowledge flows one way. The arrow means "imports".
|
||||
Knowledge flows one way. The arrow means "imports"; follow a name to that
|
||||
package's own AGENTS.md.
|
||||
|
||||
```
|
||||
cmd/kettle thin main; exit status only
|
||||
cmd/release build infrastructure, not a kettle verb — see its own file
|
||||
|
||||
internal/cmd the command tree: flags, receipts, exit codes
|
||||
│ │ │
|
||||
@@ -86,185 +60,92 @@ internal/project ROOT which directory is the project, and every path
|
||||
depends on nothing
|
||||
```
|
||||
|
||||
Both the transport and the bridge also import `code.gitea.io/sdk/gitea`, which
|
||||
is where the payloads now live. The arrow that used to point at `internal/wire`
|
||||
for them points there instead.
|
||||
| package | layer | what its own file opens with |
|
||||
|---|---|---|
|
||||
| [`cmd/kettle`](cmd/kettle/AGENTS.md) | entry point | `os.Exit(cmd.Main(os.Args[1:]))`, and why there is nothing else in it |
|
||||
| [`cmd/release`](cmd/release/AGENTS.md) | build tool | why publishing a release is not a `kettle` verb, and why it goes around the transport |
|
||||
| [`internal/cmd`](internal/cmd/AGENTS.md) | commands | the registry every command is a value in, and the generator that writes the plugin's docs from it |
|
||||
| [`internal/config`](internal/config/AGENTS.md) | configuration | two files, and why the tokens are not in the one inside the repository |
|
||||
| [`internal/gitea`](internal/gitea/AGENTS.md) | transport | the SDK, the payload scratchpad, the ledger, the dependency endpoint |
|
||||
| [`internal/mapping`](internal/mapping/AGENTS.md) | bridge | md ↔ payload, the id marker, label colours, the checkbox merge |
|
||||
| [`internal/wire`](internal/wire/AGENTS.md) | addresses | `42`, `#42`, `owner/repo#42`, a URL — four spellings of one thing |
|
||||
| [`internal/issue`](internal/issue/AGENTS.md) | domain | the format, the taxonomy, the store, eviction — all of it offline |
|
||||
| [`internal/project`](internal/project/AGENTS.md) | root | the walk, and every path resolved from its answer |
|
||||
|
||||
Read it bottom-up and each layer knows strictly less about trackers than the one
|
||||
above it. Four rules hold the line, seven tests between them, and each fails on
|
||||
a real mistake rather than on a naming convention:
|
||||
The rules that hold the layers apart, and the seven tests that fail when one
|
||||
breaks, are in [`internal/AGENTS.md`](internal/AGENTS.md). Read the diagram
|
||||
bottom-up: each layer knows strictly less about trackers than the one above it.
|
||||
|
||||
- `internal/issue` may import `internal/project` and the standard library, and
|
||||
nothing else. One test walks `go list -deps` and fails on any path with a dot
|
||||
in its first element — which is also what keeps yaml AND the SDK out of the
|
||||
domain — and another names `net/http`, `net`, `os/exec` and `encoding/json`,
|
||||
standard library the first test would not catch. **Untouched by the migration
|
||||
to the SDK, and that is the point: the domain did not notice it happened.**
|
||||
- `internal/wire` imports only the standard library, checked the same two ways.
|
||||
- `internal/gitea` must not import `internal/issue` **or** `internal/mapping`:
|
||||
the transport knows numbers, logins, HTTP and JSON, and none of what they mean.
|
||||
- `internal/mapping` reaches for nothing but the domain and the SDK — checked on
|
||||
its DIRECT imports, with `os`, `net/http` and `internal/gitea` named — and a
|
||||
second test greps its sources for `time.Now`.
|
||||
## Dependencies, and building
|
||||
|
||||
**That fourth rule was stronger before the SDK, and the trade is deliberate.**
|
||||
The payload shapes lived in `internal/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. `time` is allowed 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.
|
||||
|
||||
`wire` used to exist 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.
|
||||
|
||||
If a tracker concept — an issue number, a login, an HTTP call, a label colour —
|
||||
shows up in `internal/issue`, it is in the wrong place.
|
||||
|
||||
## The walk
|
||||
|
||||
`internal/project` answers one question and everything else reads the answer.
|
||||
|
||||
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`.
|
||||
|
||||
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.
|
||||
|
||||
**The marker is created by `kettle init`, never inferred.** `.git` was tried and
|
||||
is in every clone. No marker anywhere is an answer, not a fallback: the command
|
||||
reports which directories it searched and stops.
|
||||
|
||||
## Configuration, and where secrets are not
|
||||
|
||||
Two files, and the split is the whole design.
|
||||
|
||||
`<project>/.kettle/config.yaml` — written by `kettle init`, says which tracker
|
||||
repository the issues belong to and which login to reach it under. It pins a
|
||||
login by **name**. The name is worth nothing on its own, which is what makes it
|
||||
safe to keep in a file inside a working tree.
|
||||
|
||||
`~/.config/kettle/logins.yaml` (`$KETTLE_CONFIG_HOME` or `$XDG_CONFIG_HOME`
|
||||
override it) — one file per machine, mode 0600, holding the tokens. Managed with
|
||||
`kettle auth`, which reads the token from standard input by default because an
|
||||
argument is in the shell history the moment it is typed. Nothing prints a token
|
||||
back: `kettle config` shows `(set)`.
|
||||
|
||||
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.
|
||||
|
||||
`KETTLE_LOGIN`, `KETTLE_REPO`, `KETTLE_URL` and `KETTLE_TOKEN` each override the
|
||||
file they shadow, for CI and for anyone who would rather have no token on disk.
|
||||
|
||||
Unknown keys in either file 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.
|
||||
|
||||
`init` gitignores `.kettle/` wholesale. An `origin: local` issue is the only copy
|
||||
of that work, and what goes into a shared history is the operator's call, not
|
||||
this binary's — drop the line if the team decides otherwise.
|
||||
|
||||
## The store
|
||||
|
||||
`<project root>/.kettle/issues`, one flat markdown file per issue, named by its
|
||||
slug, one metadata field per line so plain grep works without a parser.
|
||||
|
||||
It holds two kinds of file and only one of them is a store. An `origin: local`
|
||||
issue lives here and nowhere else — that file **is** the issue. Anything with a
|
||||
tracker origin is a working copy, deleted the moment a push confirms the tracker
|
||||
is up to date. Eviction makes the same trade one step earlier and asks the file
|
||||
instead of the tracker, which is why it lives in the domain.
|
||||
|
||||
`.kettle/payload/` is a sibling, 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 an `http.RoundTripper` installed on the SDK's
|
||||
client, 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, still leaves no
|
||||
directory: 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.
|
||||
|
||||
`kettle init` migrates older layouts in, oldest first — `tmp/issues` and then
|
||||
`.tea/issues` — and each is a **move**. A store left behind at an old path is a
|
||||
store somebody will edit by accident months later. It refuses to pick a winner
|
||||
when both sides hold a file of the same name.
|
||||
|
||||
## Tests
|
||||
`gopkg.in/yaml.v3` and `code.gitea.io/sdk/gitea` — eight modules once the SDK's
|
||||
own are counted. No cobra: commands are values in a registry, which is what lets
|
||||
the plugin's SKILL.md files be generated from the same struct that holds the code.
|
||||
|
||||
```bash
|
||||
cd cli && go test ./...
|
||||
make install # build straight onto your PATH, version stamped
|
||||
make install BINDIR=$(go env GOPATH)/bin
|
||||
|
||||
go build -o ~/.local/bin/kettle ./cmd/kettle # the same thing, unstamped
|
||||
go install git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest
|
||||
```
|
||||
|
||||
`internal/cmd` builds the binary once in `TestMain` and runs it as a subprocess
|
||||
against a throwaway project in a temp directory — the same discipline the Python
|
||||
suite arrived at, for the same reason. **The binary is never run in the
|
||||
directory it was built in**, because that is exactly the arrangement that hid
|
||||
the `__file__` bug: a tool is installed in one place and used on projects in
|
||||
another, and a test that collapses the two proves nothing about resolution.
|
||||
**`make` is the build, and it is also the CI.** There is no act_runner on the
|
||||
instance this lives on and none is planned, so nothing runs on a push: `make check`
|
||||
— fmt, vet, test, `go mod verify`, build, `gen skills --check` — is the only thing
|
||||
standing between a mistake and the tracker, and it is on whoever is committing to run
|
||||
it. `make help` lists the rest; `make dist` cross-compiles four platforms with a
|
||||
`SHA256SUMS`, and `make release TAG=v1.2.3` publishes them through
|
||||
[`cmd/release`](cmd/release/AGENTS.md) after refusing a dirty tree, a `TAG` that is
|
||||
not what `git describe` reports, and a tag the remote does not have.
|
||||
|
||||
Every fixture strips `CLAUDE_PROJECT_DIR`. It is the first anchor of the walk,
|
||||
so the harness's own value would point every fixture at this repository.
|
||||
Anything touching credentials sets `KETTLE_CONFIG_HOME` at a temp directory, so
|
||||
a test run can neither read nor overwrite the developer's own tokens.
|
||||
A hand build says `dev` for `kettle version` and means it: a binary out of somebody's
|
||||
working tree is not a release and must not claim to be one. The version is stamped at
|
||||
link time into `internal/cmd.Version`, derived from `git describe` rather than kept in
|
||||
a file — a number somebody has to remember to bump is a number that will be wrong.
|
||||
|
||||
**Every fake tracker answers `/api/v1/version`**, because building a client asks
|
||||
for it: the SDK will not hand one back until the instance has said what it is,
|
||||
and that answer is what the dependency gate is decided on later. The fakes say
|
||||
1.26.1. One says 1.19.4, and that is a test — an instance too old for the issue
|
||||
dependency endpoints is answered from its version, with no request made.
|
||||
**`go.mod` says `go 1.26`**, which the SDK requires, and **`vendor/` is
|
||||
committed** — 281 files, 2.3 MB, which is the price of knowing exactly what
|
||||
compiled.
|
||||
|
||||
Be precise about what that buys, because it is easy to overclaim: vendoring
|
||||
pins the *contents* of every dependency in this repository's own history, so a
|
||||
dependency that is retagged, yanked or unreachable cannot change what this
|
||||
binary is built from. It does **not** by itself give a network-free build.
|
||||
`go 1.26` in `go.mod` means `GOTOOLCHAIN=auto` fetches a toolchain over the
|
||||
network on any machine whose local Go is older, which is most of them. If an
|
||||
air-gapped build is ever a requirement, pin the toolchain too — vendoring alone
|
||||
will not get you there.
|
||||
|
||||
`make check` runs both halves: `go mod verify` says the module cache matches
|
||||
`go.sum`, and `go build -mod=vendor ./...` says the committed tree is complete
|
||||
and is what actually compiles. A `vendor/` that has drifted from `go.mod` fails
|
||||
nothing until somebody builds with a cold cache, which is exactly when nobody
|
||||
wants to find out.
|
||||
|
||||
The transport is the official SDK rather than hand-rolled `net/http`. What that
|
||||
bought: the payload shapes are one vocabulary maintained by the people who
|
||||
maintain the server, and the instance's version arrives for free, which is what
|
||||
lets the transport answer "does this instance have issue dependencies?" from the
|
||||
version instead of guessing from a status code. What it cost is written down where
|
||||
it happened — see [`internal/gitea`](internal/gitea/AGENTS.md) and
|
||||
[`internal/mapping`](internal/mapping/AGENTS.md).
|
||||
|
||||
The SDK is imported as `sdk` everywhere, so one type has one spelling across the
|
||||
tree.
|
||||
|
||||
## The round trip
|
||||
|
||||
`push` and `pull` are the two halves of one rule, and the rule is that **the
|
||||
store holds what has not left this machine.**
|
||||
store holds what has not left this machine.** A successful push deletes the local
|
||||
file — on `--update` too, one rule with no exception — and only after the tracker
|
||||
confirms the write and the number → slug ledger is written. A pull is how the copy
|
||||
comes back, under the same slug, on a machine that has never seen it.
|
||||
|
||||
A successful push deletes `<id>.md` and every sidecar under that slug, on create
|
||||
and on `--update` alike, and prints the number and URL the issue now lives at.
|
||||
An `--update` can take one extra request with it: Gitea's edit endpoint carries
|
||||
no labels, so when the answer's label set and the issue's disagree — a label
|
||||
added or removed locally, or one a create silently dropped — the whole set goes
|
||||
up in a PUT, and a warning on stderr says which names moved.
|
||||
The deletion happens **only after a confirmed tracker response and only after the
|
||||
number -> slug ledger has been written** — network down, non-2xx, or an answer
|
||||
that does not carry the right number, and the file stays where it is while the
|
||||
run stops. A never-pushed `origin: local` issue is never touched by any of it.
|
||||
|
||||
The slug survives that round trip two ways over, and a test proves both: it goes
|
||||
up in the body as `<!-- kettle:id … -->`, and it is indexed by number in
|
||||
`.kettle/issues/.remote.json`. A rename in the web UI, a lost ledger, a fresh
|
||||
clone, another machine — the file comes back under the same name and every
|
||||
`depends:` pointing at it still resolves. The marker is written in that spelling
|
||||
and read in both it and the older `<!-- tea:id … -->`, because issues pushed
|
||||
before the rename are still in the tracker.
|
||||
|
||||
Pull by number fetches an issue in **any** state: a number is an address, not a
|
||||
query, and `42`, `#42`, `owner/repo#42` and a URL all name one. Only filter mode
|
||||
leaves closed issues out. A pull returns the unit of work rather than one row of
|
||||
it — blockers come down with it unless `--no-deps` says otherwise — and it
|
||||
overwrites the body, because it is a fetch and not a merge. The one exception is
|
||||
checkbox state, which is the local half of the work and is merged rather than
|
||||
clobbered.
|
||||
The mechanics of each half live with the commands that implement them, in
|
||||
[`internal/cmd`](internal/cmd/AGENTS.md); the ledger that survives the deletion is
|
||||
[`internal/gitea`](internal/gitea/AGENTS.md)'s, and the marker that survives a lost
|
||||
ledger is [`internal/mapping`](internal/mapping/AGENTS.md)'s.
|
||||
|
||||
## No guard hook
|
||||
|
||||
@@ -276,45 +157,70 @@ for the transport and the guard to disagree — the failure the hook existed to
|
||||
catch is not expressible any more.
|
||||
|
||||
There is also no `--login` and no `--repo` on any sync command bar `labels`.
|
||||
Which login a project runs under, and which repository its issues belong to, are
|
||||
facts about the project, stated once by `kettle init`. A cross-repository address
|
||||
is still an address: `kettle pull owner/repo#42` re-points the client for that
|
||||
one call — `Client.For(repo)`, which is bookkeeping and not a second connection,
|
||||
because the SDK takes the owner and the name per call. The credentials, the
|
||||
negotiated version and the scratchpad come along.
|
||||
A cross-repository address is still an address: `kettle pull owner/repo#42`
|
||||
re-points the client for that one call, which is bookkeeping and not a second
|
||||
connection.
|
||||
|
||||
## Issue dependencies
|
||||
## Tests
|
||||
|
||||
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, and it is read back the same way on a pull.
|
||||
```bash
|
||||
make check # fmt, vet, test, go mod verify, build, docs — the whole gate
|
||||
go test ./... # just the tests
|
||||
```
|
||||
|
||||
- **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 ever
|
||||
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 version gates both.** The routes are absent from Gitea 1.19 and present
|
||||
in 1.20, checked against the release tags themselves, so an instance older
|
||||
than that 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 and never was.
|
||||
Three disciplines every test follows:
|
||||
|
||||
- **`internal/cmd` builds the binary once in `TestMain` and runs it as a
|
||||
subprocess** against a throwaway project in a temp directory. The binary is
|
||||
never run in the directory it was built in, because that is exactly the
|
||||
arrangement that hid the `__file__` bug: a tool is installed in one place and
|
||||
used on projects in another, and a test that collapses the two proves nothing
|
||||
about resolution.
|
||||
- **Every fixture strips `CLAUDE_PROJECT_DIR`**, the first anchor of the walk, or
|
||||
the harness's own value would point every fixture at this repository. Anything
|
||||
touching credentials sets `KETTLE_CONFIG_HOME` at a temp directory, so a run can
|
||||
neither read nor overwrite the developer's own tokens.
|
||||
- **Every fake tracker answers `/api/v1/version`**, because building an SDK client
|
||||
asks for it. The fakes say 1.26.1; one says 1.19.4, and that one is a test — an
|
||||
instance too old for the dependency endpoints is answered from its version with
|
||||
no request made.
|
||||
|
||||
## Documentation that is generated
|
||||
|
||||
```bash
|
||||
kettle gen skills --out ../plugins/kettle/skills # rewrite the blocks
|
||||
kettle gen skills --out ../plugins/kettle/skills --check # exit 1 if stale
|
||||
```
|
||||
|
||||
Everything between `<!-- kettle:gen -->` and `<!-- /kettle:gen -->` in the
|
||||
plugin's SKILL.md files comes from the command registry, so a renamed flag cannot
|
||||
ship with documentation that recommends the old one. `--check` is what a
|
||||
pre-commit hook or a CI step calls. The generator is
|
||||
[`internal/cmd`](internal/cmd/AGENTS.md)'s, and the seam between command groups
|
||||
and plugin skills is [the plugin's](../plugins/kettle/AGENTS.md).
|
||||
|
||||
Note the asymmetry, because it is easy to get backwards: **a SKILL.md command
|
||||
block is generated and must never be hand-edited; every AGENTS.md in this tree is
|
||||
hand-written and must never be generated.** One is a flag table, the other is a
|
||||
reason.
|
||||
|
||||
## Status
|
||||
|
||||
Done and tested: all seven packages, and the commands `init`, `auth`, `config`,
|
||||
`new`, `check`, `ac`, `tree`, `index`, `evict`, `pull`, `push`, `remote`,
|
||||
`comment`, `close`, `labels`, `sync-evict`. 99 tests.
|
||||
Done and tested: every package in the table above, and the commands `init`, `auth`,
|
||||
`config`, `gen`, `version`, `new`, `check`, `ac`, `tree`, `index`, `evict`, `pull`,
|
||||
`push`, `remote`, `comment`, `close`, `labels`, `sync-evict` — plus `cmd/release`,
|
||||
against a fake Gitea.
|
||||
|
||||
The plugin is rewired: it lives at `plugins/kettle`, ships no Python and no guard
|
||||
hook, and its command reference is generated from this registry —
|
||||
`kettle gen skills --out plugins/kettle/skills`, with `--check` as the invariant.
|
||||
The generator writes one file per GROUP (`project`, `issue`, `sync`); the plugin
|
||||
also carries `init`, `auth` and `use`, which hold procedure rather than flags and
|
||||
point at the generated `project` block. Adding a group here adds a skill
|
||||
directory there, so name one only when it is a subject somebody would load on its
|
||||
own.
|
||||
The plugin is rewired: it lives at `plugins/kettle`, ships no Python domain code
|
||||
and no guard hook, and its command reference is generated from this registry.
|
||||
|
||||
## Keeping this file true
|
||||
|
||||
- **Scope:** the binary as a whole — why it exists, what it depends on, how it is
|
||||
built and tested, and where each package's own document is. Files: `go.mod`,
|
||||
`go.sum`, `Makefile`, and the shape of the tree.
|
||||
- **Update it when** a package is added or removed (the diagram and the table both
|
||||
name every one), a dependency changes, a `make` target is added or its meaning
|
||||
changes, or a cross-package contract like the round trip changes.
|
||||
- **Do not** describe a package's internals here. That is what the files it links to
|
||||
are for, and a summary that drifts is worse than a link.
|
||||
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
# The gate, and the release, for a repository with no CI.
|
||||
#
|
||||
# There is no act_runner on the instance this lives on and none is planned, so
|
||||
# NOTHING RUNS ON A PUSH. `make check` is the only thing standing between a
|
||||
# mistake and the tracker, and it is on whoever is committing to run it — the
|
||||
# same five steps a workflow would have run, in one command, exiting non-zero
|
||||
# the moment one of them fails.
|
||||
#
|
||||
# `make release` is the other half: a release is cut by hand, from a developer's
|
||||
# machine, by this module's own code (cmd/release) talking to Gitea's API. It
|
||||
# refuses to publish a dirty tree or a tag that is not the version it is about
|
||||
# to stamp into the binaries, because a release built from uncommitted code is
|
||||
# unreproducible and nobody finds out until they need to reproduce it.
|
||||
|
||||
SHELL := /bin/sh
|
||||
|
||||
# Sequential on purpose: `check` builds the binary and then asks it whether the
|
||||
# plugin's documentation still matches, and -j would let the second start first.
|
||||
.NOTPARALLEL:
|
||||
|
||||
MODULE := git.noodles.cam/claude-skills/marketplace/cli
|
||||
SKILLS := ../plugins/kettle/skills
|
||||
DIST := dist
|
||||
BIN := $(DIST)/kettle
|
||||
REMOTE ?= origin
|
||||
|
||||
# Where `make install` puts the binary. The plugin's skills expect `kettle` on
|
||||
# PATH and say so when it is not; override for a Go-style layout:
|
||||
# make install BINDIR=$(go env GOPATH)/bin
|
||||
BINDIR ?= $(HOME)/.local/bin
|
||||
|
||||
# VERSION is what a binary reports for `kettle version`. It is derived from git
|
||||
# rather than kept in a file: a number somebody has to remember to bump is a
|
||||
# number that will be wrong. A tree that is not on a tag says so, and a dirty
|
||||
# tree says that too — "v0.2.0-4-g1a2b3c4-dirty" is the honest answer, and it is
|
||||
# exactly what you want to see in a bug report.
|
||||
VERSION ?= $(shell git describe --tags --dirty --always 2>/dev/null || echo dev)
|
||||
LDFLAGS := -X $(MODULE)/internal/cmd.Version=$(VERSION)
|
||||
|
||||
PLATFORMS := darwin/amd64 darwin/arm64 linux/amd64 linux/arm64
|
||||
|
||||
.PHONY: help check fmt vet test verify build gen-check dist release install clean
|
||||
|
||||
help:
|
||||
@echo 'kettle — there is no CI here; these are what a person runs.'
|
||||
@echo
|
||||
@echo ' make check the gate: fmt, vet, test, modules, build, docs'
|
||||
@echo ' make build dist/kettle, version stamped'
|
||||
@echo ' make install [BINDIR=…] build straight onto your PATH'
|
||||
@echo ' make dist cross-compile every platform + SHA256SUMS'
|
||||
@echo ' make release TAG=v1.2.3 [NOTES=notes.md] [TITLE="…"]'
|
||||
@echo ' dist, then publish it to Gitea'
|
||||
@echo ' make clean remove dist/'
|
||||
@echo
|
||||
@echo " version $(VERSION)"
|
||||
@echo " bindir $(BINDIR)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
check: fmt vet test verify build gen-check
|
||||
@echo 'check all clear — $(VERSION)'
|
||||
|
||||
# gofmt reports rather than rewrites: a formatting change made silently by a
|
||||
# build is a change nobody reviewed. cmd and internal, never the module cache.
|
||||
fmt:
|
||||
@out=`gofmt -l cmd internal`; \
|
||||
if [ -n "$$out" ]; then \
|
||||
echo 'gofmt these files are not formatted:'; \
|
||||
echo "$$out" | sed 's/^/ /'; \
|
||||
echo ' run: gofmt -w cmd internal'; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
echo 'gofmt clean'
|
||||
|
||||
vet:
|
||||
@echo 'vet go vet ./...'
|
||||
@go vet ./...
|
||||
|
||||
# -count=1 defeats the test cache. A gate that can pass because it passed an
|
||||
# hour ago on different code is not a gate.
|
||||
test:
|
||||
@echo 'test go test -count=1 ./...'
|
||||
@go test -count=1 ./...
|
||||
|
||||
# Two checks, because they answer different questions. `go mod verify` says the
|
||||
# module cache matches go.sum; the vendored build says the committed vendor/ is
|
||||
# complete and is what actually compiles. A vendor/ that has drifted from go.mod
|
||||
# fails nothing until somebody builds on a machine with a cold cache.
|
||||
verify:
|
||||
@echo 'modules go mod verify'
|
||||
@go mod verify
|
||||
@echo 'vendor go build -mod=vendor ./...'
|
||||
@go build -mod=vendor ./...
|
||||
|
||||
build:
|
||||
@mkdir -p $(DIST)
|
||||
@go build -trimpath -ldflags '$(LDFLAGS)' -o $(BIN) ./cmd/kettle
|
||||
@echo 'build $(BIN) — $(VERSION)'
|
||||
|
||||
# The documentation invariant: everything the plugin's SKILL.md files say about
|
||||
# a kettle command is generated from the registry the binary is built from, and
|
||||
# this exits 1 the moment the two disagree. Run `kettle gen skills --out …`
|
||||
# without --check to fix it.
|
||||
gen-check: build
|
||||
@echo 'docs gen skills --check'
|
||||
@$(BIN) gen skills --out $(SKILLS) --check
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# building for other people
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
install:
|
||||
@mkdir -p $(BINDIR)
|
||||
@go build -trimpath -ldflags '$(LDFLAGS)' -o $(BINDIR)/kettle ./cmd/kettle
|
||||
@echo 'installed $(BINDIR)/kettle — $(VERSION)'
|
||||
@case ":$$PATH:" in \
|
||||
*":$(BINDIR):"*) ;; \
|
||||
*) echo "note: $(BINDIR) is not on your PATH, so the plugin's skills will not find it" ;; \
|
||||
esac
|
||||
|
||||
# CGO_ENABLED=0 because these binaries are downloaded by people whose machines
|
||||
# are not this one: a build that links against the host's libc is a build that
|
||||
# runs on the host.
|
||||
dist:
|
||||
@rm -f $(DIST)/kettle_* $(DIST)/SHA256SUMS
|
||||
@mkdir -p $(DIST)
|
||||
@for p in $(PLATFORMS); do \
|
||||
os=$${p%/*}; arch=$${p#*/}; \
|
||||
out=$(DIST)/kettle_$(VERSION)_$${os}_$${arch}; \
|
||||
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch \
|
||||
go build -trimpath -ldflags '$(LDFLAGS)' -o $$out ./cmd/kettle || exit 1; \
|
||||
echo "dist $$out"; \
|
||||
done
|
||||
@cd $(DIST) && \
|
||||
if command -v sha256sum >/dev/null 2>&1; then \
|
||||
sha256sum kettle_$(VERSION)_* > SHA256SUMS; \
|
||||
else \
|
||||
shasum -a 256 kettle_$(VERSION)_* > SHA256SUMS; \
|
||||
fi
|
||||
@echo 'dist $(DIST)/SHA256SUMS'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cutting one
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Published by cmd/release, which is this module's own code against Gitea's own
|
||||
# API — no third-party action, nothing between the tag and what people download
|
||||
# that is not in this repository.
|
||||
#
|
||||
# The three refusals are the point of doing it here rather than by hand:
|
||||
#
|
||||
# dirty tree what shipped would not be what is committed, and nobody could
|
||||
# rebuild it;
|
||||
# wrong tag TAG must be the version `git describe` reports, which is only
|
||||
# true when the tag exists and HEAD is standing on it — so this
|
||||
# also catches "I forgot to tag" and "I tagged, then committed";
|
||||
# unpushed tag a release naming a tag the server does not have is a download
|
||||
# page pointing at a commit nobody else can fetch.
|
||||
release:
|
||||
@test -n '$(TAG)' || { echo 'usage: make release TAG=v1.2.3 [NOTES=notes.md] [TITLE="…"]'; exit 2; }
|
||||
@if [ -n "`git status --porcelain`" ]; then \
|
||||
echo 'refusing: the working tree is dirty — a release built from uncommitted code cannot be rebuilt'; \
|
||||
git status --short | sed 's/^/ /'; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if [ '$(VERSION)' != '$(TAG)' ]; then \
|
||||
echo 'refusing: TAG is $(TAG) but this commit describes as $(VERSION)'; \
|
||||
echo ' the binaries would be stamped $(VERSION) and the release would claim $(TAG).'; \
|
||||
echo ' tag this commit first: git tag -a $(TAG) -m $(TAG)'; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if ! git ls-remote --exit-code --tags $(REMOTE) 'refs/tags/$(TAG)' >/dev/null 2>&1; then \
|
||||
echo 'refusing: $(TAG) is not on $(REMOTE) — push it first: git push $(REMOTE) $(TAG)'; \
|
||||
echo ' (another remote? make release TAG=$(TAG) REMOTE=…)'; \
|
||||
exit 1; \
|
||||
fi
|
||||
@$(MAKE) dist
|
||||
@echo 'release publishing $(TAG) with cmd/release'
|
||||
@go run ./cmd/release --tag '$(TAG)' \
|
||||
$(if $(TITLE),--title '$(TITLE)') \
|
||||
$(if $(NOTES),--notes-file '$(NOTES)') \
|
||||
$(DIST)/kettle_$(VERSION)_* $(DIST)/SHA256SUMS
|
||||
|
||||
clean:
|
||||
@rm -rf $(DIST)
|
||||
@echo 'clean $(DIST) removed'
|
||||
@@ -0,0 +1,43 @@
|
||||
# AGENTS.md — cmd/kettle
|
||||
|
||||
The binary's entry point, and all of it:
|
||||
|
||||
```go
|
||||
func main() { os.Exit(cmd.Main(os.Args[1:])) }
|
||||
```
|
||||
|
||||
One file, `main.go`, holding a package comment and that line. The sibling
|
||||
[`cmd/release`](../release/AGENTS.md) is the module's other binary — build
|
||||
infrastructure, deliberately not a `kettle` verb.
|
||||
|
||||
## Why it is empty
|
||||
|
||||
Everything a `main` usually accumulates — flag parsing, dispatch, usage text,
|
||||
error formatting, exit codes — is in [`internal/cmd`](../../internal/cmd/AGENTS.md),
|
||||
where it is **testable**. A `main` package cannot be imported, so anything written
|
||||
here can only be exercised by running the binary; the command tree is instead a
|
||||
library with one caller, and its tests run it as a subprocess *and* call into it
|
||||
directly where that is cheaper.
|
||||
|
||||
The exit status is the only thing this layer owns, and it owns it because
|
||||
`os.Exit` skips deferred functions: it has to happen after everything else is
|
||||
finished, at the outermost frame, and nowhere else in the tree may call it.
|
||||
|
||||
The version a build reports is **not** stamped here either. `-ldflags -X` names
|
||||
`internal/cmd.Version`, because that is where the `version` command reads it and
|
||||
where a test can build with the flag and read the answer back — a `-X` whose symbol
|
||||
path is one character wrong is silently ignored, and the binary goes on saying `dev`.
|
||||
|
||||
## Adding a command
|
||||
|
||||
Nothing here changes. A new command is a `register(&Command{…})` in an `init()`
|
||||
over in [`internal/cmd`](../../internal/cmd/AGENTS.md) — that is the whole point
|
||||
of a registry.
|
||||
|
||||
## Keeping this file true
|
||||
|
||||
- **Scope:** `main.go`, and the reason it stays this short.
|
||||
- **Update it when** this package grows a second file or a line that does anything
|
||||
but delegate — which should be treated as a design change and argued for, not
|
||||
documented after the fact.
|
||||
- **Do not** describe commands, flags or exit codes here.
|
||||
@@ -0,0 +1,113 @@
|
||||
# AGENTS.md — cmd/release
|
||||
|
||||
**The release tool: publishes a Gitea release for this repository, from this
|
||||
repository's own code.** Driven by `make release TAG=v1.2.3`, never by a user.
|
||||
|
||||
| file | what is in it |
|
||||
|---|---|
|
||||
| `main.go` | flags, argument validation, exit codes — everything that can fail before a socket is opened |
|
||||
| `publish.go` | `spec`, `receipt`, its own small SDK `client`, and the converge/upload logic |
|
||||
| `release_test.go` | the whole tool against a fake Gitea, including every refusal |
|
||||
|
||||
## Why it is not a `kettle` subcommand
|
||||
|
||||
`kettle`'s command tree is not just a menu: it is what `kettle gen skills`
|
||||
generates the plugin's SKILL.md files from. A verb added there arrives in the
|
||||
documentation an agent loads and in the reference an operator reads, and **"publish
|
||||
a release" is not something either of them does.** Publishing is build
|
||||
infrastructure — it runs once, on a tag, by the person cutting it — and the thing
|
||||
users install should not carry it.
|
||||
|
||||
It is still this module's code, built on the same Gitea SDK, and that is the point:
|
||||
the release is published by the repository it is a release *of*, with nothing to
|
||||
trust that is not in this tree and no third-party tool between a tag and what people
|
||||
download.
|
||||
|
||||
## Why it does not use internal/gitea
|
||||
|
||||
[`internal/gitea`](../../internal/gitea/AGENTS.md) is otherwise the one door for
|
||||
every request. Two reasons this one goes around it, both facts about where it runs
|
||||
rather than preferences:
|
||||
|
||||
- that transport files every request body under `.kettle/payload/`, a path resolved
|
||||
from the project marker — and the marker is gitignored, so a fresh clone has none
|
||||
and a build tool has no business creating one;
|
||||
- **an asset upload's request body IS the binary.** Filing a 20 MB multipart body as
|
||||
JSON in a scratchpad helps nobody.
|
||||
|
||||
What it does **not** reinvent is credentials or error vocabulary.
|
||||
[`internal/config`](../../internal/config/AGENTS.md) resolves the instance, the token
|
||||
and the repository exactly as `kettle` does — through `ResolveOutsideAProject`,
|
||||
which falls back to the environment when there is no marker and reads the project
|
||||
config when there is — and `gitea.Fail` turns an SDK `(response, error)` pair into
|
||||
the same `*APIError` a `kettle push` would report, so "the tracker said no" has one
|
||||
spelling in the tree.
|
||||
|
||||
## Idempotent end to end
|
||||
|
||||
A tag that already has a release **reuses** it, an asset whose name is already there
|
||||
is **replaced**, and a run repeated because the first died half way through converges
|
||||
on the same release with the same assets — not a second release with doubled
|
||||
attachments.
|
||||
|
||||
Reuse alone would only make a re-run *not fail*; it would not make it **converge**. A
|
||||
second run with corrected notes has to leave the release holding the corrected notes,
|
||||
or the retry that fixed the mistake published the mistake again. Empty notes mean
|
||||
"leave what is there", not "clear them": `--notes-file` is how notes are supplied,
|
||||
and a run that supplied none is not asking for the release to be emptied.
|
||||
|
||||
A 404 from the release lookup is an **answer** — it is what "no release yet" looks
|
||||
like — and anything else is reported, because "the instance refused us" and "there is
|
||||
nothing there" must not both read as "create one".
|
||||
|
||||
The by-tag route is a lookup *through the tag*, and a draft need not have one, so a
|
||||
404 there is followed by a scan of the release listing before anything is created.
|
||||
Without it a retried `--draft` publish would file a second release for one tag —
|
||||
which is the failure this whole section exists to prevent, arriving through the one
|
||||
door that looks like the ordinary case.
|
||||
|
||||
## Order of operations
|
||||
|
||||
Everything that can be wrong in the arguments is reported **before a release exists
|
||||
to be half-published**:
|
||||
|
||||
1. `--tag` is required;
|
||||
2. every asset is stat'ed up front — a release that exists with half its assets on
|
||||
it, published by a run that then failed on a typo, is the failure this prevents;
|
||||
3. two files with one basename are refused, because an attachment is addressed by
|
||||
name and the second would silently replace the first while the receipt claimed
|
||||
both went up;
|
||||
4. notes are read from disk;
|
||||
5. only then does anything dial. The attachment listing is read once, before the
|
||||
first upload, so the names that matter are the ones that were there when the run
|
||||
started.
|
||||
|
||||
## Usage
|
||||
|
||||
Through the Makefile, which adds the three refusals that make a release
|
||||
reproducible — dirty tree, `TAG` that is not what `git describe` reports, tag not
|
||||
pushed to the remote:
|
||||
|
||||
```bash
|
||||
make release TAG=v1.2.3 [NOTES=notes.md] [TITLE="…"]
|
||||
```
|
||||
|
||||
Directly, when the Makefile is not what you want:
|
||||
|
||||
```bash
|
||||
KETTLE_URL=… KETTLE_TOKEN=… KETTLE_REPO=owner/name \
|
||||
go run ./cmd/release --tag v1.2.3 --notes-file notes.md dist/kettle_* dist/SHA256SUMS
|
||||
```
|
||||
|
||||
`--draft` and `--prerelease` are there; `--target` names the commitish a tag is
|
||||
created from when the tag does not exist yet.
|
||||
|
||||
## Keeping this file true
|
||||
|
||||
- **Scope:** `main.go`, `publish.go` and their test — the argument checks, the
|
||||
convergence rules, and the two decisions above about what this tool does *not*
|
||||
share with `kettle`.
|
||||
- **Update it when** a flag is added, the idempotency rules change, it starts or
|
||||
stops borrowing something from `internal/`, or the Makefile's refusals change.
|
||||
- **Do not** move any of this into `kettle`'s command registry without answering the
|
||||
first section — a verb here becomes documentation an agent loads.
|
||||
@@ -0,0 +1,155 @@
|
||||
// Command release publishes a Gitea release for this repository, from this
|
||||
// repository's own code.
|
||||
//
|
||||
// DELIBERATELY NOT A `kettle` SUBCOMMAND, and not for tidiness. `kettle` is a
|
||||
// tool for issues, and its command tree is not just a menu: it is what
|
||||
// `kettle gen skills` generates the plugin's SKILL.md files from. A verb added
|
||||
// there arrives in the documentation an agent loads and in the reference an
|
||||
// operator reads, and "publish a release" is not something either of them does.
|
||||
// Publishing is build infrastructure — it runs once, on a tag, by the person
|
||||
// cutting it — and the thing users install should not carry it.
|
||||
//
|
||||
// It is still this module's code, built on the same Gitea SDK the binary uses,
|
||||
// and that is the point: the release is published by the repository it is a
|
||||
// release of, with nothing to trust that is not in this tree and no third-party
|
||||
// tool between a tag and what people download.
|
||||
//
|
||||
// WHY IT DOES NOT USE internal/gitea, which is otherwise the one door for every
|
||||
// request. Two reasons, both facts about where this runs rather than
|
||||
// preferences:
|
||||
//
|
||||
// - that transport files every request body under `.kettle/payload/`, a path
|
||||
// resolved from the project marker — and the marker is gitignored, so a
|
||||
// fresh clone has none and a build tool has no business creating one;
|
||||
// - an asset upload's request body IS the binary. Filing a 20 MB multipart
|
||||
// body as JSON in a scratchpad helps nobody and would double the memory
|
||||
// this uses for no reader's benefit.
|
||||
//
|
||||
// What it does not reinvent is credentials. internal/config resolves the
|
||||
// instance, the token and the repository exactly as kettle does, environment
|
||||
// first, and refuses a half-filled configuration by naming what is missing.
|
||||
// The error vocabulary is internal/gitea's too — a failure here carries the
|
||||
// status and quotes what the server said, in the same words a `kettle push`
|
||||
// would use.
|
||||
//
|
||||
// IDEMPOTENT END TO END. A tag that already has a release reuses it, an asset
|
||||
// whose name is already there replaces it, and a run that is repeated because
|
||||
// the first one died half way through converges on the same release with the
|
||||
// same assets instead of a second release and doubled attachments.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
||||
)
|
||||
|
||||
const usage = `usage: release --tag <tag> [flags] [<file>…]
|
||||
|
||||
Publish a Gitea release for the repository this configuration points at, and
|
||||
upload each named file as an asset. Re-running it is safe: an existing release
|
||||
for the tag is reused and an asset of the same name is replaced, never doubled.
|
||||
|
||||
Credentials resolve the way kettle's do — ` + config.EnvURL + `, ` + config.EnvToken + ` and
|
||||
` + config.EnvRepo + `, or, when this is run from inside an initialized project, that
|
||||
project's .kettle/config.yaml and the machine's login file.
|
||||
|
||||
flags:
|
||||
`
|
||||
|
||||
func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) }
|
||||
|
||||
// run is main with its edges handed in, so a test can drive the whole tool.
|
||||
func run(argv []string, stdout, stderr io.Writer) int {
|
||||
fs := flag.NewFlagSet("release", flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
fs.Usage = func() {
|
||||
fmt.Fprint(stderr, usage)
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
|
||||
tag := fs.String("tag", "", "the tag to publish, e.g. v1.2.3 (required)")
|
||||
title := fs.String("title", "", "release title (default: the tag)")
|
||||
notesFile := fs.String("notes-file", "", "file holding the release notes; empty leaves an existing release's notes alone")
|
||||
target := fs.String("target", "", "commitish a tag is created from when the tag does not exist yet (default: the default branch)")
|
||||
draft := fs.Bool("draft", false, "publish as a draft")
|
||||
prerelease := fs.Bool("prerelease", false, "mark as a prerelease")
|
||||
|
||||
if err := fs.Parse(argv); err != nil {
|
||||
return 2 // flag has already said what it did not like
|
||||
}
|
||||
|
||||
s := spec{
|
||||
Tag: strings.TrimSpace(*tag),
|
||||
Title: strings.TrimSpace(*title),
|
||||
Target: strings.TrimSpace(*target),
|
||||
Draft: *draft,
|
||||
Prerelease: *prerelease,
|
||||
Files: fs.Args(),
|
||||
}
|
||||
if s.Tag == "" {
|
||||
fmt.Fprintln(stderr, "release: --tag is required — the tag this release is for")
|
||||
fs.Usage()
|
||||
return 2
|
||||
}
|
||||
if err := checkFiles(s.Files); err != nil {
|
||||
fmt.Fprintf(stderr, "release: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
if *notesFile != "" {
|
||||
raw, err := os.ReadFile(*notesFile)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "release: reading the notes: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
s.Notes = string(raw)
|
||||
}
|
||||
|
||||
// Nothing above this line dials, and that is the order it is written in:
|
||||
// every mistake a person can make in the arguments is reported before a
|
||||
// release exists to be half-published.
|
||||
cfg, err := config.ResolveOutsideAProject("")
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "release: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
got, err := publish(cfg, s)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "release: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
got.print(stdout)
|
||||
return 0
|
||||
}
|
||||
|
||||
// checkFiles refuses what would fail later, before anything is created.
|
||||
//
|
||||
// Every asset is stat'ed up front because the alternative is a release that
|
||||
// exists with half its assets on it, published by a run that then failed on a
|
||||
// typo. Two files with one basename are refused for the same reason from the
|
||||
// other direction: an attachment is addressed by name, so the second would
|
||||
// replace the first and the receipt would claim both went up.
|
||||
func checkFiles(files []string) error {
|
||||
seen := map[string]string{}
|
||||
for _, path := range files {
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot upload %s: %w", path, err)
|
||||
}
|
||||
if fi.IsDir() {
|
||||
return fmt.Errorf("cannot upload %s: it is a directory", path)
|
||||
}
|
||||
name := filepath.Base(path)
|
||||
if first, ok := seen[name]; ok {
|
||||
return fmt.Errorf("%s and %s are both %q — an asset is addressed by name, so the second would replace the first",
|
||||
first, path, name)
|
||||
}
|
||||
seen[name] = path
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
sdk "code.gitea.io/sdk/gitea"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
|
||||
)
|
||||
|
||||
const (
|
||||
// userAgent names this tool in the instance's log. An admin looking at a
|
||||
// burst of requests should be able to tell a release from a push.
|
||||
userAgent = "kettle-release"
|
||||
// requestTimeout bounds one call. Generous next to the transport's 30s
|
||||
// because one of these calls is an upload: a 20 MB binary over a domestic
|
||||
// connection is minutes, and a run that gives up half way through its
|
||||
// assets is exactly the mess this tool exists to avoid.
|
||||
requestTimeout = 10 * time.Minute
|
||||
// pageLimit and maxPages bound the two listings this makes. A repository
|
||||
// with a runaway number of releases must not turn one publish into an
|
||||
// unbounded read.
|
||||
pageLimit = 50
|
||||
maxPages = 20
|
||||
)
|
||||
|
||||
// spec is what was asked for: one release, and the files that belong on it.
|
||||
type spec struct {
|
||||
Tag string
|
||||
Title string
|
||||
Notes string
|
||||
Target string
|
||||
Draft bool
|
||||
Prerelease bool
|
||||
Files []string
|
||||
}
|
||||
|
||||
// title defaults to the tag, because Gitea refuses a release without one and
|
||||
// "v1.2.3" is what a person would have typed anyway.
|
||||
func (s spec) title() string {
|
||||
if s.Title != "" {
|
||||
return s.Title
|
||||
}
|
||||
return s.Tag
|
||||
}
|
||||
|
||||
// asset is one file that ended up on the release.
|
||||
type asset struct {
|
||||
Name string
|
||||
URL string
|
||||
// Replaced records that an attachment of this name was already there and
|
||||
// was removed to make room. Two assets with one name is the failure mode a
|
||||
// retried publish has, and it is silent: the download URL is by name.
|
||||
Replaced bool
|
||||
}
|
||||
|
||||
// receipt is what happened, in the words the run will print.
|
||||
type receipt struct {
|
||||
Repo string
|
||||
Release *sdk.Release
|
||||
// State is "created", "updated" or "reused" — which of the three a re-run
|
||||
// hit is the whole question an operator has about idempotency.
|
||||
State string
|
||||
Assets []asset
|
||||
}
|
||||
|
||||
// publish makes the tracker say what the spec says, and reports what it did.
|
||||
func publish(cfg *config.Resolved, s spec) (*receipt, error) {
|
||||
c, err := newClient(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rel, state, err := c.releaseFor(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assets, err := c.uploadAll(rel, s.Files)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &receipt{Repo: c.slug(), Release: rel, State: state, Assets: assets}, nil
|
||||
}
|
||||
|
||||
// print writes the receipt: what happened to the release, every asset that
|
||||
// ended up on it, and the URL a person opens.
|
||||
func (r *receipt) print(w io.Writer) {
|
||||
fmt.Fprintf(w, "%-9s release %s in %s\n", r.State, r.Release.TagName, r.Repo)
|
||||
|
||||
width := 0
|
||||
for _, a := range r.Assets {
|
||||
if n := utf8.RuneCountInString(a.Name); n > width {
|
||||
width = n
|
||||
}
|
||||
}
|
||||
uploaded, replaced := 0, 0
|
||||
for _, a := range r.Assets {
|
||||
verb := "uploaded"
|
||||
uploaded++
|
||||
if a.Replaced {
|
||||
verb, replaced = "replaced", replaced+1
|
||||
}
|
||||
fmt.Fprintf(w, "%-9s %-*s %s\n", verb, width, a.Name, a.URL)
|
||||
}
|
||||
|
||||
if url := r.Release.HTMLURL; url != "" {
|
||||
fmt.Fprintf(w, "%-9s %s\n", "release", url)
|
||||
}
|
||||
fmt.Fprintf(w, "%d asset(s): %d uploaded, %d replaced — draft: %s, prerelease: %s\n",
|
||||
len(r.Assets), uploaded-replaced, replaced,
|
||||
yesNo(r.Release.IsDraft), yesNo(r.Release.IsPrerelease))
|
||||
}
|
||||
|
||||
func yesNo(b bool) string {
|
||||
if b {
|
||||
return "yes"
|
||||
}
|
||||
return "no"
|
||||
}
|
||||
|
||||
// client is one repository on one instance.
|
||||
type client struct {
|
||||
api *sdk.Client
|
||||
owner, name string
|
||||
}
|
||||
|
||||
func (c *client) slug() string { return c.owner + "/" + c.name }
|
||||
|
||||
// newClient refuses a half-filled configuration before it dials, the same way
|
||||
// gitea.New does and for the same reason: building a client is itself a
|
||||
// request — the SDK asks the instance for its version before it hands one back
|
||||
// — and a missing token reported as a connection failure sends whoever is
|
||||
// reading it to the wrong place.
|
||||
func newClient(cfg *config.Resolved) (*client, error) {
|
||||
if cfg == nil {
|
||||
return nil, errors.New("no resolved configuration — call config.ResolveOutsideAProject first")
|
||||
}
|
||||
if err := cfg.Complete(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
base := strings.TrimRight(cfg.URL, "/")
|
||||
api, err := sdk.NewClient(base,
|
||||
sdk.SetToken(cfg.Token),
|
||||
sdk.SetHTTPClient(&http.Client{Timeout: requestTimeout}),
|
||||
sdk.SetUserAgent(userAgent))
|
||||
if err != nil {
|
||||
if errors.Is(err, &sdk.ErrUnknownVersion{}) {
|
||||
return nil, fmt.Errorf("%s did not answer with a version this can read (%w)"+
|
||||
" — check that %s points at a Gitea instance", base, err, config.EnvURL)
|
||||
}
|
||||
return nil, fmt.Errorf("cannot reach the Gitea instance at %s: %w", base, err)
|
||||
}
|
||||
return &client{api: api, owner: cfg.Owner, name: cfg.Repo}, nil
|
||||
}
|
||||
|
||||
// releaseFor is the release this tag should have, created or brought into line.
|
||||
func (c *client) releaseFor(s spec) (*sdk.Release, string, error) {
|
||||
found, err := c.find(s.Tag)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if found == nil {
|
||||
rel, err := c.create(s)
|
||||
return rel, "created", err
|
||||
}
|
||||
rel, changed, err := c.converge(found, s)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if changed {
|
||||
return rel, "updated", nil
|
||||
}
|
||||
return rel, "reused", nil
|
||||
}
|
||||
|
||||
// find is the release for this tag, or nil when the repository has none.
|
||||
//
|
||||
// A 404 is an answer here and not a failure — it is what "no release yet"
|
||||
// looks like, which is the ordinary case the first time a tag is published.
|
||||
// Anything else is reported, because "the instance refused us" and "there is
|
||||
// nothing there" must not both read as "create one".
|
||||
func (c *client) find(tag string) (*sdk.Release, error) {
|
||||
got, resp, err := c.api.GetReleaseByTag(c.owner, c.name, tag)
|
||||
if err == nil {
|
||||
return got, nil
|
||||
}
|
||||
if failed := gitea.Fail(resp, err); !gitea.StatusIs(failed, http.StatusNotFound) {
|
||||
return nil, fmt.Errorf("looking for a release on %s: %w", tag, failed)
|
||||
}
|
||||
// The by-tag route is a lookup through the tag, and a draft need not have
|
||||
// one — so a draft this tool created on an earlier run can answer 404 to
|
||||
// the question "is it already there?". Scanning the listing is what keeps a
|
||||
// retried `--draft` publish from filing a second release for one tag.
|
||||
return c.scan(tag)
|
||||
}
|
||||
|
||||
// scan walks the release listing for this tag.
|
||||
func (c *client) scan(tag string) (*sdk.Release, error) {
|
||||
for page := 1; page <= maxPages; page++ {
|
||||
batch, resp, err := c.api.ListReleases(c.owner, c.name, sdk.ListReleasesOptions{
|
||||
ListOptions: sdk.ListOptions{Page: page, PageSize: pageLimit},
|
||||
})
|
||||
if err := gitea.Fail(resp, err); err != nil {
|
||||
return nil, fmt.Errorf("listing releases: %w", err)
|
||||
}
|
||||
for _, rel := range batch {
|
||||
if rel.TagName == tag {
|
||||
return rel, nil
|
||||
}
|
||||
}
|
||||
if len(batch) < pageLimit {
|
||||
return nil, nil // a short page is the last one
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (c *client) create(s spec) (*sdk.Release, error) {
|
||||
got, resp, err := c.api.CreateRelease(c.owner, c.name, sdk.CreateReleaseOption{
|
||||
TagName: s.Tag,
|
||||
Target: s.Target,
|
||||
Title: s.title(),
|
||||
Note: s.Notes,
|
||||
IsDraft: s.Draft,
|
||||
IsPrerelease: s.Prerelease,
|
||||
})
|
||||
if err := gitea.Fail(resp, err); err != nil {
|
||||
return nil, fmt.Errorf("creating the release for %s: %w", s.Tag, err)
|
||||
}
|
||||
if got == nil || got.ID == 0 {
|
||||
return nil, fmt.Errorf("creating the release for %s: the tracker's answer carries no id", s.Tag)
|
||||
}
|
||||
return got, nil
|
||||
}
|
||||
|
||||
// converge edits an existing release until it says what the spec says, and
|
||||
// reports whether anything had to change.
|
||||
//
|
||||
// Reuse alone would be enough to make a re-run not fail; it would not make it
|
||||
// CONVERGE. A second run with corrected notes has to leave the release holding
|
||||
// the corrected notes, or the retry that fixed the mistake published the
|
||||
// mistake again.
|
||||
//
|
||||
// Empty notes mean "leave what is there", not "clear them": `--notes-file` is
|
||||
// how notes are supplied, and a run that did not supply any is not a run asking
|
||||
// for the release to be emptied.
|
||||
func (c *client) converge(rel *sdk.Release, s spec) (*sdk.Release, bool, error) {
|
||||
note := s.Notes
|
||||
if note == "" {
|
||||
note = rel.Note
|
||||
}
|
||||
if rel.Title == s.title() && rel.Note == note &&
|
||||
rel.IsDraft == s.Draft && rel.IsPrerelease == s.Prerelease {
|
||||
return rel, false, nil
|
||||
}
|
||||
|
||||
draft, prerelease := s.Draft, s.Prerelease
|
||||
got, resp, err := c.api.EditRelease(c.owner, c.name, rel.ID, sdk.EditReleaseOption{
|
||||
TagName: rel.TagName,
|
||||
Title: s.title(),
|
||||
Note: note,
|
||||
IsDraft: &draft,
|
||||
IsPrerelease: &prerelease,
|
||||
})
|
||||
if err := gitea.Fail(resp, err); err != nil {
|
||||
return nil, false, fmt.Errorf("updating the release for %s: %w", rel.TagName, err)
|
||||
}
|
||||
if got == nil || got.ID == 0 {
|
||||
return nil, false, fmt.Errorf("updating the release for %s: the tracker's answer carries no id", rel.TagName)
|
||||
}
|
||||
return got, true, nil
|
||||
}
|
||||
|
||||
// uploadAll puts every named file on the release, replacing an attachment that
|
||||
// already carries that name.
|
||||
//
|
||||
// The listing is read once, before the first upload, because the names that
|
||||
// matter are the ones that were there when the run started: the files being
|
||||
// uploaded are checked for duplicate basenames up front, so nothing this run
|
||||
// adds can collide with something else this run adds.
|
||||
func (c *client) uploadAll(rel *sdk.Release, files []string) ([]asset, error) {
|
||||
if len(files) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
existing, err := c.attachments(rel.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byName := map[string][]*sdk.Attachment{}
|
||||
for _, a := range existing {
|
||||
byName[a.Name] = append(byName[a.Name], a)
|
||||
}
|
||||
|
||||
out := make([]asset, 0, len(files))
|
||||
for _, path := range files {
|
||||
name := filepath.Base(path)
|
||||
replaced := false
|
||||
// Removed before the upload rather than after it. Gitea will happily
|
||||
// hold two attachments with one name, and the download URL names the
|
||||
// file — so the state to avoid at all costs is the ambiguous one, not
|
||||
// the momentarily absent one.
|
||||
for _, old := range byName[name] {
|
||||
if resp, err := c.api.DeleteReleaseAttachment(c.owner, c.name, rel.ID, old.ID); err != nil {
|
||||
return out, fmt.Errorf("removing the old %s: %w", name, gitea.Fail(resp, err))
|
||||
}
|
||||
replaced = true
|
||||
}
|
||||
got, err := c.upload(rel.ID, path, name)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out = append(out, asset{Name: name, URL: got.DownloadURL, Replaced: replaced})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *client) attachments(release int64) ([]*sdk.Attachment, error) {
|
||||
var out []*sdk.Attachment
|
||||
for page := 1; page <= maxPages; page++ {
|
||||
batch, resp, err := c.api.ListReleaseAttachments(c.owner, c.name, release,
|
||||
sdk.ListReleaseAttachmentsOptions{ListOptions: sdk.ListOptions{Page: page, PageSize: pageLimit}})
|
||||
if err := gitea.Fail(resp, err); err != nil {
|
||||
return nil, fmt.Errorf("listing the release's assets: %w", err)
|
||||
}
|
||||
out = append(out, batch...)
|
||||
if len(batch) < pageLimit {
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *client) upload(release int64, path, name string) (*sdk.Attachment, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("uploading %s: %w", name, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
got, resp, err := c.api.CreateReleaseAttachment(c.owner, c.name, release, f, name)
|
||||
if err := gitea.Fail(resp, err); err != nil {
|
||||
return nil, fmt.Errorf("uploading %s: %w", name, err)
|
||||
}
|
||||
if got == nil {
|
||||
return nil, fmt.Errorf("uploading %s: the tracker's answer carries no attachment", name)
|
||||
}
|
||||
return got, nil
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
package main
|
||||
|
||||
// The publisher is tested against httptest, never against an instance: a test
|
||||
// that needs a server somewhere is a test nobody runs, and this is the one tool
|
||||
// in the tree whose mistakes are visible to everybody who downloads a binary.
|
||||
//
|
||||
// Every fixture points CLAUDE_PROJECT_DIR at an empty temp directory — no
|
||||
// `.kettle/` marker anywhere on the way up, which is the state a fresh clone is
|
||||
// in and the whole reason this tool resolves its configuration the way it does
|
||||
// — and KETTLE_CONFIG_HOME at another, so a run can neither read nor overwrite
|
||||
// the developer's own tokens.
|
||||
//
|
||||
// THE FAKE ANSWERS /api/v1/version, because building an SDK client is itself a
|
||||
// request: the SDK asks the instance what it is before it hands a client back,
|
||||
// and a fake that did not answer is a fake nothing can be built against.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
sdk "code.gitea.io/sdk/gitea"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
||||
)
|
||||
|
||||
// modernGitea is what the fake says it is: new enough for every route this
|
||||
// tool asks for.
|
||||
const modernGitea = "1.26.1"
|
||||
|
||||
// harmless points every fixture away from the machine it runs on.
|
||||
func harmless(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("CLAUDE_PROJECT_DIR", dir)
|
||||
t.Setenv(config.EnvHome, filepath.Join(dir, "config"))
|
||||
// An exported KETTLE_URL in the developer's shell would otherwise decide
|
||||
// what a test resolved to, and one of these tests is about resolving
|
||||
// nothing at all.
|
||||
for _, key := range []string{config.EnvURL, config.EnvToken, config.EnvRepo, config.EnvLogin} {
|
||||
t.Setenv(key, "")
|
||||
}
|
||||
}
|
||||
|
||||
func configFor(url string) *config.Resolved {
|
||||
return &config.Resolved{URL: url, Token: "s3cret", Owner: "acme", Repo: "widgets"}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// the fake tracker
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
type fake struct {
|
||||
mu sync.Mutex
|
||||
base string
|
||||
version string
|
||||
nextID int64
|
||||
releases []*sdk.Release
|
||||
assets map[int64][]*sdk.Attachment
|
||||
content map[int64][]byte
|
||||
requests []string
|
||||
|
||||
// hideDraftsFromTheTagRoute makes the by-tag lookup answer 404 for a draft,
|
||||
// which is what an instance does when the tag itself is not in git yet.
|
||||
hideDraftsFromTheTagRoute bool
|
||||
}
|
||||
|
||||
func newFake(t *testing.T) *fake {
|
||||
t.Helper()
|
||||
f := &fake{
|
||||
version: modernGitea,
|
||||
assets: map[int64][]*sdk.Attachment{},
|
||||
content: map[int64][]byte{},
|
||||
}
|
||||
srv := httptest.NewServer(f)
|
||||
t.Cleanup(srv.Close)
|
||||
f.base = srv.URL
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fake) url() string { return f.base }
|
||||
|
||||
func (f *fake) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.requests = append(f.requests, r.Method+" "+r.URL.Path)
|
||||
|
||||
if r.URL.Path == "/api/v1/version" {
|
||||
writeJSON(w, map[string]string{"version": f.version})
|
||||
return
|
||||
}
|
||||
rest, ok := strings.CutPrefix(r.URL.Path, "/api/v1/repos/acme/widgets/releases")
|
||||
if !ok {
|
||||
f.refuse(w, http.StatusNotFound, "the fake has no route for "+r.URL.Path)
|
||||
return
|
||||
}
|
||||
var parts []string
|
||||
if rest = strings.Trim(rest, "/"); rest != "" {
|
||||
parts = strings.Split(rest, "/")
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(parts) == 0 && r.Method == http.MethodGet:
|
||||
f.list(w, r)
|
||||
case len(parts) == 0 && r.Method == http.MethodPost:
|
||||
f.create(w, r)
|
||||
case len(parts) == 2 && parts[0] == "tags" && r.Method == http.MethodGet:
|
||||
f.byTag(w, parts[1])
|
||||
case len(parts) == 1 && r.Method == http.MethodPatch:
|
||||
f.edit(w, r, parts[0])
|
||||
case len(parts) == 2 && parts[1] == "assets" && r.Method == http.MethodGet:
|
||||
f.listAssets(w, parts[0])
|
||||
case len(parts) == 2 && parts[1] == "assets" && r.Method == http.MethodPost:
|
||||
f.addAsset(w, r, parts[0])
|
||||
case len(parts) == 3 && parts[1] == "assets" && r.Method == http.MethodDelete:
|
||||
f.dropAsset(w, parts[0], parts[2])
|
||||
default:
|
||||
f.refuse(w, http.StatusNotFound, "the fake has no route for "+r.Method+" "+r.URL.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fake) list(w http.ResponseWriter, r *http.Request) {
|
||||
if page := r.URL.Query().Get("page"); page != "" && page != "1" {
|
||||
writeJSON(w, []*sdk.Release{})
|
||||
return
|
||||
}
|
||||
writeJSON(w, f.releases)
|
||||
}
|
||||
|
||||
func (f *fake) create(w http.ResponseWriter, r *http.Request) {
|
||||
var opt sdk.CreateReleaseOption
|
||||
if err := json.NewDecoder(r.Body).Decode(&opt); err != nil {
|
||||
f.refuse(w, http.StatusUnprocessableEntity, err.Error())
|
||||
return
|
||||
}
|
||||
f.nextID++
|
||||
rel := &sdk.Release{
|
||||
ID: f.nextID,
|
||||
TagName: opt.TagName,
|
||||
Target: opt.Target,
|
||||
Title: opt.Title,
|
||||
Note: opt.Note,
|
||||
IsDraft: opt.IsDraft,
|
||||
IsPrerelease: opt.IsPrerelease,
|
||||
HTMLURL: f.base + "/acme/widgets/releases/tag/" + opt.TagName,
|
||||
}
|
||||
f.releases = append(f.releases, rel)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
writeJSON(w, rel)
|
||||
}
|
||||
|
||||
func (f *fake) byTag(w http.ResponseWriter, tag string) {
|
||||
for _, rel := range f.releases {
|
||||
if rel.TagName != tag {
|
||||
continue
|
||||
}
|
||||
if rel.IsDraft && f.hideDraftsFromTheTagRoute {
|
||||
break
|
||||
}
|
||||
writeJSON(w, rel)
|
||||
return
|
||||
}
|
||||
f.refuse(w, http.StatusNotFound, "release with tag '"+tag+"' not found")
|
||||
}
|
||||
|
||||
func (f *fake) edit(w http.ResponseWriter, r *http.Request, id string) {
|
||||
rel := f.release(id)
|
||||
if rel == nil {
|
||||
f.refuse(w, http.StatusNotFound, "no release "+id)
|
||||
return
|
||||
}
|
||||
var opt sdk.EditReleaseOption
|
||||
if err := json.NewDecoder(r.Body).Decode(&opt); err != nil {
|
||||
f.refuse(w, http.StatusUnprocessableEntity, err.Error())
|
||||
return
|
||||
}
|
||||
// Gitea's own semantics: an empty string leaves the field alone.
|
||||
if opt.Title != "" {
|
||||
rel.Title = opt.Title
|
||||
}
|
||||
if opt.Note != "" {
|
||||
rel.Note = opt.Note
|
||||
}
|
||||
if opt.IsDraft != nil {
|
||||
rel.IsDraft = *opt.IsDraft
|
||||
}
|
||||
if opt.IsPrerelease != nil {
|
||||
rel.IsPrerelease = *opt.IsPrerelease
|
||||
}
|
||||
writeJSON(w, rel)
|
||||
}
|
||||
|
||||
func (f *fake) listAssets(w http.ResponseWriter, id string) {
|
||||
rel := f.release(id)
|
||||
if rel == nil {
|
||||
f.refuse(w, http.StatusNotFound, "no release "+id)
|
||||
return
|
||||
}
|
||||
got := f.assets[rel.ID]
|
||||
if got == nil {
|
||||
got = []*sdk.Attachment{}
|
||||
}
|
||||
writeJSON(w, got)
|
||||
}
|
||||
|
||||
func (f *fake) addAsset(w http.ResponseWriter, r *http.Request, id string) {
|
||||
rel := f.release(id)
|
||||
if rel == nil {
|
||||
f.refuse(w, http.StatusNotFound, "no release "+id)
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("attachment")
|
||||
if err != nil {
|
||||
f.refuse(w, http.StatusUnprocessableEntity, "no attachment in the form: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
raw, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
f.refuse(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
f.nextID++
|
||||
a := &sdk.Attachment{
|
||||
ID: f.nextID,
|
||||
Name: header.Filename,
|
||||
Size: int64(len(raw)),
|
||||
DownloadURL: f.base + "/acme/widgets/releases/download/" + rel.TagName + "/" + header.Filename,
|
||||
}
|
||||
f.assets[rel.ID] = append(f.assets[rel.ID], a)
|
||||
f.content[a.ID] = raw
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
writeJSON(w, a)
|
||||
}
|
||||
|
||||
func (f *fake) dropAsset(w http.ResponseWriter, id, asset string) {
|
||||
rel := f.release(id)
|
||||
if rel == nil {
|
||||
f.refuse(w, http.StatusNotFound, "no release "+id)
|
||||
return
|
||||
}
|
||||
want, _ := strconv.ParseInt(asset, 10, 64)
|
||||
kept := make([]*sdk.Attachment, 0, len(f.assets[rel.ID]))
|
||||
for _, a := range f.assets[rel.ID] {
|
||||
if a.ID != want {
|
||||
kept = append(kept, a)
|
||||
}
|
||||
}
|
||||
f.assets[rel.ID] = kept
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (f *fake) release(id string) *sdk.Release {
|
||||
want, err := strconv.ParseInt(id, 10, 64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for _, rel := range f.releases {
|
||||
if rel.ID == want {
|
||||
return rel
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fake) refuse(w http.ResponseWriter, status int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"message": message})
|
||||
}
|
||||
|
||||
// assetNamed is what the tracker holds under this name, for the test that says
|
||||
// a replacement leaves exactly one.
|
||||
func (f *fake) assetNamed(name string) []*sdk.Attachment {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []*sdk.Attachment
|
||||
for _, batch := range f.assets {
|
||||
for _, a := range batch {
|
||||
if a.Name == name {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (f *fake) bytesOf(a *sdk.Attachment) string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return string(f.content[a.ID])
|
||||
}
|
||||
|
||||
func (f *fake) calls() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]string{}, f.requests...)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, dir, name, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// the tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// The ordinary case: a tag nobody has published yet, and two files that belong
|
||||
// on it.
|
||||
func TestItCreatesTheReleaseAndUploadsEveryAsset(t *testing.T) {
|
||||
harmless(t)
|
||||
f := newFake(t)
|
||||
dir := t.TempDir()
|
||||
binary := writeFile(t, dir, "kettle_v1.2.3_linux_amd64", "a binary, honestly")
|
||||
sums := writeFile(t, dir, "SHA256SUMS", "beef kettle_v1.2.3_linux_amd64\n")
|
||||
|
||||
got, err := publish(configFor(f.url()), spec{
|
||||
Tag: "v1.2.3",
|
||||
Notes: "what changed\n",
|
||||
Files: []string{binary, sums},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("publish: %v", err)
|
||||
}
|
||||
|
||||
if got.State != "created" {
|
||||
t.Errorf("state is %q, want created", got.State)
|
||||
}
|
||||
if len(f.releases) != 1 {
|
||||
t.Fatalf("the tracker holds %d release(s), want 1", len(f.releases))
|
||||
}
|
||||
rel := f.releases[0]
|
||||
if rel.TagName != "v1.2.3" || rel.Note != "what changed\n" {
|
||||
t.Errorf("the release is %+v", rel)
|
||||
}
|
||||
// Gitea refuses a release with no title, so the tag stands in for one.
|
||||
if rel.Title != "v1.2.3" {
|
||||
t.Errorf("title is %q, want the tag", rel.Title)
|
||||
}
|
||||
if len(got.Assets) != 2 {
|
||||
t.Fatalf("got %d asset(s), want 2", len(got.Assets))
|
||||
}
|
||||
for name, want := range map[string]string{
|
||||
"kettle_v1.2.3_linux_amd64": "a binary, honestly",
|
||||
"SHA256SUMS": "beef kettle_v1.2.3_linux_amd64\n",
|
||||
} {
|
||||
held := f.assetNamed(name)
|
||||
if len(held) != 1 {
|
||||
t.Fatalf("the tracker holds %d attachment(s) called %s, want 1", len(held), name)
|
||||
}
|
||||
if body := f.bytesOf(held[0]); body != want {
|
||||
t.Errorf("%s arrived as %q, want %q", name, body, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The receipt is the whole user experience of a tool nobody watches run.
|
||||
var out strings.Builder
|
||||
got.print(&out)
|
||||
for _, want := range []string{"created", "v1.2.3", "acme/widgets", rel.HTMLURL,
|
||||
"kettle_v1.2.3_linux_amd64", "SHA256SUMS", "2 asset(s)"} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Errorf("the receipt does not name %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A re-run is not a failure and not a second release. It is also not a no-op
|
||||
// when something changed: a retry that fixed the notes has to leave the fixed
|
||||
// notes behind.
|
||||
func TestARerunConvergesInsteadOfPublishingTwice(t *testing.T) {
|
||||
harmless(t)
|
||||
f := newFake(t)
|
||||
dir := t.TempDir()
|
||||
binary := writeFile(t, dir, "kettle_v2.0.0_darwin_arm64", "one")
|
||||
|
||||
first, err := publish(configFor(f.url()), spec{Tag: "v2.0.0", Notes: "first go", Files: []string{binary}})
|
||||
if err != nil {
|
||||
t.Fatalf("the first publish: %v", err)
|
||||
}
|
||||
again, err := publish(configFor(f.url()), spec{Tag: "v2.0.0", Notes: "first go", Files: []string{binary}})
|
||||
if err != nil {
|
||||
t.Fatalf("the second publish: %v", err)
|
||||
}
|
||||
|
||||
if len(f.releases) != 1 {
|
||||
t.Fatalf("a re-run left %d releases for one tag", len(f.releases))
|
||||
}
|
||||
if again.State != "reused" {
|
||||
t.Errorf("state is %q, want reused — nothing had changed", again.State)
|
||||
}
|
||||
if again.Release.ID != first.Release.ID {
|
||||
t.Errorf("the re-run published a different release (%d, was %d)", again.Release.ID, first.Release.ID)
|
||||
}
|
||||
if held := f.assetNamed("kettle_v2.0.0_darwin_arm64"); len(held) != 1 {
|
||||
t.Errorf("the tracker holds %d copies of the one asset", len(held))
|
||||
}
|
||||
|
||||
// And the corrected notes actually land.
|
||||
fixed, err := publish(configFor(f.url()), spec{Tag: "v2.0.0", Notes: "second go", Files: []string{binary}})
|
||||
if err != nil {
|
||||
t.Fatalf("the third publish: %v", err)
|
||||
}
|
||||
if fixed.State != "updated" {
|
||||
t.Errorf("state is %q, want updated — the notes changed", fixed.State)
|
||||
}
|
||||
if f.releases[0].Note != "second go" {
|
||||
t.Errorf("the notes are %q, want the corrected ones", f.releases[0].Note)
|
||||
}
|
||||
if len(f.releases) != 1 {
|
||||
t.Errorf("converging forked the release: %d of them", len(f.releases))
|
||||
}
|
||||
}
|
||||
|
||||
// Two attachments with one name is the silent failure: the download URL names
|
||||
// the file, so the second copy is not addressable and nobody notices which one
|
||||
// people got.
|
||||
func TestAnAssetOfTheSameNameIsReplacedRatherThanDoubled(t *testing.T) {
|
||||
harmless(t)
|
||||
f := newFake(t)
|
||||
dir := t.TempDir()
|
||||
|
||||
path := writeFile(t, dir, "kettle_v3.0.0_linux_arm64", "the first build")
|
||||
if _, err := publish(configFor(f.url()), spec{Tag: "v3.0.0", Files: []string{path}}); err != nil {
|
||||
t.Fatalf("the first publish: %v", err)
|
||||
}
|
||||
// Same name, different bytes — a rebuild after a fix, which is exactly when
|
||||
// somebody re-runs this.
|
||||
writeFile(t, dir, "kettle_v3.0.0_linux_arm64", "the second build")
|
||||
got, err := publish(configFor(f.url()), spec{Tag: "v3.0.0", Files: []string{path}})
|
||||
if err != nil {
|
||||
t.Fatalf("the second publish: %v", err)
|
||||
}
|
||||
|
||||
held := f.assetNamed("kettle_v3.0.0_linux_arm64")
|
||||
if len(held) != 1 {
|
||||
t.Fatalf("the release carries %d attachments of that name, want 1", len(held))
|
||||
}
|
||||
if body := f.bytesOf(held[0]); body != "the second build" {
|
||||
t.Errorf("the asset is %q — the replacement did not take", body)
|
||||
}
|
||||
if len(got.Assets) != 1 || !got.Assets[0].Replaced {
|
||||
t.Errorf("the receipt does not report a replacement: %+v", got.Assets)
|
||||
}
|
||||
var out strings.Builder
|
||||
got.print(&out)
|
||||
if !strings.Contains(out.String(), "replaced") {
|
||||
t.Errorf("the receipt does not say it replaced anything:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A draft has no git tag behind it, so the by-tag route can answer 404 for a
|
||||
// release that is plainly there. A publish that believed it would file a second
|
||||
// release every time it was retried.
|
||||
func TestADraftIsFoundEvenWhenTheTagRouteHidesIt(t *testing.T) {
|
||||
harmless(t)
|
||||
f := newFake(t)
|
||||
f.hideDraftsFromTheTagRoute = true
|
||||
|
||||
s := spec{Tag: "v4.0.0", Draft: true}
|
||||
if _, err := publish(configFor(f.url()), s); err != nil {
|
||||
t.Fatalf("the first publish: %v", err)
|
||||
}
|
||||
again, err := publish(configFor(f.url()), s)
|
||||
if err != nil {
|
||||
t.Fatalf("the second publish: %v", err)
|
||||
}
|
||||
if len(f.releases) != 1 {
|
||||
t.Fatalf("a retried draft published %d releases for one tag", len(f.releases))
|
||||
}
|
||||
if again.State != "reused" {
|
||||
t.Errorf("state is %q, want reused", again.State)
|
||||
}
|
||||
if !f.releases[0].IsDraft {
|
||||
t.Error("the release stopped being a draft")
|
||||
}
|
||||
}
|
||||
|
||||
// A half-filled configuration is refused before anything is dialled, naming the
|
||||
// variable or the command that supplies what is missing. "401 Unauthorized"
|
||||
// names nothing anybody can act on.
|
||||
func TestAHalfFilledConfigurationIsRefusedBeforeItDials(t *testing.T) {
|
||||
harmless(t)
|
||||
f := newFake(t)
|
||||
|
||||
for _, tc := range []struct {
|
||||
what string
|
||||
cfg *config.Resolved
|
||||
want string
|
||||
}{
|
||||
{"no url", &config.Resolved{Token: "t", Owner: "a", Repo: "b"}, config.EnvURL},
|
||||
{"no token", &config.Resolved{URL: f.url(), Owner: "a", Repo: "b"}, config.EnvToken},
|
||||
{"no repo", &config.Resolved{URL: f.url(), Token: "t"}, config.EnvRepo},
|
||||
{"nothing at all", &config.Resolved{}, config.EnvURL},
|
||||
} {
|
||||
_, err := publish(tc.cfg, spec{Tag: "v0.0.1"})
|
||||
if err == nil {
|
||||
t.Errorf("%s: accepted", tc.what)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("%s: the refusal does not name the fix (%q): %v", tc.what, tc.want, err)
|
||||
}
|
||||
}
|
||||
if calls := f.calls(); len(calls) != 0 {
|
||||
t.Errorf("a request went out for a configuration that was refused: %v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// End to end through main's own argument handling, with the credentials in the
|
||||
// environment and no project anywhere on the way up — which is the state a
|
||||
// clone is in, and the reason this resolves configuration the way it does.
|
||||
func TestRunPublishesFromTheEnvironmentWithNoProjectInSight(t *testing.T) {
|
||||
harmless(t)
|
||||
f := newFake(t)
|
||||
dir := t.TempDir()
|
||||
binary := writeFile(t, dir, "kettle_v5.0.0_darwin_amd64", "mach-o, trust me")
|
||||
notes := writeFile(t, dir, "NOTES.md", "## v5.0.0\n\nIt does the thing.\n")
|
||||
|
||||
t.Setenv(config.EnvURL, f.url())
|
||||
t.Setenv(config.EnvToken, "s3cret")
|
||||
t.Setenv(config.EnvRepo, "acme/widgets")
|
||||
|
||||
var stdout, stderr strings.Builder
|
||||
code := run([]string{"--tag", "v5.0.0", "--title", "kettle v5.0.0", "--notes-file", notes, binary},
|
||||
&stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit = %d, want 0\nstdout:\n%s\nstderr:\n%s", code, stdout.String(), stderr.String())
|
||||
}
|
||||
if len(f.releases) != 1 || f.releases[0].Title != "kettle v5.0.0" {
|
||||
t.Fatalf("the tracker holds %+v", f.releases)
|
||||
}
|
||||
if !strings.Contains(f.releases[0].Note, "It does the thing.") {
|
||||
t.Errorf("the notes file did not arrive: %q", f.releases[0].Note)
|
||||
}
|
||||
for _, want := range []string{"created", "uploaded", "kettle_v5.0.0_darwin_amd64", f.releases[0].HTMLURL} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Errorf("the receipt does not name %q:\n%s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
// A token in a receipt is a token in a terminal scrollback and a pasted
|
||||
// bug report.
|
||||
if strings.Contains(stdout.String()+stderr.String(), "s3cret") {
|
||||
t.Errorf("the run printed the token:\n%s%s", stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Everything a person can get wrong in the arguments is reported before a
|
||||
// release exists to be half-published.
|
||||
func TestRunRefusesBadArgumentsWithoutTouchingTheTracker(t *testing.T) {
|
||||
harmless(t)
|
||||
f := newFake(t)
|
||||
dir := t.TempDir()
|
||||
here := writeFile(t, dir, "kettle_v6.0.0_linux_amd64", "x")
|
||||
elsewhere := filepath.Join(t.TempDir(), "kettle_v6.0.0_linux_amd64")
|
||||
if err := os.WriteFile(elsewhere, []byte("y"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv(config.EnvURL, f.url())
|
||||
t.Setenv(config.EnvToken, "s3cret")
|
||||
t.Setenv(config.EnvRepo, "acme/widgets")
|
||||
|
||||
for _, tc := range []struct {
|
||||
what string
|
||||
argv []string
|
||||
want string
|
||||
}{
|
||||
{"no tag", []string{here}, "--tag is required"},
|
||||
{"a file that is not there", []string{"--tag", "v6.0.0", filepath.Join(dir, "absent")}, "cannot upload"},
|
||||
{"a directory", []string{"--tag", "v6.0.0", dir}, "it is a directory"},
|
||||
{"two files with one name", []string{"--tag", "v6.0.0", here, elsewhere}, "would replace the first"},
|
||||
{"notes that are not there", []string{"--tag", "v6.0.0", "--notes-file", filepath.Join(dir, "absent.md")}, "reading the notes"},
|
||||
} {
|
||||
var stdout, stderr strings.Builder
|
||||
if code := run(tc.argv, &stdout, &stderr); code != 2 {
|
||||
t.Errorf("%s: exit = %d, want 2\n%s%s", tc.what, code, stdout.String(), stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), tc.want) {
|
||||
t.Errorf("%s: stderr does not say %q:\n%s", tc.what, tc.want, stderr.String())
|
||||
}
|
||||
}
|
||||
if calls := f.calls(); len(calls) != 0 {
|
||||
t.Errorf("a refused run still talked to the tracker: %v", calls)
|
||||
}
|
||||
if len(f.releases) != 0 {
|
||||
t.Errorf("a refused run created %d release(s)", len(f.releases))
|
||||
}
|
||||
}
|
||||
|
||||
// A failure carries the status and what the server said, in the transport's own
|
||||
// error type, because "500" on its own has never helped anybody.
|
||||
func TestAFailureNamesTheStatusAndWhatTheServerSaid(t *testing.T) {
|
||||
harmless(t)
|
||||
// A token that is not allowed to write releases is the failure somebody
|
||||
// will actually meet: reads are fine, the create is refused.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/api/v1/version":
|
||||
writeJSON(w, map[string]string{"version": modernGitea})
|
||||
case r.Method == http.MethodPost:
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = io.WriteString(w, `{"message":"token does not have at least one of required scope(s): [write:repository]"}`)
|
||||
case strings.Contains(r.URL.Path, "/releases/tags/"):
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = io.WriteString(w, `{"message":"release with tag 'v7.0.0' not found"}`)
|
||||
default:
|
||||
writeJSON(w, []*sdk.Release{})
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
_, err := publish(configFor(srv.URL), spec{Tag: "v7.0.0"})
|
||||
if err == nil {
|
||||
t.Fatal("a 403 published a release")
|
||||
}
|
||||
for _, want := range []string{"403", "write:repository", "v7.0.0"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the failure does not mention %q:\n%v", want, err)
|
||||
}
|
||||
}
|
||||
if strings.Contains(err.Error(), "s3cret") {
|
||||
t.Errorf("the failure quotes the token:\n%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A tool nobody watches run has to be readable when somebody finally does.
|
||||
func TestTheReceiptIsAligned(t *testing.T) {
|
||||
r := &receipt{
|
||||
Repo: "acme/widgets",
|
||||
State: "created",
|
||||
Release: &sdk.Release{TagName: "v1.0.0", HTMLURL: "https://git.example.com/acme/widgets/releases/tag/v1.0.0"},
|
||||
Assets: []asset{
|
||||
{Name: "kettle_v1.0.0_darwin_arm64", URL: "https://git.example.com/a"},
|
||||
{Name: "SHA256SUMS", URL: "https://git.example.com/b", Replaced: true},
|
||||
},
|
||||
}
|
||||
var out strings.Builder
|
||||
r.print(&out)
|
||||
|
||||
// One line for the release, one per asset, the URL, and the summary.
|
||||
lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
|
||||
if len(lines) != 5 {
|
||||
t.Fatalf("the receipt is %d line(s):\n%s", len(lines), out.String())
|
||||
}
|
||||
// The URLs line up, which is what makes a column of them scannable.
|
||||
first := strings.Index(lines[1], "https://")
|
||||
if second := strings.Index(lines[2], "https://"); first != second {
|
||||
t.Errorf("the asset URLs do not line up (%d vs %d):\n%s", first, second, out.String())
|
||||
}
|
||||
if !strings.HasPrefix(lines[2], "replaced") {
|
||||
t.Errorf("a replaced asset is not called one:\n%s", out.String())
|
||||
}
|
||||
// The URL a person opens is on its own line, not buried in a summary.
|
||||
if !strings.HasPrefix(lines[3], "release ") || !strings.HasSuffix(lines[3], "/releases/tag/v1.0.0") {
|
||||
t.Errorf("the release URL is not on its own line:\n%s", out.String())
|
||||
}
|
||||
if want := fmt.Sprintf("%d asset(s): 1 uploaded, 1 replaced", 2); !strings.Contains(lines[4], want) {
|
||||
t.Errorf("the summary does not read %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
@@ -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