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>
6.7 KiB
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, wire and the SDK.
Nothing imports it but cmd — 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,
#Nreferences 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.
Keeping this file true
- Scope: every
.gofile 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;
giteasends them.