Compare commits
37 Commits
c18b16a14b
..
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b1b11001a | |||
| f18a633185 | |||
| e177f46510 | |||
| a685f23bc0 | |||
| 4058cf5a4e | |||
| 01fb5a2703 | |||
| ec0a1893b7 | |||
| e628ad6fd9 | |||
| 1239fdee70 | |||
| 9480e48312 | |||
| fb5445915f | |||
| 27e4b6b1da | |||
| 83f73c5cea | |||
| 23f78beafb | |||
| 5a8bd1c299 | |||
| 81119a3bd9 | |||
| f97ac952f7 | |||
| 493a787940 | |||
| 7ab967bfaa | |||
| edb2f5a627 | |||
| 62027db76c | |||
| 9479babfe9 | |||
| e330a11e8f | |||
| bf0936526d | |||
| f7cffd7c48 | |||
| e6b4cf773c | |||
| 17567cd6a2 | |||
| f5977fa4fc | |||
| 74a0e3b173 | |||
| bb964d5a55 | |||
| 40016e06f2 | |||
| 2a8da81359 | |||
| 1d7abc11ae | |||
| 627df76812 | |||
| 2f82b501bd | |||
| 9679e2c000 | |||
| 596cf853e8 |
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "tea",
|
||||
"name": "claude-skills",
|
||||
"owner": {
|
||||
"name": "naudachu"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "tea",
|
||||
"source": "./",
|
||||
"description": "Gitea issues as local markdown, cleanly layered: /tea:issue works on issues offline (format, validation, dependency graph), /tea:sync moves them to and from Gitea, /tea:use is the CLI reference, the tea-runner subagent executes the scripts on a cheap model, and a PreToolUse hook blocks any command that would touch Gitea without the operator-pinned login."
|
||||
"name": "tdl",
|
||||
"source": "./plugins/tdl",
|
||||
"description": "Three Dots Labs Go conventions as an enforceable rule set: /tdl:audit scans a Go project against 63 CQRS/DDD/Clean-Architecture rules and reports violations by severity, or scaffolds new services, handlers, entities, repositories and Watermill adapters from templates that already follow them."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"name": "tea",
|
||||
"description": "Gitea issues and wiki pages as local markdown, cleanly layered: /tea:issue works on issues offline (format, validation, dependency graph) and /tea:page turns a discussion's artifacts into a titled, ordered page tree; /tea:sync and /tea:wiki move each to and from Gitea; /tea:use is the CLI reference, the tea-runner subagent executes the scripts on a cheap model, and a PreToolUse hook blocks any command that would touch Gitea without the operator-pinned login.",
|
||||
"version": "2.2.0",
|
||||
"author": {
|
||||
"name": "naudachu"
|
||||
},
|
||||
"license": "MIT",
|
||||
"keywords": ["gitea", "cli", "git", "issues", "wiki", "login-guard"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "command -v kettle >/dev/null && kettle mirror --hook || true" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+18
-1
@@ -1,9 +1,26 @@
|
||||
.DS_Store
|
||||
.docs/
|
||||
.claude/
|
||||
.tea/
|
||||
tmp/
|
||||
|
||||
# `.claude/*` and not `.claude/`, because git does not descend into an excluded
|
||||
# DIRECTORY and the negation below would never be reached.
|
||||
#
|
||||
# The exception is the hook registration, which is a fact about this repository
|
||||
# rather than about whoever cloned it: `kettle mirror --hook` keeps every
|
||||
# AGENTS.md paired with its CLAUDE.md symlink, and a fresh clone should get that
|
||||
# without anyone remembering to wire it up. Everything else under .claude/ here
|
||||
# is a local scratch tree and stays ignored.
|
||||
.claude/*
|
||||
!.claude/settings.json
|
||||
|
||||
# `make dist` and `make build` — release artifacts are rebuilt from a tag, never
|
||||
# committed
|
||||
cli/dist/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# agents-sync regenerates these symlinks next to every AGENTS.md
|
||||
CLAUDE.md
|
||||
|
||||
.kettle/
|
||||
|
||||
@@ -1,187 +1,120 @@
|
||||
# AGENTS.md
|
||||
# AGENTS.md — the repository root
|
||||
|
||||
## Project goals
|
||||
|
||||
1. **Unify and systematize issue workflow** for the development team with
|
||||
minimal context usage. Issue operations are wrapped in scripts so agents
|
||||
spend tokens on the task, not on re-deriving commands and formats.
|
||||
2. **Keep the tracker out of the work.** An issue is a unit of work first and a
|
||||
Gitea row second. The two are separate layers, and the first one does not
|
||||
know the second exists.
|
||||
3. **Route all Gitea interaction through the `tea` CLI via scripts** instead of
|
||||
direct ad-hoc calls wherever possible. Scripts give deterministic,
|
||||
reviewable behavior; the `tea-guard` hook enforces that every `tea`
|
||||
invocation runs under the operator-pinned login.
|
||||
|
||||
## Layers
|
||||
|
||||
The hard rule of this repo. Two domains, two bridges, one transport, and
|
||||
knowledge flows one way only:
|
||||
One repository holding the **`kettle` binary** and a Claude Code **plugin
|
||||
marketplace** with one plugin left in it. Three things live here and nothing else
|
||||
does:
|
||||
|
||||
```
|
||||
skills/issue DOMAIN what an issue is: format, validation, dependency graph
|
||||
skills/page DOMAIN what a page tree is: title <-> path, order, the index
|
||||
▲ offline — no tracker, no network, stdlib imports only
|
||||
│ imports
|
||||
skills/sync BRIDGE map.py md <-> Gitea issue JSON, pure, no I/O
|
||||
_gitea.py login pin, tea api, pagination, filters
|
||||
skills/wiki BRIDGE wikimap.py md <-> Gitea wiki JSON, pure, no I/O
|
||||
transport is _gitea.py — there is no second one
|
||||
skills/use REFERENCE tea CLI docs for everything that is not an issue
|
||||
skills/auth IDENTITY pin the login the whole tracker side runs under
|
||||
▲
|
||||
│ calls
|
||||
agents/ EXECUTION tea-runner: runs the scripts, reports a receipt
|
||||
cli/ the kettle binary — Go, no cobra, 9 packages
|
||||
plugins/ one directory per plugin
|
||||
.claude-plugin/marketplace.json the catalog: one entry per plugin
|
||||
```
|
||||
|
||||
A domain never imports its bridge, and the two domains do not import each
|
||||
other: delete `skills/sync` and issues still work, delete `skills/wiki` and page
|
||||
trees still work, delete either domain and the other is untouched. The check is
|
||||
mechanical — every import under a domain's `scripts/` is stdlib, and
|
||||
`subprocess` is not among them:
|
||||
Where to go from here, and each of these directories documents itself:
|
||||
|
||||
| directory | what it is |
|
||||
|---|---|
|
||||
| [`cli/`](cli/AGENTS.md) | one Go module, two binaries: `kettle`, which owns this project's connection to its tracker — the format, the store, the credentials, the transport, through `kettle api` every Gitea entity that has no command of its own, and the skills and commands it writes into a project's `.claude/` — and [`cmd/release`](cli/cmd/release/AGENTS.md), which publishes this repository's own releases. `make check` is the gate; there is no CI here |
|
||||
| [`plugins/`](plugins/AGENTS.md) | what a plugin is here, and what the catalog entry has to match |
|
||||
| [`plugins/tdl/`](plugins/tdl/AGENTS.md) | the Three Dots Labs Go rule set — no binary, no state, just rules and templates |
|
||||
|
||||
## The one architectural fact worth carrying
|
||||
|
||||
**A binary holds what can be enforced; prose holds what can only be stated.**
|
||||
Anything mechanical belongs in Go where a test can hold it down; anything that is
|
||||
a judgement an operator makes belongs in a SKILL.md.
|
||||
|
||||
There used to be a second half to that sentence — *and a plugin holds the prose* —
|
||||
and it is gone, because it was wrong in a way that took a year to show. `kettle`
|
||||
shipped as a plugin wrapping a binary, on two release cadences, and **nothing on
|
||||
an operator's machine ever checked that the plugin they installed described the
|
||||
binary they installed.** The generated flag block existed precisely so a renamed
|
||||
flag could not ship with documentation recommending the old one, and it shipped
|
||||
one version behind the registry it came from — the same bug, one hop downstream.
|
||||
|
||||
So the prose moved *into* the binary. `cli/internal/scaffold` embeds it,
|
||||
`kettle init` and `kettle gen scaffold` write it into a project's own `.claude/`,
|
||||
and the two cannot disagree because there is only one artefact. What a project
|
||||
gets:
|
||||
|
||||
```
|
||||
.claude/commands/kettle/*.md /kettle:init, /kettle:auth, /kettle:issue, …
|
||||
.claude/skills/kettle-*/ loaded by description, never by name
|
||||
.claude/agents/kettle-runner.md the batch runner, on a cheap model
|
||||
```
|
||||
|
||||
The same rule made `kettle api` (one door for every request, rather than
|
||||
requiring `tea` and its second set of invisible logins) and `kettle mirror`
|
||||
(below). Each time, something mechanical was sitting where no test could reach
|
||||
it, and each time it came into Go.
|
||||
|
||||
## The AGENTS.md convention
|
||||
|
||||
**Every directory with a story documents itself, in that directory.** This file
|
||||
is a map, not a manual — it says what lives where and sends you down. A reader
|
||||
who opens `cli/internal/gitea/` gets the transport's rules from
|
||||
`cli/internal/gitea/AGENTS.md` and does not have to load the whole repository's
|
||||
design to change one request.
|
||||
|
||||
Three rules make that work:
|
||||
|
||||
1. **`AGENTS.md` is the real file; `CLAUDE.md` beside it is a symlink to it.**
|
||||
`kettle mirror` enforces that and repairs any directory that drifted — it
|
||||
renames, re-points and swaps, and it never deletes content. Two real files
|
||||
with different content is the one case it refuses to resolve and reports
|
||||
instead. `CLAUDE.md` is gitignored, because it is generated.
|
||||
2. **A directory's file describes that directory only.** What a parent or a child
|
||||
owns gets a link, never a second copy — the copy is what goes stale. If a
|
||||
sentence is true of the whole binary it belongs in `cli/AGENTS.md`; if it is
|
||||
true of one package it belongs in that package's file.
|
||||
3. **Every file ends with its own maintenance contract** — the *Keeping this file
|
||||
true* section. It names the files the document covers and what kind of change
|
||||
obliges an edit.
|
||||
|
||||
### What keeps them true
|
||||
|
||||
Nothing automatic, and that is a choice. Keeping these files honest is the job of
|
||||
whoever changes the code they describe, which is what the *Keeping this file true*
|
||||
section at the bottom of each one is for.
|
||||
|
||||
A hook that nagged after every write was written and then removed: it would have
|
||||
fired for every user of the `kettle` plugin, on every edit in every repository
|
||||
they touched, to enforce a documentation convention that is this repository's and
|
||||
nobody else's. **That argument survived the plugin.** `kettle mirror --hook` is
|
||||
what stayed, and it is narrower on both axes: it repairs the filesystem layout
|
||||
rather than asking anybody for anything, and it is **opt-in per project** —
|
||||
`kettle init --mirror-hook`, or one question in `kettle init --interactive`.
|
||||
It cannot fail a tool call; it exits 0 on every path, including its own bugs,
|
||||
because documentation maintenance is not permitted to break a build.
|
||||
|
||||
This repository registers it in `.claude/settings.json`, which is the one thing
|
||||
under `.claude/` that is not gitignored here — a fresh clone should get the
|
||||
convention without anyone remembering to wire it up.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
grep -rh '^import \|^from ' skills/issue/scripts/ | sort -u
|
||||
grep -rh '^import \|^from ' skills/page/scripts/ | sort -u
|
||||
cd cli && make check # fmt, vet, test, go mod verify, build, docs — the gate
|
||||
cd cli && make help # install, dist, release
|
||||
```
|
||||
|
||||
If a tracker concept (issue number, login, HTTP call, label color, `sub_url`,
|
||||
`content_base64`) shows up in a domain layer, it is in the wrong place.
|
||||
**There is no CI on the instance this lives on**, so `make check` is the only thing
|
||||
between a mistake and the tracker, and it is on whoever is committing to run it.
|
||||
Its last step is the repository's one mechanical documentation invariant, and it
|
||||
is circular on purpose: the documents an operator will be given are embedded in
|
||||
the binary with the registry's flag tables already spliced in, so a flag change
|
||||
makes them stale and `gen scaffold --check` exits 1. Regenerate, then **rebuild** —
|
||||
the binary has to pick the new bytes up.
|
||||
|
||||
## Repo layout
|
||||
Everything else in this tree — including every `AGENTS.md` — is prose, held true
|
||||
by `kettle mirror` and by whoever is editing.
|
||||
|
||||
- `skills/auth` — pin the Gitea login used by `tea` (`/tea:auth`)
|
||||
- `skills/issue` — issues as units of work (`/tea:issue`), entirely offline
|
||||
- `references/format.md` — canonical issue format; single source of truth
|
||||
- `scripts/issue.py` — domain module: slug identity, parse/render, validation,
|
||||
taxonomy, dependency graph, body checkboxes
|
||||
- `scripts/issue_new.py` — create a local issue from its type template
|
||||
- `scripts/issue_check.py` — validate against the format
|
||||
- `scripts/issue_ac.py` — list the body's checkboxes; tick one by number or
|
||||
substring, changing exactly one character of the file
|
||||
- `scripts/issue_tree.py` — draw the dependency graph
|
||||
- `scripts/issue_index.py` — rebuild `tmp/issues/INDEX.md`
|
||||
- `skills/sync` — move issues between the local store and Gitea (`/tea:sync`)
|
||||
- `scripts/map.py` — md ↔ Gitea JSON, pure, no I/O; label colors live here
|
||||
- `scripts/_gitea.py` — transport: login pin, `tea api`, pagination, filters,
|
||||
label ids, the remote-id map
|
||||
- `scripts/pull.py`, `push.py`, `remote.py`, `comment.py`
|
||||
- `skills/page` — a discussion's artifacts as a page tree (`/tea:page`),
|
||||
entirely offline
|
||||
- `references/pages.md` — canonical page-tree format; single source of truth
|
||||
- `scripts/page.py` — domain module: title ↔ path, ordering, the manifest,
|
||||
importing a directory of markdown, the index
|
||||
- `scripts/page_import.py` — copy a directory of markdown into a space,
|
||||
titling every file
|
||||
- `scripts/page_index.py` — write the table-of-contents page
|
||||
- `scripts/page_ls.py` — the tree, the titles, one sync-state tag per page
|
||||
- `skills/wiki` — move page trees between a local space and a Gitea wiki
|
||||
(`/tea:wiki`)
|
||||
- `scripts/wikimap.py` — md ↔ Gitea wiki JSON, pure, no I/O
|
||||
- `scripts/wiki_ls.py`, `wiki_pull.py`, `wiki_push.py` — transport is
|
||||
`skills/sync/scripts/_gitea.py`
|
||||
- `skills/use` — `tea` CLI reference for everything that is not an issue
|
||||
(`/tea:use`); `references/tea/` holds the command docs
|
||||
- `agents/tea-runner.md` — subagent on Haiku that executes the scripts and
|
||||
returns a compact receipt. Delegate batches (bulk pull, push a named set,
|
||||
bootstrap labels, rebuild the index), never the thinking: it has no `Edit`
|
||||
and no `Write`, may not `--force`, and may not decide what an issue says.
|
||||
Delegating a single call costs more than running it inline — the win is the
|
||||
loop, the retry, and the error triage.
|
||||
- `hooks/` — PreToolUse hooks: `tea-guard` blocks or rewrites `tea` invocations
|
||||
that don't use the pinned login; `agents-sync` keeps every directory canonical
|
||||
(`AGENTS.md` real file, `CLAUDE.md` symlink to it)
|
||||
- `tests/` — stdlib `unittest`, no third-party anything
|
||||
## Keeping this file true
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
python3 -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
Plain `unittest`; no pytest, no dependencies — the scripts under test are
|
||||
stdlib-only and the tests hold the same line. `skills/*/scripts/` are not
|
||||
packages, so a test that needs the domain module imports it with
|
||||
`sys.path.insert`.
|
||||
|
||||
**A test never touches `tmp/issues/` or `tmp/wiki/`.** Anything that needs a
|
||||
store builds a throwaway repository in a `tempfile.TemporaryDirectory()` — a
|
||||
`.git` marker, a copy of the script layers, fixture issues or artifacts — and
|
||||
runs the real scripts inside it as subprocesses. That is the only way to test
|
||||
behavior that depends on where a script is run from, and it keeps the
|
||||
developer's own store out of the blast radius.
|
||||
|
||||
## Local issue store
|
||||
|
||||
`tmp/issues/` (gitignored) holds **two kinds of file, and only one of them is a
|
||||
store.** An `origin: local` issue lives here and nowhere else — this file *is*
|
||||
the issue, and losing it loses the work. Anything with `origin: gitea` is a
|
||||
**cache**: the tracker has it, this copy is a working copy, and it is deleted
|
||||
the moment a push confirms the tracker is up to date.
|
||||
|
||||
One flat markdown file per issue, named by its slug, with one metadata field per
|
||||
line so plain grep works without a parser.
|
||||
|
||||
- **The path is `<repo root>/tmp/issues`, resolved from `issue.py`'s own
|
||||
location, not from cwd.** `issue.store_root()` walks up from `__file__` to the
|
||||
nearest `.git` or `AGENTS.md` — so every script in both layers sees one store
|
||||
whatever directory it is run from. An explicit `--out` overrides it and is
|
||||
used exactly as typed; a relative `--out` stays relative to cwd.
|
||||
- Nothing creates the store as a side effect of a write. Readers distinguish
|
||||
"does not exist" from "is empty"; only `issue_new.py` and `pull.py` create it,
|
||||
and they say so on stderr.
|
||||
- Identity is the slug (`wire-sqlc-appclick.md`), never a tracker number.
|
||||
Numbers live in the `gitea:` field.
|
||||
- `origin: local` is a complete state, not a draft: an issue that never leaves
|
||||
this machine is valid and finished. It is not a *durable* state, though —
|
||||
pushing ends it, and the local file goes with it.
|
||||
- **A successful push deletes the local file** (`<id>.md` and
|
||||
`<id>.comments.md`), and prints the number and URL the issue now lives at.
|
||||
`--update` too: one rule, no exception. What is in the store is what has not
|
||||
left. Get it back with `pull.py <n>` — which brings its blockers back with it:
|
||||
a pull returns the unit of work, not one row of it. `--no-deps` narrows it to
|
||||
the one issue, and the cost of the default is in `pull.py`'s docstring.
|
||||
- Deletion happens only after a confirmed tracker response and only after
|
||||
`.remote.json` has been written. Network down, non-2xx, an answer that does
|
||||
not carry the right number: the file stays and the run stops. A never-pushed
|
||||
`origin: local` issue is never touched by any of this.
|
||||
- The slug survives the round trip because it goes up in the body as
|
||||
`<!-- tea:id … -->` (`map.with_id_marker`) and is indexed by number in
|
||||
`tmp/issues/.remote.json`. A rename in the web UI, a lost `.remote.json`, a
|
||||
fresh clone, another machine — the file comes back under the same name and
|
||||
every `depends:` that points at it still resolves.
|
||||
- `.remote.json` is therefore no longer "an index over the files": it is the
|
||||
local number → slug ledger, its entries outlive the files they name, and
|
||||
nothing prunes them. It is still recoverable — from the markers in Gitea, not
|
||||
from the files.
|
||||
- Pulling overwrites the body — a fetch, not a merge. It is also how a pushed
|
||||
issue comes back at all.
|
||||
- No drift tracking, and now nothing to track: there is no second copy to
|
||||
diverge from. `synced:` tells you how old your working copy is.
|
||||
|
||||
## Local wiki cache
|
||||
|
||||
`tmp/wiki/<space>/` (gitignored) holds page trees — a discussion's artifacts,
|
||||
organized. Same stance as the issue store, resolved the same way from
|
||||
`page.py`'s own location, with the same `--out` rule.
|
||||
|
||||
- Identity is the **title**, and `/` inside it is the only hierarchy there is.
|
||||
The Gitea wiki is flat: it escapes a title into one filename by rules of its
|
||||
own (`space -> -`, `/ -> %2F`, a literal `-` forces a trailing `.-`).
|
||||
- **`sub_url` is Gitea's address for a page and is never constructed.** It is
|
||||
read back from the API and stored in `.pages.json`. One built by hand that is
|
||||
almost right creates a second page instead of editing the first.
|
||||
- **Never commit a subdirectory into a wiki's git repository.** Gitea does not
|
||||
see it — the page exists on disk and nowhere in the API or the UI. Do not
|
||||
clone the wiki repo to work in; use the scripts.
|
||||
- A title is a decision, not a derivation. A re-import replaces bodies and
|
||||
keeps titles, so editing a heading cannot silently rename a published page.
|
||||
`--retitle` opts in, and the rename reaches the wiki on the next push.
|
||||
- A page with no `sub_url` has never been published — a complete state, the way
|
||||
`origin: local` is for an issue. **The parallel stops at the push**: a pushed
|
||||
page stays on disk, a pushed issue does not.
|
||||
- Change detection is one hash (`pushed`). Pulling overwrites; pushing is
|
||||
additive and never deletes — the one place the two domains deliberately
|
||||
disagree, because a page tree is worked on locally and an issue is not.
|
||||
- The `tea` CLI has no wiki subcommand. `tea api` is the only route, through
|
||||
`_gitea.py`.
|
||||
- **Scope:** the repository layout, the binary/prose split, and the AGENTS.md
|
||||
convention itself. Every deeper subject belongs to a deeper file.
|
||||
- **Update it when** a top-level directory appears or goes, a plugin is added or
|
||||
removed, the marketplace catalog changes shape, or the mirror hook's behaviour
|
||||
changes.
|
||||
- **Do not** put a command reference, a package's rules, or a skill's procedure
|
||||
here. Link to the file that owns it.
|
||||
|
||||
@@ -1,173 +1,322 @@
|
||||
# tea — Claude Code plugin for the Gitea CLI
|
||||
# claude-skills — the `kettle` binary, and a plugin marketplace
|
||||
|
||||
A Claude Code plugin that gives Claude a reference for the `tea` CLI and enforces a hard rule: every `tea` command runs under the login **the operator chose**, never one Claude picked.
|
||||
One repository holding two things: **`kettle`**, a Go binary that keeps a
|
||||
project's issues as local markdown and syncs them with Gitea, and a small Claude
|
||||
Code **plugin marketplace** with one plugin in it.
|
||||
|
||||
## What it ships
|
||||
## Installing kettle
|
||||
|
||||
| Piece | What it does |
|
||||
`kettle` is one binary and it installs itself into your project. There is no
|
||||
plugin to add — `cli/go.mod` requires **Go 1.26**, and the first build downloads
|
||||
eight modules, verified against `go.sum`:
|
||||
|
||||
```bash
|
||||
go install git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest
|
||||
|
||||
# or from a clone of this repository, version stamped:
|
||||
cd cli && make install # ~/.local/bin/kettle
|
||||
cd cli && make install BINDIR=$(go env GOPATH)/bin
|
||||
```
|
||||
|
||||
Put the target directory on your `PATH` and check with `kettle version`. Then, in
|
||||
a project you want to track issues in:
|
||||
|
||||
```bash
|
||||
kettle init --interactive
|
||||
```
|
||||
|
||||
That walks you through the login, the token (typed with the echo off, so it lands
|
||||
in no history and no file), the tracker repository, and the `.claude/` tree —
|
||||
which is where the slash commands and skills come from:
|
||||
|
||||
```
|
||||
.claude/commands/kettle/*.md /kettle:init /kettle:auth /kettle:issue
|
||||
/kettle:sync /kettle:api /kettle:project
|
||||
.claude/skills/kettle-*/ loaded by description, never by name
|
||||
.claude/agents/kettle-runner.md the batch runner, on a cheap model
|
||||
```
|
||||
|
||||
Every flag table in those documents is rendered from the binary's own command
|
||||
registry, and the prose around it is embedded in the same binary. **They cannot
|
||||
describe a version of `kettle` other than the one that wrote them** — which is
|
||||
the whole reason this stopped being a plugin. `kettle config` tells you when the
|
||||
tree on disk was written by an older build; `kettle gen scaffold` refreshes it.
|
||||
|
||||
Prefer flags to prompts? Everything `--interactive` asks has one:
|
||||
|
||||
```bash
|
||||
kettle init --login noodles --repo owner/name --mirror-hook
|
||||
kettle init --no-scaffold # marker and config only
|
||||
```
|
||||
|
||||
## The marketplace
|
||||
|
||||
```
|
||||
/plugin marketplace add https://git.noodles.cam/claude-skills/marketplace.git
|
||||
/plugin install tdl@claude-skills
|
||||
```
|
||||
|
||||
| Plugin | Commands | What it does |
|
||||
|---|---|---|
|
||||
| [`tdl`](plugins/tdl) | `/tdl:audit` | Three Dots Labs Go conventions as an enforceable rule set — audits a Go project against nine categories of CQRS/DDD/Clean-Architecture rules by severity, or scaffolds services, handlers, entities, repositories and Watermill adapters from templates that already follow them |
|
||||
|
||||
---
|
||||
|
||||
# The `kettle` CLI
|
||||
|
||||
A single Go binary that keeps a project's issues as flat markdown files and moves
|
||||
them to and from Gitea when you say so — and, with `kettle api`, reaches every
|
||||
other Gitea entity under the same login, so nothing here needs a second CLI.
|
||||
**Everything outside the `sync` and `api` groups works with the network off.** Two direct dependencies, vendored, no cobra, seven
|
||||
internal packages with one direction of imports — see
|
||||
[`cli/AGENTS.md`](cli/AGENTS.md).
|
||||
|
||||
## What it does
|
||||
|
||||
**project** — the project itself
|
||||
|
||||
| command | what it does |
|
||||
|---|---|
|
||||
| `/tea:auth` skill | Prompts you to pick a Gitea login and pins it to the project |
|
||||
| `/tea:issue` skill | Issues as units of work — create, read, grep, validate, walk the dependency graph. Entirely offline |
|
||||
| `/tea:sync` skill | Moves issues between the local store and Gitea — pull, push, comment |
|
||||
| `/tea:use` skill | Tea CLI reference for everything that is not an issue — loads command docs on demand |
|
||||
| `tea-runner` agent | Subagent on Haiku that runs the scripts and reports back a receipt — the mechanical half, off your main context |
|
||||
| `tea-guard` hook | PreToolUse hook that blocks or rewrites every `tea` invocation |
|
||||
| `kettle init` | makes this directory a project: creates the `.kettle/` marker every other command resolves the store from, migrates an older store in, gitignores it |
|
||||
| `kettle auth` | manages the tokens this machine holds — one file, mode 0600, outside every working tree |
|
||||
| `kettle config` | prints every path and setting a run resolved to, with overrides applied. Never prints a token |
|
||||
| `kettle gen scaffold` | writes the `.claude/` commands, skills and subagent, prose and flag tables both, from inside this binary |
|
||||
| `kettle mirror` | keeps `CLAUDE.md` a symlink to `AGENTS.md` in every directory below here. `--hook` is the `PreToolUse` form and never fails a call |
|
||||
| `kettle version` | what this binary was built as — `dev` from a working tree, the tag when a release build stamped it in |
|
||||
|
||||
## The layering
|
||||
**issue** — units of work, offline, no tracker involved
|
||||
|
||||
An issue is a unit of work first and a Gitea row second. Those are two layers,
|
||||
and knowledge flows one way:
|
||||
| command | what it does |
|
||||
|---|---|
|
||||
| `kettle new` | creates a local issue from its type template. `origin: local` is a complete state, not a draft |
|
||||
| `kettle check` | validates issues against the canonical format. Exit 1 on errors, so a hook or a CI step can call it |
|
||||
| `kettle ac` | lists and ticks an issue's checkboxes — one byte of the file changes, so the diff is the state that changed |
|
||||
| `kettle tree` | draws the dependency graph read off `depends:` |
|
||||
| `kettle index` | rebuilds `INDEX.md` from what is on disk, progress counted from the bodies |
|
||||
| `kettle evict` | removes closed issues that also live in a tracker. A local one is never evicted, in any state |
|
||||
|
||||
```
|
||||
skills/issue DOMAIN what an issue is: format, validation, dependency graph
|
||||
▲ offline — no tracker, no network, stdlib only
|
||||
│ imports
|
||||
skills/sync BRIDGE md <-> Gitea JSON, then over the wire
|
||||
▲
|
||||
│ calls
|
||||
tea-runner EXECUTION runs the scripts, reports a receipt — no opinions
|
||||
**sync** — moving issues between the store and the tracker
|
||||
|
||||
| command | what it does |
|
||||
|---|---|
|
||||
| `kettle pull` | fetches issues by address (`42`, `#42`, `owner/repo#42`, a URL) or by filter, with their blockers, comments included |
|
||||
| `kettle push` | sends local issues up, blockers first, and deletes the local copy once the tracker confirms it |
|
||||
| `kettle remote` | lists what exists in the tracker, one line each. Writes nothing |
|
||||
| `kettle comment` | posts or edits a comment, body from a file or inline, then refetches the whole thread |
|
||||
| `kettle close` | closes or reopens issues in the tracker, and on disk with them. State only |
|
||||
| `kettle labels` | creates the canonical `type/*` and `severity/*` labels in a repository, `exclusive` flag and all |
|
||||
| `kettle sync-evict` | refreshes state from the tracker first, then evicts what is closed |
|
||||
|
||||
**api** — everything else Gitea has, reached directly
|
||||
|
||||
| command | what it does |
|
||||
|---|---|
|
||||
| `kettle api` | one request to an endpoint this binary has no command for — pull requests, releases, tags, milestones, branches, actions, webhooks — under the login the project already pins. One invocation is one request; it does not paginate, does not reformat the answer, and `-X DELETE` needs `--yes` |
|
||||
|
||||
`kettle help` prints the tree; `kettle help <command>` prints one command in full —
|
||||
flags, defaults and worked examples. Flags may be typed after positional arguments.
|
||||
|
||||
## Usage
|
||||
|
||||
### Once per project
|
||||
|
||||
```bash
|
||||
kettle auth add --name noodles --url https://git.example.com < token.txt
|
||||
kettle init --login noodles --repo owner/name
|
||||
kettle config # what did that actually resolve to?
|
||||
```
|
||||
|
||||
Delete `skills/sync` and the domain layer keeps working — issues that live only
|
||||
on your machine are first-class, not drafts waiting to be uploaded. That is the
|
||||
point of the split: you can plan, write, validate, and track work without a
|
||||
tracker, and publish only what you choose to.
|
||||
The token is read from standard input because an argument is in the shell history
|
||||
the moment it is typed. What lands in the repository is the login's **name**; the
|
||||
tokens stay in `~/.config/kettle/logins.yaml`.
|
||||
|
||||
## Prerequisites
|
||||
### Working offline
|
||||
|
||||
- **Claude Code** — CLI, desktop app, or IDE extension
|
||||
- **Python 3** — required by the `tea-guard` hook (`python3` must be on `$PATH`)
|
||||
- **`tea`** — Gitea's official CLI. Install with `brew install tea` (macOS) or from [gitea.com/gitea/tea/releases](https://gitea.com/gitea/tea/releases)
|
||||
- At least one login configured: `tea logins add` (interactive — run it in a terminal, not via Claude)
|
||||
```bash
|
||||
kettle new --type task --title "Wire sqlc into the appclick repo layer" \
|
||||
--label tech/sql --label comp/appclick
|
||||
kettle new --type bug --title "Fix the index rebuild on an empty store" \
|
||||
--depends wire-sqlc-appclick --milestone v0.2
|
||||
|
||||
## Installation
|
||||
|
||||
This is a Claude Code plugin — install it through the plugin marketplace, not by hand-editing `settings.json`.
|
||||
|
||||
1. Register this repo as a marketplace:
|
||||
|
||||
```
|
||||
/plugin marketplace add https://git.noodles.cam/claude-skills/tea.git
|
||||
```
|
||||
|
||||
Already have a local clone? Point at the directory instead:
|
||||
|
||||
```
|
||||
/plugin marketplace add /path/to/tea
|
||||
```
|
||||
|
||||
2. Install the plugin:
|
||||
|
||||
```
|
||||
/plugin install tea@tea
|
||||
```
|
||||
|
||||
The skills (`/tea:auth`, `/tea:issue`, `/tea:sync`, `/tea:use`) and the `tea-guard` hook load immediately. Use `/plugin` to enable, disable, or update it later.
|
||||
|
||||
> The marketplace registration is written to `extraKnownMarketplaces` and the plugin to `enabledPlugins` in your settings automatically — you don't edit those by hand. There is **no** top-level `"plugins"` settings key; if you've added one from older instructions, remove it.
|
||||
|
||||
## First use
|
||||
|
||||
Run `/tea:auth` once per project. Claude will list your available Gitea logins and ask you to pick one. The choice is written to `.claude/settings.local.json` and takes effect immediately — no restart needed.
|
||||
|
||||
```
|
||||
/tea:auth
|
||||
kettle check # every issue in the store
|
||||
kettle check --strict # warnings count as errors
|
||||
kettle ac wire-sqlc-appclick # numbered checkboxes with their state
|
||||
kettle ac wire-sqlc-appclick --check 3
|
||||
kettle tree --depth 2 # every root, two levels down
|
||||
kettle index
|
||||
```
|
||||
|
||||
After that, just ask Claude to do something with issues or Gitea — it loads the
|
||||
right skill automatically. `/tea:auth` is only needed for the tracker side;
|
||||
`/tea:issue` works without any login at all.
|
||||
No login, no network and no tracker for any of that. The store is flat markdown with
|
||||
one metadata field per line, so plain grep works without a parser:
|
||||
|
||||
## How the login guard works
|
||||
```bash
|
||||
grep -l 'labels:.*type/bug' .kettle/issues/*.md
|
||||
grep -ln 'depends:.*migrate-schema' .kettle/issues/*.md # who depends on it
|
||||
```
|
||||
|
||||
Every `tea` invocation Claude writes must carry the literal placeholder `--login "$GITEA_LOGIN"`. The `tea-guard` hook intercepts the Bash call before it runs, looks up the pinned login from `.claude/settings.local.json`, and rewrites the command to use it.
|
||||
### The tracker
|
||||
|
||||
Claude is **blocked** from:
|
||||
- running `tea` without `--login` at all
|
||||
- naming a login itself (e.g. `--login myaccount`)
|
||||
- using any variable other than `$GITEA_LOGIN`
|
||||
```bash
|
||||
kettle labels --dry-run # print the plan; not one writing request
|
||||
kettle labels # create whatever is missing
|
||||
|
||||
This prevents silent fallback to the machine's default login (often a personal account) when working in a project that belongs to a different identity.
|
||||
kettle push # everything the tracker lacks, blockers first
|
||||
kettle push --update wire-sqlc-appclick
|
||||
kettle push --dry-run # validate and print the plan; no network
|
||||
|
||||
`tea logins list` and `tea --version / --help` are exempt — they don't touch Gitea data.
|
||||
kettle remote --state all --label type/bug --limit 50
|
||||
kettle pull 42 # the issue and everything blocking it, any state
|
||||
kettle pull 42 --no-deps # just that one — one request
|
||||
kettle pull owner/repo#42 # an issue in another repository
|
||||
kettle pull --milestone v0.2 --limit 20
|
||||
|
||||
## The tea-runner agent
|
||||
kettle comment wire-sqlc-appclick --file notes.md
|
||||
kettle close 42 43
|
||||
kettle close --reopen 42
|
||||
kettle sync-evict --dry-run
|
||||
```
|
||||
|
||||
The skills carry meaning; the scripts carry work. `tea-runner` is a subagent on
|
||||
Haiku that does the second half in its own context and hands back a receipt —
|
||||
what ran, what it touched, what failed, verbatim.
|
||||
### The rule worth knowing before you push
|
||||
|
||||
Delegate a **batch**: pull a milestone and rebuild the index, push the three
|
||||
issues you just wrote, bootstrap the label set, post a comment from a file you
|
||||
prepared. Spawning it for a single `pull.py 42` costs more than running the
|
||||
command yourself; the saving is in the loop, the retry, and reading somebody
|
||||
else's stderr.
|
||||
**The 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. Get it back with `kettle pull <n>`: it lands under the
|
||||
same slug, with the same `depends:`, even on a machine that has never seen it,
|
||||
because the slug travelled up in the body as a marker and was recorded in a local
|
||||
number → slug ledger.
|
||||
|
||||
It cannot decide anything. No `Edit`, no `Write`, no `--force`, no closing or
|
||||
retitling, no raw `tea`, no pushing beyond the set it was handed. A missing
|
||||
type, a failed validation, an unpushed dependency come back as a question, not
|
||||
as a guess. The `tea-guard` hook applies to it exactly as it does to the main
|
||||
session — the pinned login is enforced on every call it makes.
|
||||
A never-pushed `origin: local` issue is the only copy of that work, and nothing
|
||||
deletes it — in any state, not even when it is named on the command line.
|
||||
|
||||
## Project layout
|
||||
## Configuration
|
||||
|
||||
| file | holds | notes |
|
||||
|---|---|---|
|
||||
| `<project>/.kettle/config.yaml` | `login:` (a name) and `repo:` | written by `kettle init`; safe inside a repository because a name is worth nothing on its own |
|
||||
| `~/.config/kettle/logins.yaml` | the tokens | one file per machine, mode 0600, outside every working tree |
|
||||
|
||||
`KETTLE_LOGIN`, `KETTLE_REPO`, `KETTLE_URL`, `KETTLE_TOKEN` and
|
||||
`KETTLE_CONFIG_HOME` each override the file they shadow — for CI, and for anyone who
|
||||
would rather have no token on disk at all. An unknown key in either file is an
|
||||
error, not a silent drop: an older binary would otherwise delete the setting it did
|
||||
not recognize the next time it wrote the file.
|
||||
|
||||
---
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
.claude-plugin/
|
||||
plugin.json plugin manifest
|
||||
marketplace.json marketplace catalog (makes `/plugin install` work)
|
||||
agents/
|
||||
tea-runner.md subagent (Haiku) that executes the scripts
|
||||
hooks/
|
||||
hooks.json registers the PreToolUse hook
|
||||
tea-guard.sh the guard (Python 3, no deps)
|
||||
skills/
|
||||
auth/SKILL.md /tea:auth skill
|
||||
issue/ /tea:issue — the domain layer, offline
|
||||
SKILL.md
|
||||
references/format.md canonical issue format (identity, types, templates)
|
||||
scripts/ Python 3, stdlib only, no network:
|
||||
issue.py domain module: slug identity, parse/render,
|
||||
validation, taxonomy, dependency graph,
|
||||
body checkboxes
|
||||
issue_new.py create a local issue from its type template
|
||||
issue_check.py validate against the format
|
||||
issue_ac.py list the body's checkboxes; tick one
|
||||
issue_tree.py draw the dependency graph
|
||||
issue_index.py rebuild tmp/issues/INDEX.md
|
||||
sync/ /tea:sync — the bridge to Gitea
|
||||
SKILL.md
|
||||
scripts/
|
||||
map.py md <-> Gitea JSON, pure functions, no I/O
|
||||
_gitea.py transport: login pin, tea api, pagination, filters
|
||||
pull.py Gitea -> tmp/issues/
|
||||
push.py tmp/issues/ -> Gitea, then drops the local file
|
||||
remote.py discovery listing to stdout
|
||||
comment.py post or edit a comment
|
||||
use/ /tea:use — tea CLI reference (non-issue entities)
|
||||
SKILL.md
|
||||
references/tea/ command docs
|
||||
marketplace.json the catalog — one entry per plugin, source is a
|
||||
path into plugins/
|
||||
cli/ one Go module, two binaries
|
||||
Makefile the gate and the release: make check | dist | release
|
||||
cmd/kettle/ what people install
|
||||
cmd/release/ what publishes this repository's own releases
|
||||
internal/ seven packages, one direction of imports
|
||||
plugins/
|
||||
kettle/
|
||||
.claude-plugin/plugin.json
|
||||
agents/ hooks/ skills/
|
||||
README.md AGENTS.md
|
||||
tdl/
|
||||
.claude-plugin/plugin.json
|
||||
skills/audit/{references,templates}
|
||||
```
|
||||
|
||||
## Local issue store
|
||||
A plugin's root is its directory under `plugins/`, so `${CLAUDE_PLUGIN_ROOT}`
|
||||
resolves inside it and every path a plugin uses stays relative to itself.
|
||||
Adding a plugin means adding a directory here plus one entry in
|
||||
`marketplace.json` — nothing else in the repo needs to know about it.
|
||||
|
||||
Issues live in `tmp/issues/` (gitignore it) as flat markdown with one metadata
|
||||
field per line — so `grep -l 'labels:.*type/bug' tmp/issues/*.md` works without
|
||||
a parser.
|
||||
`cli/` is deliberately not inside a plugin: a binary is installed on a machine,
|
||||
while a plugin is a directory Claude Code loads, and collapsing the two is what
|
||||
put an earlier version's issue store inside a versioned plugin cache.
|
||||
|
||||
An `origin: local` file **is** the issue — the store, and the only copy.
|
||||
Anything with `origin: gitea` is a working copy of something the tracker
|
||||
already has, and it is deleted as soon as a push confirms the tracker is up to
|
||||
date:
|
||||
## Development
|
||||
|
||||
- Identity is a slug (`wire-sqlc-appclick.md`), never a tracker number. Numbers
|
||||
live in a `gitea:` field.
|
||||
- `origin: local` is a complete state. An issue that never leaves your machine
|
||||
is valid and finished — but it is not permanent: pushing ends it.
|
||||
- **A successful push deletes the local file** (`--update` too) and prints the
|
||||
number and URL it now lives at. Only after a confirmed response: a failed
|
||||
call leaves the file exactly where it was. Get it back with `pull.py <n>` —
|
||||
same slug, same `depends:`, even after a rename in Gitea.
|
||||
- Pulling overwrites the body: a fetch, not a merge. It is also how a pushed
|
||||
issue comes back.
|
||||
- Nothing tracks drift, and there is no second copy to drift. A file that is
|
||||
still here has not been pushed.
|
||||
**There is no CI.** The instance this lives on has no Actions runner and none is
|
||||
planned, so nothing runs on a push, nothing checks a branch, and nothing will
|
||||
tell you afterwards. `make check` is the whole gate, it takes about ten seconds,
|
||||
and **it is on whoever is committing to run it**:
|
||||
|
||||
```bash
|
||||
cd cli && make check
|
||||
```
|
||||
|
||||
Five steps in one command, non-zero the moment any of them fails:
|
||||
|
||||
| step | what it holds down |
|
||||
|---|---|
|
||||
| `gofmt -l cmd internal` | nothing in the tree is unformatted. It reports and never rewrites — a formatting change made silently by a build is a change nobody reviewed |
|
||||
| `go vet ./...` | the compiler's own second opinion |
|
||||
| `go test -count=1 ./...` | the whole suite, with the test cache defeated: a gate that can pass because it passed an hour ago on different code is not a gate |
|
||||
| `go mod verify` + `go build -mod=vendor ./...` | every module matches its hash in `go.sum`, and the committed `vendor/` is complete and is what compiles. A `vendor/` that has drifted from `go.mod` breaks nobody until somebody builds with a cold module cache |
|
||||
| `kettle gen scaffold --check` | the documents embedded in the binary still agree with the registry it is built from. Exit 1 the moment they do not, and `dist/kettle gen scaffold --out internal/scaffold/assets` is the fix |
|
||||
|
||||
That last one is circular on purpose. What a project gets is embedded in the
|
||||
binary with the registry's flag tables **already spliced in**, so the embedded
|
||||
copy has to hold what the current registry would produce. Change a flag and it
|
||||
goes stale; regenerate, then rebuild, because the binary has to pick the new
|
||||
bytes up.
|
||||
|
||||
### Where the design notes are
|
||||
|
||||
**Every directory with a story carries its own `AGENTS.md`**, starting at [the
|
||||
repository root](AGENTS.md) and recursing into [`cli/`](cli/AGENTS.md), each of its
|
||||
packages, and each plugin. A `CLAUDE.md` beside one is a generated symlink to it,
|
||||
and `kettle mirror` keeps that arrangement honest — six of the seven states it can
|
||||
find are repaired losslessly, and the seventh (two real files whose contents
|
||||
differ) is reported and left alone, because one of them is somebody's writing.
|
||||
Register it with `kettle init --mirror-hook` if you want it run before every Bash
|
||||
call. Read the file next to the code you are changing rather than the whole
|
||||
tree.
|
||||
|
||||
`make help` lists the rest — `build`, `install [BINDIR=…]`, `dist`, `clean`.
|
||||
`make install` puts a version-stamped binary in `~/.local/bin` (override with
|
||||
`BINDIR=$(go env GOPATH)/bin`) and says so if that directory is not on your
|
||||
`PATH`, which is the whole of what a skill means by `command not found: kettle`.
|
||||
|
||||
Design notes live next to the code they describe: **every directory with a story
|
||||
carries its own `AGENTS.md`**, starting at [the repository root](AGENTS.md) and
|
||||
recursing into [`cli/`](cli/AGENTS.md), each of its packages, and each plugin. A
|
||||
`CLAUDE.md` beside one is a generated symlink to it. Two hooks in the `kettle`
|
||||
plugin keep the arrangement honest — one repairs the layout before every Bash call,
|
||||
the other asks for the nearest `AGENTS.md` to be corrected after a file is written.
|
||||
|
||||
## Cutting a release
|
||||
|
||||
Releases are cut **by hand, from a developer's machine**, and published by this
|
||||
repository's own code. `cli/cmd/release` is a second binary in the same module
|
||||
that talks to Gitea's API through the same SDK `kettle` uses: no third-party
|
||||
action, and nothing between a tag and what people download that is not in this
|
||||
tree. It is deliberately not a `kettle` subcommand — `kettle` is a tool for
|
||||
issues, and its command tree is what generates the documents it writes into a
|
||||
project.
|
||||
|
||||
```bash
|
||||
cd cli
|
||||
make check # nothing else is going to run this for you
|
||||
git tag -a v0.2.0 -m v0.2.0 # on the commit you mean to ship
|
||||
git push origin v0.2.0
|
||||
export KETTLE_URL=https://git.noodles.cam KETTLE_TOKEN=… # or run it from an
|
||||
# initialized project and it reads the
|
||||
# login pinned there
|
||||
make release TAG=v0.2.0 NOTES=../notes-v0.2.0.md TITLE="kettle v0.2.0"
|
||||
```
|
||||
|
||||
| step | what it verifies |
|
||||
|---|---|
|
||||
| `make check` | the five things above. A release is the worst place to find out |
|
||||
| `git tag` / `git push` | the tag exists, and it exists on the server. A release naming a tag nobody else can fetch is a download page pointing at nothing |
|
||||
| `make release` | **refuses a dirty working tree** — what shipped would not be what is committed, and nobody could rebuild it; **refuses a `TAG` that is not what `git describe` reports** — which is only true when the tag exists and `HEAD` is standing on it, so it also catches "I tagged, then committed one more thing"; and **refuses a tag that is not on the remote** |
|
||||
| the `dist` it runs first | `kettle` cross-compiled for darwin and linux on amd64 and arm64, `CGO_ENABLED=0`, each stamped with the tag via `-ldflags`, plus a `SHA256SUMS` over all four |
|
||||
| the publish | creates the release or reuses the one already there, uploads each artifact, replaces an asset of the same name rather than doubling it, and prints the release URL with every asset under it |
|
||||
|
||||
**Re-running it is safe, and that is the point.** An upload that died half way
|
||||
through is fixed by running the same command again: the tag's release is reused,
|
||||
the assets that made it are replaced by the freshly built ones, and corrected
|
||||
notes actually land. You get one release and one copy of each asset either way.
|
||||
|
||||
`kettle version` on a downloaded binary reports the tag it was built from;
|
||||
a binary somebody built out of a working tree says `dev` and means it.
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
---
|
||||
name: tea-runner
|
||||
description: Executes the tea plugin's scripts and reports back a compact receipt. Use for the mechanical half of tracker work — bulk pulls, pushing issues the caller already named, posting a comment from a file, bootstrapping labels, rebuilding the index or the tree. It runs commands; it never decides what an issue should say. Delegate a batch, not a single call.
|
||||
tools: Bash, Read, Grep, Glob, Skill
|
||||
model: haiku
|
||||
---
|
||||
|
||||
# tea-runner — the execution layer
|
||||
|
||||
You run this project's issue scripts and hand back a short receipt. You are the
|
||||
fourth layer of the plugin, below the three that carry meaning:
|
||||
|
||||
```
|
||||
skills/issue DOMAIN what an issue is
|
||||
skills/sync BRIDGE md <-> Gitea, over the wire
|
||||
skills/use REFERENCE tea CLI docs
|
||||
▲
|
||||
│ calls
|
||||
tea-runner EXECUTION runs the scripts, reports the result
|
||||
```
|
||||
|
||||
Knowledge still flows one way. You call those layers; nothing in them knows you
|
||||
exist. **You hold no opinion about content.** Titles, bodies, types, labels,
|
||||
dependencies, what is worth filing and what is worth closing — all of that was
|
||||
decided before you were called, and if it was not, the answer is to say so, not
|
||||
to fill the gap yourself.
|
||||
|
||||
## Where the commands come from
|
||||
|
||||
Load the skill, do not remember the flags:
|
||||
|
||||
- `/tea:sync` — `pull.py`, `push.py`, `comment.py`, `remote.py`, `labels.py`
|
||||
- `/tea:issue` — `issue_check.py`, `issue_tree.py`, `issue_index.py`,
|
||||
`issue_new.py`, `issue_ac.py`
|
||||
- `/tea:wiki` — `wiki_ls.py`, `wiki_pull.py`, `wiki_push.py`
|
||||
- `/tea:page` — `page_import.py`, `page_index.py`, `page_ls.py`
|
||||
|
||||
Invoke `Skill` with the one that owns the task at the start, and use the command
|
||||
table it gives you verbatim. The skill is the single source of
|
||||
truth for the script surface; a flag you recall from another session is a
|
||||
guess. If the skill does not document a flag, it does not exist — report that
|
||||
instead of trying it.
|
||||
|
||||
## Hard rules
|
||||
|
||||
1. **No raw `tea`.** Every tracker call goes through a script in
|
||||
`skills/sync/scripts/` or `skills/wiki/scripts/`. The one exception is a
|
||||
diagnostic the skill itself
|
||||
documents, written with the literal `--login "$GITEA_LOGIN"` placeholder —
|
||||
the `tea-guard` hook substitutes the pinned login. Never name a login.
|
||||
2. **No writing to issue files.** You have no `Edit` and no `Write`. Scripts
|
||||
write files; you do not. If a task needs a body edited or a metadata field
|
||||
changed by hand, stop and say which file and which field. `issue_ac.py` is
|
||||
the one script that touches a body, and it changes a single character: tick
|
||||
only the items the caller named, by the number or the substring the caller
|
||||
gave. Whether a criterion is actually met is a judgement about content, and
|
||||
content is never yours.
|
||||
3. **Push only what you were told to push.** `push.py` and `wiki_push.py`
|
||||
publish to a tracker other people read, **and `push.py` deletes the local
|
||||
file on success** — so a widened set is not an over-share, it is somebody
|
||||
else's working copy gone. Run them with the ids, titles, or filter the
|
||||
caller named. Never widen the set, never run a bare `push.py` because it
|
||||
looked like the obvious next step, and never pass `--force` — a validation
|
||||
failure is a result to report, not an obstacle to route around.
|
||||
`wiki_push.py` needs `-m`; use the caller's words, never your own summary.
|
||||
Report the number and URL `push.py` printed; that is now the only address
|
||||
the issue has.
|
||||
4. **Do not close, delete, or retitle anything** on either side. On the wiki
|
||||
that means no `--retitle`: renaming a published page abandons the old one.
|
||||
The one deletion you may cause is push's own, on the issue you were told to
|
||||
push.
|
||||
5. **One retry, maximum.** A command that fails twice is a finding. Do not
|
||||
permute flags looking for one that works.
|
||||
6. **No payload dumps.** Never run `tea issues -o json`, never `cat` a pulled
|
||||
issue body back into your report. The scripts print compact output by
|
||||
design; the caller reads the files it needs from disk.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. Load the skill you need.
|
||||
2. Run the commands. Prefer one filtered call over a loop —
|
||||
`pull.py --milestone 6` is one request per 50 issues, `pull.py 41 42 43…`
|
||||
is one per issue.
|
||||
3. If a command exits non-zero, capture the last lines of stderr and stop that
|
||||
branch. Keep going on independent branches.
|
||||
4. Report.
|
||||
|
||||
## Report format
|
||||
|
||||
Your final message is the return value. Keep it under ~20 lines. No preamble,
|
||||
no restatement of the request, no advice about what to do next.
|
||||
|
||||
```
|
||||
ran:
|
||||
pull.py --milestone 6 --state all ok 7 issues, 3 threads
|
||||
issue_index.py ok INDEX.md rebuilt
|
||||
push.py wire-sqlc-appclick FAIL exit 1
|
||||
|
||||
touched: tmp/issues/{a,b,c}.md, tmp/issues/INDEX.md
|
||||
|
||||
failed: push.py wire-sqlc-appclick
|
||||
ERROR wire-sqlc-appclick: missing section '## Acceptance criteria'
|
||||
|
||||
blocked: none
|
||||
```
|
||||
|
||||
- `ran` — one line per command: what, ok/FAIL, and the one number that matters.
|
||||
- `touched` — paths only. Never contents.
|
||||
- `failed` — the command, then stderr verbatim, trimmed to the lines that name
|
||||
the cause. Quote it exactly; do not paraphrase an error.
|
||||
- `blocked` — what you refused to decide, phrased as the question the caller
|
||||
has to answer. `none` when there is nothing.
|
||||
|
||||
## Known stops
|
||||
|
||||
Report these and halt; none of them is yours to resolve.
|
||||
|
||||
| Condition | Report |
|
||||
|---|---|
|
||||
| no login pinned (`tea-guard` blocks, or a script points at `/tea:auth`) | `blocked: no pinned login — operator must run /tea:auth` |
|
||||
| `issue_check.py` errors before a push | the validator's own lines, verbatim |
|
||||
| a dependency is still `origin: local` | name the id; the caller decides whether to push it |
|
||||
| a milestone or label does not exist in the repo | the script prints the real ones — pass that list through |
|
||||
| a script asks for a decision (type, label, `--force`) | `blocked:` with the question |
|
||||
@@ -0,0 +1 @@
|
||||
.kettle/
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
# AGENTS.md — the kettle CLI
|
||||
|
||||
`kettle` is a globally installed binary, and **it is the whole product**. There is
|
||||
no plugin any more: it owns this project's connection to its tracker — the
|
||||
credentials, the transport, the payload scratchpad — and it owns the documents an
|
||||
operator and a model read, which it writes into `.claude/` and which are embedded
|
||||
in it.
|
||||
|
||||
Issues are its main subject and no longer its only one: what an issue is, where
|
||||
the store lives, who this machine is, how issues move to and from Gitea, and,
|
||||
through `kettle api`, every other Gitea entity that has no command of its own.
|
||||
|
||||
That is a widening, three times over, and it is worth being straight about each.
|
||||
"Issues and nothing else" was the line until `cmd/release` crossed it (publishing
|
||||
this repository's own releases), then `kettle api` (because the alternative was
|
||||
requiring `tea` — a second CLI with a second set of logins nothing here could
|
||||
see, documented in 400 lines of somebody else's flag reference nothing here could
|
||||
check), and then `kettle mirror` (141 lines of Python that used to be a plugin
|
||||
hook and had nowhere to live once the plugin went).
|
||||
|
||||
One rule made all three: **a binary holds what can be enforced.** Every one of
|
||||
them was mechanical, and every one of them was somewhere a test could not reach.
|
||||
What has *not* widened is the domain — `internal/issue` still knows nothing about
|
||||
trackers, `api` is transport plus a command, and `mirror` and `scaffold` import
|
||||
nothing at all.
|
||||
|
||||
**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, and then why no plugin
|
||||
|
||||
Three failures in the Python version were failures of *runtime*, not of logic:
|
||||
|
||||
- the store resolved from `__file__`, so it landed inside a versioned plugin
|
||||
cache and issues written from one project were invisible from the next;
|
||||
- the walk that answers "which directory is the project" was written three
|
||||
times — store, login pin, guard hook — and in a linked worktree the three
|
||||
disagreed;
|
||||
- `sys.path.insert` was the import mechanism, so the layering rule was a
|
||||
convention checked by grep.
|
||||
|
||||
A compiled binary answers all three by construction. There is one walk
|
||||
([`internal/project`](internal/project/AGENTS.md)), it is imported rather than
|
||||
re-derived, and the layering rule is a build graph a test walks.
|
||||
|
||||
A fourth failure survived that migration and outlived it by a year. The plugin
|
||||
shipped the skills; **nothing on an operator's machine ever checked that the
|
||||
plugin they had installed described the binary they had installed.** The
|
||||
generated block existed precisely so a renamed flag could not ship with
|
||||
documentation recommending the old one — and then shipped one version behind the
|
||||
registry it was generated from, which is the same bug one hop downstream. So the
|
||||
documents moved inside the binary ([`internal/scaffold`](internal/scaffold/AGENTS.md))
|
||||
and `kettle gen scaffold` writes them into a project. Prose that travels inside
|
||||
the binary cannot be a version behind it.
|
||||
|
||||
What that cost is worth naming rather than glossing: the marketplace was
|
||||
discovery, and a README URL is not a shop window. The `/kettle:…` prefix came
|
||||
free from the plugin and had to be rebuilt out of a `commands/kettle/`
|
||||
subdirectory. Both were paid knowingly — a document describing the wrong binary
|
||||
is worse than one nobody found.
|
||||
|
||||
## Layers
|
||||
|
||||
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
|
||||
│ │ │ │ │
|
||||
│ │ │ │ ├─────► internal/scaffold the documents written into .claude/,
|
||||
│ │ │ │ │ embedded. Imports nothing.
|
||||
│ │ │ │ └─────► internal/mirror AGENTS.md ⇄ CLAUDE.md, seven
|
||||
│ │ │ │ branches. Imports nothing.
|
||||
│ │ │ └────────► internal/config who this machine is, what this
|
||||
│ │ │ project points at; yaml lives here
|
||||
│ │ │ and only here
|
||||
│ │ └────────────► internal/gitea TRANSPORT: one door for every
|
||||
│ │ │ request, pagination, payload dumps,
|
||||
│ │ │ the number -> slug ledger
|
||||
│ │ ▼
|
||||
├──┼──────────────────► internal/wire ADDRESSES: Repo and Key, and the
|
||||
│ │ ▲ parsing that reads them. Imports
|
||||
│ │ │ nothing.
|
||||
└──► internal/mapping ──────┘ BRIDGE: md <-> the SDK's payloads,
|
||||
│ no I/O; label colours live here
|
||||
▼
|
||||
internal/issue DOMAIN what an issue is: format, taxonomy, validation,
|
||||
│ checkboxes, dependency graph, the store, eviction
|
||||
│ offline — no tracker, no network, no JSON
|
||||
▼
|
||||
internal/project ROOT which directory is the project, and every path
|
||||
resolved from it: store, payload, config, scaffold
|
||||
depends on nothing
|
||||
```
|
||||
|
||||
Three packages at the bottom depend on nothing — `project`, `wire`, and now
|
||||
`mirror` and `scaffold` beside them. The last two are there for a reason pointing
|
||||
outward rather than inward: `mirror` walks any directory on any machine, and
|
||||
`scaffold`'s documents must exist wherever the binary does. One import of
|
||||
`internal/config` in the first, or one `os.ReadFile` in the second, and each
|
||||
stops being the thing it is for.
|
||||
|
||||
| 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 a project's documents 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/scaffold`](internal/scaffold/AGENTS.md) | documents | the commands, skills and subagent written into `.claude/`, and why they are not a plugin |
|
||||
| [`internal/mirror`](internal/mirror/AGENTS.md) | filesystem | one invariant per directory, seven branches, one refusal |
|
||||
| [`internal/project`](internal/project/AGENTS.md) | root | the walk, and every path resolved from its answer |
|
||||
|
||||
The rules that hold the layers apart, and the eleven 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.
|
||||
|
||||
## Dependencies, and building
|
||||
|
||||
`gopkg.in/yaml.v3`, `code.gitea.io/sdk/gitea` and `golang.org/x/term` — eight
|
||||
modules once the SDK's own are counted, and the third of those was already among
|
||||
them before anything here imported it. It became direct for one prompt:
|
||||
`term.ReadPassword`, so an operator typing a token at `kettle init --interactive`
|
||||
leaves it in no history, no file and no scrollback.
|
||||
|
||||
No cobra: commands are values in a registry, which is what lets a project's
|
||||
skills be generated from the same struct that holds the code.
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
**`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 scaffold --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.
|
||||
|
||||
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.
|
||||
|
||||
**`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.** 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.
|
||||
|
||||
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 (and the one hook that stayed)
|
||||
|
||||
The Python version needed a `PreToolUse` hook to block any `tea` command that
|
||||
would run under a login the model picked instead of the operator. That whole
|
||||
apparatus is gone. The binary holds its own credentials and reads the login out
|
||||
of the project's own configuration, so there is no argument to police and no way
|
||||
for the transport and the guard to disagree — the failure the hook existed to
|
||||
catch is not expressible any more. `kettle api` did not put it back: it takes an
|
||||
endpoint and a body, never a login, and a full URL on another host is refused
|
||||
rather than sent with this project's token attached.
|
||||
|
||||
There is also no `--login` and no `--repo` on any sync command bar `labels`.
|
||||
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.
|
||||
|
||||
The one `PreToolUse` hook that exists now is `kettle mirror --hook`, and it
|
||||
polices nothing: it repairs an `AGENTS.md`/`CLAUDE.md` pair and reports. It is
|
||||
**opt-in per project** — `kettle init --mirror-hook`, or a question in
|
||||
`--interactive` — which is strictly narrower than what it replaced, a hook that
|
||||
was on for everybody who installed the plugin, in every repository they touched,
|
||||
enforcing a convention that is this repository's. It cannot fail a tool call: it
|
||||
exits 0 on every path including its own bugs.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
make check # fmt, vet, test, go mod verify, build, docs — the whole gate
|
||||
go test ./... # just the tests
|
||||
```
|
||||
|
||||
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 scaffold # into <project>/.claude
|
||||
dist/kettle gen scaffold --out internal/scaffold/assets # into the embedded copy
|
||||
make check # exit 1 if that copy is stale
|
||||
```
|
||||
|
||||
Everything between `<!-- kettle:gen -->` and `<!-- /kettle:gen -->` comes from the
|
||||
command registry, so a renamed flag cannot ship with documentation that recommends
|
||||
the old one — and since the prose around it is embedded too, the whole document
|
||||
ships with the binary rather than beside it. The generator is
|
||||
[`internal/cmd`](internal/cmd/AGENTS.md)'s and the documents are
|
||||
[`internal/scaffold`](internal/scaffold/AGENTS.md)'s.
|
||||
|
||||
**The loop is circular on purpose.** `assets/` holds the block already spliced in,
|
||||
because that is what a project gets byte for byte; so a registry change makes the
|
||||
render differ from the embedded copy, `--check` exits 1, regenerating fixes it,
|
||||
and the binary has to be **rebuilt** to pick the new bytes up.
|
||||
|
||||
Note the asymmetry, because it is easy to get backwards: **everything under
|
||||
`internal/scaffold/assets` is written out and must never be edited in a project;
|
||||
every AGENTS.md in this tree is hand-written and must never be generated.** One is
|
||||
a document with a flag table in it, the other is a reason.
|
||||
|
||||
## Status
|
||||
|
||||
Done and tested: every package in the table above, and the commands `init`, `auth`,
|
||||
`config`, `gen`, `mirror`, `version`, `new`, `check`, `ac`, `tree`, `index`,
|
||||
`evict`, `pull`, `push`, `remote`, `comment`, `close`, `labels`, `sync-evict`,
|
||||
`api` — plus `cmd/release`, against a fake Gitea.
|
||||
|
||||
**The plugin is gone.** Its skills are embedded here, its hook is `kettle mirror`,
|
||||
and `kettle init` writes the whole `.claude/` tree. The marketplace catalogue at
|
||||
the repository root now holds one entry, [`tdl`](../plugins/tdl/AGENTS.md), which
|
||||
has no binary and never needed one.
|
||||
|
||||
## 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, a cross-package contract like the round trip changes, or the argument
|
||||
for embedding the documents rather than shipping them stops holding.
|
||||
- **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.
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
# 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
|
||||
# documents embedded in it still match its own command registry, and -j would
|
||||
# let the second start first.
|
||||
.NOTPARALLEL:
|
||||
|
||||
MODULE := git.noodles.cam/claude-skills/marketplace/cli
|
||||
ASSETS := internal/scaffold/assets
|
||||
DIST := dist
|
||||
BIN := $(DIST)/kettle
|
||||
REMOTE ?= origin
|
||||
|
||||
# Where `make install` puts the binary. Everything kettle writes into a project
|
||||
# expects `kettle` on PATH and says 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, and it is circular on purpose. The documents an
|
||||
# operator gets are embedded in the binary, and the flag tables inside them are
|
||||
# rendered from the registry that same binary is built from — so the embedded
|
||||
# copy has to already hold what the current registry would produce. This exits 1
|
||||
# the moment it does not; `$(BIN) gen scaffold --out $(ASSETS)` fixes it, and
|
||||
# then the binary has to be rebuilt to pick the new bytes up.
|
||||
gen-check: build
|
||||
@echo 'docs gen scaffold --check'
|
||||
@$(BIN) gen scaffold --out $(ASSETS) --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 nothing kettle writes will 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,14 @@
|
||||
// Command kettle keeps a project's issues as local markdown and moves them to
|
||||
// and from a tracker.
|
||||
//
|
||||
// An issue is a unit of work first and a tracker row second. Everything under
|
||||
// `kettle help` that is not in the sync group works with the network off.
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/cmd"
|
||||
)
|
||||
|
||||
func main() { os.Exit(cmd.Main(os.Args[1:])) }
|
||||
@@ -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 scaffold`
|
||||
writes a project's skills and commands 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 scaffold` writes a project's skills and commands 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,686 @@
|
||||
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 puts BOTH anchors of the walk — CLAUDE_PROJECT_DIR and the
|
||||
// working directory — in an empty temp directory, so there is no `.kettle/`
|
||||
// marker anywhere on the way up. That is the state a fresh clone is in and the
|
||||
// whole reason this tool resolves its configuration the way it does. It also
|
||||
// points KETTLE_CONFIG_HOME at another temp directory, 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)
|
||||
// The walk has two anchors and CLAUDE_PROJECT_DIR is only the first: with
|
||||
// the working directory left where `go test` put it, a run inside a tree
|
||||
// that has a marker resolves that tree's config. This repository has one —
|
||||
// it is gitignored, so it is present in a developer's checkout and in no
|
||||
// clone — and a fixture about resolving nothing must not find it.
|
||||
t.Chdir(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())
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
module git.noodles.cam/claude-skills/marketplace/cli
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
code.gitea.io/sdk/gitea v0.25.1
|
||||
golang.org/x/term v0.45.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/42wim/httpsig v1.2.4 // indirect
|
||||
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||
github.com/go-fed/httpsig v1.1.0 // indirect
|
||||
github.com/hashicorp/go-version v1.9.0 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
)
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
code.gitea.io/sdk/gitea v0.25.1 h1:yywxWwoV+SdjHtbC6unBiXojWdZOtoHuGhEazEXeWuE=
|
||||
code.gitea.io/sdk/gitea v0.25.1/go.mod h1:uDFWYBU8dgZsgOHwe6C/6olxvf8FHguNB3wW1i83fgg=
|
||||
github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU=
|
||||
github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
|
||||
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
|
||||
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
||||
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
||||
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
|
||||
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,101 @@
|
||||
# AGENTS.md — internal/, and the boundaries between the packages in it
|
||||
|
||||
Nine 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.
|
||||
|
||||
## Six rules, eleven 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` |
|
||||
| [`mirror`](mirror/AGENTS.md) imports **only** the standard library, and shells out to nothing | `TestMirrorDependsOnNothing` and `TestMirrorNeitherDialsNorShellsOut` |
|
||||
| [`scaffold`](scaffold/AGENTS.md) imports **only** the standard library, and reads nothing off the disk | `TestScaffoldDependsOnNothing` and `TestScaffoldReadsNothingOffTheDisk` |
|
||||
|
||||
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 last two rows are the newest and are there for the same reason as `wire`'s,
|
||||
turned outward rather than inward. `mirror` walks any directory on the machine
|
||||
and `scaffold` hands out documents that must exist wherever the binary does — so
|
||||
one import of `internal/config` would make `mirror` unusable outside a project,
|
||||
and one `os.ReadFile` would make `scaffold`'s documents files that can be
|
||||
missing. Neither failure would show up in this repository; both would show up on
|
||||
somebody else's machine.
|
||||
|
||||
`mirror` bans `os/exec` by name, and that one is a small monument: this package
|
||||
was 141 lines of Python behind a filename that said `.sh`, so the shell-out it
|
||||
must not grow is the exact thing it used to be.
|
||||
|
||||
## 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 six
|
||||
rules, the eleven 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,287 @@
|
||||
# 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 a project's skills 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. Four groups, in presentation order:
|
||||
`project`, `issue`, `sync`, `api`.
|
||||
|
||||
| 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 scaffold`: rendering [`scaffold`](../scaffold/AGENTS.md)'s documents with the registry's flag tables spliced in |
|
||||
| `init.go` `auth.go` `config.go` `version.go` `mirror.go` | group `project`. `version.go` also holds `Version`, the string a release build stamps in with `-ldflags -X` |
|
||||
| `interactive.go` | `kettle init --interactive`: the only thing here that reads a terminal |
|
||||
| `settings.go` | registering `kettle mirror --hook` in `.claude/settings.json`, and refusing to reformat one it did not write |
|
||||
| `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` |
|
||||
| `api.go` | group `api`, alone in it: one request to an endpoint nothing here wraps |
|
||||
| `cli_test.go` | builds the binary in `TestMain`, runs it as a subprocess |
|
||||
| `sync_pull_test.go` `sync_write_test.go` `api_test.go` | the tracker halves, against fake servers |
|
||||
| `gen_test.go` | the generator: the whole tree, determinism, and that a local edit does not survive |
|
||||
| `mirror_test.go` | the repair command, and the hook form's promise never to fail a Bash call |
|
||||
| `init_scaffold_test.go` | the `.claude/` tree, the settings refusal, the worktree refusal, the not-a-terminal refusal |
|
||||
| `scaffold_coverage_test.go` | the registry ⇄ documents seam, in the one package that can see both |
|
||||
|
||||
The fourth group is one command and was still worth naming, because a group is a
|
||||
skill written into a project: `api` is a subject somebody loads on its own —
|
||||
which endpoint, and does it paginate — and folding it into `sync` would have put
|
||||
"how do I cut a release" behind a skill about the issue round trip.
|
||||
|
||||
**`mirror` is the one command in the tree that has nothing to do with issues**,
|
||||
and it is here rather than in a second binary for the reason this repository
|
||||
keeps repeating to itself: a binary holds what can be enforced. Repairing an
|
||||
`AGENTS.md`/`CLAUDE.md` pair is a seven-branch filesystem decision with one
|
||||
refusal in it, which is a thing a table test can hold down and a shell script
|
||||
cannot. It was 141 lines of Python behind a filename that said `.sh`, shipped in
|
||||
the plugin's hooks; when the plugin went, the shell had nowhere to live and Go
|
||||
did. The domain is untouched: [`mirror`](../mirror/AGENTS.md) imports nothing.
|
||||
|
||||
## 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.
|
||||
|
||||
`api` keeps that rule and needs no flag to: a cross-repository address is an
|
||||
address, so `repos/other-owner/other-repo/releases` is simply a path with nothing
|
||||
to substitute — `{owner}` and `{repo}` are filled in only where they are spelled.
|
||||
Another **instance** is `KETTLE_URL`/`KETTLE_TOKEN`, and a full URL pointing at a
|
||||
host that is not this project's is refused by the transport rather than sent with
|
||||
the token attached. It also resolves the store it never reads, exactly as `labels`
|
||||
does, so "there is no project here" fails the same way for every command that
|
||||
talks to a tracker.
|
||||
|
||||
**`-X DELETE` needs `--yes`.** The only gate of its kind in the tree, and it is
|
||||
here because this is the only command that can delete something that is not an
|
||||
issue — a release, a tag, a branch — from an argument. A flag typed on purpose is
|
||||
an operator's decision; everything else about the request goes out as spelled.
|
||||
|
||||
`--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 --interactive # a person at a terminal, walked through it
|
||||
kettle init --login noodles --repo owner/name
|
||||
kettle mirror --check # AGENTS.md ⇄ CLAUDE.md, everywhere below here
|
||||
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 splices into
|
||||
[`scaffold`](../scaffold/AGENTS.md)'s documents. **Do not restate a flag table
|
||||
here**; it would be a third copy of something already in two places, one of them
|
||||
mechanically checked.
|
||||
|
||||
## `--interactive`, and the two things it may never become
|
||||
|
||||
`interactive.go` is the only code in this binary that reads a terminal. Two rules
|
||||
hold it in place, and both are about what it must not turn into.
|
||||
|
||||
**It answers no question the flags cannot answer.** Every prompt has a field in
|
||||
`initOptions` and therefore a flag — `--login`, `--repo`, `--scaffold-out`,
|
||||
`--no-scaffold`, `--mirror-hook`. A capability reachable only by answering a
|
||||
prompt would be a capability no script, no CI run and no agent could ever use.
|
||||
|
||||
**It performs nothing.** It fills the struct in and hands it back, so `runInit`
|
||||
is one code path: the run that follows an interactive session is byte for byte
|
||||
the run somebody else gets from flags.
|
||||
|
||||
It **refuses a standard input that is not a terminal**, and that refusal is load
|
||||
bearing rather than fussy. A model that reached for `--interactive` would
|
||||
otherwise block forever on a prompt nobody is going to answer; the error names
|
||||
the flags instead. It is also what makes the one genuinely better thing here
|
||||
possible — `term.ReadPassword` puts a token in no history, no file and no
|
||||
scrollback, which every other route into this binary can only approximate.
|
||||
|
||||
`--repo` is offered with a guess read out of `.git/config`, parsed rather than
|
||||
shelled out to. `git` is one more thing that has to be installed and one more
|
||||
process to misread; a guess is worth what it costs, and anything unparseable is
|
||||
no guess at all.
|
||||
|
||||
## Writing into `.claude/settings.json`
|
||||
|
||||
`settings.go` registers `kettle mirror --hook` on `PreToolUse(Bash)`, and is the
|
||||
only place this binary touches a file the operator owns and commits. It has three
|
||||
outcomes and the third is the whole reason it is not ten lines long:
|
||||
|
||||
- **no file** — written, hook and all;
|
||||
- **a file already holding a `kettle mirror` hook** — nothing happens;
|
||||
- **a file holding something else** — **refused**, with the snippet printed to
|
||||
paste, unless `--force-settings`.
|
||||
|
||||
Go's `encoding/json` cannot preserve key order, so any merge reformats the whole
|
||||
document. An operator who asked for a documentation hook and got an unrelated
|
||||
diff across a file they share with their team has been badly served; a snippet
|
||||
they paste costs them ten seconds. The refusal is reported on stderr and the run
|
||||
still **succeeds** — everything before it already happened, and reporting a
|
||||
failure would send somebody looking for damage there is none of.
|
||||
|
||||
The registered command carries a `command -v kettle` guard. That line outlives
|
||||
this binary: an operator who uninstalls `kettle` would otherwise get
|
||||
`command not found` on every Bash call, from a hook they set up months ago and
|
||||
have long stopped thinking about.
|
||||
|
||||
## 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 a project's `.claude/` tree: [`scaffold`](../scaffold/AGENTS.md)'s
|
||||
embedded documents, with this registry's flag tables spliced into the four that
|
||||
declare a region.
|
||||
|
||||
**It writes every file whole, and that is a deliberate reversal.** The old
|
||||
generator owned a region and left every byte outside it alone, because the prose
|
||||
around the block was somebody's hand-written file in this repository. It is not
|
||||
any more — it is embedded — so there is no hand-written half left to protect, and
|
||||
preserving local edits would freeze a project's documentation at whatever version
|
||||
first initialized it. The markers stay in the output so a reader can still see
|
||||
which half came from the registry.
|
||||
|
||||
The principle the old refusal protected did not go away; it moved. **Nobody's
|
||||
prose is clobbered because nobody's prose is there** — it lives in `assets/`, next
|
||||
to the code it describes, and `--check` warns before an upgrade replaces a local
|
||||
edit.
|
||||
|
||||
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 document per **group**, so adding a group here means adding one under
|
||||
`internal/scaffold/assets`; name a group only when it is a subject somebody would
|
||||
load on its own. `TestEveryGroupHasSomewhereToBeWritten` is what makes that a
|
||||
two-step change rather than a silent one-step mistake — the generator walks the
|
||||
documents, not the registry, so a group with no document would have its commands
|
||||
written nowhere and nothing would say so. A `Long` or `Example` that spells a
|
||||
region marker out in full is a hard error: the generated block would end inside
|
||||
itself.
|
||||
|
||||
`--out` defaults to `<project>/.claude`, resolved by the same walk everything else
|
||||
uses, and no marker is an answer rather than a fallback.
|
||||
|
||||
## 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, a command
|
||||
gains a confirmation gate, the push/pull ordering guarantees change, or the rules
|
||||
around writing into `.claude/` 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.
|
||||
@@ -0,0 +1,187 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
)
|
||||
|
||||
var numberRe = regexp.MustCompile(`^\d+$`)
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "ac",
|
||||
Group: GroupIssue,
|
||||
Args: "<id>",
|
||||
Short: "list and tick an issue's checkboxes",
|
||||
Long: `A checkbox is the one part of a body that is *state* and not prose. Everything
|
||||
else is written once; boxes get ticked as the work goes, and the only other ways
|
||||
to tick one are a human with an editor or a model rewriting the whole body — the
|
||||
second worse than the first, because the rewrite re-flows the text and the
|
||||
issue's diff swells around a change of one character. This changes that one
|
||||
character and nothing else.
|
||||
|
||||
Named after ` + "`## Acceptance criteria`" + `, where most boxes live, but every checkbox in
|
||||
the body is listed and tickable: a type/feature keeps its children under
|
||||
` + "`## Issues`" + `, and binding this to one heading would silently lose half of them.
|
||||
|
||||
A substring picks an item only when it picks exactly one. Two matches is an
|
||||
error listing both — a coin flip would tick the wrong box and look like it
|
||||
worked.
|
||||
|
||||
Delivering the changed body to a tracker is not part of this; that is
|
||||
` + "`kettle push --update`" + `.`,
|
||||
Examples: []Example{
|
||||
{"kettle ac wire-sqlc-appclick", "numbered list with state"},
|
||||
{"kettle ac wire-sqlc-appclick --check 3", "tick by number"},
|
||||
{"kettle ac wire-sqlc-appclick --check регресс", "tick by substring"},
|
||||
{"kettle ac wire-sqlc-appclick --uncheck 3", "untick it again"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
check := fs.String("check", "", "tick one item: number or substring")
|
||||
uncheck := fs.String("uncheck", "", "untick one item: number or substring")
|
||||
out := storeFlag(fs)
|
||||
|
||||
return func(args []string) error {
|
||||
if len(args) != 1 {
|
||||
return Fail("give exactly one issue id")
|
||||
}
|
||||
checking, unchecking := wasSet(fs, "check"), wasSet(fs, "uncheck")
|
||||
if checking && unchecking {
|
||||
return Fail("--check and --uncheck are mutually exclusive")
|
||||
}
|
||||
id := args[0]
|
||||
root, err := storeRoot(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := issue.PathOf(root, id)
|
||||
// Raw bytes in and raw bytes out: byte-for-byte means the line
|
||||
// endings too. Reading a CRLF file with translation and writing
|
||||
// it back would rewrite every line while claiming to have
|
||||
// changed one character.
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Fail("no issue %q in %s", id, root)
|
||||
}
|
||||
text := string(raw)
|
||||
|
||||
// The whole file, not just the body: line numbers then point at
|
||||
// the file, and the metadata block is rewritten by nobody.
|
||||
// Round-tripping through the parser would re-render metadata and
|
||||
// re-strip the body, which is exactly the churn this avoids.
|
||||
items := issue.Checkboxes(text)
|
||||
needle, checked := *check, true
|
||||
if unchecking {
|
||||
needle, checked = *uncheck, false
|
||||
}
|
||||
selecting := checking || unchecking
|
||||
|
||||
if len(items) == 0 {
|
||||
if selecting {
|
||||
return Fail("%s has no checkboxes", id)
|
||||
}
|
||||
fmt.Printf("%s — no checkboxes\n", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !selecting {
|
||||
done, total := issue.CheckboxProgress(text)
|
||||
fmt.Printf("%s — %d/%d %s\n", id, done, total, path)
|
||||
fmt.Println(strings.Join(listing(items), "\n"))
|
||||
return nil
|
||||
}
|
||||
|
||||
item, err := selectItem(items, needle)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updated, err := issue.SetCheckbox(text, item.Line, checked)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if updated == text {
|
||||
fmt.Printf("unchanged %2d %s %s\n", item.Index, box(item.Checked), item.Text)
|
||||
return nil
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := issue.BuildIndex(root); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
verb := "checked"
|
||||
if !checked {
|
||||
verb = "unchecked"
|
||||
}
|
||||
done, total := issue.CheckboxProgress(updated)
|
||||
fmt.Printf("%s %2d %s %s\n", verb, item.Index, box(checked), item.Text)
|
||||
fmt.Printf("%s — %d/%d %s:%d\n", id, done, total, path, item.Line)
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func box(checked bool) string {
|
||||
if checked {
|
||||
return "[x]"
|
||||
}
|
||||
return "[ ]"
|
||||
}
|
||||
|
||||
// listing is the numbered list, grouped by the heading each item sits under.
|
||||
func listing(items []issue.Checkbox) []string {
|
||||
var out []string
|
||||
section := "\x00" // no heading can equal this, so the first item opens a group
|
||||
for _, c := range items {
|
||||
if c.Section != section {
|
||||
section = c.Section
|
||||
head := section
|
||||
if head == "" {
|
||||
head = "(above the first heading)"
|
||||
}
|
||||
out = append(out, "", head)
|
||||
}
|
||||
out = append(out, fmt.Sprintf(" %2d %s %s", c.Index, box(c.Checked), c.Text))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// selectItem resolves a --check/--uncheck argument to exactly one item.
|
||||
func selectItem(items []issue.Checkbox, needle string) (issue.Checkbox, error) {
|
||||
needle = strings.TrimSpace(needle)
|
||||
if needle == "" {
|
||||
return issue.Checkbox{}, Fail("empty selector — give an item number or a substring")
|
||||
}
|
||||
if numberRe.MatchString(needle) {
|
||||
n, _ := strconv.Atoi(needle)
|
||||
if n < 1 || n > len(items) {
|
||||
return issue.Checkbox{}, Fail("no item %d — the issue has %d", n, len(items))
|
||||
}
|
||||
return items[n-1], nil
|
||||
}
|
||||
var hits []issue.Checkbox
|
||||
for _, c := range items {
|
||||
if strings.Contains(strings.ToLower(c.Text), strings.ToLower(needle)) {
|
||||
hits = append(hits, c)
|
||||
}
|
||||
}
|
||||
switch len(hits) {
|
||||
case 0:
|
||||
return issue.Checkbox{}, Fail("nothing matches %q", needle)
|
||||
case 1:
|
||||
return hits[0], nil
|
||||
}
|
||||
lines := []string{fmt.Sprintf("%q matches %d items — narrow it down, or use a number:", needle, len(hits))}
|
||||
for _, c := range hits {
|
||||
lines = append(lines, fmt.Sprintf(" %2d %s %s", c.Index, box(c.Checked), c.Text))
|
||||
}
|
||||
return issue.Checkbox{}, Fail("%s", strings.Join(lines, "\n"))
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
// apiMethods is what this command will send. Not a defence against a typo so
|
||||
// much as against a shell: an unquoted endpoint that swallowed a word must not
|
||||
// be sent as a verb the server then answers 405 to.
|
||||
var apiMethods = map[string]bool{
|
||||
http.MethodGet: true,
|
||||
http.MethodPost: true,
|
||||
http.MethodPut: true,
|
||||
http.MethodPatch: true,
|
||||
http.MethodDelete: true,
|
||||
}
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "api",
|
||||
Group: GroupAPI,
|
||||
Args: "<endpoint>",
|
||||
Short: "one request to this project's Gitea, for everything that is not an issue",
|
||||
Long: `Releases, pull requests, milestones, branches, tags, actions, webhooks,
|
||||
notifications: everything Gitea has that this binary has no command for. One
|
||||
invocation is ONE request — the credentials, the repository and the payload
|
||||
scratchpad are the ones this project already resolved, so there is nothing to
|
||||
configure and no second tool to log in.
|
||||
|
||||
THE ENDPOINT IS SPELLED THE WAY GITEA'S OWN DOCUMENTATION SPELLS IT. A bare path
|
||||
is taken as relative to ` + "`/api/v1/`" + `; a path that already begins ` + "`/api/`" + ` is sent as it
|
||||
stands, which is how anything outside v1 is reached; a full URL is allowed only
|
||||
on the instance this project points at, because every request here carries the
|
||||
project's token in a header and a URL somewhere else would hand that token over.
|
||||
` + "`{owner}`" + ` and ` + "`{repo}`" + ` are filled in from the project's configuration. Quote an
|
||||
endpoint that contains ? or & or the shell will take it apart.
|
||||
|
||||
ANOTHER REPOSITORY NEEDS NO FLAG — write its address into the path
|
||||
(` + "`repos/other-owner/other-repo/releases`" + `) and nothing is substituted. There is no
|
||||
--repo and no --login here for the same reason there is none on push or pull:
|
||||
which login a project runs under is a fact about the project. Another INSTANCE
|
||||
is KETTLE_URL and KETTLE_TOKEN, which is also what a CI run uses.
|
||||
|
||||
THE ANSWER IS THE SERVER'S BYTES ON STDOUT, unparsed and unreformatted — pipe it
|
||||
to jq, redirect it to a file. There is no flag that names an output file: in
|
||||
this tree --out is the issue store, and one word meaning two things is exactly
|
||||
the trap the tool this replaces set with an -o that wrote a file called "json".
|
||||
|
||||
IT DOES NOT PAGINATE. One call is one request, so a listing answers with one
|
||||
page: ask for the next with ?page=2, and for a bigger one with ?limit=50 (the
|
||||
server's own default is 30, its maximum is usually 50). A passthrough that
|
||||
stitched pages together silently would report as one answer something that was
|
||||
several.
|
||||
|
||||
ISSUES ARE NOT THIS COMMAND'S JOB even though it can reach them. An issue read
|
||||
this way arrives as a full JSON payload — every comment, every label object,
|
||||
every URL — which is what /kettle:issue and /kettle:sync exist to keep out of a
|
||||
context window. Use pull, push, comment and close.
|
||||
|
||||
A 403 here is usually the token rather than the request: a token minted for
|
||||
issues carries write:issue, and releases, pull requests, branches and tags are
|
||||
all under repository. ` + "`kettle auth list`" + ` shows what each login records.
|
||||
|
||||
-X DELETE NEEDS --yes. Everything else goes through as typed; a deletion does
|
||||
not, because a flag typed on purpose is an operator's decision and the URL of a
|
||||
release is one character away from the URL of the wrong release.
|
||||
|
||||
What it cannot do: an upload. Release attachments are multipart/form-data and
|
||||
this sends JSON — the release tooling in cmd/release does those.`,
|
||||
Examples: []Example{
|
||||
{"kettle api repos/{owner}/{repo}/releases", "the latest page of releases, as JSON"},
|
||||
{"kettle api user", "who this project's token belongs to"},
|
||||
{`kettle api 'repos/{owner}/{repo}/pulls?state=open&limit=50'`, "quote anything with ? or & in it"},
|
||||
{"kettle api --data @tmp/release/v0-2-0.json repos/{owner}/{repo}/releases", "a body from a file; POST is implied"},
|
||||
{"kettle api --field body=lgtm repos/{owner}/{repo}/issues/7/comments", "a small body without a file"},
|
||||
{"kettle api -X DELETE --yes repos/{owner}/{repo}/releases/12", "a deletion, said out loud"},
|
||||
{"kettle api repos/{owner}/{repo}/milestones | jq '.[].title'", "the bytes are the server's; jq is yours"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
var method string
|
||||
fs.StringVar(&method, "method", "", "GET, POST, PUT, PATCH or DELETE (default GET, or POST when there is a body)")
|
||||
fs.StringVar(&method, "X", "", "the same flag as --method, spelled the way curl and the tool this replaces spell it")
|
||||
data := fs.String("data", "", "the request body: @file, @- for standard input, or the JSON itself")
|
||||
var fields stringList
|
||||
fs.Var(&fields, "field", "key=value, added to a JSON body as a string; repeatable")
|
||||
status := fs.Bool("status", false, "print the status line on standard error")
|
||||
yes := fs.Bool("yes", false, "confirm a DELETE")
|
||||
|
||||
return func(args []string) error {
|
||||
if len(args) != 1 {
|
||||
return Fail("give exactly one endpoint, e.g. `kettle api repos/{owner}/{repo}/releases`")
|
||||
}
|
||||
if *data != "" && len(fields) > 0 {
|
||||
return Fail("--data and --field are two ways of writing one body — use one of them")
|
||||
}
|
||||
body, err := apiBody(*data, fields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
verb := strings.ToUpper(method)
|
||||
switch {
|
||||
case verb == "" && body != nil:
|
||||
verb = http.MethodPost
|
||||
case verb == "":
|
||||
verb = http.MethodGet
|
||||
case !apiMethods[verb]:
|
||||
return Fail("%s is not a method this sends — GET, POST, PUT, PATCH or DELETE", verb)
|
||||
}
|
||||
if verb == http.MethodDelete && !*yes {
|
||||
return Fail("-X DELETE deletes something on the tracker — re-run with --yes if that is what you mean")
|
||||
}
|
||||
|
||||
// The store is resolved and then dropped, exactly as `labels`
|
||||
// does: this command touches no issue, but it must fail the same
|
||||
// way as every other tracker command when there is no project,
|
||||
// naming `kettle init` rather than a connection.
|
||||
_, client, err := syncStart("")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
code, answer, err := client.Do(verb, apiEndpoint(args[0], client.Repo()), body, "")
|
||||
if *status && code != 0 {
|
||||
fmt.Fprintf(os.Stderr, "%d %s\n", code, http.StatusText(code))
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stdout.Write(answer); err != nil {
|
||||
return err
|
||||
}
|
||||
// A newline only when the server did not send one: what came
|
||||
// back is what goes out, and a terminal prompt half way along a
|
||||
// line of JSON is nobody's idea of raw fidelity.
|
||||
if n := len(answer); n > 0 && answer[n-1] != '\n' {
|
||||
fmt.Println()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// apiEndpoint fills the two placeholders in.
|
||||
//
|
||||
// Two and no more: the owner and the name are what a project pins, and every
|
||||
// other id in a Gitea path — an issue number, a release id, a comment id — is
|
||||
// the caller's to know. A path that spells another repository out in full is
|
||||
// left alone, which is how one project reaches another's releases without a
|
||||
// flag.
|
||||
func apiEndpoint(spelled string, repo wire.Repo) string {
|
||||
return strings.NewReplacer("{owner}", repo.Owner, "{repo}", repo.Name).Replace(spelled)
|
||||
}
|
||||
|
||||
// apiBody is the request body, from whichever of the two flags supplied it.
|
||||
//
|
||||
// A nil body is a request with no body at all, which is what a GET and a DELETE
|
||||
// want — as distinct from `--data '{}'`, which is an empty object and a
|
||||
// different thing to send.
|
||||
func apiBody(data string, fields stringList) ([]byte, error) {
|
||||
if len(fields) > 0 {
|
||||
out := make(map[string]string, len(fields))
|
||||
for _, f := range fields {
|
||||
key, value, ok := strings.Cut(f, "=")
|
||||
if !ok || key == "" {
|
||||
return nil, Fail("--field %q is not key=value", f)
|
||||
}
|
||||
out[key] = value
|
||||
}
|
||||
// Every value is a STRING. Guessing at types is how a tag_name of 1.0
|
||||
// goes up as the number 1 — and a body that needs a boolean, a number or
|
||||
// nesting is a body worth writing down, which is what --data is for.
|
||||
return json.Marshal(out)
|
||||
}
|
||||
if data == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
raw := []byte(data)
|
||||
switch {
|
||||
case data == "@-":
|
||||
read, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw = read
|
||||
case strings.HasPrefix(data, "@"):
|
||||
read, err := os.ReadFile(data[1:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw = read
|
||||
}
|
||||
// Checked here rather than left to the server, because the answer from
|
||||
// there is a 400 with a parser's opinion in it, and the file that produced
|
||||
// it is not named anywhere in that.
|
||||
if !json.Valid(raw) {
|
||||
if strings.HasPrefix(data, "@") {
|
||||
return nil, Fail("%s does not hold JSON — every body this sends is JSON", data[1:])
|
||||
}
|
||||
return nil, Fail("--data is not JSON — pass @file, @- for standard input, or valid JSON")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package cmd_test
|
||||
|
||||
// `kettle api` end to end: the real binary, in a throwaway project, against a
|
||||
// fake that records what it was asked for and answers with bytes.
|
||||
//
|
||||
// What is worth proving here is not that HTTP works — internal/gitea has that
|
||||
// against httptest — but the four things this command decides on its own: which
|
||||
// verb goes out, what the endpoint resolves to, that the answer reaches stdout
|
||||
// unchanged, and that a deletion does not happen because a model typed it.
|
||||
//
|
||||
// Every helper is named `ap…` so it cannot collide with the two fakes already in
|
||||
// this package. The version handshake is pullVersionRoute's, because a fake that
|
||||
// does not answer it is a fake no command can build a client against.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
||||
)
|
||||
|
||||
// apCall is one request as the fake saw it.
|
||||
type apCall struct {
|
||||
Method string
|
||||
URI string
|
||||
Body string
|
||||
Auth string
|
||||
}
|
||||
|
||||
// apTracker answers everything with the same little JSON object and remembers
|
||||
// what it was asked. A status can be armed for the one test that wants a
|
||||
// refusal.
|
||||
type apTracker struct {
|
||||
mu sync.Mutex
|
||||
calls []apCall
|
||||
status int
|
||||
answer string
|
||||
}
|
||||
|
||||
func (tr *apTracker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if pullVersionRoute(w, r) {
|
||||
return
|
||||
}
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
|
||||
tr.mu.Lock()
|
||||
tr.calls = append(tr.calls, apCall{
|
||||
Method: r.Method,
|
||||
URI: r.URL.RequestURI(),
|
||||
Body: string(raw),
|
||||
Auth: r.Header.Get("Authorization"),
|
||||
})
|
||||
status, answer := tr.status, tr.answer
|
||||
tr.mu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
if answer == "" {
|
||||
answer = `{"tag_name":"v0.2.0"}`
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
io.WriteString(w, answer)
|
||||
}
|
||||
|
||||
func (tr *apTracker) apCalls() []apCall {
|
||||
tr.mu.Lock()
|
||||
defer tr.mu.Unlock()
|
||||
return append([]apCall{}, tr.calls...)
|
||||
}
|
||||
|
||||
// apEnv starts the fake and returns the environment that points the binary at
|
||||
// it — the same shape a CI run uses, and a credential home that is a temp
|
||||
// directory so no fixture can read the developer's own tokens.
|
||||
func apEnv(t *testing.T, tr *apTracker) []string {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(tr)
|
||||
t.Cleanup(srv.Close)
|
||||
return []string{
|
||||
config.EnvURL + "=" + srv.URL,
|
||||
config.EnvToken + "=t0ken",
|
||||
config.EnvRepo + "=owner/repo",
|
||||
config.EnvHome + "=" + t.TempDir(),
|
||||
}
|
||||
}
|
||||
|
||||
// A read: GET by default, the placeholders filled from the project, and the
|
||||
// server's bytes on stdout with nothing done to them.
|
||||
func TestAPIGetsAndPrintsWhatCameBack(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
tr := &apTracker{answer: `{"tag_name":"v0.2.0","draft":false}`}
|
||||
|
||||
r := runWith(t, dir, apEnv(t, tr), "", "api", "repos/{owner}/{repo}/releases?limit=50")
|
||||
if r.code != 0 {
|
||||
t.Fatalf("exit %d:\n%s", r.code, r.out())
|
||||
}
|
||||
if strings.TrimSpace(r.stdout) != `{"tag_name":"v0.2.0","draft":false}` {
|
||||
t.Errorf("stdout is not the server's bytes:\n%q", r.stdout)
|
||||
}
|
||||
|
||||
calls := tr.apCalls()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("%d request(s) went out, want 1 — one invocation is one request: %v", len(calls), calls)
|
||||
}
|
||||
if calls[0].Method != http.MethodGet {
|
||||
t.Errorf("method was %s, want GET", calls[0].Method)
|
||||
}
|
||||
if calls[0].URI != "/api/v1/repos/owner/repo/releases?limit=50" {
|
||||
t.Errorf("endpoint resolved to %s", calls[0].URI)
|
||||
}
|
||||
if calls[0].Auth != "token t0ken" {
|
||||
t.Errorf("Authorization was %q — Gitea's scheme is the word token", calls[0].Auth)
|
||||
}
|
||||
}
|
||||
|
||||
// A body from a file: POST is implied by having one, the bytes arrive as they
|
||||
// were written, and the transport files a copy in the project's scratchpad.
|
||||
func TestAPIPostsTheFileItWasGivenAndFilesIt(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
tr := &apTracker{}
|
||||
|
||||
body := `{"tag_name":"v0.2.0","body":"## Changes\n\nwith ` + "`code`" + ` in it"}`
|
||||
path := filepath.Join(dir, "release.json")
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := runWith(t, dir, apEnv(t, tr), "", "api", "--data", "@"+path, "repos/{owner}/{repo}/releases")
|
||||
if r.code != 0 {
|
||||
t.Fatalf("exit %d:\n%s", r.code, r.out())
|
||||
}
|
||||
|
||||
calls := tr.apCalls()
|
||||
if len(calls) != 1 || calls[0].Method != http.MethodPost {
|
||||
t.Fatalf("want one POST, got %v", calls)
|
||||
}
|
||||
if calls[0].Body != body {
|
||||
t.Errorf("the server got\n%s\nwant\n%s", calls[0].Body, body)
|
||||
}
|
||||
// The scratchpad is the transport's, and it holds what went out whether or
|
||||
// not the caller named the file.
|
||||
entries, err := os.ReadDir(filepath.Join(dir, ".kettle", "payload"))
|
||||
if err != nil || len(entries) != 1 {
|
||||
t.Fatalf("the request body was not filed under .kettle/payload/ (%v, %v)", entries, err)
|
||||
}
|
||||
filed, err := os.ReadFile(filepath.Join(dir, ".kettle", "payload", entries[0].Name()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(filed), "v0.2.0") {
|
||||
t.Errorf("the filed body is not the one that was sent:\n%s", filed)
|
||||
}
|
||||
}
|
||||
|
||||
// --field is the small-body form. Every value is a string, and the object it
|
||||
// builds is what goes on the wire.
|
||||
func TestAPIFieldsBuildAJSONObject(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
tr := &apTracker{}
|
||||
|
||||
r := runWith(t, dir, apEnv(t, tr), "", "api",
|
||||
"--field", "title=Wire sqlc", "--field", "head=feat/x", "repos/{owner}/{repo}/pulls")
|
||||
if r.code != 0 {
|
||||
t.Fatalf("exit %d:\n%s", r.code, r.out())
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal([]byte(tr.apCalls()[0].Body), &got); err != nil {
|
||||
t.Fatalf("the body is not JSON: %v (%s)", err, tr.apCalls()[0].Body)
|
||||
}
|
||||
if got["title"] != "Wire sqlc" || got["head"] != "feat/x" {
|
||||
t.Errorf("the fields did not arrive: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A path that spells another repository out in full is left alone: that is how
|
||||
// one project reaches another's releases, and why there is no --repo flag.
|
||||
func TestAPILeavesAFullyNamedRepositoryAlone(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
tr := &apTracker{}
|
||||
|
||||
mustRunWith(t, dir, apEnv(t, tr), "api", "repos/other-owner/other-repo/releases")
|
||||
if got := tr.apCalls()[0].URI; got != "/api/v1/repos/other-owner/other-repo/releases" {
|
||||
t.Errorf("the project's own repository was substituted into a path that named one: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Outside a project there is nothing to run against, and the failure says which
|
||||
// command makes one — never a connection error, and never a guess at a tracker.
|
||||
func TestAPIOutsideAProjectNamesInit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tr := &apTracker{}
|
||||
|
||||
r := runWith(t, dir, apEnv(t, tr), "", "api", "user")
|
||||
if r.code != 1 {
|
||||
t.Fatalf("exit %d, want 1:\n%s", r.code, r.out())
|
||||
}
|
||||
if !strings.Contains(r.stderr, "no .kettle/ found") {
|
||||
t.Errorf("the failure does not name what was searched:\n%s", r.stderr)
|
||||
}
|
||||
if len(tr.apCalls()) != 0 {
|
||||
t.Error("a request went out from a directory that is not a project")
|
||||
}
|
||||
}
|
||||
|
||||
// A refusal is an exit 1 that quotes the status and what the server said —
|
||||
// which is the only thing that tells four different 422s apart.
|
||||
func TestAPIReportsTheStatusAndTheBodyOnAFailure(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
tr := &apTracker{status: http.StatusNotFound, answer: `{"message":"release does not exist"}`}
|
||||
|
||||
r := runWith(t, dir, apEnv(t, tr), "", "api", "--status", "repos/{owner}/{repo}/releases/9")
|
||||
if r.code != 1 {
|
||||
t.Fatalf("exit %d, want 1:\n%s", r.code, r.out())
|
||||
}
|
||||
for _, want := range []string{"404", "release does not exist"} {
|
||||
if !strings.Contains(r.stderr, want) {
|
||||
t.Errorf("stderr does not mention %q:\n%s", want, r.stderr)
|
||||
}
|
||||
}
|
||||
if strings.Contains(r.stdout, "release does not exist") {
|
||||
t.Errorf("a failed body was printed as though it were an answer:\n%s", r.stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// A deletion is an operator's decision. Without --yes nothing is sent at all —
|
||||
// the refusal comes before the request, not after it.
|
||||
func TestAPIDeleteNeedsYes(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
tr := &apTracker{}
|
||||
env := apEnv(t, tr)
|
||||
|
||||
r := runWith(t, dir, env, "", "api", "-X", "DELETE", "repos/{owner}/{repo}/releases/12")
|
||||
if r.code != 1 || !strings.Contains(r.stderr, "--yes") {
|
||||
t.Fatalf("a DELETE without --yes must be refused by name:\n%s", r.out())
|
||||
}
|
||||
if len(tr.apCalls()) != 0 {
|
||||
t.Fatal("the request went out anyway — the gate is before the socket, or it is not a gate")
|
||||
}
|
||||
|
||||
mustRunWith(t, dir, env, "api", "-X", "DELETE", "--yes", "repos/{owner}/{repo}/releases/12")
|
||||
calls := tr.apCalls()
|
||||
if len(calls) != 1 || calls[0].Method != http.MethodDelete {
|
||||
t.Errorf("--yes did not let the deletion through: %v", calls)
|
||||
}
|
||||
if calls[0].Body != "" {
|
||||
t.Errorf("a DELETE carried a body: %q", calls[0].Body)
|
||||
}
|
||||
}
|
||||
|
||||
// A method this does not send is refused before anything is resolved: an
|
||||
// unquoted endpoint that lost a word to the shell must not go out as a verb.
|
||||
func TestAPIRefusesAMethodItDoesNotSend(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
tr := &apTracker{}
|
||||
|
||||
r := runWith(t, dir, apEnv(t, tr), "", "api", "-X", "HEAD", "user")
|
||||
if r.code != 1 || !strings.Contains(r.stderr, "GET, POST, PUT, PATCH or DELETE") {
|
||||
t.Errorf("an unsupported method was not named:\n%s", r.out())
|
||||
}
|
||||
if len(tr.apCalls()) != 0 {
|
||||
t.Error("a request went out for a method this does not send")
|
||||
}
|
||||
}
|
||||
|
||||
// mustRunWith is mustRun with an environment.
|
||||
func mustRunWith(t *testing.T, dir string, env []string, args ...string) result {
|
||||
t.Helper()
|
||||
r := runWith(t, dir, env, "", args...)
|
||||
if r.code != 0 {
|
||||
t.Fatalf("kettle %v exited %d:\n%s", args, r.code, r.out())
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "auth",
|
||||
Group: GroupProject,
|
||||
Args: "list | add | remove <name>",
|
||||
Short: "manage the tokens this machine holds",
|
||||
Long: `Credentials live in one file per machine, outside every working tree, mode
|
||||
0600. A project 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 the repository.
|
||||
|
||||
The token is read from standard input unless --token is given, because an
|
||||
argument is in the shell history the moment it is typed:
|
||||
|
||||
kettle auth add --name noodles --url https://git.example.com < token.txt
|
||||
pass show gitea/token | kettle auth add --name noodles --url https://git.example.com
|
||||
|
||||
` + "`list`" + ` never prints a token. There is no flag to make it.
|
||||
|
||||
--scopes RECORDS WHAT THE TOKEN WAS MINTED WITH, and records is all it does:
|
||||
nothing is checked against it and nothing is refused because of it. It is worth
|
||||
writing down because the instance will not answer the question — Gitea's own
|
||||
token listing needs a password, not a token, so a token cannot be asked what it
|
||||
may do. Gitea spells them <read|write>:<category>; issues need ` + "`write:issue`" + `,
|
||||
and everything ` + "`kettle api`" + ` reaches outside issues — releases, pull requests,
|
||||
branches, tags, actions — is ` + "`repository`" + `. A token minted for issues alone
|
||||
answers 403 there, and the 403 names no scope.`,
|
||||
Examples: []Example{
|
||||
{"kettle auth list", "what this machine holds"},
|
||||
{"pass show gitea | kettle auth add --name noodles --url https://git.example.com", "add one, token on stdin"},
|
||||
{"kettle auth add --name noodles --url https://git.example.com --scopes write:issue,write:repository < t.txt", "and write down what it can do"},
|
||||
{"kettle auth remove noodles", "forget it"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
name := fs.String("name", "", "login name (add)")
|
||||
url := fs.String("url", "", "instance URL, e.g. https://git.example.com (add)")
|
||||
user := fs.String("user", "", "account this token belongs to; documentation only (add)")
|
||||
scopes := fs.String("scopes", "", "what the token was minted with, comma separated, e.g. write:issue,write:repository; documentation only (add)")
|
||||
token := fs.String("token", "", "token, if you would rather not use stdin (add)")
|
||||
|
||||
return func(args []string) error {
|
||||
verb := "list"
|
||||
if len(args) > 0 {
|
||||
verb = args[0]
|
||||
}
|
||||
logins, err := config.LoadLogins()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch verb {
|
||||
case "list":
|
||||
if len(logins.Logins) == 0 {
|
||||
fmt.Printf("no logins in %s\n", config.LoginsPath())
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("%s\n\n", config.LoginsPath())
|
||||
for _, l := range logins.Logins {
|
||||
who := l.User
|
||||
if who == "" {
|
||||
who = "—"
|
||||
}
|
||||
// Not recorded is not the same as none, and a listing
|
||||
// that printed "—" for both would be the reason somebody
|
||||
// re-mints a token that was fine.
|
||||
scopes := "(not recorded)"
|
||||
if len(l.Scopes) > 0 {
|
||||
scopes = strings.Join(l.Scopes, ", ")
|
||||
}
|
||||
fmt.Printf(" %-16s %-40s %-16s %s\n", l.Name, l.URL, who, scopes)
|
||||
}
|
||||
return nil
|
||||
|
||||
case "add":
|
||||
if *name == "" || *url == "" {
|
||||
return Fail("--name and --url are both required")
|
||||
}
|
||||
secret := *token
|
||||
if secret == "" {
|
||||
if secret, err = readToken(os.Stdin); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if secret == "" {
|
||||
return Fail("no token — pipe one in, or pass --token")
|
||||
}
|
||||
entry := config.Login{
|
||||
Name: *name,
|
||||
URL: strings.TrimRight(*url, "/"),
|
||||
User: *user,
|
||||
Scopes: splitScopes(*scopes),
|
||||
Token: secret,
|
||||
}
|
||||
if existing := logins.Find(*name); existing != nil {
|
||||
*existing = entry
|
||||
} else {
|
||||
logins.Logins = append(logins.Logins, entry)
|
||||
}
|
||||
if err := config.SaveLogins(logins); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s -> %s %s\n", entry.Name, entry.URL, config.LoginsPath())
|
||||
return nil
|
||||
|
||||
case "remove":
|
||||
if len(args) != 2 {
|
||||
return Fail("give exactly one login name to remove")
|
||||
}
|
||||
target := args[1]
|
||||
kept := logins.Logins[:0]
|
||||
found := false
|
||||
for _, l := range logins.Logins {
|
||||
if l.Name == target {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, l)
|
||||
}
|
||||
if !found {
|
||||
return Fail("no login %q in %s", target, config.LoginsPath())
|
||||
}
|
||||
logins.Logins = kept
|
||||
if err := config.SaveLogins(logins); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("removed %s\n", target)
|
||||
return nil
|
||||
}
|
||||
return Fail("unknown subcommand %q — list, add, or remove", verb)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// splitScopes reads the comma-separated list --scopes takes.
|
||||
//
|
||||
// Nothing here validates a scope name against Gitea's set: the set is the
|
||||
// server's and it grows, and a spelling this binary has not heard of is more
|
||||
// likely a newer Gitea than a typo. The field is a note to a human either way.
|
||||
func splitScopes(v string) []string {
|
||||
var out []string
|
||||
for _, s := range strings.Split(v, ",") {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// readToken takes the first non-empty line of r, trimmed.
|
||||
//
|
||||
// The first line, not the whole stream: a token piped from a password manager
|
||||
// often arrives with a trailing newline and sometimes with notes underneath it.
|
||||
func readToken(r io.Reader) (string, error) {
|
||||
sc := bufio.NewScanner(r)
|
||||
for sc.Scan() {
|
||||
if line := strings.TrimSpace(sc.Text()); line != "" {
|
||||
return line, nil
|
||||
}
|
||||
}
|
||||
return "", sc.Err()
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "check",
|
||||
Group: GroupIssue,
|
||||
Args: "[<id>…]",
|
||||
Short: "validate issues against the canonical format",
|
||||
Long: `The same check the sync layer runs before it pushes anything, available on its
|
||||
own so a local-only issue can be held to the format without a tracker being
|
||||
involved.
|
||||
|
||||
Errors mean malformed; warnings mean it deviates from its type's template or its
|
||||
graph looks suspect. An unticked checkbox is neither: work not done yet is the
|
||||
normal state of a perfectly well-formed issue.
|
||||
|
||||
Exit status is 1 when anything has errors, which is what makes this usable in a
|
||||
hook or a CI step.`,
|
||||
Examples: []Example{
|
||||
{"kettle check", "every issue in the store"},
|
||||
{"kettle check wire-sqlc-appclick", "one issue"},
|
||||
{"kettle check --quiet", "exit status only"},
|
||||
{"kettle check --strict", "treat warnings as errors"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
quiet := fs.Bool("quiet", false, "exit status only, print nothing")
|
||||
strict := fs.Bool("strict", false, "treat warnings as errors")
|
||||
out := storeFlag(fs)
|
||||
|
||||
return func(args []string) error {
|
||||
root, err := storeRoot(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := issue.StoreError(root); err != nil {
|
||||
return err
|
||||
}
|
||||
issues, err := issue.LoadAll(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ids := args
|
||||
if len(ids) == 0 {
|
||||
for id := range issues {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
}
|
||||
known := map[string]bool{}
|
||||
for id := range issues {
|
||||
known[id] = true
|
||||
}
|
||||
for _, id := range ids {
|
||||
if !known[id] {
|
||||
return Fail("no issue %q in %s", id, root)
|
||||
}
|
||||
}
|
||||
|
||||
bad := 0
|
||||
for _, id := range ids {
|
||||
errs, warns := issue.Validate(issues[id], known)
|
||||
if *strict {
|
||||
errs, warns = append(errs, warns...), nil
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
bad++
|
||||
}
|
||||
if *quiet {
|
||||
continue
|
||||
}
|
||||
if len(errs) == 0 && len(warns) == 0 {
|
||||
fmt.Printf("ok %s\n", id)
|
||||
continue
|
||||
}
|
||||
for _, e := range errs {
|
||||
fmt.Printf("ERROR %s: %s\n", id, e)
|
||||
}
|
||||
for _, w := range warns {
|
||||
fmt.Printf("warn %s: %s\n", id, w)
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range issue.FindCycles(issue.Graph(issues)) {
|
||||
bad++
|
||||
if !*quiet {
|
||||
fmt.Printf("ERROR cycle: %s\n", strings.Join(c, " -> "))
|
||||
}
|
||||
}
|
||||
|
||||
if !*quiet {
|
||||
fmt.Printf("%d issue(s) checked, %d with errors\n", len(ids), bad)
|
||||
}
|
||||
if bad > 0 {
|
||||
return SilentError{Code: 1}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
package cmd_test
|
||||
|
||||
// The CLI is tested the way the Python suite it replaces was: the binary is
|
||||
// built once and run as a subprocess against a throwaway project somewhere
|
||||
// else entirely.
|
||||
//
|
||||
// That separation IS the contract. A tool is installed in one place and used on
|
||||
// projects in another, and the bug this discipline exists to catch — a store
|
||||
// resolved from the executable's own directory rather than from the tree it was
|
||||
// pointed at — is invisible to any test that runs the code in the directory it
|
||||
// lives in.
|
||||
//
|
||||
// Every fixture also strips CLAUDE_PROJECT_DIR unless the test is about it: it
|
||||
// is the first anchor of the walk, so the harness's own value would point every
|
||||
// fixture at this repository.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var kettle string
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
dir, err := os.MkdirTemp("", "kettle-bin")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
kettle = filepath.Join(dir, "kettle")
|
||||
build := exec.Command("go", "build", "-o", kettle, "../../cmd/kettle")
|
||||
if out, err := build.CombinedOutput(); err != nil {
|
||||
panic("building kettle: " + err.Error() + "\n" + string(out))
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
type result struct {
|
||||
stdout, stderr string
|
||||
code int
|
||||
}
|
||||
|
||||
func (r result) out() string { return r.stdout + r.stderr }
|
||||
|
||||
// run invokes the binary in dir with a clean environment.
|
||||
func run(t *testing.T, dir string, args ...string) result {
|
||||
t.Helper()
|
||||
return runWith(t, dir, nil, "", args...)
|
||||
}
|
||||
|
||||
// runWith is run plus extra environment and standard input.
|
||||
func runWith(t *testing.T, dir string, env []string, stdin string, args ...string) result {
|
||||
t.Helper()
|
||||
cmd := exec.Command(kettle, args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(append(os.Environ(), "CLAUDE_PROJECT_DIR="), env...)
|
||||
if stdin != "" {
|
||||
cmd.Stdin = strings.NewReader(stdin)
|
||||
}
|
||||
|
||||
var stdout, stderr strings.Builder
|
||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||
err := cmd.Run()
|
||||
|
||||
code := 0
|
||||
var ee *exec.ExitError
|
||||
if err != nil {
|
||||
if !asExitError(err, &ee) {
|
||||
t.Fatalf("running kettle %v: %v", args, err)
|
||||
}
|
||||
code = ee.ExitCode()
|
||||
}
|
||||
return result{stdout.String(), stderr.String(), code}
|
||||
}
|
||||
|
||||
func mustRun(t *testing.T, dir string, args ...string) result {
|
||||
t.Helper()
|
||||
r := run(t, dir, args...)
|
||||
if r.code != 0 {
|
||||
t.Fatalf("kettle %v exited %d:\n%s", args, r.code, r.out())
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// newProject makes an initialized project in a temp directory and returns it.
|
||||
func newProject(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustRun(t, dir, "init")
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestInitIsIdempotentAndGitignoresTheStore(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
|
||||
for _, d := range []string{".kettle/issues", ".kettle/payload"} {
|
||||
if fi, err := os.Stat(filepath.Join(dir, d)); err != nil || !fi.IsDir() {
|
||||
t.Errorf("%s was not created", d)
|
||||
}
|
||||
}
|
||||
// An `origin: local` issue is the only copy of that work, and what goes in
|
||||
// a shared history is the operator's call, not this command's.
|
||||
ignore, err := os.ReadFile(filepath.Join(dir, ".gitignore"))
|
||||
if err != nil || !strings.Contains(string(ignore), ".kettle/") {
|
||||
t.Errorf(".kettle/ was not gitignored: %q", ignore)
|
||||
}
|
||||
|
||||
again := mustRun(t, dir, "init")
|
||||
if !strings.Contains(again.stdout, "already initialized") {
|
||||
t.Errorf("a second init should be a no-op, got:\n%s", again.stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoMarkerIsReportedNotGuessed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
r := run(t, dir, "check")
|
||||
|
||||
if r.code == 0 {
|
||||
t.Fatal("a directory that is not a project must not read as an empty store")
|
||||
}
|
||||
// The operator is owed the directories the search began from — that is how
|
||||
// they see whether it began where they meant it to.
|
||||
if !strings.Contains(r.stderr, "no .kettle/ found") || !strings.Contains(r.stderr, dir) {
|
||||
t.Errorf("the failure must name what it searched:\n%s", r.stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheGoldenPath(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
|
||||
mustRun(t, dir, "new", "--type", "task", "--title", "Wire sqlc into the appclick layer")
|
||||
const id = "wire-sqlc-into-the-appclick-layer"
|
||||
|
||||
if r := mustRun(t, dir, "check"); !strings.Contains(r.stdout, "ok "+id) {
|
||||
t.Errorf("a fresh issue from its own template must validate:\n%s", r.out())
|
||||
}
|
||||
|
||||
// Progress is counted off the body every time, never stored.
|
||||
mustRun(t, dir, "ac", id, "--check", "1")
|
||||
index, err := os.ReadFile(filepath.Join(dir, ".kettle", "issues", "INDEX.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(index), "| 1/2 |") {
|
||||
t.Errorf("the index did not pick up the ticked box:\n%s", index)
|
||||
}
|
||||
|
||||
if r := mustRun(t, dir, "tree"); !strings.Contains(r.stdout, id) {
|
||||
t.Errorf("tree did not draw the issue:\n%s", r.stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTickingABoxChangesOneByte(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
mustRun(t, dir, "new", "--type", "task", "--title", "Tick one box")
|
||||
path := filepath.Join(dir, ".kettle", "issues", "tick-one-box.md")
|
||||
|
||||
before, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustRun(t, dir, "ac", "tick-one-box", "--check", "1")
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(before) != len(after) {
|
||||
t.Fatalf("length changed: %d -> %d", len(before), len(after))
|
||||
}
|
||||
diff := 0
|
||||
for i := range before {
|
||||
if before[i] != after[i] {
|
||||
diff++
|
||||
}
|
||||
}
|
||||
if diff != 1 {
|
||||
t.Errorf("%d bytes changed, want 1 — a tick must not re-render the file", diff)
|
||||
}
|
||||
|
||||
// And back again, byte for byte: the metadata block is rewritten by nobody.
|
||||
mustRun(t, dir, "ac", "tick-one-box", "--uncheck", "1")
|
||||
back, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(back) != string(before) {
|
||||
t.Error("unticking did not restore the file byte for byte")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagsWorkAfterPositionalArguments(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
mustRun(t, dir, "new", "--type", "task", "--title", "Order of arguments")
|
||||
|
||||
// `kettle ac <id> --check 1` is how everybody types it. A flag silently
|
||||
// read as a positional would tick nothing and report success.
|
||||
r := mustRun(t, dir, "ac", "order-of-arguments", "--check", "1")
|
||||
if !strings.Contains(r.stdout, "checked") {
|
||||
t.Errorf("the flag after the id was ignored:\n%s", r.out())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheStoreResolvesFromAnywhereInsideTheProject(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
mustRun(t, dir, "new", "--type", "task", "--title", "Seen from below")
|
||||
|
||||
deep := filepath.Join(dir, "internal", "adapters")
|
||||
if err := os.MkdirAll(deep, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := mustRun(t, deep, "check")
|
||||
if !strings.Contains(r.stdout, "seen-from-below") {
|
||||
t.Errorf("a subdirectory saw a different store:\n%s", r.out())
|
||||
}
|
||||
}
|
||||
|
||||
func TestADifferentProjectAnswersWithItsOwnStore(t *testing.T) {
|
||||
a, b := newProject(t), newProject(t)
|
||||
mustRun(t, a, "new", "--type", "task", "--title", "Belongs to A")
|
||||
mustRun(t, b, "new", "--type", "task", "--title", "Belongs to B")
|
||||
|
||||
r := mustRun(t, b, "check")
|
||||
if strings.Contains(r.stdout, "belongs-to-a") {
|
||||
t.Errorf("project B saw project A's issues:\n%s", r.out())
|
||||
}
|
||||
}
|
||||
|
||||
func TestALocalIssueIsNeverEvictedEvenWhenNamed(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
mustRun(t, dir, "new", "--type", "task", "--title", "Only copy there is")
|
||||
path := filepath.Join(dir, ".kettle", "issues", "only-copy-there-is.md")
|
||||
closeIssue(t, path)
|
||||
|
||||
r := mustRun(t, dir, "evict", "only-copy-there-is")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatal("a closed origin: local issue was deleted — that file IS the work")
|
||||
}
|
||||
if !strings.Contains(r.stdout, "kept") {
|
||||
t.Errorf("keeping it must be said out loud:\n%s", r.out())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAClosedTrackedIssueIsEvictedWithItsSidecars(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
mustRun(t, dir, "new", "--type", "task", "--title", "Done and elsewhere")
|
||||
store := filepath.Join(dir, ".kettle", "issues")
|
||||
path := filepath.Join(store, "done-and-elsewhere.md")
|
||||
closeIssue(t, path)
|
||||
setField(t, path, "origin", "gitea")
|
||||
|
||||
sidecar := filepath.Join(store, "done-and-elsewhere.comments.md")
|
||||
if err := os.WriteFile(sidecar, []byte("# thread\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A dry run touches nothing, and says so.
|
||||
dry := mustRun(t, dir, "evict", "--dry-run")
|
||||
if !strings.Contains(dry.stdout, "would evict") {
|
||||
t.Errorf("dry run said nothing:\n%s", dry.out())
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatal("a dry run deleted the issue")
|
||||
}
|
||||
|
||||
mustRun(t, dir, "evict")
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
t.Error("the issue survived eviction")
|
||||
}
|
||||
// The domain does not need to know what a comment thread is to know a file
|
||||
// named after this issue goes when it goes.
|
||||
if _, err := os.Stat(sidecar); err == nil {
|
||||
t.Error("the sidecar was left behind")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckExitsNonZeroOnAMalformedIssue(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
mustRun(t, dir, "new", "--type", "task", "--title", "Loses its type")
|
||||
path := filepath.Join(dir, ".kettle", "issues", "loses-its-type.md")
|
||||
setField(t, path, "labels", "[]")
|
||||
|
||||
r := run(t, dir, "check")
|
||||
if r.code != 1 {
|
||||
t.Errorf("exit = %d, want 1 — this is what makes check usable in a hook", r.code)
|
||||
}
|
||||
if !strings.Contains(r.stdout, "need exactly one type/* label") {
|
||||
t.Errorf("the finding was not reported:\n%s", r.out())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRefusesToOverwriteAnExistingIssue(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
mustRun(t, dir, "new", "--type", "task", "--title", "Same title twice")
|
||||
// Without an explicit id the slug is allocated around the collision…
|
||||
mustRun(t, dir, "new", "--type", "task", "--title", "Same title twice")
|
||||
if _, err := os.Stat(filepath.Join(dir, ".kettle", "issues", "same-title-twice-2.md")); err != nil {
|
||||
t.Error("the second issue did not get its own slug")
|
||||
}
|
||||
// …but an id typed by hand is taken literally, and taken means taken.
|
||||
r := run(t, dir, "new", "--type", "task", "--title", "Third", "--id", "same-title-twice")
|
||||
if r.code == 0 || !strings.Contains(r.stderr, "already exists") {
|
||||
t.Errorf("an explicit id must not overwrite:\n%s", r.out())
|
||||
}
|
||||
}
|
||||
|
||||
// An older layout is migrated in, and it is a MOVE: a store left behind at the
|
||||
// old path is a store somebody will edit by accident months later.
|
||||
func TestInitMigratesAnOlderStore(t *testing.T) {
|
||||
dir, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := filepath.Join(dir, ".tea", "issues")
|
||||
if err := os.MkdirAll(old, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const body = "---\nid: from-the-old-store\nstate: open\nlabels: [type/task]\norigin: local\n---\n# From the old store\n\n## Summary\nx\n\n## Spec\nnone\n\n## Acceptance criteria\n- [ ] x\n"
|
||||
if err := os.WriteFile(filepath.Join(old, "from-the-old-store.md"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := mustRun(t, dir, "init")
|
||||
if !strings.Contains(r.stdout, "moved 1 file(s)") {
|
||||
t.Errorf("the migration said nothing:\n%s", r.stdout)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, ".kettle", "issues", "from-the-old-store.md")); err != nil {
|
||||
t.Fatal("the issue did not arrive in the new store")
|
||||
}
|
||||
if _, err := os.Stat(old); err == nil {
|
||||
t.Error("the old store is still there — two stores is what the marker exists to prevent")
|
||||
}
|
||||
if r := mustRun(t, dir, "check"); !strings.Contains(r.stdout, "from-the-old-store") {
|
||||
t.Errorf("the migrated issue is not readable:\n%s", r.out())
|
||||
}
|
||||
}
|
||||
|
||||
// A migration never picks a winner. Two files of the same name are two versions
|
||||
// of one issue, and choosing quietly is how the wrong one survives.
|
||||
func TestInitRefusesToResolveAMigrationClash(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
old := filepath.Join(dir, ".tea", "issues")
|
||||
if err := os.MkdirAll(old, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustRun(t, dir, "new", "--type", "task", "--title", "Both sides have this")
|
||||
if err := os.WriteFile(filepath.Join(old, "both-sides-have-this.md"), []byte("older\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := run(t, dir, "init")
|
||||
if r.code == 0 {
|
||||
t.Fatal("a clash must stop the run")
|
||||
}
|
||||
if !strings.Contains(r.stderr, "both hold") || !strings.Contains(r.stderr, "nothing was changed") {
|
||||
t.Errorf("the clash was not explained:\n%s", r.stderr)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(old, "both-sides-have-this.md")); err != nil {
|
||||
t.Error("the older file was moved anyway")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitWritesTheConfigAndKeepsWhatItWasNotGiven(t *testing.T) {
|
||||
dir, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustRun(t, dir, "init", "--login", "noodles", "--repo", "claude-skills/marketplace")
|
||||
|
||||
cfg := filepath.Join(dir, ".kettle", "config.yaml")
|
||||
raw, err := os.ReadFile(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "login: noodles") ||
|
||||
!strings.Contains(string(raw), "repo: claude-skills/marketplace") {
|
||||
t.Fatalf("config did not record what it was given:\n%s", raw)
|
||||
}
|
||||
|
||||
// Re-running init to change one setting must not drop the other.
|
||||
mustRun(t, dir, "init", "--repo", "claude-skills/other")
|
||||
raw, err = os.ReadFile(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "login: noodles") {
|
||||
t.Errorf("the pinned login was dropped by an unrelated init:\n%s", raw)
|
||||
}
|
||||
if !strings.Contains(string(raw), "repo: claude-skills/other") {
|
||||
t.Errorf("the repository was not updated:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRefusesAMalformedRepo(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
r := run(t, dir, "init", "--repo", "marketplace")
|
||||
if r.code == 0 || !strings.Contains(r.stderr, "owner/name") {
|
||||
t.Errorf("a repo without an owner must be rejected before anything is written:\n%s", r.out())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, ".kettle")); err == nil {
|
||||
t.Error("the marker was created despite the bad argument")
|
||||
}
|
||||
}
|
||||
|
||||
// The project pins a login by NAME. The credential lives in one file per
|
||||
// machine, outside every working tree — a token in a repository ends up in a
|
||||
// commit, and a secret that has been pushed has to be rotated.
|
||||
func TestTokensNeverLandInTheProject(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
home := t.TempDir()
|
||||
env := []string{"KETTLE_CONFIG_HOME=" + home}
|
||||
|
||||
runWith(t, dir, env, "s3cr3t-token\n", "auth", "add",
|
||||
"--name", "noodles", "--url", "https://git.example.com/")
|
||||
mustRun(t, dir, "init", "--login", "noodles", "--repo", "owner/name")
|
||||
|
||||
logins, err := os.ReadFile(filepath.Join(home, "logins.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal("the token file was not written where it was told to go")
|
||||
}
|
||||
if !strings.Contains(string(logins), "s3cr3t-token") {
|
||||
t.Errorf("the token was not stored:\n%s", logins)
|
||||
}
|
||||
if fi, err := os.Stat(filepath.Join(home, "logins.yaml")); err != nil || fi.Mode().Perm() != 0o600 {
|
||||
t.Errorf("the token file must be 0600, got %v", fi.Mode().Perm())
|
||||
}
|
||||
|
||||
cfg, err := os.ReadFile(filepath.Join(dir, ".kettle", "config.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(cfg), "s3cr3t-token") {
|
||||
t.Fatal("the token was written into the project — that file ends up in a commit")
|
||||
}
|
||||
|
||||
// And nothing prints it back, either.
|
||||
shown := runWith(t, dir, env, "", "config")
|
||||
if strings.Contains(shown.out(), "s3cr3t-token") {
|
||||
t.Errorf("`kettle config` printed the token:\n%s", shown.out())
|
||||
}
|
||||
if !strings.Contains(shown.stdout, "https://git.example.com") {
|
||||
t.Errorf("the resolved URL was not shown:\n%s", shown.out())
|
||||
}
|
||||
if !strings.Contains(shown.stdout, "token (set)") {
|
||||
t.Errorf("whether a token was found must still be visible:\n%s", shown.stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListNeverPrintsATokenAndRemoveForgetsIt(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
home := t.TempDir()
|
||||
env := []string{"KETTLE_CONFIG_HOME=" + home}
|
||||
|
||||
runWith(t, dir, env, "s3cr3t-token\n", "auth", "add", "--name", "noodles", "--url", "https://git.example.com")
|
||||
listed := runWith(t, dir, env, "", "auth", "list")
|
||||
if strings.Contains(listed.out(), "s3cr3t-token") {
|
||||
t.Errorf("`auth list` printed a token:\n%s", listed.out())
|
||||
}
|
||||
if !strings.Contains(listed.stdout, "noodles") {
|
||||
t.Errorf("`auth list` did not list the login:\n%s", listed.out())
|
||||
}
|
||||
|
||||
runWith(t, dir, env, "", "auth", "remove", "noodles")
|
||||
after := runWith(t, dir, env, "", "auth", "list")
|
||||
if strings.Contains(after.stdout, "noodles") {
|
||||
t.Errorf("the login survived removal:\n%s", after.stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// What a token was minted with is written down because the instance will not
|
||||
// say: Gitea's own token listing needs a password, not a token. It is
|
||||
// documentation — nothing is checked against it — and the one thing it must not
|
||||
// do is read as "none" when nobody wrote it down.
|
||||
func TestScopesAreRecordedAndShownButNeverInvented(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
home := t.TempDir()
|
||||
env := []string{"KETTLE_CONFIG_HOME=" + home}
|
||||
|
||||
runWith(t, dir, env, "s3cr3t-token\n", "auth", "add", "--name", "noodles",
|
||||
"--url", "https://git.example.com", "--scopes", "write:issue, write:repository")
|
||||
mustRun(t, dir, "init", "--login", "noodles", "--repo", "owner/name")
|
||||
|
||||
listed := runWith(t, dir, env, "", "auth", "list")
|
||||
if !strings.Contains(listed.stdout, "write:issue, write:repository") {
|
||||
t.Errorf("`auth list` does not show what was recorded:\n%s", listed.out())
|
||||
}
|
||||
if strings.Contains(listed.out(), "s3cr3t-token") {
|
||||
t.Errorf("`auth list` printed a token:\n%s", listed.out())
|
||||
}
|
||||
shown := runWith(t, dir, env, "", "config")
|
||||
if !strings.Contains(shown.stdout, "scopes write:issue, write:repository") {
|
||||
t.Errorf("`config` does not show the scopes beside the token they belong to:\n%s", shown.stdout)
|
||||
}
|
||||
|
||||
// A login nobody recorded scopes for says so in those words. "—" would read
|
||||
// as "no scopes", which is the sentence that gets a working token re-minted.
|
||||
runWith(t, dir, env, "other-token\n", "auth", "add", "--name", "bare", "--url", "https://git.example.com")
|
||||
bare := runWith(t, dir, env, "", "auth", "list")
|
||||
if !strings.Contains(bare.stdout, "(not recorded)") {
|
||||
t.Errorf("a login with no scopes written down must say so:\n%s", bare.stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// A pinned login that is not on this machine is a fixable mistake, and the
|
||||
// message has to say which file was read and what it holds.
|
||||
func TestAMissingLoginIsExplained(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
home := t.TempDir()
|
||||
env := []string{"KETTLE_CONFIG_HOME=" + home}
|
||||
mustRun(t, dir, "init", "--login", "absent", "--repo", "owner/name")
|
||||
|
||||
r := runWith(t, dir, env, "", "config")
|
||||
if r.code == 0 {
|
||||
t.Fatal("a login that does not exist must not resolve")
|
||||
}
|
||||
if !strings.Contains(r.stderr, `no login "absent"`) || !strings.Contains(r.stderr, "kettle auth add") {
|
||||
t.Errorf("the failure must name the file and the fix:\n%s", r.stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
func setField(t *testing.T, path, key, value string) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := strings.Split(string(raw), "\n")
|
||||
for i, line := range lines {
|
||||
if strings.HasPrefix(line, key+": ") {
|
||||
lines[i] = key + ": " + value
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func asExitError(err error, target **exec.ExitError) bool {
|
||||
ee, ok := err.(*exec.ExitError)
|
||||
if ok {
|
||||
*target = ee
|
||||
}
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "code.gitea.io/sdk/gitea"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "close",
|
||||
Group: GroupSync,
|
||||
Args: "<id|number> [<id|number>…]",
|
||||
Short: "close or reopen issues in the tracker, and on disk with them",
|
||||
Long: `STATE ONLY. This sends ` + "`{\"state\": …}`" + ` and nothing else: no title, no body, no
|
||||
labels, no milestone. Editing an issue is ` + "`kettle pull`" + ` -> edit ->
|
||||
` + "`kettle push --update`" + `; closing it is not an edit.
|
||||
|
||||
EXPLICIT IDS ONLY. No --milestone, no --label, no "close everything that looks
|
||||
done". Which issues are finished is a judgement about content; this carries that
|
||||
judgement out, one named id at a time. Nothing here deletes an issue either —
|
||||
the tracker can, and it is not an operation of this workflow.
|
||||
|
||||
WHAT MAY BE NAMED: a local slug, or a tracker key (42, #42, owner/repo#42, an
|
||||
issue URL). Both, and for the same reason: a push deletes the local file, so
|
||||
most issues in the tracker have no slug on disk to name them by. A slug is
|
||||
resolved through the file's ` + "`gitea:`" + ` handle when the file is there, and through
|
||||
the ledger (` + "`.remote.json`" + `) when push has already dropped it. A bare number is
|
||||
this project's repository; a qualified key names its own, so a foreign #42 can
|
||||
never be closed against the repository that happens to be configured here.
|
||||
|
||||
An ` + "`origin: local`" + ` issue cannot be closed. It is not in the tracker, so there is
|
||||
no state there to change, and the run stops naming the id rather than quietly
|
||||
editing one field of a local file. Push it first, or delete it.
|
||||
|
||||
THE LOCAL FILE IS WRITTEN ONLY AFTER THE TRACKER CONFIRMS: the answer has to be
|
||||
the very issue that was patched, in the state that was asked for. Anything else
|
||||
and the file is left exactly as it was. An issue whose local copy is gone
|
||||
(pushed and dropped) is closed in the tracker and nothing is written; the state
|
||||
comes down with the next pull.
|
||||
|
||||
A tracker that refuses to close an issue its own dependency graph still blocks
|
||||
says so in the answer, and the run stops with its words: close the blockers
|
||||
first, or unlink them.`,
|
||||
Examples: []Example{
|
||||
{"kettle close wire-sqlc-appclick", "one issue, by slug"},
|
||||
{"kettle close wire-sqlc-appclick 42 #43", "several, by slug or number"},
|
||||
{"kettle close --reopen 42", "the same thing backwards"},
|
||||
{"kettle close --dry-run 42 43", "what would change; no request at all"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
reopen := fs.Bool("reopen", false, "set the state back to open instead of closed")
|
||||
dryRun := fs.Bool("dry-run", false, "print what would change; makes no request")
|
||||
out := storeFlag(fs)
|
||||
|
||||
return func(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return Fail("name at least one issue: a slug, or 42, #42, owner/repo#42, a URL")
|
||||
}
|
||||
state, verb, past := "closed", "close", "closed"
|
||||
if *reopen {
|
||||
state, verb, past = "open", "reopen", "reopened"
|
||||
}
|
||||
|
||||
root, client, err := syncStartExisting(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
issues, err := issue.LoadAll(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ledger := closeLedger(root)
|
||||
|
||||
// Every argument is resolved before anything is sent, so a typo in
|
||||
// the third id does not leave the first two closed.
|
||||
var targets []closeTarget
|
||||
for _, arg := range args {
|
||||
t, err := closeResolve(arg, root, issues, ledger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !closeHas(targets, t) {
|
||||
targets = append(targets, t)
|
||||
}
|
||||
}
|
||||
|
||||
if *dryRun {
|
||||
for _, t := range targets {
|
||||
where := "no local copy"
|
||||
if i, ok := issues[t.id]; ok {
|
||||
where = fmt.Sprintf("%s (state: %s)", issue.PathOf(root, t.id), i.State)
|
||||
}
|
||||
fmt.Printf("would %-6s %-24s %-20s %s\n",
|
||||
verb, closeName(t.id), t.key.In(client.Repo()), where)
|
||||
}
|
||||
fmt.Printf("%d issue(s) would be %s; no request was made\n", len(targets), past)
|
||||
return nil
|
||||
}
|
||||
|
||||
touched := 0
|
||||
for _, t := range targets {
|
||||
c := client
|
||||
if !t.key.Repo.Zero() {
|
||||
c = client.For(t.key.Repo)
|
||||
}
|
||||
// STATE AND NOTHING ELSE. Every other field of an edit is
|
||||
// left at its zero value, which the SDK sends as null and
|
||||
// Gitea reads as "no opinion" — bar the title, which goes
|
||||
// as the empty string Gitea reads the same way. Building
|
||||
// this by hand rather than through mapping.ToEdit is the
|
||||
// point: a translated issue would carry its body.
|
||||
want := sdk.StateType(state)
|
||||
req := sdk.EditIssueOption{State: &want}
|
||||
got, err := c.EditIssue(t.key.Number, req, fmt.Sprintf("state-%d", t.key.Number))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The gate. Above it nothing local has been written; below it the
|
||||
// file is about to say something the tracker had better agree
|
||||
// with. An answer counts only when it is the very issue that was
|
||||
// patched, in the state that was asked for.
|
||||
if int(got.Index) != t.key.Number || got.State != want {
|
||||
return Fail("%s: the %s did not go through — the tracker answered for issue #%d in state %q. Nothing local was changed.",
|
||||
t.key.In(c.Repo()), verb, got.Index, got.State)
|
||||
}
|
||||
fmt.Printf("%-8s %-24s %-20s %s\n",
|
||||
past, closeName(t.id), t.key.In(c.Repo()), got.HTMLURL)
|
||||
|
||||
i, ok := issues[t.id]
|
||||
if !ok {
|
||||
fmt.Printf(" no local copy — `kettle pull %d` to get one\n", t.key.Number)
|
||||
continue
|
||||
}
|
||||
path, err := closeApply(root, i, state, got)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf(" state: %s %s\n", state, path)
|
||||
touched++
|
||||
}
|
||||
|
||||
// Only when a file actually changed: INDEX.md is a view of the
|
||||
// directory, and rewriting it after a run that wrote nothing local
|
||||
// is a write nobody asked for.
|
||||
if touched > 0 {
|
||||
path, n, err := issue.BuildIndex(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("index: %s — %d issue(s)\n", path, n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// closeTarget is one issue a run will act on: where it is in the tracker, and
|
||||
// what this machine calls it, when this machine has a name for it at all.
|
||||
type closeTarget struct {
|
||||
// id is the local slug, "" when nothing here names this issue. Closing one
|
||||
// of those is ordinary — push deletes the file it would have been named by.
|
||||
id string
|
||||
// key is the tracker address. Its Repo is zero only when the argument was a
|
||||
// bare number and no local copy or ledger entry qualified it, which means
|
||||
// this project's own repository.
|
||||
key wire.Key
|
||||
}
|
||||
|
||||
// closeEntry is one row of the number -> slug ledger, parsed.
|
||||
type closeEntry struct {
|
||||
key wire.Key
|
||||
slug string
|
||||
}
|
||||
|
||||
// closeLedger is `.remote.json` as pairs, sorted so two identical runs report
|
||||
// an ambiguity in the same order.
|
||||
//
|
||||
// Read rather than ignored because it is the only thing on this machine that
|
||||
// still names an issue push has dropped: the file is gone, the slug is not.
|
||||
func closeLedger(root string) []closeEntry {
|
||||
var out []closeEntry
|
||||
for raw, slug := range gitea.LoadRemoteMap(root) {
|
||||
k, err := wire.ParseKey(raw)
|
||||
if err != nil || k.Repo.Zero() || k.Number < 1 {
|
||||
continue
|
||||
}
|
||||
out = append(out, closeEntry{key: k, slug: slug})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].key.String() < out[j].key.String() })
|
||||
return out
|
||||
}
|
||||
|
||||
// closeResolve turns one argument into a target.
|
||||
//
|
||||
// The order is the order of what is most authoritative about this machine: a
|
||||
// file on disk, then the ledger, then nothing. A key is already the tracker's
|
||||
// answer, so the only thing still wanted for it is the slug — so that the local
|
||||
// copy, if there is one, can be kept honest — and the file that carries the
|
||||
// handle knows that before the ledger does.
|
||||
//
|
||||
// The repository travels with the number, because a key may name one and a
|
||||
// `gitea:` handle always does. Sending a foreign key to whatever repository
|
||||
// this project points at would close somebody else's issue of the same number.
|
||||
func closeResolve(arg, root string, issues map[string]*issue.Issue, ledger []closeEntry) (closeTarget, error) {
|
||||
// A key first, and a slug never looks like one: slugs hold no `#`, no `/`
|
||||
// and no `:`, so the two vocabularies cannot collide.
|
||||
if k, err := wire.ParseKey(arg); err == nil {
|
||||
var hits []closeEntry
|
||||
for id, i := range issues {
|
||||
if h, ok := mapping.RemoteKeyOf(i); ok && h.Number == k.Number && (k.Repo.Zero() || h.Repo == k.Repo) {
|
||||
hits = append(hits, closeEntry{key: h, slug: id})
|
||||
}
|
||||
}
|
||||
sort.Slice(hits, func(a, b int) bool { return hits[a].slug < hits[b].slug })
|
||||
if len(hits) == 0 {
|
||||
for _, e := range ledger {
|
||||
if e.key.Number == k.Number && (k.Repo.Zero() || e.key.Repo == k.Repo) {
|
||||
hits = append(hits, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
hit, err := closeOne(hits, arg, "a slug")
|
||||
if err != nil {
|
||||
return closeTarget{}, err
|
||||
}
|
||||
t := closeTarget{key: k}
|
||||
if hit != nil {
|
||||
t.id = hit.slug
|
||||
t.key = k.In(hit.key.Repo)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
if i, ok := issues[arg]; ok {
|
||||
k, ok := mapping.RemoteKeyOf(i)
|
||||
if !ok {
|
||||
return closeTarget{}, Fail("%s is not in the tracker (origin: %s, no usable `%s:` handle) — "+
|
||||
"there is no state there to change; `kettle push %s` first",
|
||||
arg, i.Origin, mapping.GiteaKey, arg)
|
||||
}
|
||||
return closeTarget{id: arg, key: k}, nil
|
||||
}
|
||||
|
||||
var hits []closeEntry
|
||||
for _, e := range ledger {
|
||||
if e.slug == arg {
|
||||
hits = append(hits, e)
|
||||
}
|
||||
}
|
||||
hit, err := closeOne(hits, arg, "a number")
|
||||
if err != nil {
|
||||
return closeTarget{}, err
|
||||
}
|
||||
if hit != nil {
|
||||
return closeTarget{id: arg, key: hit.key}, nil // pushed, and its file went with the push
|
||||
}
|
||||
return closeTarget{}, Fail("no issue %q in %s or in its %s — name a tracker key "+
|
||||
"(42, #42, owner/repo#42, or the issue's URL) to close one this machine has never seen",
|
||||
arg, root, gitea.RemoteMapName)
|
||||
}
|
||||
|
||||
// closeOne is the single ledger row for an argument, nil when the ledger knows
|
||||
// nothing about it, or an error when it knows two.
|
||||
//
|
||||
// Two answers mean one number (or one slug) under more than one repository, and
|
||||
// only a qualified key can settle that. Guessing would close the wrong issue.
|
||||
func closeOne(hits []closeEntry, arg, what string) (*closeEntry, error) {
|
||||
seen := map[string]bool{}
|
||||
var uniq []closeEntry
|
||||
for _, h := range hits {
|
||||
if k := h.key.String() + " " + h.slug; !seen[k] {
|
||||
seen[k] = true
|
||||
uniq = append(uniq, h)
|
||||
}
|
||||
}
|
||||
switch len(uniq) {
|
||||
case 0:
|
||||
return nil, nil
|
||||
case 1:
|
||||
return &uniq[0], nil
|
||||
}
|
||||
var where []string
|
||||
for _, h := range uniq {
|
||||
where = append(where, h.key.String())
|
||||
}
|
||||
return nil, Fail("%q matches %s under more than one repository (%s) — say which, as owner/repo#N",
|
||||
arg, what, strings.Join(where, ", "))
|
||||
}
|
||||
|
||||
// closeApply writes the confirmed state onto the local file and returns its
|
||||
// path.
|
||||
//
|
||||
// `state:` is the domain's own field, so it is set on the issue and written out
|
||||
// by the domain's own writer. The sync-owned freshness fields travel with it:
|
||||
// the answer that authorized this write is also the newest thing the tracker has
|
||||
// said about the issue, so `synced:` and `remote-updated:` are stamped from it
|
||||
// rather than left describing an older read.
|
||||
func closeApply(root string, i *issue.Issue, state string, got *sdk.Issue) (string, error) {
|
||||
i.State = state
|
||||
if i.Extra == nil {
|
||||
i.Extra = map[string]string{}
|
||||
}
|
||||
i.Extra[mapping.SyncedKey] = time.Now().UTC().Format(time.RFC3339)
|
||||
if stamp := mapping.Stamp(got.Updated); stamp != "" {
|
||||
i.Extra[mapping.RemoteUpdatedKey] = stamp
|
||||
}
|
||||
return issue.Save(root, i)
|
||||
}
|
||||
|
||||
func closeHas(targets []closeTarget, t closeTarget) bool {
|
||||
for _, have := range targets {
|
||||
if have == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// closeName is what a receipt calls an issue this machine has no name for.
|
||||
func closeName(id string) string {
|
||||
if id == "" {
|
||||
return "(no local copy)"
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
// Package cmd is the kettle command tree.
|
||||
//
|
||||
// Commands are values, not init() side effects on a framework: each one carries
|
||||
// the metadata a human needs (what it does, what it takes, worked examples) in
|
||||
// the same struct that carries the code. That is deliberate — the skills kettle
|
||||
// writes into a project are generated from this list, so a command whose flags
|
||||
// changed cannot ship with documentation that says otherwise.
|
||||
//
|
||||
// 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.
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Groups, in the order they are presented. They name the layer a command
|
||||
// belongs to, which is the one thing a reader has to keep straight: the domain
|
||||
// works offline and the tracker does not exist to it.
|
||||
const (
|
||||
GroupProject = "project"
|
||||
GroupIssue = "issue"
|
||||
GroupSync = "sync"
|
||||
GroupAPI = "api"
|
||||
)
|
||||
|
||||
var groupOrder = []string{GroupProject, GroupIssue, GroupSync, GroupAPI}
|
||||
|
||||
var groupBlurb = map[string]string{
|
||||
GroupProject: "the project itself",
|
||||
GroupIssue: "issues as units of work — offline, no tracker involved",
|
||||
GroupSync: "moving issues between the store and the tracker",
|
||||
GroupAPI: "everything else Gitea has, reached directly — not issues",
|
||||
}
|
||||
|
||||
// Example is one worked invocation. Both halves are shown in help and in the
|
||||
// generated skill docs.
|
||||
type Example struct {
|
||||
Cmd string
|
||||
What string
|
||||
}
|
||||
|
||||
// Command is one verb.
|
||||
type Command struct {
|
||||
// Name is what the user types.
|
||||
Name string
|
||||
// Group is the layer it belongs to; documentation only.
|
||||
Group string
|
||||
// Args is the positional-argument spec, e.g. "<id> [<id>…]".
|
||||
Args string
|
||||
// Short is one line, shown in the command list.
|
||||
Short string
|
||||
// Long is the full explanation, shown by `kettle help <name>`.
|
||||
Long string
|
||||
// Examples are worked invocations.
|
||||
Examples []Example
|
||||
// Setup registers this command's flags on fs and returns the function that
|
||||
// runs it, closing over them. Splitting it this way lets the doc generator
|
||||
// walk the flags without running anything.
|
||||
Setup func(fs *flag.FlagSet) func(args []string) error
|
||||
}
|
||||
|
||||
var registry []*Command
|
||||
|
||||
func register(c *Command) { registry = append(registry, c) }
|
||||
|
||||
// Commands lists every command, sorted by group and then by name.
|
||||
func Commands() []*Command {
|
||||
out := append([]*Command{}, registry...)
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
gi, gj := groupIndex(out[i].Group), groupIndex(out[j].Group)
|
||||
if gi != gj {
|
||||
return gi < gj
|
||||
}
|
||||
return out[i].Name < out[j].Name
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Lookup finds a command by name.
|
||||
func Lookup(name string) *Command {
|
||||
for _, c := range registry {
|
||||
if c.Name == name {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Flags returns this command's flags without running it — what the doc
|
||||
// generator walks.
|
||||
func (c *Command) Flags() []*flag.Flag {
|
||||
fs := flag.NewFlagSet(c.Name, flag.ContinueOnError)
|
||||
fs.SetOutput(discard{})
|
||||
c.Setup(fs)
|
||||
var out []*flag.Flag
|
||||
fs.VisitAll(func(f *flag.Flag) { out = append(out, f) })
|
||||
return out
|
||||
}
|
||||
|
||||
// Usage is the one-line synopsis.
|
||||
func (c *Command) Usage() string {
|
||||
s := "kettle " + c.Name
|
||||
if c.Args != "" {
|
||||
s += " " + c.Args
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SilentError carries an exit status for a command that has already said
|
||||
// everything it has to say. `check` uses it: findings went to stdout and a
|
||||
// second copy on stderr would be noise.
|
||||
type SilentError struct{ Code int }
|
||||
|
||||
func (e SilentError) Error() string { return "" }
|
||||
|
||||
// Fail is the error every command returns for an ordinary failure. Main
|
||||
// prefixes it with the command name.
|
||||
func Fail(format string, a ...any) error { return fmt.Errorf(format, a...) }
|
||||
|
||||
// Main runs argv (without the program name) and returns the exit status.
|
||||
func Main(argv []string) int {
|
||||
if len(argv) == 0 {
|
||||
printUsage(os.Stdout)
|
||||
return 0
|
||||
}
|
||||
|
||||
name := argv[0]
|
||||
switch name {
|
||||
case "help", "-h", "--help":
|
||||
if len(argv) > 1 {
|
||||
c := Lookup(argv[1])
|
||||
if c == nil {
|
||||
fmt.Fprintf(os.Stderr, "kettle: no command %q\n", argv[1])
|
||||
return 2
|
||||
}
|
||||
printCommand(os.Stdout, c)
|
||||
return 0
|
||||
}
|
||||
printUsage(os.Stdout)
|
||||
return 0
|
||||
}
|
||||
|
||||
c := Lookup(name)
|
||||
if c == nil {
|
||||
fmt.Fprintf(os.Stderr, "kettle: no command %q — try `kettle help`\n", name)
|
||||
return 2
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet(name, flag.ContinueOnError)
|
||||
fs.Usage = func() { printCommand(os.Stderr, c) }
|
||||
run := c.Setup(fs)
|
||||
if err := fs.Parse(permute(fs, argv[1:])); err != nil {
|
||||
if err == flag.ErrHelp {
|
||||
return 0
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
switch err := run(fs.Args()).(type) {
|
||||
case nil:
|
||||
return 0
|
||||
case SilentError:
|
||||
return err.Code
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "kettle %s: %v\n", name, err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func printUsage(w *os.File) {
|
||||
fmt.Fprint(w, "kettle — issues as local markdown, and the tracker they sync with\n\n")
|
||||
fmt.Fprint(w, "usage: kettle <command> [flags] [args]\n")
|
||||
|
||||
current := ""
|
||||
for _, c := range Commands() {
|
||||
if c.Group != current {
|
||||
current = c.Group
|
||||
fmt.Fprintf(w, "\n%s — %s\n", current, groupBlurb[current])
|
||||
}
|
||||
fmt.Fprintf(w, " %-11s %s\n", c.Name, c.Short)
|
||||
}
|
||||
fmt.Fprint(w, "\n`kettle help <command>` for one command in full.\n")
|
||||
}
|
||||
|
||||
func printCommand(w *os.File, c *Command) {
|
||||
fmt.Fprintf(w, "%s\n\n%s\n", c.Usage(), c.Short)
|
||||
if c.Long != "" {
|
||||
fmt.Fprintf(w, "\n%s\n", strings.TrimSpace(c.Long))
|
||||
}
|
||||
if flags := c.Flags(); len(flags) > 0 {
|
||||
fmt.Fprint(w, "\nflags:\n")
|
||||
for _, f := range flags {
|
||||
name := "--" + f.Name
|
||||
if f.DefValue != "" && f.DefValue != "false" {
|
||||
name += "=" + f.DefValue
|
||||
}
|
||||
fmt.Fprintf(w, " %-22s %s\n", name, f.Usage)
|
||||
}
|
||||
}
|
||||
if len(c.Examples) > 0 {
|
||||
fmt.Fprint(w, "\nexamples:\n")
|
||||
for _, e := range c.Examples {
|
||||
fmt.Fprintf(w, " %s\n %s\n", e.Cmd, e.What)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// permute moves flags ahead of positional arguments.
|
||||
//
|
||||
// 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. 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.
|
||||
//
|
||||
// A flag that takes a value swallows the next argument, which is why this needs
|
||||
// the FlagSet: only the set knows whether --check wants one. `--` ends the
|
||||
// permutation, and everything after it is positional whatever it looks like.
|
||||
func permute(fs *flag.FlagSet, args []string) []string {
|
||||
var flags, positional []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
if a == "--" {
|
||||
positional = append(positional, args[i+1:]...)
|
||||
break
|
||||
}
|
||||
if len(a) < 2 || a[0] != '-' {
|
||||
positional = append(positional, a)
|
||||
continue
|
||||
}
|
||||
flags = append(flags, a)
|
||||
if strings.Contains(a, "=") {
|
||||
continue
|
||||
}
|
||||
f := fs.Lookup(strings.TrimLeft(a, "-"))
|
||||
// An unknown flag consumes nothing; Parse will reject it by name in a
|
||||
// moment, which is a better message than one about its value.
|
||||
if f == nil || isBoolFlag(f.Value) {
|
||||
continue
|
||||
}
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
flags = append(flags, args[i])
|
||||
}
|
||||
}
|
||||
return append(flags, positional...)
|
||||
}
|
||||
|
||||
func isBoolFlag(v flag.Value) bool {
|
||||
b, ok := v.(interface{ IsBoolFlag() bool })
|
||||
return ok && b.IsBoolFlag()
|
||||
}
|
||||
|
||||
func groupIndex(g string) int {
|
||||
for i, name := range groupOrder {
|
||||
if name == g {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(groupOrder)
|
||||
}
|
||||
|
||||
type discard struct{}
|
||||
|
||||
func (discard) Write(p []byte) (int, error) { return len(p), nil }
|
||||
@@ -0,0 +1,147 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
sdk "code.gitea.io/sdk/gitea"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "comment",
|
||||
Group: GroupSync,
|
||||
Args: "<id>",
|
||||
Short: "post or edit a comment on a synced issue",
|
||||
Long: `The target is a LOCAL ID, not a number. Which issue this is, is a fact about the
|
||||
work; where it lives in the tracker is bookkeeping, and the ` + "`gitea:`" + ` handle on the
|
||||
file is what turns one into the other. An ` + "`origin: local`" + ` issue cannot be
|
||||
commented on at all — it is not in the tracker, so there is nothing there to
|
||||
comment on; push it first.
|
||||
|
||||
The body comes from a file or from --body, and multi-line prose is what --file
|
||||
is for. This is why comments go through the API rather than through a tracker
|
||||
CLI: an entity command with an empty-looking positional opens $EDITOR, and on a
|
||||
TTY that does not exist it hangs forever.
|
||||
|
||||
After the write the whole thread is refetched into ` + "`<id>.comments.md`" + `, so the
|
||||
local copy is not stale by one comment — the one this run just made.
|
||||
|
||||
COMMENTS ARE PULL-ONLY IN THE STORE. Nothing round-trips them back: editing
|
||||
` + "`<id>.comments.md`" + ` by hand changes nothing in the tracker. Use --edit with a
|
||||
comment id for that.`,
|
||||
Examples: []Example{
|
||||
{"kettle comment wire-sqlc-appclick --file notes.md", "post the contents of a file"},
|
||||
{`kettle comment wire-sqlc-appclick --body "готово, задеплоено"`, "post one line"},
|
||||
{"kettle comment wire-sqlc-appclick --file fix.md --edit 1234", "rewrite comment 1234 instead"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
file := fs.String("file", "", "markdown file holding the comment body")
|
||||
body := fs.String("body", "", "comment body inline (short, single-line)")
|
||||
edit := fs.Int64("edit", 0, "comment id to rewrite, instead of posting a new one")
|
||||
out := storeFlag(fs)
|
||||
|
||||
return func(args []string) error {
|
||||
if len(args) != 1 {
|
||||
return Fail("give exactly one issue id")
|
||||
}
|
||||
id := args[0]
|
||||
withFile, withBody := wasSet(fs, "file"), wasSet(fs, "body")
|
||||
switch {
|
||||
case withFile && withBody:
|
||||
return Fail("--file and --body are mutually exclusive")
|
||||
case !withFile && !withBody:
|
||||
return Fail("give the comment body: --file <path>, or --body \"…\"")
|
||||
}
|
||||
|
||||
root, client, err := syncStartExisting(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i, err := issue.Load(root, id)
|
||||
if err != nil {
|
||||
return Fail("no issue %q in %s", id, root)
|
||||
}
|
||||
key, ok := mapping.RemoteKeyOf(i)
|
||||
if !ok {
|
||||
return Fail("%s is local-only (origin: %s, no usable `%s:` handle) — "+
|
||||
"there is nothing in the tracker to comment on; `kettle push %s` first",
|
||||
id, i.Origin, mapping.GiteaKey, id)
|
||||
}
|
||||
text, err := commentBodyFrom(*file, *body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The handle names the repository, so a comment lands where the
|
||||
// issue actually is — even when the store has ever pointed at two.
|
||||
client = client.For(key.Repo)
|
||||
|
||||
var got *sdk.Comment
|
||||
verb := "posted"
|
||||
if *edit != 0 {
|
||||
verb = "edited"
|
||||
got, err = client.EditComment(*edit, text, fmt.Sprintf("comment-%d", *edit))
|
||||
} else {
|
||||
got, err = client.CreateComment(key.Number, text, "comment-"+id)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A 2xx that carries no id is not a comment. Nothing local has been
|
||||
// written yet, and nothing will be if the answer is that shape.
|
||||
if got.ID == 0 {
|
||||
return Fail("the %s answer carries no comment id — nothing local was changed", verb)
|
||||
}
|
||||
fmt.Printf("%s comment %d on %s (%s) %s\n", verb, got.ID, id, key, got.HTMLURL)
|
||||
|
||||
comments, err := client.ListComments(key.Number)
|
||||
if err != nil {
|
||||
return Fail("the comment went up, but refetching the thread failed: %v — "+
|
||||
"`kettle pull %d` to refresh the local copy", err, key.Number)
|
||||
}
|
||||
path := commentsSidecarPath(root, id)
|
||||
if len(comments) == 0 {
|
||||
// Only reachable when the thread was emptied elsewhere between
|
||||
// the write and the read. A stale sidecar for a thread that no
|
||||
// longer exists is worse than no sidecar.
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("thread: none — %s removed\n", path)
|
||||
return nil
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(mapping.RenderComments(comments)), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("thread: %s (%d comment(s))\n", path, len(comments))
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// commentBodyFrom reads the comment body from a file or takes it as given.
|
||||
//
|
||||
// Trimmed and then required to be non-empty: a file of whitespace is somebody
|
||||
// pointing at the wrong path, and posting it would leave an empty comment in a
|
||||
// thread that nobody can delete from here.
|
||||
func commentBodyFrom(file, inline string) (string, error) {
|
||||
if file != "" {
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return "", Fail("cannot read the comment body: %v", err)
|
||||
}
|
||||
inline = string(raw)
|
||||
}
|
||||
text := strings.TrimSpace(inline)
|
||||
if text == "" {
|
||||
return "", Fail("the comment body is empty — nothing was posted")
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "config",
|
||||
Group: GroupProject,
|
||||
Short: "show what this project resolved to",
|
||||
Long: `Every path and every setting, with the overrides already applied, so a run that
|
||||
went somewhere unexpected can be explained without guessing.
|
||||
|
||||
The token is never printed — only whether one was found.
|
||||
|
||||
This is the command to reach for when the store looks empty, when a push says
|
||||
401, or when two directories disagree about which project they are in.`,
|
||||
Examples: []Example{
|
||||
{"kettle config", "resolved paths and settings"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
return func(args []string) error {
|
||||
root := project.Root("")
|
||||
if root == "" {
|
||||
return project.NotFoundError("")
|
||||
}
|
||||
|
||||
fmt.Printf("project %s\n", root)
|
||||
fmt.Printf("store %s\n", issue.Root(""))
|
||||
fmt.Printf("payload %s\n", project.PayloadRoot(""))
|
||||
fmt.Printf("config %s\n", config.ProjectPath(""))
|
||||
fmt.Printf("logins %s\n", config.LoginsPath())
|
||||
printScaffold()
|
||||
|
||||
r, err := config.Resolve("")
|
||||
if err != nil {
|
||||
fmt.Println()
|
||||
return err
|
||||
}
|
||||
red := r.Redacted()
|
||||
fmt.Println()
|
||||
fmt.Printf("login %s\n", orNone(red.Login))
|
||||
fmt.Printf("url %s\n", orNone(red.URL))
|
||||
fmt.Printf("token %s\n", orNone(red.Token))
|
||||
// What the login says its token can do, which is a note somebody
|
||||
// wrote and not an answer from the instance — a 403 out of
|
||||
// `kettle api` is read against this line.
|
||||
scopes := "(not recorded)"
|
||||
if len(red.Scopes) > 0 {
|
||||
scopes = strings.Join(red.Scopes, ", ")
|
||||
}
|
||||
fmt.Printf("scopes %s\n", scopes)
|
||||
if r.Owner != "" {
|
||||
fmt.Printf("repo %s\n", r.Slug())
|
||||
} else {
|
||||
fmt.Printf("repo none\n")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// printScaffold says where the agent-harness tree went and whether the build
|
||||
// that wrote it is the build that is running now.
|
||||
//
|
||||
// The mismatch is worth a line because these documents are generated whole:
|
||||
// there is nothing inside one that tells an operator how old it is, and a skill
|
||||
// four releases behind describes flags that have since been renamed. It is a
|
||||
// note, never a failure — `kettle gen scaffold` is the fix and the operator
|
||||
// decides when to run it.
|
||||
func printScaffold() {
|
||||
rec, existed, err := config.ReadScaffoldFile(config.ScaffoldPath(""))
|
||||
if err != nil || !existed {
|
||||
fmt.Printf("scaffold (none written — `kettle gen scaffold`)\n")
|
||||
return
|
||||
}
|
||||
out := rec.Out
|
||||
if out == "" {
|
||||
out = "(unrecorded)"
|
||||
}
|
||||
switch {
|
||||
case rec.Version == Version:
|
||||
fmt.Printf("scaffold %s (kettle %s)\n", out, rec.Version)
|
||||
default:
|
||||
fmt.Printf("scaffold %s (written by kettle %s; this is %s — run `kettle gen scaffold`)\n",
|
||||
out, rec.Version, Version)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "evict",
|
||||
Group: GroupIssue,
|
||||
Args: "[<id>…]",
|
||||
Short: "remove closed issues from the local store",
|
||||
Long: `The store is a working set, not an archive. What is evicted is two conditions,
|
||||
both read off the file:
|
||||
|
||||
state: closed the work is done
|
||||
origin: <tracker> the work is somewhere else too
|
||||
|
||||
THE SECOND CONDITION IS THE WHOLE SAFETY ARGUMENT. ` + "`origin: local`" + ` means this
|
||||
file IS the issue — there is no other copy and deleting it deletes the work. It
|
||||
is never evicted, in any state, not even when named explicitly on the command
|
||||
line: a closed local issue is reported and kept.
|
||||
|
||||
Eviction asks the file rather than the tracker, because state and origin are
|
||||
domain fields and the answer is already in the store — which is why this needs
|
||||
no network and no login. ` + "`kettle sync-evict`" + ` is the variant that refreshes state
|
||||
from the tracker first and then makes the same decision.
|
||||
|
||||
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. Evict it again when
|
||||
you are done with it.
|
||||
|
||||
INDEX.md is rebuilt, because it IS a view of the directory. The number -> slug
|
||||
ledger is deliberately not pruned: its entries outlive the files they name, and
|
||||
that is what makes a pull land on the same slug afterwards.`,
|
||||
Examples: []Example{
|
||||
{"kettle evict", "every closed issue that is not origin: local"},
|
||||
{"kettle evict old-thing another-thing", "only these"},
|
||||
{"kettle evict --dry-run", "print what would go; touch nothing"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
dryRun := fs.Bool("dry-run", false, "print what would be removed; touch nothing")
|
||||
out := storeFlag(fs)
|
||||
|
||||
return func(args []string) error {
|
||||
root, err := storeRoot(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !issue.StoreExists(root) {
|
||||
return Fail("store %s does not exist — nothing to evict", root)
|
||||
}
|
||||
issues, err := issue.LoadAll(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var missing []string
|
||||
for _, id := range args {
|
||||
if _, ok := issues[id]; !ok {
|
||||
missing = append(missing, id)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return Fail("no such issue(s) in the store: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
rep, err := issue.Evict(root, issues, args, *dryRun)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printEviction(rep, len(args) > 0)
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func printEviction(rep *issue.EvictReport, named bool) {
|
||||
verb := "evicted"
|
||||
if rep.DryRun {
|
||||
verb = "would evict"
|
||||
}
|
||||
for _, e := range rep.Evicted {
|
||||
fmt.Printf("%-11s %s\n", verb, e.ID)
|
||||
for _, p := range e.Paths {
|
||||
fmt.Printf(" %s\n", p)
|
||||
}
|
||||
}
|
||||
for _, k := range rep.Kept {
|
||||
// An open issue is the normal case and says nothing worth a line —
|
||||
// unless the operator named it, in which case they are owed the reason.
|
||||
if k.Open && !named {
|
||||
continue
|
||||
}
|
||||
if k.Open {
|
||||
fmt.Printf("%-11s %s %s\n", "kept", k.ID, k.Why)
|
||||
} else {
|
||||
fmt.Printf("%-11s %s closed, %s\n", "kept", k.ID, k.Why)
|
||||
}
|
||||
}
|
||||
|
||||
if rep.DryRun {
|
||||
fmt.Printf("%d issue(s) would be evicted, %d kept — nothing was touched\n",
|
||||
len(rep.Evicted), len(rep.Kept))
|
||||
return
|
||||
}
|
||||
fmt.Printf("%d issue(s) evicted, %d kept\n", len(rep.Evicted), len(rep.Kept))
|
||||
if rep.IndexPath != "" {
|
||||
fmt.Printf("index: %s — %d issue(s)\n", rep.IndexPath, rep.IndexCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
sdk "code.gitea.io/sdk/gitea"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "sync-evict",
|
||||
Group: GroupSync,
|
||||
Args: "[<id>…]",
|
||||
Short: "refresh state from the tracker, then evict what is closed",
|
||||
Long: `` + "`kettle evict`" + ` is the command that decides and deletes. This adds exactly one
|
||||
thing in front of it: a ` + "`state:`" + ` that is not stale. A local ` + "`state:`" + ` is only as
|
||||
fresh as the last pull, so an issue closed in the web UI an hour ago still reads
|
||||
` + "`open`" + ` here and the offline command will — correctly — leave it alone. That is
|
||||
the gap this closes, and before it existed the operator had to pull the five
|
||||
closed issues back onto disk before anything could remove them.
|
||||
|
||||
ORDER OF OPERATIONS, AND IT IS THE WHOLE SAFETY ARGUMENT:
|
||||
|
||||
1. every candidate's state is fetched — ALL of them, before anything is
|
||||
removed;
|
||||
2. each answer must be the issue that was asked about, in a state the domain
|
||||
recognizes;
|
||||
3. only then is the eviction run, by handing the refreshed issues to the
|
||||
domain — the same decision, the same deletion, the same protection of
|
||||
` + "`origin: local`" + `, in one place.
|
||||
|
||||
A dead connection, a non-2xx, an answer about another issue, a state nobody
|
||||
recognizes: the run stops at step 2 and NOTHING is deleted, not even the issues
|
||||
whose answers had already arrived. That is stricter than push, which deletes as
|
||||
it goes, and it costs nothing here — there is no ordering constraint between
|
||||
evictions, so there is no reason to start before every answer is in.
|
||||
|
||||
A candidate is an issue carrying a ` + "`gitea:`" + ` handle. ` + "`origin: local`" + ` work has
|
||||
none, is never asked about, and is never evicted — it is not in the tracker to
|
||||
be closed. A tracked issue whose handle is missing or unreadable cannot be
|
||||
verified, so it is reported and kept rather than guessed at.
|
||||
|
||||
Cost: one request per candidate. The store is a working set that push keeps
|
||||
small, and a wrong answer here deletes a file, so each issue is asked about by
|
||||
its own address rather than inferred from a list a limit could have truncated.
|
||||
|
||||
The refreshed state is written back even for the issues that stay: the answer is
|
||||
already paid for, and a store that keeps a state the tracker has disowned is the
|
||||
thing this command exists to fix.`,
|
||||
Examples: []Example{
|
||||
{"kettle sync-evict", "ask about every synced issue; evict the closed ones"},
|
||||
{"kettle sync-evict old-thing another-thing", "only these"},
|
||||
{"kettle sync-evict --dry-run", "ask, report, write and delete nothing"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
dryRun := fs.Bool("dry-run", false, "ask the tracker and report; write and delete nothing")
|
||||
out := storeFlag(fs)
|
||||
|
||||
return func(args []string) error {
|
||||
root, client, err := syncStartExisting(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
issues, err := issue.LoadAll(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var missing []string
|
||||
for _, id := range args {
|
||||
if _, ok := issues[id]; !ok {
|
||||
missing = append(missing, id)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return Fail("no such issue(s) in the store: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
checkable, unverifiable, local := syncEvictCandidates(issues, args)
|
||||
for _, s := range unverifiable {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s: %s — kept, and not asked about\n", s.id, s.why)
|
||||
}
|
||||
// A local issue is reported only when the operator named it: they
|
||||
// asked about this file by name and are owed the reason it stayed.
|
||||
if len(args) > 0 {
|
||||
for _, s := range local {
|
||||
fmt.Printf("%-11s %s %s\n", "kept", s.id, s.why)
|
||||
}
|
||||
}
|
||||
if len(checkable) == 0 {
|
||||
fmt.Println("nothing to check: nothing named carries a `gitea:` handle")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Every answer first, deletions after.
|
||||
fresh := make(map[string]string, len(checkable))
|
||||
for _, c := range checkable {
|
||||
got, err := client.For(c.key.Repo).GetIssue(c.key.Number)
|
||||
if err != nil {
|
||||
return Fail("%s: asking the tracker about %s failed: %v\nNothing was evicted.",
|
||||
c.id, c.key, err)
|
||||
}
|
||||
state, ok := syncEvictConfirms(got, c.key.Number)
|
||||
if !ok {
|
||||
return Fail("%s: the answer for %s does not confirm a state "+
|
||||
"(issue #%d, state %q). Nothing was evicted.",
|
||||
c.id, c.key, got.Index, got.State)
|
||||
}
|
||||
fresh[c.id] = state
|
||||
}
|
||||
|
||||
// The store stops lying even about the issues that stay. This is
|
||||
// the only write made before the decision, and a dry run makes
|
||||
// none.
|
||||
changed := 0
|
||||
for _, c := range checkable {
|
||||
was := issues[c.id].State
|
||||
if was == fresh[c.id] {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%-11s %s %s -> %s\n", "state", c.id, was, fresh[c.id])
|
||||
issues[c.id].State = fresh[c.id]
|
||||
if *dryRun {
|
||||
continue
|
||||
}
|
||||
if _, err := issue.Save(root, issues[c.id]); err != nil {
|
||||
return err
|
||||
}
|
||||
changed++
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(checkable))
|
||||
for _, c := range checkable {
|
||||
ids = append(ids, c.id)
|
||||
}
|
||||
rep, err := issue.Evict(root, issues, ids, *dryRun)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printEviction(rep, len(args) > 0)
|
||||
|
||||
// Evict rebuilds INDEX.md when something went; a state written back
|
||||
// without an eviction changed the store too, and the index is a
|
||||
// view of it. Neither happening means nothing changed on disk, and
|
||||
// then nothing is rewritten.
|
||||
if changed > 0 && rep.IndexPath == "" {
|
||||
path, n, err := issue.BuildIndex(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("index: %s — %d issue(s)\n", path, n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// syncEvictTarget is one issue the tracker can be asked about, and the address
|
||||
// to ask at — its own, so an issue that lives in another repository is asked
|
||||
// about there.
|
||||
type syncEvictTarget struct {
|
||||
id string
|
||||
key wire.Key
|
||||
}
|
||||
|
||||
// syncEvictSkip is an issue that was not asked about, with the reason.
|
||||
type syncEvictSkip struct{ id, why string }
|
||||
|
||||
// syncEvictCandidates splits the store into what the tracker can be asked
|
||||
// about, what names a tracker but cannot be reached, and what is local.
|
||||
//
|
||||
// An unverifiable issue names a tracker but carries no handle to reach it by,
|
||||
// which is a file to report and never one to delete on a guess. A local issue is
|
||||
// in neither of those: it has no handle because it has never left this machine,
|
||||
// and asking about it is not a question that has an answer.
|
||||
//
|
||||
// ids restricts the question to those issues; empty asks about the whole store.
|
||||
func syncEvictCandidates(issues map[string]*issue.Issue, ids []string) (checkable []syncEvictTarget, unverifiable, local []syncEvictSkip) {
|
||||
chosen := ids
|
||||
if len(chosen) == 0 {
|
||||
for id := range issues {
|
||||
chosen = append(chosen, id)
|
||||
}
|
||||
sort.Strings(chosen)
|
||||
}
|
||||
for _, id := range chosen {
|
||||
i, ok := issues[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if i.IsLocal() {
|
||||
local = append(local, syncEvictSkip{id, issue.LocalReason})
|
||||
continue
|
||||
}
|
||||
key, ok := mapping.RemoteKeyOf(i)
|
||||
if !ok {
|
||||
unverifiable = append(unverifiable, syncEvictSkip{id,
|
||||
fmt.Sprintf("origin: %s but no usable `%s:` handle", i.Origin, mapping.GiteaKey)})
|
||||
continue
|
||||
}
|
||||
checkable = append(checkable, syncEvictTarget{id: id, key: key})
|
||||
}
|
||||
return checkable, unverifiable, local
|
||||
}
|
||||
|
||||
// syncEvictConfirms is the state the tracker confirmed for this number — the
|
||||
// deletion gate.
|
||||
//
|
||||
// Deliberately boring, and saying no by default, because everything downstream
|
||||
// of a yes here may delete a file. An answer counts only when it is about the
|
||||
// very issue that was asked about and names a state the domain recognizes. A
|
||||
// non-2xx never reaches this: the transport has already returned an error.
|
||||
func syncEvictConfirms(got *sdk.Issue, number int) (string, bool) {
|
||||
if got == nil || int(got.Index) != number {
|
||||
return "", false
|
||||
}
|
||||
for _, s := range issue.States {
|
||||
if string(got.State) == s {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
|
||||
)
|
||||
|
||||
// storeFlag registers the one flag almost every command has.
|
||||
//
|
||||
// An explicit --out overrides the resolved store and is used exactly as typed:
|
||||
// a relative --out stays relative to the working directory, because that is
|
||||
// what the operator asked for.
|
||||
func storeFlag(fs *flag.FlagSet) *string {
|
||||
return fs.String("out", "", "store root (default: <project>/.kettle/issues)")
|
||||
}
|
||||
|
||||
// storeRoot resolves the store, or explains which directories were searched.
|
||||
//
|
||||
// No marker anywhere is an answer, not a fallback: a store placed in a
|
||||
// plausible-looking directory is the failure the marker exists to replace.
|
||||
func storeRoot(out string) (string, error) {
|
||||
if root := issue.Root(out); root != "" {
|
||||
return root, nil
|
||||
}
|
||||
return "", project.NotFoundError("")
|
||||
}
|
||||
|
||||
// wasSet reports whether the operator actually typed this flag.
|
||||
//
|
||||
// Needed wherever the empty string is a legitimate value to reject rather than
|
||||
// a synonym for "not given": `--check ""` is an empty selector and an error,
|
||||
// while no --check at all means "just list the boxes".
|
||||
func wasSet(fs *flag.FlagSet, name string) bool {
|
||||
found := false
|
||||
fs.Visit(func(f *flag.Flag) {
|
||||
if f.Name == name {
|
||||
found = true
|
||||
}
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
// stringList is a repeatable flag: --label tech/sql --label comp/appclick.
|
||||
type stringList []string
|
||||
|
||||
func (l *stringList) String() string { return strings.Join(*l, ", ") }
|
||||
|
||||
func (l *stringList) Set(v string) error {
|
||||
*l = append(*l, v)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/scaffold"
|
||||
)
|
||||
|
||||
// The region markers. What sits between them comes from the registry; the rest
|
||||
// of the document is the embedded prose around it.
|
||||
const (
|
||||
genOpen = "<!-- kettle:gen -->"
|
||||
genClose = "<!-- /kettle:gen -->"
|
||||
)
|
||||
|
||||
// genBanner opens every generated region. The first thing anybody who finds the
|
||||
// block wants to do is edit it in place, so the block says who wrote it and
|
||||
// which command writes it again.
|
||||
const genBanner = "**Generated from the kettle command registry by `kettle gen scaffold`.** " +
|
||||
"Everything between the two markers is replaced on the next run — " +
|
||||
"the prose around it is embedded in the binary and replaced with it."
|
||||
|
||||
// exampleAlign is the widest example command that still gets its `# what`
|
||||
// padded into a column. One long pipeline would otherwise push every other
|
||||
// comment off the right edge of the page.
|
||||
const exampleAlign = 56
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "gen",
|
||||
Group: GroupProject,
|
||||
Args: "scaffold",
|
||||
Short: "write this project's .claude/ commands, skills and subagent",
|
||||
Long: `A skill tells an agent how to invoke this binary, and a command is how an
|
||||
operator invokes one by hand. Both are written from here, whole, because both
|
||||
travel INSIDE the binary: the prose is embedded next to the code it describes
|
||||
and the flag tables are rendered from the command registry the binary is built
|
||||
from, so neither can be a version behind the other.
|
||||
|
||||
That is the whole reason these documents are not a plugin any more. A plugin
|
||||
ships on its own cadence, and nothing on an operator's machine ever checked that
|
||||
the one they installed described the binary they installed — so a renamed flag
|
||||
could still arrive with documentation recommending the old one, which is exactly
|
||||
the failure the generated block was invented to prevent, one hop further
|
||||
downstream.
|
||||
|
||||
EVERY FILE IS WRITTEN WHOLE, and that is a deliberate reversal. The old
|
||||
generator owned a region and left every byte outside it alone, because the prose
|
||||
around the block was somebody's hand-written file. It is not any more: it is
|
||||
embedded, so there is no hand-written half left to protect, and preserving local
|
||||
edits would mean freezing a project's documentation at whatever version first
|
||||
initialized it. The markers stay in the output so a reader can see which half
|
||||
came from the registry.
|
||||
|
||||
WHAT THIS MEANS FOR A LOCAL EDIT: it does not survive. Run --check before an
|
||||
upgrade if you have made one; the fix for a sentence that is wrong is a newer
|
||||
kettle, not a patch that the next run silently discards.
|
||||
|
||||
The output is deterministic to the byte — no timestamps, no map iteration — so
|
||||
regenerating something that has not changed produces no diff. --check is that
|
||||
property made useful: it writes nothing and exits 1 when any file on disk
|
||||
differs from what would be written, which is what a pre-commit hook or a CI step
|
||||
calls. It wins over --dry-run when both are given.`,
|
||||
Examples: []Example{
|
||||
{"kettle gen scaffold", "write .claude/ under this project"},
|
||||
{"kettle gen scaffold --out ~/code/x/.claude", "write it somewhere else"},
|
||||
{"kettle gen scaffold --dry-run", "print what would change; write nothing"},
|
||||
{"kettle gen scaffold --check", "exit 1 if the documents are out of date"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
out := fs.String("out", "", "directory to write into (default: <project>/"+scaffold.Marker+")")
|
||||
dryRun := fs.Bool("dry-run", false, "print what would change; write nothing")
|
||||
check := fs.Bool("check", false, "write nothing, exit 1 if anything is out of date")
|
||||
|
||||
return func(args []string) error {
|
||||
target := "scaffold"
|
||||
if len(args) > 0 {
|
||||
target = args[0]
|
||||
}
|
||||
if len(args) > 1 || target != "scaffold" {
|
||||
return Fail("the only target is `scaffold` — try `kettle gen scaffold`")
|
||||
}
|
||||
dir, err := scaffoldDir(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return genScaffold(dir, *dryRun, *check)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// scaffoldDir resolves where the tree goes.
|
||||
//
|
||||
// An explicit --out is used exactly as typed, relative and all, because that is
|
||||
// what the operator asked for. Without one the answer comes from the marker, the
|
||||
// same walk every other command uses — and no marker is an answer rather than a
|
||||
// fallback, because a `.claude/` written into a plausible-looking directory is
|
||||
// the failure the marker exists to replace.
|
||||
func scaffoldDir(out string) (string, error) {
|
||||
if out != "" {
|
||||
return out, nil
|
||||
}
|
||||
root := project.Root("")
|
||||
if root == "" {
|
||||
return "", project.NotFoundError("")
|
||||
}
|
||||
return filepath.Join(root, scaffold.Marker), nil
|
||||
}
|
||||
|
||||
func genScaffold(dir string, dryRun, check bool) error {
|
||||
// --check is a read-only question about the working tree, so it overrules
|
||||
// --dry-run rather than combining with it.
|
||||
if check {
|
||||
dryRun = true
|
||||
}
|
||||
|
||||
files, err := renderAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var written, unchanged, outdated int
|
||||
for _, f := range files {
|
||||
path := filepath.Join(dir, filepath.FromSlash(f.Path))
|
||||
existing, err := os.ReadFile(path)
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
outdated++
|
||||
if err := report(path, "missing", "would create", "created", dryRun, check, func() error {
|
||||
return writeFile(path, f.Body)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if !dryRun {
|
||||
written++
|
||||
}
|
||||
|
||||
case err != nil:
|
||||
return err
|
||||
|
||||
case string(existing) == f.Body:
|
||||
unchanged++
|
||||
fmt.Printf("%-13s %s\n", "unchanged", path)
|
||||
|
||||
default:
|
||||
outdated++
|
||||
if err := report(path, "stale", "would update", "updated", dryRun, check, func() error {
|
||||
return writeFile(path, f.Body)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if !dryRun {
|
||||
written++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case check:
|
||||
fmt.Printf("%d file(s) checked, %d out of date\n", len(files), outdated)
|
||||
if outdated > 0 {
|
||||
fmt.Printf("run `kettle gen scaffold --out %s`\n", dir)
|
||||
return SilentError{Code: 1}
|
||||
}
|
||||
case dryRun:
|
||||
fmt.Printf("%d file(s) would change, %d unchanged — nothing was written\n", outdated, unchanged)
|
||||
default:
|
||||
fmt.Printf("%d file(s) written, %d unchanged\n", written, unchanged)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// report prints one line for one file and performs the write unless this run is
|
||||
// only answering a question.
|
||||
func report(path, checkWord, dryWord, doneWord string, dryRun, check bool, write func() error) error {
|
||||
switch {
|
||||
case check:
|
||||
fmt.Printf("%-13s %s\n", checkWord, path)
|
||||
case dryRun:
|
||||
fmt.Printf("%-13s %s\n", dryWord, path)
|
||||
default:
|
||||
if err := write(); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%-13s %s\n", doneWord, path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderAll is every embedded document with its generated region filled in.
|
||||
//
|
||||
// Nothing here touches the disk: the result is what the tree SHOULD be, and
|
||||
// comparing it against what is there is a separate question asked by the caller.
|
||||
// That split is what lets --check be exact rather than a heuristic about
|
||||
// timestamps.
|
||||
func renderAll() ([]scaffold.File, error) {
|
||||
files := scaffold.Files()
|
||||
out := make([]scaffold.File, 0, len(files))
|
||||
for _, f := range files {
|
||||
if f.Group == "" {
|
||||
out = append(out, f)
|
||||
continue
|
||||
}
|
||||
block, err := renderGroup(commandsIn(f.Group))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := spliceRegion(f.Body, block)
|
||||
if err != nil {
|
||||
// The embedded document is shipped inside this binary, so a missing
|
||||
// marker is a build-time mistake in this repository and not
|
||||
// something an operator can have caused.
|
||||
return nil, Fail("%s: %v — this is a bug in the embedded document, not in your project", f.Path, err)
|
||||
}
|
||||
f.Body = body
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// errNoRegion is what a document that declares a region and has none reports.
|
||||
var errNoRegion = errors.New("no " + genOpen + " … " + genClose + " region")
|
||||
|
||||
// renderGroup is the generated block for one group, without the markers and
|
||||
// without a trailing newline.
|
||||
func renderGroup(cmds []*Command) (string, error) {
|
||||
var b strings.Builder
|
||||
b.WriteString(genBanner)
|
||||
b.WriteString("\n")
|
||||
|
||||
for _, c := range cmds {
|
||||
text := renderCommand(c)
|
||||
// A block holding either marker would cut itself in half on the next
|
||||
// run — the splice would end the region in the middle of the prose that
|
||||
// mentions it. Loud here rather than quietly truncated on disk.
|
||||
if strings.Contains(text, genOpen) || strings.Contains(text, genClose) {
|
||||
return "", Fail("command %q spells a region marker out in full; the generated block would then end inside itself — write it another way", c.Name)
|
||||
}
|
||||
b.WriteString(text)
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n"), nil
|
||||
}
|
||||
|
||||
func renderCommand(c *Command) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "\n## `%s`\n\n%s\n", c.Usage(), c.Short)
|
||||
if long := strings.TrimSpace(c.Long); long != "" {
|
||||
b.WriteString("\n" + long + "\n")
|
||||
}
|
||||
if flags := c.Flags(); len(flags) > 0 {
|
||||
b.WriteString("\n| flag | default | what it does |\n| --- | --- | --- |\n")
|
||||
for _, f := range flags {
|
||||
fmt.Fprintf(&b, "| `--%s` | %s | %s |\n", f.Name, defaultCell(f.DefValue), cell(f.Usage))
|
||||
}
|
||||
}
|
||||
if len(c.Examples) > 0 {
|
||||
w := exampleWidth(c.Examples)
|
||||
b.WriteString("\n```bash\n")
|
||||
for _, e := range c.Examples {
|
||||
pad := w - utf8.RuneCountInString(e.Cmd)
|
||||
if pad < 0 {
|
||||
pad = 0
|
||||
}
|
||||
fmt.Fprintf(&b, "%s%s # %s\n", e.Cmd, strings.Repeat(" ", pad), e.What)
|
||||
}
|
||||
b.WriteString("```\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// exampleWidth is the column the `# what` comments line up at. Runes, not
|
||||
// bytes: an example with Cyrillic in it would otherwise pull the column left by
|
||||
// however many multi-byte characters it holds.
|
||||
func exampleWidth(examples []Example) int {
|
||||
w := 0
|
||||
for _, e := range examples {
|
||||
if n := utf8.RuneCountInString(e.Cmd); n > w && n <= exampleAlign {
|
||||
w = n
|
||||
}
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func defaultCell(v string) string {
|
||||
if v == "" {
|
||||
return "—"
|
||||
}
|
||||
return "`" + cell(v) + "`"
|
||||
}
|
||||
|
||||
// cell keeps a value from breaking out of its table row.
|
||||
func cell(s string) string {
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
return strings.ReplaceAll(s, "|", `\|`)
|
||||
}
|
||||
|
||||
func region(block string) string {
|
||||
return genOpen + "\n" + block + "\n" + genClose
|
||||
}
|
||||
|
||||
// spliceRegion swaps the block into existing, leaving every other byte alone.
|
||||
func spliceRegion(existing, block string) (string, error) {
|
||||
start := strings.Index(existing, genOpen)
|
||||
if start < 0 {
|
||||
return "", errNoRegion
|
||||
}
|
||||
rest := start + len(genOpen)
|
||||
end := strings.Index(existing[rest:], genClose)
|
||||
if end < 0 {
|
||||
return "", fmt.Errorf("%s is missing its %s", genOpen, genClose)
|
||||
}
|
||||
return existing[:start] + region(block) + existing[rest+end+len(genClose):], nil
|
||||
}
|
||||
|
||||
// commandsIn lists a group's commands in the order Commands() returns them —
|
||||
// the same order twice, so two runs cannot differ.
|
||||
func commandsIn(group string) []*Command {
|
||||
var out []*Command
|
||||
for _, c := range Commands() {
|
||||
if c.Group == group {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writeFile(path, content string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, []byte(content), 0o644)
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package cmd_test
|
||||
|
||||
// `kettle gen scaffold` writes the documents an operator invokes and a model
|
||||
// loads. They are embedded in the binary, so this file tests the two properties
|
||||
// that follow from that: what it produces is the same twice over, and it is the
|
||||
// binary's answer rather than whatever happens to be on disk.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
genOpen = "<!-- kettle:gen -->"
|
||||
genClose = "<!-- /kettle:gen -->"
|
||||
)
|
||||
|
||||
// everything the tree is made of. Named here rather than derived, because a file
|
||||
// that silently stopped being written is exactly the failure this catches.
|
||||
var scaffoldFiles = []string{
|
||||
"agents/kettle-runner.md",
|
||||
"commands/kettle/api.md",
|
||||
"commands/kettle/auth.md",
|
||||
"commands/kettle/init.md",
|
||||
"commands/kettle/issue.md",
|
||||
"commands/kettle/project.md",
|
||||
"commands/kettle/sync.md",
|
||||
"skills/kettle-api/SKILL.md",
|
||||
"skills/kettle-issue/SKILL.md",
|
||||
"skills/kettle-issue/references/format.md",
|
||||
"skills/kettle-project/SKILL.md",
|
||||
"skills/kettle-sync/SKILL.md",
|
||||
}
|
||||
|
||||
func TestGenWritesTheWholeTreeAndIsIdempotent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "out")
|
||||
|
||||
first := mustRun(t, dir, "gen", "scaffold", "--out", out)
|
||||
for _, rel := range scaffoldFiles {
|
||||
path := filepath.Join(out, filepath.FromSlash(rel))
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("%s was not created: %v\n%s", rel, err, first.out())
|
||||
}
|
||||
}
|
||||
|
||||
// The four skills that carry a flag table carry the markers around it, so a
|
||||
// reader can see which half came from the registry.
|
||||
for _, group := range []string{"project", "issue", "sync", "api"} {
|
||||
path := filepath.Join(out, "skills", "kettle-"+group, "SKILL.md")
|
||||
body := readFile(t, path)
|
||||
if !strings.HasPrefix(body, "---\nname: kettle-"+group+"\n") {
|
||||
t.Errorf("%s does not name itself after its directory:\n%s", path, firstLines(body, 4))
|
||||
}
|
||||
if !strings.Contains(body, genOpen) || !strings.Contains(body, genClose) {
|
||||
t.Errorf("%s has no region markers", path)
|
||||
}
|
||||
// The block has to say what wrote it: the first thing anybody who finds
|
||||
// it will want to do is edit it in place.
|
||||
if !strings.Contains(body, "kettle gen scaffold") {
|
||||
t.Errorf("%s does not name the command that regenerates it", path)
|
||||
}
|
||||
}
|
||||
|
||||
// A command is invoked by a person who typed it, and takes its name from its
|
||||
// filename — a `name:` here would be a second spelling free to drift.
|
||||
initBody := readFile(t, filepath.Join(out, "commands", "kettle", "init.md"))
|
||||
if strings.Contains(firstLines(initBody, 6), "\nname:") {
|
||||
t.Errorf("the init command carries a name: of its own:\n%s", firstLines(initBody, 6))
|
||||
}
|
||||
if !strings.Contains(initBody, "description:") {
|
||||
t.Errorf("the init command has no description for the command list:\n%s", firstLines(initBody, 6))
|
||||
}
|
||||
|
||||
// One command's documentation, end to end: usage line, short, a flag out of
|
||||
// the flag set, and a worked example with its explanation beside it.
|
||||
issues := readFile(t, filepath.Join(out, "skills", "kettle-issue", "SKILL.md"))
|
||||
for _, want := range []string{
|
||||
"## `kettle evict [<id>…]`",
|
||||
"remove closed issues from the local store",
|
||||
"| `--dry-run` | `false` | print what would be removed; touch nothing |",
|
||||
"kettle evict --dry-run",
|
||||
"# print what would go; touch nothing",
|
||||
} {
|
||||
if !strings.Contains(issues, want) {
|
||||
t.Errorf("the issue skill is missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic to the byte: a regeneration of something that has not
|
||||
// changed must produce no diff at all, or every run of a CI step is a
|
||||
// spurious one.
|
||||
before := readAll(t, out)
|
||||
second := mustRun(t, dir, "gen", "scaffold", "--out", out)
|
||||
if strings.Contains(second.stdout, "updated") {
|
||||
t.Errorf("a second run rewrote a file:\n%s", second.out())
|
||||
}
|
||||
for path, content := range before {
|
||||
if now := readFile(t, path); now != content {
|
||||
t.Errorf("%s changed on a second run with nothing else changed", path)
|
||||
}
|
||||
}
|
||||
|
||||
if r := run(t, dir, "gen", "scaffold", "--out", out, "--check"); r.code != 0 {
|
||||
t.Errorf("--check exited %d on files that were just written:\n%s", r.code, r.out())
|
||||
}
|
||||
}
|
||||
|
||||
// The reversal, and the one behaviour worth stating out loud: these files are
|
||||
// the binary's, whole. The old generator owned a region and left the prose
|
||||
// around it alone, because that prose was somebody's hand-written file. It is
|
||||
// embedded now — there is no hand-written half left to protect, and preserving
|
||||
// local edits would freeze a project's documentation at whatever version first
|
||||
// initialized it.
|
||||
func TestGenReplacesLocalEditsRatherThanPreservingThem(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "out")
|
||||
mustRun(t, dir, "gen", "scaffold", "--out", out)
|
||||
|
||||
path := filepath.Join(out, "skills", "kettle-issue", "SKILL.md")
|
||||
pristine := readFile(t, path)
|
||||
edited := strings.Replace(pristine, "# /kettle:issue", "# my own heading", 1)
|
||||
edited = strings.Replace(edited, genClose, "hand-added line\n"+genClose, 1)
|
||||
if edited == pristine {
|
||||
t.Fatal("the fixture did not actually edit anything")
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(edited), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// --check is the warning, and it comes before the loss rather than after.
|
||||
if r := run(t, dir, "gen", "scaffold", "--out", out, "--check"); r.code != 1 {
|
||||
t.Errorf("--check exited %d on an edited file, want 1:\n%s", r.code, r.out())
|
||||
}
|
||||
|
||||
mustRun(t, dir, "gen", "scaffold", "--out", out)
|
||||
if got := readFile(t, path); got != pristine {
|
||||
t.Errorf("regenerating did not restore the binary's own copy:\n%s", firstLines(got, 8))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenCheckFailsOnAStaleFileAndNamesIt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "out")
|
||||
mustRun(t, dir, "gen", "scaffold", "--out", out)
|
||||
|
||||
stale := filepath.Join(out, "skills", "kettle-sync", "SKILL.md")
|
||||
edited := strings.Replace(readFile(t, stale), genClose, "kettle push --thoroughly-renamed-flag\n"+genClose, 1)
|
||||
if err := os.WriteFile(stale, []byte(edited), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := run(t, dir, "gen", "scaffold", "--out", out, "--check")
|
||||
if r.code != 1 {
|
||||
t.Fatalf("--check exited %d, want 1 — this is what a hook or a CI step calls:\n%s", r.code, r.out())
|
||||
}
|
||||
if !strings.Contains(r.stdout, stale) {
|
||||
t.Errorf("--check did not say which file is out of date:\n%s", r.out())
|
||||
}
|
||||
// A question about the tree, never an answer written into it.
|
||||
if got := readFile(t, stale); got != edited {
|
||||
t.Error("--check wrote to the file it was asked about")
|
||||
}
|
||||
|
||||
// A file that is not there at all is out of date too, not a nothing.
|
||||
if err := os.Remove(stale); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := run(t, dir, "gen", "scaffold", "--out", out, "--check"); r.code != 1 {
|
||||
t.Errorf("--check exited %d for a missing file, want 1:\n%s", r.code, r.out())
|
||||
}
|
||||
if _, err := os.Stat(stale); err == nil {
|
||||
t.Error("--check created the file it was asked about")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenDryRunWritesNothingAtAll(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "out")
|
||||
|
||||
fresh := mustRun(t, dir, "gen", "scaffold", "--out", out, "--dry-run")
|
||||
if !strings.Contains(fresh.stdout, "would create") {
|
||||
t.Errorf("a dry run said nothing about what it would do:\n%s", fresh.out())
|
||||
}
|
||||
if _, err := os.Stat(out); err == nil {
|
||||
t.Fatal("a dry run created the output directory")
|
||||
}
|
||||
|
||||
// And on an existing tree: the file is described, never touched.
|
||||
mustRun(t, dir, "gen", "scaffold", "--out", out)
|
||||
path := filepath.Join(out, "skills", "kettle-issue", "SKILL.md")
|
||||
edited := strings.Replace(readFile(t, path), genClose, "gutted\n"+genClose, 1)
|
||||
if err := os.WriteFile(path, []byte(edited), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := mustRun(t, dir, "gen", "scaffold", "--out", out, "--dry-run")
|
||||
if !strings.Contains(r.stdout, "would update") || !strings.Contains(r.stdout, "nothing was written") {
|
||||
t.Errorf("the dry run did not report the pending change:\n%s", r.out())
|
||||
}
|
||||
if got := readFile(t, path); got != edited {
|
||||
t.Error("a dry run rewrote the file")
|
||||
}
|
||||
}
|
||||
|
||||
// Without --out the tree goes under the project marker, resolved by the same
|
||||
// walk every other command uses. No marker is an answer, not a fallback: a
|
||||
// `.claude/` written into a plausible-looking directory is the failure the
|
||||
// marker exists to replace.
|
||||
func TestGenWithoutOutResolvesTheProject(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
sub := filepath.Join(dir, "cli", "internal")
|
||||
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mustRun(t, sub, "gen", "scaffold")
|
||||
if _, err := os.Stat(filepath.Join(dir, ".claude", "skills", "kettle-issue", "SKILL.md")); err != nil {
|
||||
t.Errorf("run from %s, the tree did not land at the project root: %v", sub, err)
|
||||
}
|
||||
|
||||
orphan := t.TempDir()
|
||||
r := run(t, orphan, "gen", "scaffold")
|
||||
if r.code == 0 {
|
||||
t.Fatalf("gen outside a project must stop:\n%s", r.out())
|
||||
}
|
||||
if !strings.Contains(r.stderr, ".kettle") {
|
||||
t.Errorf("the refusal does not name what is missing:\n%s", r.out())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(orphan, ".claude")); err == nil {
|
||||
t.Error("the refused run created a tree anyway")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenRefusesAnUnknownTarget(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
if r := run(t, dir, "gen", "agents", "--out", filepath.Join(dir, "x")); r.code == 0 {
|
||||
t.Errorf("an unknown target must be refused:\n%s", r.out())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "x")); err == nil {
|
||||
t.Error("the refused run created its output directory anyway")
|
||||
}
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// readAll is every file under root, by path, for a byte-for-byte comparison
|
||||
// after a second run.
|
||||
func readAll(t *testing.T, root string) map[string]string {
|
||||
t.Helper()
|
||||
out := map[string]string{}
|
||||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return err
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out[path] = string(raw)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstLines(s string, n int) string {
|
||||
lines := strings.SplitN(s, "\n", n+1)
|
||||
if len(lines) > n {
|
||||
lines = lines[:n]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "index",
|
||||
Group: GroupIssue,
|
||||
Short: "rebuild INDEX.md from what is on disk",
|
||||
Long: `A map of the local store, nothing else. The ` + "`origin`" + ` column is the only place
|
||||
the index acknowledges that a tracker exists: ` + "`local`" + ` means the issue has never
|
||||
left this machine, anything else names the tracker it also lives in. Both are
|
||||
ordinary issues here.
|
||||
|
||||
` + "`progress`" + ` counts the body's checkboxes, ticked over total, and is read off the
|
||||
body at build time rather than stored — a second copy of that state in a
|
||||
metadata field would be wrong by the next edit.
|
||||
|
||||
An existing store with nothing in it is a legitimate thing to index and gets an
|
||||
"_empty_" table. A store that is not there is an error, not a directory to
|
||||
create.`,
|
||||
Examples: []Example{
|
||||
{"kettle index", "rebuild the index for this project"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
out := storeFlag(fs)
|
||||
return func(args []string) error {
|
||||
root, err := storeRoot(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path, n, err := issue.BuildIndex(root)
|
||||
if err != nil {
|
||||
return Fail("%v — nothing was created; create an issue with `kettle new`, or pass --out", err)
|
||||
}
|
||||
fmt.Printf("%s — %d issue(s)\n", path, n)
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/scaffold"
|
||||
)
|
||||
|
||||
// initOptions is everything init does, as data.
|
||||
//
|
||||
// It exists so that --interactive and the flags are two ways of filling in one
|
||||
// struct rather than two implementations of one command. Every question the
|
||||
// wizard asks has a field here and therefore a flag: a step that could only be
|
||||
// reached by answering a prompt would be a step no script, no CI run and no
|
||||
// agent could take.
|
||||
type initOptions struct {
|
||||
Root string
|
||||
Login string
|
||||
Repo string
|
||||
Scaffold bool
|
||||
ScaffoldOut string
|
||||
MirrorHook bool
|
||||
ForceSettings bool
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// writeConfig creates or updates .kettle/config.yaml, touching only the
|
||||
// settings it was given.
|
||||
//
|
||||
// Init is idempotent, and that has to include the config: re-running it to add
|
||||
// a repository must not silently drop the login somebody pinned last week.
|
||||
func writeConfig(root, login, repo string, dryRun bool) (string, error) {
|
||||
path := filepath.Join(root, project.Marker, "config.yaml")
|
||||
rel := filepath.Join(project.Marker, "config.yaml")
|
||||
|
||||
cfg, existed, err := config.ReadProjectFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
changed := !existed
|
||||
if login != "" && cfg.Login != login {
|
||||
cfg.Login, changed = login, true
|
||||
}
|
||||
if repo != "" && cfg.Repo != repo {
|
||||
cfg.Repo, changed = repo, true
|
||||
}
|
||||
if !changed {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
verb := "updated"
|
||||
if !existed {
|
||||
verb = "created"
|
||||
}
|
||||
detail := "no login or repository pinned yet — `kettle init --login … --repo …`"
|
||||
if cfg.Login != "" || cfg.Repo != "" {
|
||||
detail = fmt.Sprintf("login: %s, repo: %s", orNone(cfg.Login), orNone(cfg.Repo))
|
||||
}
|
||||
if dryRun {
|
||||
return fmt.Sprintf("%s %s (%s)", verb, rel, detail), nil
|
||||
}
|
||||
if err := config.SaveProject(path, cfg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s %s (%s)", verb, rel, detail), nil
|
||||
}
|
||||
|
||||
func orNone(s string) string {
|
||||
if s == "" {
|
||||
return "none"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "init",
|
||||
Group: GroupProject,
|
||||
Short: "make this directory a project that tracks issues",
|
||||
Long: `Creates ` + "`.kettle/`" + ` — the marker every other command resolves the store from,
|
||||
and ` + "`.kettle/config.yaml`" + `, which says which tracker repository these issues
|
||||
belong to and which login to reach it under — and writes ` + "`.claude/`" + `: the slash
|
||||
commands an operator invokes, the skills a model loads, and the runner subagent.
|
||||
|
||||
The marker is deliberately something an operator makes, not something inferred
|
||||
from the tree: ` + "`.git`" + ` is in every clone, so anything that inferred a root from
|
||||
one would write issues into whatever it happened to be installed in.
|
||||
|
||||
--login pins a name, never a credential. The tokens live in one file per
|
||||
machine, outside every working tree, managed with ` + "`kettle auth`" + `.
|
||||
|
||||
All of it is idempotent: it creates .kettle/issues and .kettle/payload, migrates
|
||||
an older store in if it finds one (either layout the tea plugin used, oldest
|
||||
first), writes the config without disturbing settings it was not given, writes
|
||||
the .claude/ tree, and adds .kettle/ to .gitignore. Each migration is a move,
|
||||
not a copy — two stores is the state the marker exists to prevent — and it
|
||||
refuses to pick a winner when both sides hold a file of the same name.
|
||||
|
||||
IT REFUSES TO RUN IN A LINKED WORKTREE, and names the main checkout instead. A
|
||||
worktree is the same project on another branch and reaches the store by a hop
|
||||
out to the main checkout; a marker here would give one project two stores, and
|
||||
the directory holding the second one disappears with the branch.
|
||||
|
||||
--interactive walks a person through the whole thing — the login, the token with
|
||||
the echo turned off, the repository, the .claude/ tree and the AGENTS.md mirror
|
||||
hook. IT REQUIRES A TERMINAL and refuses a standard input that is not one, which
|
||||
is deliberate: every question it asks has a flag beside it, so nothing that is
|
||||
not a person ever needs to answer a prompt.`,
|
||||
Examples: []Example{
|
||||
{"kettle init", "initialize the current directory"},
|
||||
{"kettle init --interactive", "be walked through it, at a terminal"},
|
||||
{"kettle init --login noodles --repo claude-skills/marketplace", "and point it at a tracker"},
|
||||
{"kettle init --mirror-hook", "register the AGENTS.md mirror on PreToolUse(Bash)"},
|
||||
{"kettle init --at ~/code/x", "initialize somewhere else"},
|
||||
{"kettle init --dry-run", "say what it would do, touch nothing"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
at := fs.String("at", "", "directory to initialize (default: the working directory)")
|
||||
login := fs.String("login", "", "name of a login in the machine-wide file (see `kettle auth`)")
|
||||
repo := fs.String("repo", "", "tracker repository, as owner/name")
|
||||
interactive := fs.Bool("interactive", false, "ask, one question at a time; requires a terminal")
|
||||
noScaffold := fs.Bool("no-scaffold", false, "do not write the .claude/ commands, skills and subagent")
|
||||
scaffoldOut := fs.String("scaffold-out", "", "where the .claude/ tree goes (default: <project>/"+scaffold.Marker+")")
|
||||
mirrorHook := fs.Bool("mirror-hook", false, "register `kettle mirror --hook` on PreToolUse(Bash)")
|
||||
forceSettings := fs.Bool("force-settings", false, "let the hook be merged into an existing settings.json, reformatting it")
|
||||
dryRun := fs.Bool("dry-run", false, "report what would happen; change nothing")
|
||||
|
||||
return func(args []string) error {
|
||||
root := *at
|
||||
if root == "" {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root = wd
|
||||
}
|
||||
root, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fi, err := os.Stat(root); err != nil || !fi.IsDir() {
|
||||
return Fail("%s is not a directory", root)
|
||||
}
|
||||
|
||||
opts := initOptions{
|
||||
Root: root,
|
||||
Login: *login,
|
||||
Repo: *repo,
|
||||
Scaffold: !*noScaffold,
|
||||
ScaffoldOut: *scaffoldOut,
|
||||
MirrorHook: *mirrorHook,
|
||||
ForceSettings: *forceSettings,
|
||||
DryRun: *dryRun,
|
||||
}
|
||||
if *interactive {
|
||||
if err := askInit(&opts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return runInit(opts)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func runInit(opts initOptions) error {
|
||||
// A worktree is the same project on another branch. The rule used to live in
|
||||
// a skill somebody had to read; it is here because the wizard is now the
|
||||
// front door and a front door cannot rely on the reader having read anything.
|
||||
if main := project.MainWorktree(opts.Root); main != "" {
|
||||
return Fail("%s is a linked worktree of the project at %s.\n"+
|
||||
"A worktree reaches that store on its own — the walk crosses to it through the `gitdir:` in the .git file — "+
|
||||
"and a marker here would give one project two stores, the second of which is deleted with the branch.\n"+
|
||||
"Initialize the main checkout instead: kettle init --at %s", opts.Root, main, main)
|
||||
}
|
||||
|
||||
// A second marker inside an existing project gives it a second store, and
|
||||
// the nearer one wins — which is a surprise worth naming before it happens,
|
||||
// not after.
|
||||
if existing := project.Root(opts.Root); existing != "" && existing != opts.Root {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"warning: %s already sits inside the project at %s — a second marker here gives it a second store, and the nearer one wins.\n",
|
||||
opts.Root, existing)
|
||||
}
|
||||
|
||||
if opts.Repo != "" {
|
||||
if owner, name, ok := strings.Cut(opts.Repo, "/"); !ok || owner == "" || name == "" {
|
||||
return Fail("--repo %q is not owner/name", opts.Repo)
|
||||
}
|
||||
}
|
||||
|
||||
done, err := project.Init(opts.Root, opts.DryRun)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
line, err := writeConfig(opts.Root, opts.Login, opts.Repo, opts.DryRun)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if line != "" {
|
||||
done = append(done, line)
|
||||
}
|
||||
|
||||
prefix := ""
|
||||
if opts.DryRun {
|
||||
prefix = "would: "
|
||||
}
|
||||
for _, l := range done {
|
||||
fmt.Println(prefix + l)
|
||||
}
|
||||
|
||||
if opts.Scaffold {
|
||||
if err := initScaffold(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if opts.MirrorHook {
|
||||
if err := initMirrorHook(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scaffoldOut is where the .claude/ tree goes for this run. An explicit
|
||||
// --scaffold-out is used exactly as typed; without one it sits beside the
|
||||
// marker, which is the only place another command can find it again.
|
||||
func (o initOptions) scaffoldOut() string {
|
||||
if o.ScaffoldOut != "" {
|
||||
return o.ScaffoldOut
|
||||
}
|
||||
return filepath.Join(o.Root, scaffold.Marker)
|
||||
}
|
||||
|
||||
func initScaffold(opts initOptions) error {
|
||||
out := opts.scaffoldOut()
|
||||
if err := genScaffold(out, opts.DryRun, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.DryRun {
|
||||
return nil
|
||||
}
|
||||
|
||||
// The stamp is written last and is not load-bearing: nothing resolves from
|
||||
// it, and deleting it costs the warning in `kettle config` and nothing else.
|
||||
rec := &config.Scaffold{Version: Version, Out: relativeTo(opts.Root, out)}
|
||||
return config.SaveScaffoldFile(filepath.Join(opts.Root, project.Marker, "scaffold.yaml"), rec)
|
||||
}
|
||||
|
||||
func initMirrorHook(opts initOptions) error {
|
||||
line, err := writeMirrorHook(opts.scaffoldOut(), opts.ForceSettings, opts.DryRun)
|
||||
if err != nil {
|
||||
// An existing settings.json is a decision for the operator, not a
|
||||
// failure of the run: everything before this point already happened and
|
||||
// saying otherwise would send them looking for damage there is none of.
|
||||
if errors.Is(err, errSettingsExist) {
|
||||
fmt.Fprintf(os.Stderr, "kettle init: the mirror hook was not registered — %v\n", err)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if line != "" {
|
||||
prefix := ""
|
||||
if opts.DryRun {
|
||||
prefix = "would: "
|
||||
}
|
||||
fmt.Println(prefix + line)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// relativeTo is path as written down in the scaffold record: relative when it
|
||||
// sits under the project, absolute when the operator sent it somewhere else.
|
||||
func relativeTo(root, path string) string {
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
return path
|
||||
}
|
||||
return rel
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package cmd_test
|
||||
|
||||
// What `kettle init` writes beyond the marker: the .claude/ tree, the record of
|
||||
// which build wrote it, and the optional mirror hook. Plus the two refusals that
|
||||
// used to be prose in a skill and are now the binary's.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInitWritesTheClaudeTreeByDefault(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
r := mustRun(t, dir, "init")
|
||||
|
||||
for _, rel := range scaffoldFiles {
|
||||
if _, err := os.Stat(filepath.Join(dir, ".claude", filepath.FromSlash(rel))); err != nil {
|
||||
t.Errorf("%s was not written: %v\n%s", rel, err, r.out())
|
||||
}
|
||||
}
|
||||
|
||||
// And the record of what wrote it, which is the only way an operator can
|
||||
// tell a current tree from one four releases old.
|
||||
stamp := readFile(t, filepath.Join(dir, ".kettle", "scaffold.yaml"))
|
||||
for _, want := range []string{"version:", "out: .claude"} {
|
||||
if !strings.Contains(stamp, want) {
|
||||
t.Errorf("the scaffold record is missing %q:\n%s", want, stamp)
|
||||
}
|
||||
}
|
||||
if cfg := mustRun(t, dir, "config"); !strings.Contains(cfg.stdout, "scaffold .claude") {
|
||||
t.Errorf("`kettle config` does not report the tree:\n%s", cfg.stdout)
|
||||
}
|
||||
|
||||
// Idempotent all the way through: a second init rewrites nothing.
|
||||
second := mustRun(t, dir, "init")
|
||||
if strings.Contains(second.stdout, "updated") {
|
||||
t.Errorf("a second init rewrote a document:\n%s", second.out())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitCanBeToldToWriteNoTreeAtAll(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustRun(t, dir, "init", "--no-scaffold")
|
||||
|
||||
if _, err := os.Stat(filepath.Join(dir, ".claude")); err == nil {
|
||||
t.Error("--no-scaffold still wrote the tree")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, ".kettle", "scaffold.yaml")); err == nil {
|
||||
t.Error("--no-scaffold recorded a tree it did not write")
|
||||
}
|
||||
// Nothing else is affected: this is still a project.
|
||||
if _, err := os.Stat(filepath.Join(dir, ".kettle", "issues")); err != nil {
|
||||
t.Errorf("--no-scaffold skipped the store as well: %v", err)
|
||||
}
|
||||
// And `kettle config` says so rather than saying nothing.
|
||||
if cfg := mustRun(t, dir, "config"); !strings.Contains(cfg.stdout, "scaffold (none written") {
|
||||
t.Errorf("`kettle config` is silent about the missing tree:\n%s", cfg.stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitScaffoldOutIsUsedAsTyped(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
elsewhere := filepath.Join(dir, "somewhere", "else")
|
||||
|
||||
mustRun(t, dir, "init", "--scaffold-out", elsewhere)
|
||||
if _, err := os.Stat(filepath.Join(elsewhere, "skills", "kettle-issue", "SKILL.md")); err != nil {
|
||||
t.Errorf("the tree did not go where it was told: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, ".claude")); err == nil {
|
||||
t.Error("it went to the default as well")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitDryRunWritesNoTreeAndNoRecord(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
r := mustRun(t, dir, "init", "--dry-run", "--mirror-hook")
|
||||
if !strings.Contains(r.stdout, "would create") {
|
||||
t.Errorf("the dry run said nothing about the tree:\n%s", r.out())
|
||||
}
|
||||
for _, p := range []string{".claude", ".kettle"} {
|
||||
if _, err := os.Stat(filepath.Join(dir, p)); err == nil {
|
||||
t.Errorf("a dry run created %s", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The mirror hook: offered, never assumed. A project that keeps no AGENTS.md
|
||||
// files wants nothing to do with it.
|
||||
func TestInitRegistersTheMirrorHookOnlyWhenAsked(t *testing.T) {
|
||||
plain := t.TempDir()
|
||||
mustRun(t, plain, "init")
|
||||
if _, err := os.Stat(filepath.Join(plain, ".claude", "settings.json")); err == nil {
|
||||
t.Error("init registered a hook nobody asked for")
|
||||
}
|
||||
|
||||
asked := t.TempDir()
|
||||
mustRun(t, asked, "init", "--mirror-hook")
|
||||
body := readFile(t, filepath.Join(asked, ".claude", "settings.json"))
|
||||
|
||||
var settings struct {
|
||||
Hooks struct {
|
||||
PreToolUse []struct {
|
||||
Matcher string `json:"matcher"`
|
||||
Hooks []struct {
|
||||
Type string `json:"type"`
|
||||
Command string `json:"command"`
|
||||
} `json:"hooks"`
|
||||
} `json:"PreToolUse"`
|
||||
} `json:"hooks"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &settings); err != nil {
|
||||
t.Fatalf("the settings file is not valid JSON: %v\n%s", err, body)
|
||||
}
|
||||
if len(settings.Hooks.PreToolUse) != 1 || settings.Hooks.PreToolUse[0].Matcher != "Bash" {
|
||||
t.Fatalf("the hook is not registered on PreToolUse(Bash):\n%s", body)
|
||||
}
|
||||
command := settings.Hooks.PreToolUse[0].Hooks[0].Command
|
||||
if !strings.Contains(command, "kettle mirror --hook") {
|
||||
t.Errorf("the registered command is %q", command)
|
||||
}
|
||||
// The guard outlives the binary: an operator who uninstalls kettle must not
|
||||
// get "command not found" on every Bash call from a hook they set up months
|
||||
// ago and have stopped thinking about.
|
||||
if !strings.Contains(command, "command -v kettle") {
|
||||
t.Errorf("the registered command has no guard against kettle being gone: %q", command)
|
||||
}
|
||||
}
|
||||
|
||||
// settings.json is a file the operator owns and commits, and Go cannot preserve
|
||||
// its key order. So an existing one is never rewritten without being asked
|
||||
// twice: the run reports what it did not do, prints the snippet, and succeeds.
|
||||
func TestInitRefusesToRewriteAnExistingSettingsFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
settings := filepath.Join(dir, ".claude", "settings.json")
|
||||
if err := os.MkdirAll(filepath.Dir(settings), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const theirs = `{ "permissions": { "allow": ["Bash(ls:*)"] } }`
|
||||
if err := os.WriteFile(settings, []byte(theirs), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := mustRun(t, dir, "init", "--mirror-hook")
|
||||
if got := readFile(t, settings); got != theirs {
|
||||
t.Errorf("the operator's settings file was rewritten:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(r.stderr, "kettle mirror") {
|
||||
t.Errorf("the refusal did not print the snippet to paste:\n%s", r.out())
|
||||
}
|
||||
if !strings.Contains(r.stderr, "--force-settings") {
|
||||
t.Errorf("the refusal did not name the way past it:\n%s", r.out())
|
||||
}
|
||||
// Everything before the hook still happened; this is a decision, not a
|
||||
// failure, and reporting it as one would send somebody looking for damage.
|
||||
if _, err := os.Stat(filepath.Join(dir, ".kettle", "issues")); err != nil {
|
||||
t.Error("the refusal rolled back the rest of the run")
|
||||
}
|
||||
|
||||
// Asked twice, it merges — and keeps what was already in the file.
|
||||
mustRun(t, dir, "init", "--mirror-hook", "--force-settings")
|
||||
merged := readFile(t, settings)
|
||||
if !strings.Contains(merged, "kettle mirror") {
|
||||
t.Errorf("--force-settings did not add the hook:\n%s", merged)
|
||||
}
|
||||
if !strings.Contains(merged, "Bash(ls:*)") {
|
||||
t.Errorf("--force-settings dropped what was already there:\n%s", merged)
|
||||
}
|
||||
|
||||
// And a third run adds nothing: the hook is already there.
|
||||
mustRun(t, dir, "init", "--mirror-hook")
|
||||
if again := readFile(t, settings); again != merged {
|
||||
t.Errorf("a re-run duplicated the hook:\n%s", again)
|
||||
}
|
||||
}
|
||||
|
||||
// The rule that used to be a paragraph in a skill an operator had to read. The
|
||||
// wizard is the front door now, and a front door cannot assume anybody read
|
||||
// anything.
|
||||
func TestInitRefusesALinkedWorktree(t *testing.T) {
|
||||
main := t.TempDir()
|
||||
worktree := filepath.Join(main, "wt")
|
||||
gitdir := filepath.Join(main, ".git", "worktrees", "wt")
|
||||
if err := os.MkdirAll(gitdir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(worktree, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(gitdir, "commondir"), []byte("../..\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(worktree, ".git"), []byte("gitdir: "+gitdir+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := run(t, worktree, "init")
|
||||
if r.code == 0 {
|
||||
t.Fatalf("init in a linked worktree must be refused:\n%s", r.out())
|
||||
}
|
||||
if !strings.Contains(r.stderr, main) {
|
||||
t.Errorf("the refusal does not name the main checkout to use instead:\n%s", r.out())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(worktree, ".kettle")); err == nil {
|
||||
t.Error("the refused run created a second marker anyway")
|
||||
}
|
||||
}
|
||||
|
||||
// --interactive is for a person. Under a test harness, a script or an agent,
|
||||
// standard input is a pipe and the answer is an error that names the flags
|
||||
// rather than a prompt nobody will ever answer.
|
||||
func TestInteractiveRefusesWhatIsNotATerminal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
r := runWith(t, dir, nil, "\n\n\n", "init", "--interactive")
|
||||
if r.code == 0 {
|
||||
t.Fatalf("--interactive succeeded without a terminal:\n%s", r.out())
|
||||
}
|
||||
if !strings.Contains(r.stderr, "terminal") {
|
||||
t.Errorf("the refusal does not say what is missing:\n%s", r.out())
|
||||
}
|
||||
for _, flag := range []string{"--login", "--repo", "--mirror-hook"} {
|
||||
if !strings.Contains(r.stderr, flag) {
|
||||
t.Errorf("the refusal does not name %s, which is the way through:\n%s", flag, r.out())
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, ".kettle")); err == nil {
|
||||
t.Error("the refused run initialized the directory anyway")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/term"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
|
||||
)
|
||||
|
||||
// askInit fills in an initOptions by asking, and is the ONLY thing in this
|
||||
// binary that reads from a terminal.
|
||||
//
|
||||
// Two rules hold it in place, and both are about what it is not allowed to
|
||||
// become. It answers no question the flags cannot answer, so nothing here is a
|
||||
// capability that exists only behind a prompt; and it performs nothing itself —
|
||||
// it fills in the struct and hands it back, so the run that follows an
|
||||
// interactive session is byte for byte the run somebody else gets from flags.
|
||||
func askInit(opts *initOptions) error {
|
||||
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
return Fail("--interactive needs a terminal, and standard input is not one.\n" +
|
||||
"Every question it asks has a flag: --login, --repo, --scaffold-out, --no-scaffold, --mirror-hook.")
|
||||
}
|
||||
in := bufio.NewReader(os.Stdin)
|
||||
|
||||
fmt.Printf("kettle init — %s\n\n", opts.Root)
|
||||
if main := project.MainWorktree(opts.Root); main != "" {
|
||||
// Asked before anything else, because every answer after it would be
|
||||
// about a project that must not be created here.
|
||||
return Fail("%s is a linked worktree of the project at %s — initialize the main checkout instead", opts.Root, main)
|
||||
}
|
||||
|
||||
if err := askLogin(in, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := askRepo(in, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
opts.Scaffold = askYesNo(in, "Write the kettle commands and skills into "+
|
||||
relativeTo(opts.Root, opts.scaffoldOut())+"?", true)
|
||||
opts.MirrorHook = askYesNo(in, "Mirror AGENTS.md to CLAUDE.md in this project? "+
|
||||
"(registers `kettle mirror --hook` on PreToolUse)", false)
|
||||
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
// askLogin offers what the machine already holds, and never invents one.
|
||||
//
|
||||
// Adding a login is offered here rather than left to `kettle auth add` for one
|
||||
// reason: this is a person at a terminal, so the token can be read with the echo
|
||||
// turned off — no shell history, no temp file, no scrollback. That is strictly
|
||||
// better than every other way of getting a secret into this program, and it is
|
||||
// the single strongest argument for the wizard existing at all.
|
||||
func askLogin(in *bufio.Reader, opts *initOptions) error {
|
||||
logins, err := config.LoadLogins()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.Login != "" {
|
||||
fmt.Printf("login %s (given on the command line)\n", opts.Login)
|
||||
return nil
|
||||
}
|
||||
|
||||
names := logins.Names()
|
||||
fmt.Println("Logins on this machine:")
|
||||
if len(names) == 0 {
|
||||
fmt.Println(" (none)")
|
||||
}
|
||||
for i, l := range logins.Logins {
|
||||
fmt.Printf(" %d) %-16s %s\n", i+1, l.Name, l.URL)
|
||||
}
|
||||
fmt.Printf(" a) add one\n s) skip — pin a login later with `kettle init --login <name>`\n")
|
||||
|
||||
for {
|
||||
answer := strings.TrimSpace(ask(in, "Which login should this project run under?", "s"))
|
||||
switch strings.ToLower(answer) {
|
||||
case "s", "skip":
|
||||
return nil
|
||||
case "a", "add":
|
||||
name, err := addLogin(in, logins)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Login = name
|
||||
return nil
|
||||
}
|
||||
if n, err := strconv.Atoi(answer); err == nil && n >= 1 && n <= len(logins.Logins) {
|
||||
opts.Login = logins.Logins[n-1].Name
|
||||
return nil
|
||||
}
|
||||
// A name typed in full is the same answer as its number, and is what
|
||||
// somebody who already knows the login will reach for.
|
||||
if logins.Find(answer) != nil {
|
||||
opts.Login = answer
|
||||
return nil
|
||||
}
|
||||
fmt.Printf(" %q is not one of them.\n", answer)
|
||||
}
|
||||
}
|
||||
|
||||
// addLogin writes one entry into the machine-wide file, token and all.
|
||||
func addLogin(in *bufio.Reader, logins *config.Logins) (string, error) {
|
||||
name := required(in, " Name for this login (a label, not a username)")
|
||||
url := strings.TrimRight(required(in, " Instance URL, e.g. https://git.example.com"), "/")
|
||||
user := ask(in, " Account it belongs to (documentation only)", "")
|
||||
scopes := ask(in, " Scopes it was minted with (documentation only)", "write:issue,write:repository")
|
||||
|
||||
token, err := askSecret(" Token (not echoed)")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if token == "" {
|
||||
return "", Fail("no token given")
|
||||
}
|
||||
|
||||
entry := config.Login{
|
||||
Name: name,
|
||||
URL: url,
|
||||
User: user,
|
||||
Scopes: splitScopes(scopes),
|
||||
Token: token,
|
||||
}
|
||||
if existing := logins.Find(name); existing != nil {
|
||||
*existing = entry
|
||||
} else {
|
||||
logins.Logins = append(logins.Logins, entry)
|
||||
}
|
||||
if err := config.SaveLogins(logins); err != nil {
|
||||
return "", err
|
||||
}
|
||||
fmt.Printf(" %s -> %s %s\n\n", entry.Name, entry.URL, config.LoginsPath())
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// askRepo offers what the git remote says, because that is right nearly every
|
||||
// time and wrong in a way the operator can see immediately.
|
||||
func askRepo(in *bufio.Reader, opts *initOptions) error {
|
||||
if opts.Repo != "" {
|
||||
fmt.Printf("repo %s (given on the command line)\n", opts.Repo)
|
||||
return nil
|
||||
}
|
||||
guess := repoFromGitConfig(opts.Root)
|
||||
answer := strings.TrimSpace(ask(in, "Tracker repository, as owner/name", guess))
|
||||
if answer == "" {
|
||||
return nil
|
||||
}
|
||||
if owner, name, ok := strings.Cut(answer, "/"); !ok || owner == "" || name == "" {
|
||||
return Fail("%q is not owner/name", answer)
|
||||
}
|
||||
opts.Repo = answer
|
||||
return nil
|
||||
}
|
||||
|
||||
// repoFromGitConfig reads owner/name out of `origin` in .git/config, or "".
|
||||
//
|
||||
// Parsed rather than shelled out to, because `git` is one more thing that has to
|
||||
// be installed and one more process to fail in a way this has to interpret. A
|
||||
// guess is worth exactly what it costs, and this costs a file read: anything it
|
||||
// cannot make sense of is no guess at all, and the operator types the answer.
|
||||
func repoFromGitConfig(root string) string {
|
||||
raw, err := os.ReadFile(filepath.Join(root, ".git", "config"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
inOrigin := false
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "[") {
|
||||
inOrigin = strings.HasPrefix(line, `[remote "origin"]`)
|
||||
continue
|
||||
}
|
||||
if !inOrigin {
|
||||
continue
|
||||
}
|
||||
value, ok := strings.CutPrefix(line, "url")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, v, found := strings.Cut(value, "="); found {
|
||||
return ownerName(strings.TrimSpace(v))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ownerName is the last two path elements of a git URL, without any .git.
|
||||
// `git@host:owner/name.git` and `https://host/owner/name` both answer
|
||||
// `owner/name`; anything else answers "".
|
||||
func ownerName(url string) string {
|
||||
url = strings.TrimSuffix(strings.TrimSpace(url), ".git")
|
||||
if _, after, found := strings.Cut(url, "://"); found {
|
||||
url = after
|
||||
if _, path, ok := strings.Cut(url, "/"); ok {
|
||||
url = path
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
} else if _, after, found := strings.Cut(url, ":"); found {
|
||||
url = after
|
||||
}
|
||||
parts := strings.Split(strings.Trim(url, "/"), "/")
|
||||
if len(parts) < 2 {
|
||||
return ""
|
||||
}
|
||||
owner, name := parts[len(parts)-2], parts[len(parts)-1]
|
||||
if owner == "" || name == "" {
|
||||
return ""
|
||||
}
|
||||
return owner + "/" + name
|
||||
}
|
||||
|
||||
// ask prints a question and returns the answer, or def when the line is empty.
|
||||
func ask(in *bufio.Reader, question, def string) string {
|
||||
if def != "" {
|
||||
fmt.Printf("%s [%s]: ", question, def)
|
||||
} else {
|
||||
fmt.Printf("%s: ", question)
|
||||
}
|
||||
line, err := in.ReadString('\n')
|
||||
if err != nil && line == "" {
|
||||
return def
|
||||
}
|
||||
if answer := strings.TrimSpace(line); answer != "" {
|
||||
return answer
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func required(in *bufio.Reader, question string) string {
|
||||
for {
|
||||
if answer := ask(in, question, ""); answer != "" {
|
||||
return answer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// askYesNo is deliberately biased: the default is what an operator gets by
|
||||
// holding down return, so every prompt whose wrong answer costs something
|
||||
// defaults to no.
|
||||
func askYesNo(in *bufio.Reader, question string, def bool) bool {
|
||||
hint := "y/N"
|
||||
if def {
|
||||
hint = "Y/n"
|
||||
}
|
||||
for {
|
||||
fmt.Printf("%s [%s]: ", question, hint)
|
||||
line, err := in.ReadString('\n')
|
||||
if err != nil && line == "" {
|
||||
return def
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(line)) {
|
||||
case "":
|
||||
return def
|
||||
case "y", "yes":
|
||||
return true
|
||||
case "n", "no":
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// askSecret reads a line with the terminal's echo turned off.
|
||||
//
|
||||
// This is the whole reason --interactive needs a terminal rather than merely
|
||||
// preferring one: a token typed at a prompt that echoes is a token in somebody's
|
||||
// scrollback, and a token passed as an argument is a token in their shell
|
||||
// history. Neither is recoverable after the fact.
|
||||
func askSecret(question string) (string, error) {
|
||||
fmt.Print(question + ": ")
|
||||
raw, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Println()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(raw)), nil
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
sdk "code.gitea.io/sdk/gitea"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "labels",
|
||||
Group: GroupSync,
|
||||
Short: "put the canonical type/* and severity/* labels into a repository",
|
||||
Long: `Every ` + "`type/*`" + ` and every ` + "`severity/*`" + ` the domain taxonomy defines, created up
|
||||
front instead of trickling in as a side effect of whichever push first happens
|
||||
to use one. Until a name exists in the repository nobody can filter by it in the
|
||||
web UI, so somebody makes their own — foreign colour, no ` + "`exclusive`" + ` — and the
|
||||
set arrives in pieces over months.
|
||||
|
||||
NO LABEL NAME IS SPELLED OUT HERE. The names come from the domain taxonomy and
|
||||
are painted by the mapping layer, because a hex code is how a tracker paints a
|
||||
chip and not what an issue is. Add a type over in the domain and the next run
|
||||
creates it.
|
||||
|
||||
THE REPOSITORY'S OWN LABELS ARE READ BEFORE ANYTHING IS WRITTEN, and read from
|
||||
the repository, never from a cache — a cache answers "what did we create last
|
||||
time" and the question here is "what does this repository have right now". A
|
||||
name that matches exactly is left alone; a colour or ` + "`exclusive`" + ` that disagrees
|
||||
with the spec is reported, and corrected only under --fix. A name that merely
|
||||
RESEMBLES a canonical one (the same tail, up to case, separator and whatever
|
||||
namespace is in front: ` + "`x`" + `, ` + "`X`" + `, ` + "`kind/x`" + `, ` + "`type: x`" + ` against ` + "`type/x`" + `) is
|
||||
reported with its id and never touched — renaming somebody else's label is a
|
||||
decision, not a step.
|
||||
|
||||
Out of scope by design: ` + "`tech/*`" + ` and ` + "`comp/*`" + `, which are open-ended and are
|
||||
created by push as they come up, and deleting or renaming anything at all. Only
|
||||
repository labels are read; an organization's own labels sit behind a different
|
||||
endpoint and are neither read nor written.
|
||||
|
||||
The issue store is out of scope too, and not incidentally: a label belongs to
|
||||
the repository and not to any issue, so this neither reads the store nor creates
|
||||
it. Request bodies go to the transport's own scratchpad, which is a sibling of
|
||||
the store and never a child.`,
|
||||
Examples: []Example{
|
||||
{"kettle labels --dry-run", "print the plan; not one writing request"},
|
||||
{"kettle labels", "create whatever is missing"},
|
||||
{"kettle labels --fix", "also patch colour / exclusive drift"},
|
||||
{"kettle labels --repo owner/name", "bootstrap another repository"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
dryRun := fs.Bool("dry-run", false, "print the plan; not one writing request")
|
||||
fix := fs.Bool("fix", false, "also patch colour/exclusive on labels that already exist")
|
||||
repo := fs.String("repo", "", "repository to bootstrap, as owner/name (default: this project's)")
|
||||
|
||||
return func(args []string) error {
|
||||
if len(args) > 0 {
|
||||
return Fail("labels takes no arguments — the set comes from the taxonomy, not the command line")
|
||||
}
|
||||
// The store root is resolved and then deliberately dropped: this
|
||||
// command must fail the same way as every other sync command when
|
||||
// there is no project, and must touch no issue once there is one.
|
||||
_, client, err := syncStart("")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if *repo != "" {
|
||||
r, err := wire.ParseRepo(*repo)
|
||||
if err != nil {
|
||||
return Fail("--repo %v", err)
|
||||
}
|
||||
client = client.For(r)
|
||||
}
|
||||
|
||||
specs := mapping.CanonicalLabelSpecs()
|
||||
existing, err := client.ListLabels()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, similar := labelPlan(specs, existing)
|
||||
|
||||
created, fixed, drifted := 0, 0, 0
|
||||
for _, row := range rows {
|
||||
mark := ""
|
||||
if row.spec.Exclusive {
|
||||
mark = " exclusive"
|
||||
}
|
||||
|
||||
if row.got == nil {
|
||||
created++
|
||||
if *dryRun {
|
||||
fmt.Printf("create %-20s %s%s\n", row.spec.Name, row.spec.Color, mark)
|
||||
continue
|
||||
}
|
||||
made, err := client.CreateLabel(row.spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("created %-20s id %-5d %s%s\n", row.spec.Name, made.ID, row.spec.Color, mark)
|
||||
continue
|
||||
}
|
||||
if len(row.drift) == 0 {
|
||||
fmt.Printf("present %-20s id %d\n", row.spec.Name, row.got.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
drifted++
|
||||
shown := labelShowDrift(row.drift)
|
||||
if !*fix {
|
||||
fmt.Printf("present %-20s id %-5d drift: %s\n", row.spec.Name, row.got.ID, shown)
|
||||
continue
|
||||
}
|
||||
if *dryRun {
|
||||
fmt.Printf("fix %-20s id %-5d %s\n", row.spec.Name, row.got.ID, shown)
|
||||
continue
|
||||
}
|
||||
// The unchanged name rides along because a server that reads an
|
||||
// absent field as empty would blank it, and the description is
|
||||
// the repository's own: a description somebody rewrote is
|
||||
// theirs, and this run is about colour and exclusivity.
|
||||
patch := row.spec
|
||||
patch.Description = row.got.Description
|
||||
if _, err := client.EditLabel(row.got.ID, patch); err != nil {
|
||||
return err
|
||||
}
|
||||
fixed++
|
||||
fmt.Printf("fixed %-20s id %-5d %s\n", row.spec.Name, row.got.ID, shown)
|
||||
}
|
||||
|
||||
for _, s := range similar {
|
||||
fmt.Fprintf(os.Stderr, "warning: %q (id %d) resembles %s — left alone; rename it by hand or ignore it\n",
|
||||
s.name, s.id, strings.Join(s.hits, ", "))
|
||||
}
|
||||
|
||||
verb := "created"
|
||||
if *dryRun {
|
||||
verb = "to create"
|
||||
}
|
||||
line := fmt.Sprintf("%d canonical label(s): %d %s, %d present",
|
||||
len(rows), created, verb, len(rows)-created)
|
||||
if drifted > 0 {
|
||||
line += fmt.Sprintf(" (%d drifted, %d fixed)", drifted, fixed)
|
||||
}
|
||||
if len(similar) > 0 {
|
||||
line += fmt.Sprintf(", %d similar", len(similar))
|
||||
}
|
||||
fmt.Println(line)
|
||||
if drifted > 0 && !*fix {
|
||||
fmt.Println("drift is shown, not applied — re-run with --fix to patch colour/exclusive")
|
||||
}
|
||||
if *dryRun {
|
||||
fmt.Println("dry-run — nothing was written")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// labelRow is one canonical label, decided before anything is written: what the
|
||||
// taxonomy says it should be, what the repository already has under that exact
|
||||
// name (nil when it has nothing), and where the two disagree.
|
||||
type labelRow struct {
|
||||
spec sdk.CreateLabelOption
|
||||
got *sdk.Label
|
||||
drift []labelDiff
|
||||
}
|
||||
|
||||
// labelDiff is one field that disagrees, with both readings, so a receipt can
|
||||
// show the change without the caller re-deriving it.
|
||||
type labelDiff struct{ field, is, want string }
|
||||
|
||||
// labelLookalike is a label of the repository's own that resembles a canonical
|
||||
// name. Reported with its id and never touched.
|
||||
type labelLookalike struct {
|
||||
name string
|
||||
id int64
|
||||
hits []string
|
||||
}
|
||||
|
||||
// labelPlan pairs the canonical set with what the repository holds.
|
||||
//
|
||||
// In taxonomy order, because a bootstrap prints its plan in that order and a map
|
||||
// would shuffle it on every run — two identical runs would look like different
|
||||
// ones.
|
||||
func labelPlan(specs []sdk.CreateLabelOption, existing []*sdk.Label) ([]labelRow, []labelLookalike) {
|
||||
byName := make(map[string]*sdk.Label, len(existing))
|
||||
for _, l := range existing {
|
||||
byName[l.Name] = l
|
||||
}
|
||||
|
||||
canonical := make(map[string]map[string]bool, len(specs))
|
||||
rows := make([]labelRow, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
canonical[spec.Name] = labelAkin(spec.Name)
|
||||
row := labelRow{spec: spec, got: byName[spec.Name]}
|
||||
if row.got != nil {
|
||||
row.drift = labelDrift(spec, row.got)
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
var similar []labelLookalike
|
||||
for _, l := range existing {
|
||||
if _, exact := canonical[l.Name]; exact {
|
||||
continue
|
||||
}
|
||||
mine := labelAkin(l.Name)
|
||||
var hits []string
|
||||
for _, spec := range specs {
|
||||
if labelIntersects(canonical[spec.Name], mine) {
|
||||
hits = append(hits, spec.Name)
|
||||
}
|
||||
}
|
||||
if len(hits) > 0 {
|
||||
similar = append(similar, labelLookalike{name: l.Name, id: l.ID, hits: hits})
|
||||
}
|
||||
}
|
||||
return rows, similar
|
||||
}
|
||||
|
||||
// labelDrift is where an existing label disagrees with the spec.
|
||||
//
|
||||
// Colour and `exclusive` only. A description somebody rewrote is theirs, and the
|
||||
// name matched exactly or this row would not exist.
|
||||
func labelDrift(spec sdk.CreateLabelOption, got *sdk.Label) []labelDiff {
|
||||
var out []labelDiff
|
||||
if labelHex(got.Color) != labelHex(spec.Color) {
|
||||
out = append(out, labelDiff{"color", labelHex(got.Color), labelHex(spec.Color)})
|
||||
}
|
||||
if got.Exclusive != spec.Exclusive {
|
||||
out = append(out, labelDiff{"exclusive",
|
||||
fmt.Sprintf("%t", got.Exclusive), fmt.Sprintf("%t", spec.Exclusive)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// labelHex normalizes a colour for comparison. Gitea reports them bare
|
||||
// (`ee0701`) and the mapping layer writes them with a `#`; same colour, so a
|
||||
// comparison has to strip before it compares.
|
||||
func labelHex(v string) string { return strings.ToLower(strings.TrimPrefix(strings.TrimSpace(v), "#")) }
|
||||
|
||||
var labelWords = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// labelAkin is the comparison keys for a label name: its tail, and the whole
|
||||
// name squashed.
|
||||
//
|
||||
// Case, separators and the namespace in front are noise — what a person meant is
|
||||
// the tail. `x`, `X` and `kind/x` all reduce to the same tail as `type/x`, and
|
||||
// `severity: x y` to the same squashed form as `severity/xy`. Two names resemble
|
||||
// each other when these sets intersect.
|
||||
func labelAkin(name string) map[string]bool {
|
||||
var parts []string
|
||||
for _, p := range labelWords.Split(strings.ToLower(name), -1) {
|
||||
if p != "" {
|
||||
parts = append(parts, p)
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return nil
|
||||
}
|
||||
return map[string]bool{parts[len(parts)-1]: true, strings.Join(parts, ""): true}
|
||||
}
|
||||
|
||||
func labelIntersects(a, b map[string]bool) bool {
|
||||
for k := range a {
|
||||
if b[k] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func labelShowDrift(drift []labelDiff) string {
|
||||
var out []string
|
||||
for _, d := range drift {
|
||||
out = append(out, fmt.Sprintf("%s %s -> %s", d.field, d.is, d.want))
|
||||
}
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/mirror"
|
||||
)
|
||||
|
||||
// hookPayload is the part of a PreToolUse payload this command reads. Every
|
||||
// other field is somebody else's business and is ignored rather than rejected —
|
||||
// a payload that grows a key must not stop a Bash call.
|
||||
type hookPayload struct {
|
||||
CWD string `json:"cwd"`
|
||||
}
|
||||
|
||||
// hookOutput is what a PreToolUse hook says back. additionalContext is
|
||||
// advisory: it is shown, and it decides nothing.
|
||||
type hookOutput struct {
|
||||
HookSpecificOutput struct {
|
||||
HookEventName string `json:"hookEventName"`
|
||||
AdditionalContext string `json:"additionalContext"`
|
||||
} `json:"hookSpecificOutput"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "mirror",
|
||||
Group: GroupProject,
|
||||
Args: "[<dir>]",
|
||||
Short: "keep CLAUDE.md a symlink to AGENTS.md in every directory below here",
|
||||
Long: `Two agent harnesses read two different filenames for the same document. A
|
||||
repository that keeps both as real files keeps TWO DOCUMENTS, and they drift —
|
||||
silently, until somebody reads the stale one and believes it. This walks a tree
|
||||
and leaves one arrangement behind everywhere:
|
||||
|
||||
AGENTS.md is the real file; CLAUDE.md is a symlink pointing at it.
|
||||
|
||||
The link is relative, so a tree that is moved, copied or cloned keeps working.
|
||||
AGENTS.md is the real one because the convention is not one vendor's: a
|
||||
repository that names its documents after a single tool has picked a side it did
|
||||
not need to pick.
|
||||
|
||||
NOTHING HERE DELETES CONTENT. Six of the seven states it can find are repaired
|
||||
losslessly — a missing link is created, a reversed layout is swapped round, a
|
||||
duplicate whose bytes match its original is replaced by the link. The seventh,
|
||||
two real files whose contents DIFFER, is reported and left exactly as it was:
|
||||
one of them is somebody's writing and no rule here knows which.
|
||||
|
||||
It walks the directory given, or the working directory. node_modules, vendor,
|
||||
venv, __pycache__ and every dot-directory are skipped, because somebody else's
|
||||
tree is somebody else's business.
|
||||
|
||||
--hook is the PreToolUse form: it reads the hook payload on standard input,
|
||||
writes any report back as additionalContext, and ALWAYS EXITS 0 — including when
|
||||
it fails. A tool that broke somebody's Bash call because its documentation
|
||||
helper crashed would be worse than no tool. --check is the opposite end: it
|
||||
writes nothing and exits 1 when the tree is not canonical, which is what a
|
||||
pre-commit hook or a make target calls.
|
||||
|
||||
` + "`kettle init --interactive`" + ` offers to register the --hook form in
|
||||
.claude/settings.json. It is offered rather than assumed: this is one
|
||||
repository's documentation convention, and a project that does not keep AGENTS.md
|
||||
files wants nothing to do with it.`,
|
||||
Examples: []Example{
|
||||
{"kettle mirror", "repair the working directory and everything below it"},
|
||||
{"kettle mirror ~/code/x", "repair somewhere else"},
|
||||
{"kettle mirror --check", "exit 1 if anything is out of place; write nothing"},
|
||||
{"kettle mirror --hook", "the PreToolUse form; reads a payload, always exits 0"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
check := fs.Bool("check", false, "write nothing, exit 1 if the tree is not canonical")
|
||||
hook := fs.Bool("hook", false, "PreToolUse form: payload on stdin, report as additionalContext, always exit 0")
|
||||
quiet := fs.Bool("quiet", false, "repair without printing what was repaired")
|
||||
|
||||
return func(args []string) error {
|
||||
if len(args) > 1 {
|
||||
return Fail("give one directory, or none for the working directory")
|
||||
}
|
||||
explicit := ""
|
||||
if len(args) == 1 {
|
||||
explicit = args[0]
|
||||
}
|
||||
if *hook {
|
||||
runHook(explicit)
|
||||
return nil
|
||||
}
|
||||
|
||||
root, err := mirrorRoot(explicit, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var res mirror.Result
|
||||
if *check {
|
||||
res = mirror.Check(root)
|
||||
} else {
|
||||
res = mirror.Sync(root)
|
||||
}
|
||||
|
||||
if !*quiet {
|
||||
printMirror(os.Stdout, res, *check)
|
||||
}
|
||||
// A conflict is a state a person has to resolve, so --check
|
||||
// reports it as a failure. A repair run says so and carries on:
|
||||
// the six branches it could fix, it fixed.
|
||||
if *check && !res.Clean() {
|
||||
return SilentError{Code: 1}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// mirrorRoot decides which tree to walk.
|
||||
//
|
||||
// An explicit argument wins, then the harness's own idea of the project, then
|
||||
// the working directory. project.Root is deliberately NOT consulted: this
|
||||
// command has nothing to do with issues and must be usable in a tree that has
|
||||
// never seen `kettle init`.
|
||||
func mirrorRoot(explicit, payloadCWD string) (string, error) {
|
||||
for _, candidate := range []string{explicit, os.Getenv("CLAUDE_PROJECT_DIR"), payloadCWD} {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
abs, err := filepath.Abs(candidate)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if fi, err := os.Stat(abs); err == nil && fi.IsDir() {
|
||||
return abs, nil
|
||||
}
|
||||
if candidate == explicit {
|
||||
return "", Fail("%s is not a directory", explicit)
|
||||
}
|
||||
}
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return wd, nil
|
||||
}
|
||||
|
||||
func printMirror(w io.Writer, res mirror.Result, check bool) {
|
||||
prefix := ""
|
||||
if check {
|
||||
prefix = "would: "
|
||||
}
|
||||
for _, line := range res.Fixes {
|
||||
fmt.Fprintln(w, prefix+line)
|
||||
}
|
||||
for _, line := range res.Conflicts {
|
||||
fmt.Fprintln(w, "conflict: "+line)
|
||||
}
|
||||
if res.Clean() {
|
||||
fmt.Fprintln(w, "every AGENTS.md has its CLAUDE.md symlink — nothing to do")
|
||||
}
|
||||
}
|
||||
|
||||
// runHook is the PreToolUse form, and its whole contract is that it cannot fail.
|
||||
//
|
||||
// Every path here returns normally and the caller exits 0: an unreadable
|
||||
// payload, an unwritable tree, a bug in this function. Documentation maintenance
|
||||
// is not permitted to break somebody's build, so silence is the failure mode and
|
||||
// a report is the only output.
|
||||
func runHook(explicit string) {
|
||||
// The one recover in the tree, and it earns its place: this function runs
|
||||
// before every Bash call in every project the hook is registered in, and a
|
||||
// panic here would surface as a failed tool call rather than as a bug in a
|
||||
// documentation helper.
|
||||
defer func() { _ = recover() }()
|
||||
|
||||
var payload hookPayload
|
||||
if raw, err := io.ReadAll(os.Stdin); err == nil && len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &payload)
|
||||
}
|
||||
|
||||
root, err := mirrorRoot(explicit, payload.CWD)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res := mirror.Sync(root)
|
||||
if res.Clean() {
|
||||
return // silence means the tree was already canonical
|
||||
}
|
||||
|
||||
var parts []string
|
||||
if len(res.Fixes) > 0 {
|
||||
parts = append(parts, "kettle mirror fixed:\n "+strings.Join(res.Fixes, "\n "))
|
||||
}
|
||||
if len(res.Conflicts) > 0 {
|
||||
parts = append(parts, "kettle mirror needs manual resolution:\n "+strings.Join(res.Conflicts, "\n "))
|
||||
}
|
||||
|
||||
var out hookOutput
|
||||
out.HookSpecificOutput.HookEventName = "PreToolUse"
|
||||
out.HookSpecificOutput.AdditionalContext = strings.Join(parts, "\n")
|
||||
if encoded, err := json.Marshal(out); err == nil {
|
||||
fmt.Println(string(encoded))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package cmd_test
|
||||
|
||||
// `kettle mirror` is the one command in the tree that has nothing to do with
|
||||
// issues, and the one that must never fail a caller. Both are tested here.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeDoc(t *testing.T, dir, name, body string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func linkTarget(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
target, err := os.Readlink(path)
|
||||
if err != nil {
|
||||
t.Fatalf("%s is not a symlink: %v", path, err)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
// The tree it walks is the working directory, not a project: this must work in
|
||||
// a directory that has never seen `kettle init`, because the convention it
|
||||
// maintains has nothing to do with issues.
|
||||
func TestMirrorNeedsNoProject(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeDoc(t, dir, "AGENTS.md", "root\n")
|
||||
writeDoc(t, filepath.Join(dir, "cli"), "AGENTS.md", "cli\n")
|
||||
|
||||
r := mustRun(t, dir, "mirror")
|
||||
if strings.Contains(r.out(), ".kettle") {
|
||||
t.Errorf("mirror asked for a project:\n%s", r.out())
|
||||
}
|
||||
for _, sub := range []string{".", "cli"} {
|
||||
if got := linkTarget(t, filepath.Join(dir, sub, "CLAUDE.md")); got != "AGENTS.md" {
|
||||
t.Errorf("%s/CLAUDE.md points at %q, want a relative AGENTS.md", sub, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Idempotent, and it says so rather than printing nothing at all.
|
||||
again := mustRun(t, dir, "mirror")
|
||||
if !strings.Contains(again.stdout, "nothing to do") {
|
||||
t.Errorf("a canonical tree did not report itself clean:\n%s", again.out())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMirrorTakesADirectoryArgument(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
elsewhere := filepath.Join(dir, "elsewhere")
|
||||
writeDoc(t, elsewhere, "AGENTS.md", "there\n")
|
||||
|
||||
mustRun(t, dir, "mirror", elsewhere)
|
||||
if _, err := os.Lstat(filepath.Join(elsewhere, "CLAUDE.md")); err != nil {
|
||||
t.Errorf("the named directory was not repaired: %v", err)
|
||||
}
|
||||
|
||||
if r := run(t, dir, "mirror", filepath.Join(dir, "nowhere")); r.code == 0 {
|
||||
t.Errorf("a directory that is not there must be refused:\n%s", r.out())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMirrorCheckWritesNothingAndFailsLoudly(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeDoc(t, dir, "AGENTS.md", "root\n")
|
||||
|
||||
r := run(t, dir, "mirror", "--check")
|
||||
if r.code != 1 {
|
||||
t.Fatalf("--check exited %d on a tree with work to do, want 1:\n%s", r.code, r.out())
|
||||
}
|
||||
if !strings.Contains(r.stdout, "would: ") {
|
||||
t.Errorf("--check did not say what it would do:\n%s", r.out())
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(dir, "CLAUDE.md")); err == nil {
|
||||
t.Error("--check wrote to the tree it was asked about")
|
||||
}
|
||||
|
||||
mustRun(t, dir, "mirror")
|
||||
if r := run(t, dir, "mirror", "--check"); r.code != 0 {
|
||||
t.Errorf("--check exited %d on a canonical tree:\n%s", r.code, r.out())
|
||||
}
|
||||
}
|
||||
|
||||
// The refusal, end to end. One of these two files is somebody's writing and no
|
||||
// rule in this binary knows which, so both survive and the operator is told.
|
||||
func TestMirrorRefusesTwoDifferentRealFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeDoc(t, dir, "AGENTS.md", "mine\n")
|
||||
writeDoc(t, dir, "CLAUDE.md", "theirs\n")
|
||||
|
||||
r := mustRun(t, dir, "mirror")
|
||||
if !strings.Contains(r.stdout, "conflict:") || !strings.Contains(r.stdout, "merge manually") {
|
||||
t.Errorf("the conflict was not reported:\n%s", r.out())
|
||||
}
|
||||
if got := readFile(t, filepath.Join(dir, "AGENTS.md")); got != "mine\n" {
|
||||
t.Errorf("AGENTS.md was changed: %q", got)
|
||||
}
|
||||
if got := readFile(t, filepath.Join(dir, "CLAUDE.md")); got != "theirs\n" {
|
||||
t.Errorf("CLAUDE.md was changed: %q", got)
|
||||
}
|
||||
// A repair run reports the conflict and still exits 0 — the six branches it
|
||||
// could fix, it fixed. --check is the one that turns it into a failure.
|
||||
if r := run(t, dir, "mirror", "--check"); r.code != 1 {
|
||||
t.Errorf("--check exited %d on a conflict, want 1", r.code)
|
||||
}
|
||||
}
|
||||
|
||||
// The hook form's whole contract: it cannot fail a Bash call. Every one of these
|
||||
// is a payload or a state that could plausibly arrive, and every one exits 0.
|
||||
func TestMirrorHookAlwaysExitsZero(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
stdin string
|
||||
setup func(t *testing.T, dir string)
|
||||
}{
|
||||
{name: "no payload at all", stdin: ""},
|
||||
{name: "a payload that is not JSON", stdin: "not json at all"},
|
||||
{name: "an empty object", stdin: "{}"},
|
||||
{name: "a payload with keys it does not know", stdin: `{"cwd":".","tool_name":"Bash","future":{"x":1}}`},
|
||||
{
|
||||
name: "a tree it cannot resolve",
|
||||
stdin: `{"cwd":"/nowhere/at/all"}`,
|
||||
setup: func(t *testing.T, dir string) { writeDoc(t, dir, "AGENTS.md", "root\n") },
|
||||
},
|
||||
{
|
||||
name: "a conflict it must not resolve",
|
||||
stdin: `{}`,
|
||||
setup: func(t *testing.T, dir string) {
|
||||
writeDoc(t, dir, "AGENTS.md", "mine\n")
|
||||
writeDoc(t, dir, "CLAUDE.md", "theirs\n")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if tc.setup != nil {
|
||||
tc.setup(t, dir)
|
||||
}
|
||||
r := runWith(t, dir, nil, tc.stdin, "mirror", "--hook")
|
||||
if r.code != 0 {
|
||||
t.Errorf("exited %d — a documentation helper may not break a Bash call:\n%s", r.code, r.out())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// What it says when it did something, and what it says when it did not.
|
||||
func TestMirrorHookReportsOnlyWhenThereIsSomethingToSay(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeDoc(t, dir, "AGENTS.md", "root\n")
|
||||
|
||||
r := runWith(t, dir, nil, "{}", "mirror", "--hook")
|
||||
var out struct {
|
||||
HookSpecificOutput struct {
|
||||
HookEventName string `json:"hookEventName"`
|
||||
AdditionalContext string `json:"additionalContext"`
|
||||
} `json:"hookSpecificOutput"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(r.stdout), &out); err != nil {
|
||||
t.Fatalf("the hook did not emit JSON: %v\n%s", err, r.out())
|
||||
}
|
||||
if out.HookSpecificOutput.HookEventName != "PreToolUse" {
|
||||
t.Errorf("hookEventName is %q", out.HookSpecificOutput.HookEventName)
|
||||
}
|
||||
if !strings.Contains(out.HookSpecificOutput.AdditionalContext, "CLAUDE.md") {
|
||||
t.Errorf("the report says nothing about what it did:\n%s", out.HookSpecificOutput.AdditionalContext)
|
||||
}
|
||||
|
||||
// Silence means the tree was already canonical. A hook that spoke on every
|
||||
// Bash call would be noise in every transcript.
|
||||
quiet := runWith(t, dir, nil, "{}", "mirror", "--hook")
|
||||
if strings.TrimSpace(quiet.stdout) != "" {
|
||||
t.Errorf("a canonical tree still produced output:\n%s", quiet.stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// CLAUDE_PROJECT_DIR is the harness's own answer to "which tree", and it wins
|
||||
// over the payload's cwd — a Bash call made from a subdirectory must still
|
||||
// repair the whole project rather than the corner it was made in.
|
||||
func TestMirrorHookPrefersTheProjectDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sub := filepath.Join(dir, "deep", "inside")
|
||||
writeDoc(t, dir, "AGENTS.md", "root\n")
|
||||
writeDoc(t, sub, "AGENTS.md", "inside\n")
|
||||
|
||||
r := runWith(t, sub, []string{"CLAUDE_PROJECT_DIR=" + dir}, `{"cwd":"`+sub+`"}`, "mirror", "--hook")
|
||||
if r.code != 0 {
|
||||
t.Fatalf("exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(dir, "CLAUDE.md")); err != nil {
|
||||
t.Errorf("the project root was not repaired: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "new",
|
||||
Group: GroupIssue,
|
||||
Short: "create a local issue from its type template",
|
||||
Long: `The issue is real the moment this writes the file. Nothing is pending, nothing
|
||||
is a draft awaiting a tracker: ` + "`origin: local`" + ` is a complete state and pushing it
|
||||
later is optional.
|
||||
|
||||
While it says local, this file is the ONLY copy of the work — the store, not a
|
||||
cache of anything. That is what a push changes: it hands the issue to the
|
||||
tracker and removes the file.
|
||||
|
||||
Writes .kettle/issues/<slug>.md prefilled with the type's template, prints the
|
||||
path, and rebuilds INDEX.md. Fill the sections in an editor, then run
|
||||
` + "`kettle check <id>`" + `.
|
||||
|
||||
Body prose is Russian, section headers and the title are English.`,
|
||||
Examples: []Example{
|
||||
{`kettle new --type task --title "Wire sqlc into the appclick repo layer" --label tech/sql --label comp/appclick`,
|
||||
"a task with two free-form labels"},
|
||||
{`kettle new --type bug --title "Fix the index rebuild on an empty store" --depends wire-sqlc-appclick --milestone v0.2`,
|
||||
"a bug that is blocked by another issue"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
typ := fs.String("type", "", "issue type, one of: "+strings.Join(issue.TypeNames(), ", ")+" (becomes the exclusive type/* label)")
|
||||
title := fs.String("title", "", "English, imperative, no type prefix")
|
||||
id := fs.String("id", "", "slug (default: derived from the title)")
|
||||
severity := fs.String("severity", "", "severity/* label, one of: "+strings.Join(issue.Severities, ", "))
|
||||
milestone := fs.String("milestone", "", "milestone title")
|
||||
var labels, assignees, depends stringList
|
||||
fs.Var(&labels, "label", "extra label, e.g. tech/sql; repeat")
|
||||
fs.Var(&assignees, "assignee", "assignee login; repeat")
|
||||
fs.Var(&depends, "depends", "id this issue depends on; repeat")
|
||||
out := storeFlag(fs)
|
||||
|
||||
return func(args []string) error {
|
||||
if *typ == "" || *title == "" {
|
||||
return Fail("--type and --title are both required")
|
||||
}
|
||||
if !issue.KnownType(*typ) {
|
||||
return Fail("unknown --type %q — known: %s", *typ, strings.Join(issue.TypeNames(), ", "))
|
||||
}
|
||||
if *severity != "" && !issue.KnownSeverity(*severity) {
|
||||
return Fail("unknown --severity %q — known: %s", *severity, strings.Join(issue.Severities, ", "))
|
||||
}
|
||||
// Before anything reads the store path. There is no store to be
|
||||
// second-guessed about when there is no project.
|
||||
root, err := storeRoot(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
labelSet := []string{"type/" + *typ}
|
||||
if *severity != "" {
|
||||
labelSet = append(labelSet, "severity/"+*severity)
|
||||
}
|
||||
for _, l := range labels {
|
||||
if !contains(labelSet, l) {
|
||||
labelSet = append(labelSet, l)
|
||||
}
|
||||
}
|
||||
|
||||
slug := *id
|
||||
if slug == "" {
|
||||
if slug, err = issue.UniqueID(root, issue.Slugify(*title, 0), nil); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !issue.IsSlug(slug) {
|
||||
return Fail("--id %q is not a slug (lowercase, digits, single dashes)", slug)
|
||||
}
|
||||
if _, err := os.Stat(issue.PathOf(root, slug)); err == nil {
|
||||
return Fail("%s already exists", issue.PathOf(root, slug))
|
||||
}
|
||||
|
||||
known := map[string]bool{}
|
||||
for _, k := range issue.AllIDs(root) {
|
||||
known[k] = true
|
||||
}
|
||||
for _, d := range depends {
|
||||
if !known[d] {
|
||||
fmt.Fprintf(os.Stderr, "warning: depends on %q, which is not in the store yet\n", d)
|
||||
}
|
||||
}
|
||||
|
||||
i := &issue.Issue{
|
||||
ID: slug,
|
||||
Title: *title,
|
||||
Body: issue.Template(*typ, depends),
|
||||
State: "open",
|
||||
Labels: labelSet,
|
||||
Assignees: assignees,
|
||||
Milestone: *milestone,
|
||||
Depends: depends,
|
||||
Origin: issue.Local,
|
||||
}
|
||||
|
||||
// The first issue in a fresh checkout has to create the store,
|
||||
// but it says so — and it says where, because the path is
|
||||
// absolute.
|
||||
created, err := issue.CreateStore(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if created {
|
||||
abs, _ := filepath.Abs(root)
|
||||
fmt.Fprintf(os.Stderr, "created store %s\n", abs)
|
||||
}
|
||||
|
||||
path, err := issue.Save(root, i)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := issue.BuildIndex(root); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s [type/%s] %s\n", path, *typ, *title)
|
||||
fmt.Printf("fill the sections, then: kettle check %s\n", slug)
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func contains(xs []string, x string) bool {
|
||||
for _, v := range xs {
|
||||
if v == x {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "code.gitea.io/sdk/gitea"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "pull",
|
||||
Group: GroupSync,
|
||||
Args: "[<key>…]",
|
||||
Short: "fetch issues from the tracker into the local store",
|
||||
Long: `THIS IS HOW A PUSHED ISSUE COMES BACK. ` + "`kettle push`" + ` deletes the local file the
|
||||
moment the tracker confirms the write, so a pull is not a refresh of a copy you
|
||||
kept — it is how the copy comes to exist at all.
|
||||
|
||||
It lands under the SAME slug it had before, after a rename in the web UI and on
|
||||
a machine that has never seen the issue. Three sources answer "what is this
|
||||
issue called here", in this order:
|
||||
|
||||
.remote.json the number -> slug ledger; the only one that knows what
|
||||
is on disk right now, so it wins
|
||||
<!-- kettle:id … --> the marker in the tracker-side body; it survives a lost
|
||||
ledger, a fresh clone, another machine, and a retitling
|
||||
the title slugified — where an issue filed in the web UI gets its
|
||||
first local name
|
||||
|
||||
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 rather than allowed to
|
||||
overwrite somebody else's issue. The marker itself is stripped out of what lands
|
||||
on disk.
|
||||
|
||||
TWO WAYS TO NAME WHAT TO PULL, and they are not the same operation:
|
||||
|
||||
kettle pull 42 #43 owner/repo#44 by key — an ADDRESS
|
||||
kettle pull --milestone v0.2 by filter — a QUERY
|
||||
|
||||
A key fetches an issue in ANY state, because a number is an address and not a
|
||||
question about state. Only filter mode leaves closed issues out — a closed issue
|
||||
is not a unit of work — and only ` + "`--state closed`" + ` puts one in the store. An issue
|
||||
already on disk is refreshed either way, so a local copy learns it was closed
|
||||
instead of staying open forever, and the count that stayed out goes to stderr.
|
||||
|
||||
` + "`--limit`" + ` IS ON THE WRITE, NOT ON THE SELECTION. It counts the issues this run puts
|
||||
in the store and never the closed ones it enumerated and threw away, so pages
|
||||
keep coming until the budget is full — and stop the moment it is. A filter that
|
||||
matches almost only closed issues ends in a warning and a short answer rather
|
||||
than a walk of the whole tracker.
|
||||
|
||||
A PULL RETURNS THE UNIT OF WORK, NOT ONE ROW OF IT. ` + "`depends:`" + ` is filled from the
|
||||
tracker's own dependency graph and every blocker comes down with it, recursively,
|
||||
to --depth. What that costs, stated rather than hidden: one request per issue
|
||||
that lands in the store, plus one per blocker the selection did not already
|
||||
carry. ` + "`--no-deps`" + ` is the way back to one request, and narrows the answer to the
|
||||
one issue you asked for. Dependencies are outside --limit: a blocker is followed
|
||||
because a stored issue named it, not because the filter selected it, so a
|
||||
filtered pull can leave more files behind than its limit — including one from
|
||||
another milestone. The one blocker that does not land is a closed one.
|
||||
|
||||
PULLING OVERWRITES THE BODY: a fetch, not a merge. Local edits you have not
|
||||
pushed are lost, with exactly one exception — checkbox state. A tick is monotone,
|
||||
so a ` + "`[x]`" + ` on either side wins for any item whose text matches; unticking is not,
|
||||
so untick locally and push. ` + "`--cached`" + ` skips an issue before any of that.
|
||||
|
||||
Comments ride along: the thread lands beside the issue in <id>.comments.md. It
|
||||
costs no request when the payload says there are none, and a file left over from
|
||||
an earlier pull is deleted — so no file means "no comments", never "not asked
|
||||
for". The thread is pull-only; post with ` + "`kettle comment`" + `.`,
|
||||
Examples: []Example{
|
||||
{"kettle pull 42", "the issue and everything blocking it, in any state"},
|
||||
{"kettle pull 42 --no-deps", "just that one issue — one request"},
|
||||
{"kettle pull owner/repo#42", "an issue in another repository"},
|
||||
{"kettle pull --milestone v0.2 --limit 20", "20 open issues from a milestone, blockers included"},
|
||||
{"kettle pull --label type/bug --state all", "every bug; the closed ones are enumerated, not stored"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
milestone := fs.String("milestone", "", "pull a whole milestone (id or title)")
|
||||
var labels stringList
|
||||
fs.Var(&labels, "label", "filter by label; repeat for AND")
|
||||
// Both spellings, because both are what somebody has in hand: `-q` is
|
||||
// what a person types and `--query` is what a script reads back.
|
||||
var query string
|
||||
fs.StringVar(&query, "q", "", "search text in title and body")
|
||||
fs.StringVar(&query, "query", "", "the long spelling of -q")
|
||||
state := fs.String("state", "open", "filter mode only: open, closed or all")
|
||||
limit := fs.Int("limit", 100, "filter mode: how many issues to STORE, not to enumerate")
|
||||
noDeps := fs.Bool("no-deps", false, "do not fill depends: and do not follow blockers")
|
||||
depth := fs.Int("depth", 3, "how deep to follow blockers")
|
||||
cached := fs.Bool("cached", false, "skip issues already on disk instead of refetching")
|
||||
out := storeFlag(fs)
|
||||
|
||||
return func(args []string) error {
|
||||
filtered := *milestone != "" || len(labels) > 0 || query != ""
|
||||
switch {
|
||||
case len(args) > 0 && filtered:
|
||||
return Fail("pass issue keys OR filters, not both")
|
||||
case len(args) == 0 && !filtered:
|
||||
return Fail("nothing to pull: pass an issue key, or --milestone / --label / -q")
|
||||
case !contains([]string{"open", "closed", "all"}, *state):
|
||||
return Fail("--state %q must be open, closed or all", *state)
|
||||
case *limit < 1:
|
||||
return Fail("--limit must be 1 or more, got %d", *limit)
|
||||
case *depth < 0:
|
||||
return Fail("--depth must be 0 or more, got %d", *depth)
|
||||
}
|
||||
|
||||
// Keys are parsed before anything is opened: a typo in a key is
|
||||
// not a network problem and must not be reported as one.
|
||||
keys, named, err := pullKeys(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
root, client, err := syncStart(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A key may name its own repository; the project's is the
|
||||
// fallback, never an override.
|
||||
if !named.Zero() {
|
||||
client = client.For(named)
|
||||
}
|
||||
repo := client.Repo()
|
||||
|
||||
// A first pull into a fresh checkout has to create the store, and
|
||||
// it says so — with an absolute path, so it cannot be a stray
|
||||
// working directory.
|
||||
created, err := issue.CreateStore(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if created {
|
||||
abs, _ := filepath.Abs(root)
|
||||
fmt.Fprintf(os.Stderr, "created store %s\n", abs)
|
||||
}
|
||||
|
||||
issues, err := issue.LoadAll(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ledger := loadLedgerOrFold(root, issues)
|
||||
namer := &pullNamer{root: root, repo: repo, ledger: ledger, taken: map[string]bool{}}
|
||||
for id := range issues {
|
||||
namer.taken[id] = true
|
||||
}
|
||||
// What the ledger already knows, so a `#N` in a body resolves to
|
||||
// a slug this run never fetched.
|
||||
numberOf := map[int]string{}
|
||||
for raw, slug := range ledger {
|
||||
if k, err := wire.ParseKey(raw); err == nil && k.Repo == repo {
|
||||
numberOf[k.Number] = slug
|
||||
}
|
||||
}
|
||||
|
||||
// A closed issue is not a unit of work: filter mode enumerates it
|
||||
// and keeps it out of the store unless the operator named the
|
||||
// state. A key is an address, not a bulk read, so key mode is
|
||||
// exempt.
|
||||
dropClosed := filtered && *state != "closed"
|
||||
|
||||
queue, err := pullSeed(client, keys, filtered, gitea.IssueFilter{
|
||||
State: *state, Labels: labels, Query: query, Milestone: *milestone,
|
||||
Limit: *limit,
|
||||
Keep: func(p *sdk.Issue) bool {
|
||||
return pullLandsInStore(p, dropClosed, namer)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var written, skipped []string
|
||||
var dropped []int
|
||||
type unresolved struct {
|
||||
id string
|
||||
numbers []int
|
||||
}
|
||||
var pending []unresolved
|
||||
|
||||
seen := map[int]bool{}
|
||||
for _, t := range queue {
|
||||
seen[int(t.payload.Index)] = true
|
||||
}
|
||||
|
||||
for len(queue) > 0 {
|
||||
task := queue[0]
|
||||
queue = queue[1:]
|
||||
p := task.payload
|
||||
// The SDK spells an issue number `Index`, and int64. It is
|
||||
// an int everywhere on this side of the transport — in the
|
||||
// ledger, in a key, in `depends:` — so it is narrowed once,
|
||||
// here, rather than cast at every use.
|
||||
number := int(p.Index)
|
||||
id, err := namer.idFor(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stored := pullStored(root, id)
|
||||
|
||||
// Closed and not already ours: nothing is written and nothing
|
||||
// is asked of the server for it — not its comments, not its
|
||||
// links, and its blockers are not followed. The slug stays
|
||||
// unclaimed too, so no other issue ends up pointing
|
||||
// `depends:` at a file that is not there.
|
||||
if dropClosed && p.State == sdk.StateClosed && !stored {
|
||||
dropped = append(dropped, number)
|
||||
continue
|
||||
}
|
||||
|
||||
namer.taken[id] = true
|
||||
numberOf[number] = id
|
||||
|
||||
// The native links, fetched ONCE for the two things they are
|
||||
// for: filling this issue's `depends:` and telling the walk
|
||||
// where to go next. One request per issue that lands in the
|
||||
// store, and only one.
|
||||
var blockers []int
|
||||
if !*noDeps {
|
||||
if blockers, err = pullBlockers(client, number, repo); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if *cached && stored {
|
||||
skipped = append(skipped, id) // body and thread unread; only the links cost
|
||||
} else {
|
||||
// The copy already on disk, as it was when this run
|
||||
// started. It contributes its ticked checkboxes and
|
||||
// nothing else.
|
||||
local := ""
|
||||
if prev := issues[id]; prev != nil {
|
||||
local = prev.Body
|
||||
}
|
||||
next, missing := mapping.FromPayload(p, id, repo, mapping.PayloadOptions{
|
||||
IDForNumber: numberOf,
|
||||
ExtraNumbers: blockers,
|
||||
// The clock is the caller's: mapping is a pure layer
|
||||
// and a package with a clock in it is not one.
|
||||
Synced: time.Now().UTC().Format(time.RFC3339),
|
||||
LocalBody: local,
|
||||
})
|
||||
if _, err := issue.Save(root, next); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := pullSyncComments(client, root, id, number, p.Comments); err != nil {
|
||||
return err
|
||||
}
|
||||
ledger.Set(wire.Key{Repo: repo, Number: number}, id)
|
||||
written = append(written, id)
|
||||
pending = append(pending, unresolved{id, missing})
|
||||
}
|
||||
|
||||
if *noDeps || task.depth >= *depth {
|
||||
continue
|
||||
}
|
||||
for _, n := range append(mapping.NumbersInBody(p.Body), blockers...) {
|
||||
if seen[n] {
|
||||
continue
|
||||
}
|
||||
seen[n] = true
|
||||
child, err := client.GetIssue(n)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
queue = append(queue, pullTask{payload: child, depth: task.depth + 1})
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing is dropped in silence.
|
||||
if len(dropped) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "%d closed issue(s) enumerated, not stored"+
|
||||
" (--state closed to pull them)\n", len(dropped))
|
||||
}
|
||||
|
||||
// Second pass: a `#N` that named an issue this run had not written
|
||||
// yet. The first write could not resolve it to a slug; by now the
|
||||
// file it names is on disk.
|
||||
for _, u := range pending {
|
||||
var newly []string
|
||||
for _, n := range u.numbers {
|
||||
if slug := numberOf[n]; slug != "" && slug != u.id {
|
||||
newly = append(newly, slug)
|
||||
}
|
||||
}
|
||||
if len(newly) == 0 {
|
||||
continue
|
||||
}
|
||||
i, err := issue.Load(root, u.id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, slug := range newly {
|
||||
if !contains(i.Depends, slug) {
|
||||
i.Depends = append(i.Depends, slug)
|
||||
}
|
||||
}
|
||||
if _, err := issue.Save(root, i); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := ledger.Save(root); err != nil {
|
||||
return err
|
||||
}
|
||||
indexPath, _, err := issue.BuildIndex(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return pullReceipt(root, written, skipped, indexPath)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// pullTask is one issue to walk, and how far from a seed it was found.
|
||||
type pullTask struct {
|
||||
payload *sdk.Issue
|
||||
depth int
|
||||
}
|
||||
|
||||
// pullKeys parses the positional arguments and the one repository they may name.
|
||||
//
|
||||
// All of them or none: a run addresses one repository, because the client, the
|
||||
// ledger keys and the `gitea:` field all have to agree about which one.
|
||||
func pullKeys(args []string) ([]wire.Key, wire.Repo, error) {
|
||||
var keys []wire.Key
|
||||
var named wire.Repo
|
||||
for _, a := range args {
|
||||
k, err := wire.ParseKey(a)
|
||||
if err != nil {
|
||||
return nil, wire.Repo{}, err
|
||||
}
|
||||
if !k.Repo.Zero() {
|
||||
if !named.Zero() && named != k.Repo {
|
||||
return nil, wire.Repo{}, Fail("all keys must name one repository, got %s and %s", named, k.Repo)
|
||||
}
|
||||
named = k.Repo
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys, named, nil
|
||||
}
|
||||
|
||||
// pullSeed is what the walk starts from: the issues a key addresses, or the ones
|
||||
// a filter selected.
|
||||
func pullSeed(c *gitea.Client, keys []wire.Key, filtered bool, f gitea.IssueFilter) ([]pullTask, error) {
|
||||
if !filtered {
|
||||
out := make([]pullTask, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
p, err := c.GetIssue(k.Number)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, pullTask{payload: p})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
listing, err := c.ListIssues(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(listing.Issues) == 0 {
|
||||
return nil, Fail("no issues match that filter")
|
||||
}
|
||||
if listing.Warning != "" {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s\n", listing.Warning)
|
||||
}
|
||||
|
||||
var what []string
|
||||
if listing.Milestone != "" {
|
||||
what = append(what, "milestone "+listing.Milestone)
|
||||
}
|
||||
for _, l := range f.Labels {
|
||||
what = append(what, "label "+l)
|
||||
}
|
||||
if f.Query != "" {
|
||||
what = append(what, fmt.Sprintf("q=%q", f.Query))
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "%d issue(s) match %s (%s)\n",
|
||||
len(listing.Issues), strings.Join(what, " + "), f.State)
|
||||
|
||||
out := make([]pullTask, 0, len(listing.Issues))
|
||||
for _, p := range listing.Issues {
|
||||
out = append(out, pullTask{payload: p})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pullLandsInStore is the --limit predicate: would this payload leave a file in
|
||||
// the store?
|
||||
//
|
||||
// It has to be the same test the walk applies, or the budget is spent on issues
|
||||
// that never land — which is the bug it exists to prevent. A closed issue counts
|
||||
// only when the store already has it (it is refreshed, and that is a write);
|
||||
// anything else counts, including one --cached will skip, because a skipped
|
||||
// issue is still an issue the store holds when the run ends.
|
||||
func pullLandsInStore(p *sdk.Issue, dropClosed bool, namer *pullNamer) bool {
|
||||
if !dropClosed || p.State != sdk.StateClosed {
|
||||
return true
|
||||
}
|
||||
id, err := namer.idFor(p)
|
||||
if err != nil {
|
||||
// An id that cannot be allocated is the walk's failure to report, not a
|
||||
// reason to spend the page budget differently.
|
||||
return true
|
||||
}
|
||||
return pullStored(namer.root, id)
|
||||
}
|
||||
|
||||
// pullBlockers is the numbers of the issues that block this one, in this
|
||||
// repository.
|
||||
//
|
||||
// A blocker in ANOTHER repository is dropped here, and deliberately: everything
|
||||
// downstream — `depends:`, the number -> slug ledger, the walk's own GETs — reads
|
||||
// a bare number against the repository being pulled, so a foreign number would
|
||||
// either resolve to the wrong issue or invent an edge. The body still names it,
|
||||
// so nothing is lost.
|
||||
func pullBlockers(c *gitea.Client, number int, repo wire.Repo) ([]int, error) {
|
||||
keys, err := c.DependencyKeys(number)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []int
|
||||
for _, k := range keys {
|
||||
if k.Repo == repo {
|
||||
out = append(out, k.Number)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pullSyncComments brings <id>.comments.md in line with the tracker and returns
|
||||
// its path, or "" when the issue has no thread.
|
||||
//
|
||||
// count is the payload's own comment count, so an issue with none costs no
|
||||
// request. A file from an earlier pull is removed when the thread is empty: the
|
||||
// absence of the file is the answer, not a gap in what was asked for.
|
||||
func pullSyncComments(c *gitea.Client, root, id string, number, count int) (string, error) {
|
||||
path := commentsSidecarPath(root, id)
|
||||
var thread []*sdk.Comment
|
||||
if count > 0 {
|
||||
var err error
|
||||
if thread, err = c.ListComments(number); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if len(thread) > 0 {
|
||||
if err := os.WriteFile(path, []byte(mapping.RenderComments(thread)), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return "", err
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// pullReceipt is the only thing that lands in a reader's context: one line per
|
||||
// issue, the raw payload nowhere.
|
||||
func pullReceipt(root string, written, skipped []string, indexPath string) error {
|
||||
cached := map[string]bool{}
|
||||
all := map[string]bool{}
|
||||
for _, id := range written {
|
||||
all[id] = true
|
||||
}
|
||||
for _, id := range skipped {
|
||||
cached[id], all[id] = true, true
|
||||
}
|
||||
ids := make([]string, 0, len(all))
|
||||
for id := range all {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
|
||||
graph := false
|
||||
for _, id := range ids {
|
||||
i, err := issue.Load(root, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
graph = graph || len(i.Depends) > 0
|
||||
note := ""
|
||||
if cached[id] {
|
||||
note = " (cached)"
|
||||
}
|
||||
if path := commentsSidecarPath(root, id); pullIsFile(path) {
|
||||
n := i.Extra[mapping.CommentsKey]
|
||||
if n == "" {
|
||||
n = "?"
|
||||
}
|
||||
note += fmt.Sprintf(" +%s comments: %s", n, path)
|
||||
}
|
||||
labels := strings.Join(i.Labels, ", ")
|
||||
if labels == "" {
|
||||
labels = "no labels"
|
||||
}
|
||||
fmt.Printf("%s [%s] %s — %s %s%s\n",
|
||||
id, labels, i.Title, i.State, issue.PathOf(root, id), note)
|
||||
}
|
||||
fmt.Printf("index: %s\n", indexPath)
|
||||
// Worth printing when there is something to draw, not on every run that
|
||||
// could have drawn something.
|
||||
if graph {
|
||||
fmt.Println("graph: run `kettle tree` (offline) to draw it")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// naming, and the files the sync layer parks beside an issue
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// pullNamer answers "what is this remote issue called here", and remembers what
|
||||
// it has already handed out so one run cannot name two issues the same thing.
|
||||
type pullNamer struct {
|
||||
root string
|
||||
repo wire.Repo
|
||||
ledger gitea.RemoteMap
|
||||
// taken is every slug the store holds plus every one this run has claimed.
|
||||
taken map[string]bool
|
||||
}
|
||||
|
||||
// idFor is the slug this remote issue belongs under. Three sources, in order —
|
||||
// see the command's own documentation for why that order and not another.
|
||||
func (n *pullNamer) idFor(p *sdk.Issue) (string, error) {
|
||||
if got := n.ledger.Slug(wire.Key{Repo: n.repo, Number: int(p.Index)}); got != "" {
|
||||
return got, nil
|
||||
}
|
||||
marked := mapping.IDInBody(p.Body)
|
||||
// A marker is an identity only while the name is free. A file of that name
|
||||
// already in the store, or a ledger entry holding it under another number,
|
||||
// makes it a collision — and overwriting somebody else's issue is worse than
|
||||
// allocating a suffix.
|
||||
if marked != "" && !n.taken[marked] && !n.ledgerHolds(marked) {
|
||||
return marked, nil
|
||||
}
|
||||
base := marked
|
||||
if base == "" {
|
||||
base = issue.Slugify(p.Title, 0)
|
||||
}
|
||||
taken := make([]string, 0, len(n.taken))
|
||||
for id := range n.taken {
|
||||
taken = append(taken, id)
|
||||
}
|
||||
return issue.UniqueID(n.root, base, taken)
|
||||
}
|
||||
|
||||
func (n *pullNamer) ledgerHolds(slug string) bool {
|
||||
for _, s := range n.ledger {
|
||||
if s == slug {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// loadLedgerOrFold is the number -> slug ledger, with the `gitea:` fields still
|
||||
// on disk folded in when there is no ledger to read.
|
||||
//
|
||||
// A MERGE and never a replacement, which is why the fold only happens when the
|
||||
// file is missing or empty: push deletes the file it has just sent, so the store
|
||||
// is a SUBSET of what the ledger knows and a rebuild from the files alone would
|
||||
// throw away every entry it cannot see. What a fold cannot recover — a
|
||||
// pushed-and-dropped issue whose entry was also lost — is not lost either: the
|
||||
// next pull of that number reads the slug off the marker in the body and writes
|
||||
// the entry back.
|
||||
func loadLedgerOrFold(root string, issues map[string]*issue.Issue) gitea.RemoteMap {
|
||||
m := gitea.LoadRemoteMap(root)
|
||||
if len(m) > 0 {
|
||||
return m
|
||||
}
|
||||
for id, i := range issues {
|
||||
if k, ok := mapping.RemoteKeyOf(i); ok {
|
||||
m.Set(k, id)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// pullStored reports whether the store already holds this issue.
|
||||
func pullStored(root, id string) bool { return pullIsFile(issue.PathOf(root, id)) }
|
||||
|
||||
func pullIsFile(path string) bool {
|
||||
fi, err := os.Stat(path)
|
||||
return err == nil && !fi.IsDir()
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "code.gitea.io/sdk/gitea"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "push",
|
||||
Group: GroupSync,
|
||||
Args: "[<id>…]",
|
||||
Short: "send local issues to the tracker; the local copy goes with them",
|
||||
Long: `A SUCCESSFUL PUSH DELETES THE LOCAL FILE — <id>.md and every sidecar under that
|
||||
slug — and prints the number and the URL the issue now lives at. Once the tracker
|
||||
has the issue, the tracker IS the issue: what is left in the store is what has
|
||||
not left this machine. Get it back with ` + "`kettle pull <n>`" + `, which returns it under
|
||||
the same slug, because the slug travelled up in the body as <!-- kettle:id … -->
|
||||
and was recorded in the number -> slug ledger.
|
||||
|
||||
ONE RULE, NO EXCEPTION: --update deletes as well. A PATCH is a push, and an issue
|
||||
that has just been sent is no more local than one that was just created. Two
|
||||
rules would put back exactly the question this removes — "is my copy the fresh
|
||||
one?".
|
||||
|
||||
THE DELETION IS THE LAST THING THAT HAPPENS TO AN ISSUE, 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 PATCHed, and
|
||||
3. the ledger has been written with number -> slug.
|
||||
|
||||
Network down, non-2xx, an answer that does not confirm the write: the file stays
|
||||
and the run stops. Nothing removes a file it has not just watched the tracker
|
||||
accept, and nothing removes a file for an issue it did not send — ` + "`origin: local`" + `
|
||||
work that has never been pushed is never touched by any of this. 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 and not
|
||||
after.
|
||||
|
||||
Every issue is validated against the canonical format first, offline and before
|
||||
a socket is opened. --force posts anyway; say why when you use it.
|
||||
|
||||
DEPENDENCIES GO FIRST, in topological order, so a blocker has its number before
|
||||
the issue that names it. Every ` + "`depends:`" + ` entry that has a number becomes a NATIVE
|
||||
tracker link — the same /dependencies a pull reads back, so the tracker shows the
|
||||
blocking panel and refuses to close a blocked issue first. A link that is already
|
||||
there is skipped, not re-POSTed, which is what makes a repeat push a no-op. A
|
||||
dependency that is still local-only has no number and becomes no link: it is
|
||||
reported, never silently dropped.
|
||||
|
||||
REMOVING a link is out of scope — push only ever adds. A dependency deleted from
|
||||
` + "`depends:`" + ` leaves its tracker link standing; unlink it in the web UI.
|
||||
|
||||
The ` + "`## Depends on`" + ` prose is never touched: slugs stay slugs and are not rewritten
|
||||
to #N, so a pull -> push round trip is byte for byte.
|
||||
|
||||
Labels the repository is missing are created with the canonical colour and, for
|
||||
type/* and severity/*, exclusive: true. ` + "`branch:`" + ` carries the tracker's ` + "`ref`" + `: an
|
||||
empty one is filled with the current git branch and an already-set one is sent as
|
||||
written. Detached HEAD or no repository at all is not an error — no ref is sent
|
||||
and a warning says so.`,
|
||||
Examples: []Example{
|
||||
{"kettle push", "every issue the tracker does not have yet, blockers first"},
|
||||
{"kettle push wire-sqlc-appclick", "one issue"},
|
||||
{"kettle push --update wire-sqlc-appclick", "PATCH one that is already there — the file still goes"},
|
||||
{"kettle push --dry-run", "validate and print the plan; no network, nothing deleted"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
update := fs.Bool("update", false, "PATCH issues that already carry a gitea: field")
|
||||
dryRun := fs.Bool("dry-run", false, "validate and print the plan; no network, nothing deleted")
|
||||
force := fs.Bool("force", false, "push despite format violations")
|
||||
out := storeFlag(fs)
|
||||
|
||||
return func(args []string) error {
|
||||
// A dry run touches no network, so it must not need a credential
|
||||
// to say what it would do. The real run goes through
|
||||
// syncStartExisting below, which resolves the store before it
|
||||
// builds a client — a missing store reported as a network
|
||||
// problem sends the operator to the wrong place.
|
||||
root, err := storeRoot(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := issue.StoreError(root); err != nil {
|
||||
return Fail("%s — create an issue with `kettle new` first", err)
|
||||
}
|
||||
issues, err := issue.LoadAll(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
chosen, err := pushSelect(issues, args, *update)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The domain's own check, offline, before anything is sent.
|
||||
known := map[string]bool{}
|
||||
for id := range issues {
|
||||
known[id] = true
|
||||
}
|
||||
blocked := false
|
||||
for _, id := range chosen {
|
||||
errs, warns := issue.Validate(issues[id], known)
|
||||
for _, w := range warns {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s: %s\n", id, w)
|
||||
}
|
||||
for _, e := range errs {
|
||||
fmt.Fprintf(os.Stderr, "%s: %s\n", id, e)
|
||||
}
|
||||
blocked = blocked || len(errs) > 0
|
||||
}
|
||||
if blocked && !*force {
|
||||
return Fail("format violations (see above); --force overrides")
|
||||
}
|
||||
|
||||
// Dependencies first, so a blocker has its number by the time the
|
||||
// issue that names it is sent. A cycle is reported and ordered
|
||||
// around rather than refused: it is a data problem, not a reason
|
||||
// to send nothing.
|
||||
edges := map[string][]string{}
|
||||
for _, id := range chosen {
|
||||
var deps []string
|
||||
for _, d := range issues[id].Depends {
|
||||
if _, ok := issues[d]; ok {
|
||||
deps = append(deps, d)
|
||||
}
|
||||
}
|
||||
edges[id] = deps
|
||||
}
|
||||
pushing := map[string]bool{}
|
||||
for _, id := range chosen {
|
||||
pushing[id] = true
|
||||
}
|
||||
var order []string
|
||||
for _, id := range issue.TopoOrder(chosen, edges) {
|
||||
if pushing[id] {
|
||||
order = append(order, id)
|
||||
}
|
||||
}
|
||||
for _, c := range issue.FindCycles(edges) {
|
||||
fmt.Fprintf(os.Stderr, "warning: dependency cycle: %s\n", strings.Join(c, " -> "))
|
||||
}
|
||||
|
||||
// Only an EMPTY branch: one written by hand is the author's
|
||||
// decision and a push does not argue with it. The value is set on
|
||||
// the in-memory issue only — the file it came from is about to be
|
||||
// deleted, and the branch comes back with the next pull.
|
||||
var blank []string
|
||||
for _, id := range order {
|
||||
if strings.TrimSpace(issues[id].Extra[mapping.BranchKey]) == "" {
|
||||
blank = append(blank, id)
|
||||
}
|
||||
}
|
||||
if len(blank) > 0 {
|
||||
if branch := pushGitBranch(); branch != "" {
|
||||
for _, id := range blank {
|
||||
if issues[id].Extra == nil {
|
||||
issues[id].Extra = map[string]string{}
|
||||
}
|
||||
issues[id].Extra[mapping.BranchKey] = branch
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "warning: no current git branch (detached HEAD, or "+
|
||||
"outside a git repository) — no `ref` on: %s\n", strings.Join(blank, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
if *dryRun {
|
||||
// Not one request is made here: everything below is read off
|
||||
// the store and the ledger, which costs nothing.
|
||||
pushPlan(root, issues, order, pushing, *update)
|
||||
return nil
|
||||
}
|
||||
|
||||
_, client, err := syncStartExisting(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
repo := client.Repo()
|
||||
|
||||
var wanted []string
|
||||
for _, id := range order {
|
||||
for _, l := range issues[id].Labels {
|
||||
if !contains(wanted, l) {
|
||||
wanted = append(wanted, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(wanted)
|
||||
labelIDs, err := pushLabelIDs(client, wanted)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
milestones := map[string]*int64{}
|
||||
ledger := loadLedgerOrFold(root, issues)
|
||||
keyOf := pushLedgerKeys(ledger, repo)
|
||||
|
||||
for _, id := range order {
|
||||
i := issues[id]
|
||||
|
||||
// Local-only means "this machine has never sent it": no
|
||||
// `gitea:` on the file AND no entry in the ledger. A blocker
|
||||
// whose file an earlier push already dropped is in the ledger
|
||||
// and is not one of these.
|
||||
var unsynced []string
|
||||
for _, d := range i.Depends {
|
||||
dep, onDisk := issues[d]
|
||||
if !onDisk || pushing[d] {
|
||||
continue
|
||||
}
|
||||
if _, synced := mapping.RemoteKeyOf(dep); synced {
|
||||
continue
|
||||
}
|
||||
if _, inLedger := keyOf[d]; !inLedger {
|
||||
unsynced = append(unsynced, d)
|
||||
}
|
||||
}
|
||||
if len(unsynced) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s: depends on local-only issue(s) %s"+
|
||||
" — no cross-link in the tracker\n", id, strings.Join(unsynced, ", "))
|
||||
}
|
||||
|
||||
msID, ok := milestones[i.Milestone]
|
||||
if i.Milestone != "" && !ok {
|
||||
m, err := client.FindMilestone(i.Milestone)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if m != nil {
|
||||
msID = sdk.OptionalInt64(m.ID)
|
||||
}
|
||||
milestones[i.Milestone] = msID
|
||||
}
|
||||
if i.Milestone != "" && msID == nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s: milestone %q does not exist in %s"+
|
||||
" — not set\n", id, i.Milestone, repo)
|
||||
}
|
||||
|
||||
opt := mapping.RequestOptions{LabelIDs: labelIDs, MilestoneID: msID}
|
||||
sent, synced := mapping.NumberOf(i)
|
||||
var got *sdk.Issue
|
||||
verb := "created"
|
||||
if synced {
|
||||
// An edit says what state it means; a create takes the
|
||||
// tracker's default.
|
||||
opt.IncludeState = true
|
||||
verb = "updated"
|
||||
got, err = client.EditIssue(sent, mapping.ToEdit(i, opt), "issue-"+id)
|
||||
} else {
|
||||
sent = 0
|
||||
got, err = client.CreateIssue(mapping.ToCreate(i, opt), "issue-"+id)
|
||||
}
|
||||
// THE GATE. Below this line a local file is going to be
|
||||
// deleted, so anything short of a confirmed write stops the
|
||||
// run right here.
|
||||
if err != nil {
|
||||
return Fail("%s: not %s: %v — %s is untouched", id, verb, err, issue.PathOf(root, id))
|
||||
}
|
||||
number, confirmed := pushConfirmedNumber(got, sent)
|
||||
if !confirmed {
|
||||
return Fail("%s: the tracker's answer does not confirm the write "+
|
||||
"(it carries number %d) — %s is untouched, nothing was deleted",
|
||||
id, got.Index, issue.PathOf(root, id))
|
||||
}
|
||||
|
||||
// The number is confirmed, so the ledger learns it NOW —
|
||||
// before the label fix-up and the links, both of which can
|
||||
// still fail, and well before the file is removed. This entry
|
||||
// is what a later `kettle pull <n>` lands on; an interrupted
|
||||
// run must cost a re-pull, never a slug.
|
||||
key := wire.Key{Repo: repo, Number: number}
|
||||
ledger.Set(key, id)
|
||||
keyOf[id] = key
|
||||
if err := ledger.Save(root); err != nil {
|
||||
return Fail("%s: the tracker has it as #%d but the ledger could not be "+
|
||||
"written (%v) — %s is untouched", id, number, err, issue.PathOf(root, id))
|
||||
}
|
||||
|
||||
// THE LABELS THE TRACKER ENDED UP WITH HAVE TO BE THE
|
||||
// ISSUE'S, and two different things leave them disagreeing:
|
||||
//
|
||||
// - a create can DROP labels handed to it, which Gitea has
|
||||
// been seen to do, so the echo is checked rather than
|
||||
// trusted;
|
||||
// - an edit cannot carry labels AT ALL — Gitea's PATCH
|
||||
// takes none — so a label added or removed locally is
|
||||
// not in the answer either way.
|
||||
//
|
||||
// One answer to both: compare the set the tracker echoed
|
||||
// with the set this issue wants, and PUT the whole list when
|
||||
// they differ. A failure here is a warning and not an abort:
|
||||
// the issue IS in the tracker, and a run that stopped now
|
||||
// would leave a file whose `gitea:` field was never written
|
||||
// — which the next push would file all over again as a new
|
||||
// issue.
|
||||
if drift := pushLabelDrift(got.Labels, i.Labels, labelIDs); len(drift) > 0 {
|
||||
if _, err := client.SetLabels(number, mapping.LabelIDsFor(i, opt), "labels-"+id); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s: could not set the labels (%s): %v\n",
|
||||
id, strings.Join(drift, ", "), err)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "%s: labels set via PUT (%s)\n",
|
||||
id, strings.Join(drift, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// The in-memory issue is stamped even though its file is
|
||||
// going: the rest of this loop reads `gitea:` off it to link
|
||||
// dependencies, and a later issue in topological order asks
|
||||
// the same of this one.
|
||||
mapping.ApplyRemote(i, got, repo, time.Now().UTC().Format(time.RFC3339))
|
||||
|
||||
// The number and the URL lead, because in a moment the local
|
||||
// path is gone and this is the only address the issue has.
|
||||
fmt.Printf("%s %s #%d %s\n", verb, id, number, got.HTMLURL)
|
||||
|
||||
if err := pushLinks(client, id, number, i, issues, pushing, keyOf, repo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// And now the local copy goes: the last thing that happens to
|
||||
// this issue, after the write, the ledger and the links. A
|
||||
// warning above lands here anyway — the issue is in the
|
||||
// tracker, and keeping a stale file beside it would put back
|
||||
// exactly the two-copies question this removes.
|
||||
gone, err := issue.Remove(root, id)
|
||||
for _, p := range gone {
|
||||
fmt.Printf(" dropped %s\n", p)
|
||||
}
|
||||
if err != nil {
|
||||
return Fail("%s: the tracker has it as #%d, but the local copy could not "+
|
||||
"be removed: %v", id, number, err)
|
||||
}
|
||||
fmt.Printf(" kettle pull %d to work on it again\n", number)
|
||||
}
|
||||
|
||||
if err := ledger.Save(root); err != nil {
|
||||
return err
|
||||
}
|
||||
path, n, err := issue.BuildIndex(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("index: %s — %d issue(s)\n", path, n)
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// pushSelect is which issues to send, and the refusal of the ambiguous
|
||||
// combinations.
|
||||
//
|
||||
// Named ids are taken as typed. With none, the default is everything this
|
||||
// machine has never sent — pushing the whole store on a bare `kettle push` would
|
||||
// re-PATCH every working copy in it.
|
||||
func pushSelect(issues map[string]*issue.Issue, ids []string, update bool) ([]string, error) {
|
||||
var chosen []string
|
||||
if len(ids) > 0 {
|
||||
var missing []string
|
||||
for _, id := range ids {
|
||||
if _, ok := issues[id]; !ok {
|
||||
missing = append(missing, id)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return nil, Fail("no such issue(s) in the store: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
chosen = append(chosen, ids...)
|
||||
} else {
|
||||
for id, i := range issues {
|
||||
if _, synced := mapping.RemoteKeyOf(i); update || !synced {
|
||||
chosen = append(chosen, id)
|
||||
}
|
||||
}
|
||||
sort.Strings(chosen)
|
||||
if len(chosen) == 0 {
|
||||
return nil, Fail("nothing to push: every issue in the store is already in the " +
|
||||
"tracker — pass --update to PATCH them, or `kettle new` to make one")
|
||||
}
|
||||
}
|
||||
if !update {
|
||||
var already []string
|
||||
for _, id := range chosen {
|
||||
if _, synced := mapping.RemoteKeyOf(issues[id]); synced {
|
||||
already = append(already, id)
|
||||
}
|
||||
}
|
||||
if len(already) > 0 {
|
||||
return nil, Fail("already in the tracker: %s — pass --update to PATCH them",
|
||||
strings.Join(already, ", "))
|
||||
}
|
||||
}
|
||||
return chosen, nil
|
||||
}
|
||||
|
||||
// pushLabelIDs is name -> id for the labels these issues carry, creating what
|
||||
// the repository is missing.
|
||||
//
|
||||
// Decided against the repository as it is right now, in one request, and never
|
||||
// against a cache: a cache answers "what did we create last time", and the
|
||||
// question here is "what does this repository have". A label the tracker does
|
||||
// not have and this cannot create is the one failure worth stopping for — an
|
||||
// issue filed without its `type/*` label is an issue nothing can find again.
|
||||
func pushLabelIDs(c *gitea.Client, names []string) (map[string]int64, error) {
|
||||
out := map[string]int64{}
|
||||
if len(names) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
have, err := c.ListLabels()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
known := make(map[string]int64, len(have))
|
||||
for _, l := range have {
|
||||
known[l.Name] = l.ID
|
||||
}
|
||||
// The spec — colour, description, exclusivity — is the bridge's, read off the
|
||||
// domain's taxonomy. This layer only decides which names are wanted.
|
||||
for _, spec := range mapping.LabelSpecs(names) {
|
||||
if id, ok := known[spec.Name]; ok {
|
||||
out[spec.Name] = id
|
||||
continue
|
||||
}
|
||||
created, err := c.CreateLabel(spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[spec.Name] = created.ID
|
||||
note := ""
|
||||
if spec.Exclusive {
|
||||
note = " (exclusive)"
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "created label %s%s\n", spec.Name, note)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pushLabelDrift is the names on which the tracker's answer and the issue
|
||||
// disagree — what a receipt says when the PUT that follows is made.
|
||||
//
|
||||
// BOTH DIRECTIONS. A wanted label the answer does not carry is one Gitea
|
||||
// dropped or one an edit could not send; a label the answer carries that the
|
||||
// issue no longer wants is one somebody removed locally, and leaving it would
|
||||
// make `push --update` a command that can add a label but never take one off.
|
||||
//
|
||||
// Only labels this run resolved to an id count as wanted: an unknown name was
|
||||
// already left off deliberately, and one the repository holds but the taxonomy
|
||||
// does not know is not this command's to remove either — which is why the
|
||||
// comparison is against the resolved set and not against `labels:` as written.
|
||||
func pushLabelDrift(got []*sdk.Label, want []string, ids map[string]int64) []string {
|
||||
have := map[string]bool{}
|
||||
for _, l := range got {
|
||||
if l != nil {
|
||||
have[l.Name] = true
|
||||
}
|
||||
}
|
||||
wanted := map[string]bool{}
|
||||
var drift []string
|
||||
for _, name := range want {
|
||||
if _, resolved := ids[name]; !resolved {
|
||||
continue
|
||||
}
|
||||
wanted[name] = true
|
||||
if !have[name] {
|
||||
drift = append(drift, name)
|
||||
}
|
||||
}
|
||||
for _, l := range got {
|
||||
if l != nil && !wanted[l.Name] {
|
||||
drift = append(drift, "-"+l.Name)
|
||||
}
|
||||
}
|
||||
return drift
|
||||
}
|
||||
|
||||
// pushDep is what one `depends:` entry is, as far as linking is concerned.
|
||||
type pushDep struct {
|
||||
// Slug is the dependency as `depends:` spells it.
|
||||
Slug string
|
||||
// Key is where it lives in the tracker; HasKey is false while it is
|
||||
// local-only.
|
||||
Key wire.Key
|
||||
HasKey bool
|
||||
// InRun says this push is about to give it a number.
|
||||
InRun bool
|
||||
}
|
||||
|
||||
// pushDepState is every dependency this run can say anything about.
|
||||
//
|
||||
// A dependency's key is read from its `gitea:` field while the file is still on
|
||||
// disk, and from the ledger when it is not — which, since push deletes what it
|
||||
// sends, is the normal state of an already-published blocker. Without that
|
||||
// fallback the graph would quietly lose an edge every time a blocker was pushed
|
||||
// before its dependent: the file is gone, the field goes with it, and the link is
|
||||
// never made.
|
||||
//
|
||||
// A slug that is in neither the store nor the ledger names nothing this machine
|
||||
// has ever seen, and is dropped — validation has already warned about it.
|
||||
func pushDepState(i *issue.Issue, issues map[string]*issue.Issue, pushing map[string]bool,
|
||||
keyOf map[string]wire.Key) []pushDep {
|
||||
var out []pushDep
|
||||
for _, d := range i.Depends {
|
||||
dep, onDisk := issues[d]
|
||||
var key wire.Key
|
||||
found := false
|
||||
if onDisk {
|
||||
key, found = mapping.RemoteKeyOf(dep)
|
||||
}
|
||||
if !found {
|
||||
key, found = keyOf[d]
|
||||
}
|
||||
if !onDisk && !found {
|
||||
continue
|
||||
}
|
||||
out = append(out, pushDep{Slug: d, Key: key, HasKey: found, InRun: pushing[d]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pushLinks turns `depends:` into the tracker's own dependency links.
|
||||
//
|
||||
// Topological order means every blocker that is going to have a number has one
|
||||
// already. The GET is the idempotence check — one request per issue that has
|
||||
// dependencies at all, and what makes a repeat push a no-op. A failure is a
|
||||
// warning, never an abort: one missing cross-link must not undo a push that has
|
||||
// already created issues.
|
||||
func pushLinks(c *gitea.Client, id string, number int, i *issue.Issue,
|
||||
issues map[string]*issue.Issue, pushing map[string]bool,
|
||||
keyOf map[string]wire.Key, repo wire.Repo) error {
|
||||
var wanted []pushDep
|
||||
for _, d := range pushDepState(i, issues, pushing, keyOf) {
|
||||
if d.HasKey && d.Key.Number > 0 {
|
||||
wanted = append(wanted, d)
|
||||
}
|
||||
}
|
||||
if len(wanted) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
have, err := c.DependencyKeys(number)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s: could not read the links #%d already has (%v)"+
|
||||
" — no link was made\n", id, number, err)
|
||||
return nil
|
||||
}
|
||||
for _, d := range wanted {
|
||||
key := d.Key.In(repo)
|
||||
if containsKey(have, key) {
|
||||
continue
|
||||
}
|
||||
if err := c.AddDependency(number, key); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s: could not link #%d -> %s (%s): %v — link it by "+
|
||||
"hand, or `kettle pull %d` and push it again\n", id, number, key, d.Slug, err, number)
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" depends on %s (%s)\n", key, d.Slug)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pushPlan is the --dry-run receipt: what would be sent, and which links would
|
||||
// exist. `#?` is a number this run has not handed out yet.
|
||||
func pushPlan(root string, issues map[string]*issue.Issue, order []string,
|
||||
pushing map[string]bool, update bool) {
|
||||
// The ledger costs no request, so a dry run resolves an already-pushed
|
||||
// blocker exactly the way the real run does.
|
||||
keyOf := pushLedgerKeys(loadLedgerOrFold(root, issues), wire.Repo{})
|
||||
links := 0
|
||||
for _, id := range order {
|
||||
i := issues[id]
|
||||
typ := i.Type()
|
||||
if typ == "" {
|
||||
typ = "?"
|
||||
}
|
||||
labels := strings.Join(i.Labels, ", ")
|
||||
if labels == "" {
|
||||
labels = "no labels"
|
||||
}
|
||||
fmt.Printf("ok %s [type/%s] %s (%s)\n", id, typ, i.Title, labels)
|
||||
for _, d := range pushDepState(i, issues, pushing, keyOf) {
|
||||
switch {
|
||||
case d.HasKey:
|
||||
fmt.Printf(" link -> %s (%s)\n", d.Key, d.Slug)
|
||||
links++
|
||||
case d.InRun:
|
||||
fmt.Printf(" link -> #? (%s, created by this run)\n", d.Slug)
|
||||
links++
|
||||
default:
|
||||
fmt.Printf(" no link: %s is local-only\n", d.Slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
verb := "created"
|
||||
if update {
|
||||
verb = "updated"
|
||||
}
|
||||
fmt.Printf("%d issue(s) would be %s, %d dependency link(s) would be created\n",
|
||||
len(order), verb, links)
|
||||
}
|
||||
|
||||
// pushLedgerKeys is slug -> key, the reverse of the ledger.
|
||||
//
|
||||
// Where a dependency's number comes from once push has deleted its file. The
|
||||
// ledger is keyed by number because that is what a pull has in hand; a push has a
|
||||
// slug, so it needs the other direction. An entry in the repository being pushed
|
||||
// to wins when a slug somehow appears under two keys.
|
||||
func pushLedgerKeys(m gitea.RemoteMap, repo wire.Repo) map[string]wire.Key {
|
||||
raw := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
raw = append(raw, k)
|
||||
}
|
||||
sort.Strings(raw)
|
||||
|
||||
out := map[string]wire.Key{}
|
||||
for _, r := range raw {
|
||||
key, err := wire.ParseKey(r)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
slug := m[r]
|
||||
if _, seen := out[slug]; !seen || key.Repo == repo {
|
||||
out[slug] = key
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pushConfirmedNumber is the number the tracker confirmed for a write, or ok
|
||||
// false — the deletion gate.
|
||||
//
|
||||
// Every local file this command removes is removed because this returned ok, so
|
||||
// it is written to be boring and to say no by default: a positive number, and on
|
||||
// a PATCH the very number that was addressed. What it does not have to catch,
|
||||
// because none of it gets this far: a non-2xx answer, a body that is not the JSON
|
||||
// expected, or a `number` that is not a number — the transport fails all three
|
||||
// before returning, and the file survives by never reaching the delete.
|
||||
func pushConfirmedNumber(got *sdk.Issue, sent int) (int, bool) {
|
||||
if got == nil || got.Index <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
number := int(got.Index)
|
||||
if sent != 0 && number != sent {
|
||||
return 0, false
|
||||
}
|
||||
return number, true
|
||||
}
|
||||
|
||||
// pushGitBranch is the branch HEAD is on, or "".
|
||||
//
|
||||
// The one git call this binary makes — read, never write. A detached HEAD prints
|
||||
// `HEAD` and outside a repository git exits non-zero; both mean "no branch to
|
||||
// name", which is not an error.
|
||||
func pushGitBranch() string {
|
||||
out, err := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
name := strings.TrimSpace(string(out))
|
||||
if name == "HEAD" {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func containsKey(keys []wire.Key, want wire.Key) bool {
|
||||
for _, k := range keys {
|
||||
if k == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
// labelColumn is how much of the label list a row shows before it is cut.
|
||||
const labelColumn = 38
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "remote",
|
||||
Group: GroupSync,
|
||||
Short: "list what exists in the tracker, one line each",
|
||||
Long: `Discovery only: this prints and WRITES NOTHING. The local store is a store, not a
|
||||
search-results folder, and a listing that landed in it would leave files nobody
|
||||
asked for beside the issues somebody did. Pick the numbers here, then pull them.
|
||||
|
||||
#42 open type/task, tech/sql Wire sqlc into the repo layer
|
||||
└─ local: wire-sqlc-appclick
|
||||
|
||||
The second line appears when the number is already in the local ledger, so it is
|
||||
obvious what a pull would refresh and what it would add.
|
||||
|
||||
--limit here caps the LISTING: N lines out, closed ones among them. That is not
|
||||
what the same flag means to ` + "`kettle pull`" + `, and the difference is not an oversight —
|
||||
pull bounds what it WRITES, this command writes nothing, and enumeration is the
|
||||
whole job.
|
||||
|
||||
Projects are not filterable: the projects API is not exposed by Gitea. Use
|
||||
milestones or labels, or the web UI.`,
|
||||
Examples: []Example{
|
||||
{"kettle remote", "the open issues, 30 of them"},
|
||||
{"kettle remote --state all --label type/bug --limit 50", "every bug, open and closed"},
|
||||
{"kettle remote --milestone v0.2", "what is in a milestone"},
|
||||
{"kettle remote -q sqlc", "keyword search over title and body"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
state := fs.String("state", "open", "open, closed or all")
|
||||
var labels stringList
|
||||
fs.Var(&labels, "label", "filter by label; repeat for AND")
|
||||
// Both spellings, the way the Python this replaces took them.
|
||||
var query string
|
||||
fs.StringVar(&query, "q", "", "search text in title and body")
|
||||
fs.StringVar(&query, "query", "", "the long spelling of -q")
|
||||
milestone := fs.String("milestone", "", "milestone id or title")
|
||||
limit := fs.Int("limit", 30, "how many lines to print")
|
||||
out := storeFlag(fs)
|
||||
|
||||
return func(args []string) error {
|
||||
if len(args) > 0 {
|
||||
return Fail("remote takes no arguments — filter with --label, --milestone or -q")
|
||||
}
|
||||
if !contains([]string{"open", "closed", "all"}, *state) {
|
||||
return Fail("--state %q must be open, closed or all", *state)
|
||||
}
|
||||
if *limit < 1 {
|
||||
return Fail("--limit must be 1 or more, got %d", *limit)
|
||||
}
|
||||
|
||||
root, client, err := syncStart(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
listing, err := client.ListIssues(gitea.IssueFilter{
|
||||
State: *state, Labels: labels, Query: query,
|
||||
Milestone: *milestone, Limit: *limit,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The ledger, not the files: a pushed issue has no file left and
|
||||
// is still something a pull would land on a known slug.
|
||||
ledger := gitea.LoadRemoteMap(root)
|
||||
repo := client.Repo()
|
||||
|
||||
for _, p := range listing.Issues {
|
||||
labels := "-"
|
||||
if names := mapping.LabelNames(p); len(names) > 0 {
|
||||
labels = strings.Join(names, ", ")
|
||||
}
|
||||
// One line per issue is the whole point; a repository that
|
||||
// namespaces heavily would wrap the column otherwise.
|
||||
if len(labels) > labelColumn {
|
||||
labels = labels[:labelColumn]
|
||||
}
|
||||
fmt.Printf("#%-5d %-7s %-38s %s\n", p.Index, p.State, labels, p.Title)
|
||||
if local := ledger.Slug(wire.Key{Repo: repo, Number: int(p.Index)}); local != "" {
|
||||
fmt.Printf("%13s└─ local: %s\n", "", local)
|
||||
}
|
||||
}
|
||||
|
||||
scope := ""
|
||||
if listing.Milestone != "" {
|
||||
scope = " in milestone " + listing.Milestone
|
||||
}
|
||||
hint := "<n>"
|
||||
if *milestone != "" {
|
||||
hint = "--milestone " + *milestone
|
||||
}
|
||||
fmt.Printf("%d issue(s)%s — pull them with: kettle pull %s\n",
|
||||
len(listing.Issues), scope, hint)
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package cmd
|
||||
|
||||
// The seam between the registry and the documents written from it. Both halves
|
||||
// are in this package's reach, so it is asserted here rather than inferred from
|
||||
// a generated file downstream.
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/scaffold"
|
||||
)
|
||||
|
||||
// A group with no document is a group whose flag table is written nowhere, and
|
||||
// nothing would say so: gen walks the documents, not the registry, so the
|
||||
// commands would simply be absent. This is the test that makes adding a group a
|
||||
// two-step change rather than a silent one-step mistake.
|
||||
func TestEveryGroupHasSomewhereToBeWritten(t *testing.T) {
|
||||
inRegistry := map[string]bool{}
|
||||
for _, c := range Commands() {
|
||||
if c.Group == "" {
|
||||
t.Errorf("command %q has no group, so it is in no document at all", c.Name)
|
||||
continue
|
||||
}
|
||||
inRegistry[c.Group] = true
|
||||
}
|
||||
|
||||
for _, g := range scaffold.Groups() {
|
||||
if !inRegistry[g] {
|
||||
t.Errorf("scaffold ships a document for group %q, which no command is in", g)
|
||||
}
|
||||
}
|
||||
for g := range inRegistry {
|
||||
if scaffold.PathFor(g) == "" {
|
||||
t.Errorf("group %q has commands and no document — add one to internal/scaffold/assets", g)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every document renders, and the flag table inside it is this binary's. A
|
||||
// command whose Long text spelled a region marker out in full would end the
|
||||
// generated block inside itself, and renderAll is where that is caught.
|
||||
func TestEveryDocumentRendersWithItsOwnFlags(t *testing.T) {
|
||||
files, err := renderAll()
|
||||
if err != nil {
|
||||
t.Fatalf("rendering the tree failed: %v", err)
|
||||
}
|
||||
if len(files) != len(scaffold.Files()) {
|
||||
t.Fatalf("rendered %d of %d documents", len(files), len(scaffold.Files()))
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
if f.Group == "" {
|
||||
continue
|
||||
}
|
||||
for _, c := range commandsIn(f.Group) {
|
||||
if !strings.Contains(f.Body, "## `"+c.Usage()+"`") {
|
||||
t.Errorf("%s does not document `%s`", f.Path, c.Usage())
|
||||
}
|
||||
for _, flag := range c.Flags() {
|
||||
if !strings.Contains(f.Body, "| `--"+flag.Name+"` |") {
|
||||
t.Errorf("%s documents `%s` without --%s", f.Path, c.Name, flag.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Contains(f.Body, genOpen+"\n"+genOpen) {
|
||||
t.Errorf("%s has a doubled marker", f.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rendering twice produces the same bytes. Everything downstream — --check, the
|
||||
// idempotence of init, a diff in somebody's repository — is built on it.
|
||||
func TestRenderingIsDeterministic(t *testing.T) {
|
||||
a, err := renderAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := renderAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a) != len(b) {
|
||||
t.Fatalf("two renders produced %d and %d documents", len(a), len(b))
|
||||
}
|
||||
for i := range a {
|
||||
if a[i].Path != b[i].Path {
|
||||
t.Fatalf("document %d is %s then %s", i, a[i].Path, b[i].Path)
|
||||
}
|
||||
if a[i].Body != b[i].Body {
|
||||
t.Errorf("%s differs between two renders", a[i].Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The guess the wizard offers for --repo. It is worth exactly what it costs —
|
||||
// a file read — so anything it cannot make sense of must answer "" and let the
|
||||
// operator type it, rather than proposing half an address.
|
||||
func TestOwnerNameReadsTheSpellingsGitActuallyWrites(t *testing.T) {
|
||||
cases := []struct{ url, want string }{
|
||||
{"git@git.example.com:claude-skills/marketplace.git", "claude-skills/marketplace"},
|
||||
{"git@git.example.com:claude-skills/marketplace", "claude-skills/marketplace"},
|
||||
{"https://git.example.com/claude-skills/marketplace.git", "claude-skills/marketplace"},
|
||||
{"https://git.example.com/claude-skills/marketplace", "claude-skills/marketplace"},
|
||||
{"ssh://git@git.example.com:2222/claude-skills/marketplace.git", "claude-skills/marketplace"},
|
||||
{" https://git.example.com/owner/name.git ", "owner/name"},
|
||||
// Deeper paths: a Gitea instance served under a prefix still ends in
|
||||
// owner/name, and the last two elements are the address.
|
||||
{"https://example.com/git/owner/name.git", "owner/name"},
|
||||
// Nothing that can be read as an address.
|
||||
{"", ""},
|
||||
{"https://git.example.com/", ""},
|
||||
{"https://git.example.com/lonely", ""},
|
||||
{"/a/local/path", "local/path"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := ownerName(tc.url); got != tc.want {
|
||||
t.Errorf("ownerName(%q) = %q, want %q", tc.url, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The document list is stable and sorted, which is what makes a receipt from one
|
||||
// machine comparable with a receipt from another.
|
||||
func TestScaffoldFilesAreSorted(t *testing.T) {
|
||||
files := scaffold.Files()
|
||||
paths := make([]string, len(files))
|
||||
for i, f := range files {
|
||||
paths[i] = f.Path
|
||||
}
|
||||
if !sort.StringsAreSorted(paths) {
|
||||
t.Errorf("the document list is not sorted: %v", paths)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// mirrorHookCommand is what gets registered on PreToolUse(Bash).
|
||||
//
|
||||
// The `command -v` guard is not decoration. This line outlives the binary that
|
||||
// wrote it: an operator who uninstalls kettle, or moves it off PATH, would
|
||||
// otherwise get a "command not found" on every Bash call in this project, from a
|
||||
// hook they set up months ago and have long stopped thinking about. The guard
|
||||
// makes the failure mode silence.
|
||||
const mirrorHookCommand = `command -v kettle >/dev/null && kettle mirror --hook || true`
|
||||
|
||||
// mirrorHookSnippet is what an operator is shown when the merge is not this
|
||||
// command's to make.
|
||||
const mirrorHookSnippet = `{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "` + mirrorHookCommand + `" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}`
|
||||
|
||||
// errSettingsExist means the file is there, does not hold the hook, and merging
|
||||
// it is a decision rather than a step.
|
||||
var errSettingsExist = errors.New("settings file already exists")
|
||||
|
||||
// settingsPath is `<out>/settings.json` — the shared file, not
|
||||
// settings.local.json. The convention this registers is a property of a
|
||||
// repository rather than of one developer's checkout, so it belongs in the file
|
||||
// that is committed.
|
||||
func settingsPath(out string) string { return filepath.Join(out, "settings.json") }
|
||||
|
||||
// writeMirrorHook registers the PreToolUse hook, and refuses to rewrite a file
|
||||
// it did not create.
|
||||
//
|
||||
// Three outcomes, and the third is the interesting one:
|
||||
//
|
||||
// - no file: it is written, hook and all.
|
||||
// - a file already holding a `kettle mirror` hook: nothing happens.
|
||||
// - a file holding something else: REFUSED unless force, and the snippet is
|
||||
// printed for the operator to paste.
|
||||
//
|
||||
// That refusal is deliberate and is the only reason this file is not a dozen
|
||||
// lines shorter. settings.json is a file the operator owns and commits, and Go's
|
||||
// encoding/json cannot preserve key order — so any merge reformats the whole
|
||||
// document, and an operator who asked for a documentation hook would find an
|
||||
// unrelated diff across a file they share with their team. A snippet they paste
|
||||
// costs them ten seconds; a reformat costs them a review.
|
||||
func writeMirrorHook(out string, force, dryRun bool) (string, error) {
|
||||
path := settingsPath(out)
|
||||
rel := filepath.Join(filepath.Base(out), "settings.json")
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
if dryRun {
|
||||
return fmt.Sprintf("created %s (PreToolUse: kettle mirror --hook)", rel), nil
|
||||
}
|
||||
if err := writeFile(path, mirrorHookSnippet+"\n"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("created %s (PreToolUse: kettle mirror --hook)", rel), nil
|
||||
|
||||
case err != nil:
|
||||
return "", err
|
||||
}
|
||||
|
||||
var settings map[string]any
|
||||
if err := json.Unmarshal(raw, &settings); err != nil {
|
||||
return "", Fail("%s is not readable as JSON (%v) — fix it, or add the hook by hand:\n\n%s", path, err, mirrorHookSnippet)
|
||||
}
|
||||
if strings.Contains(string(raw), "kettle mirror") {
|
||||
return "", nil // already registered; nothing to do and nothing to say
|
||||
}
|
||||
if !force {
|
||||
return "", fmt.Errorf("%w: %s. Add this to it, or re-run with --force-settings to have kettle merge it (which reformats the file):\n\n%s",
|
||||
errSettingsExist, path, mirrorHookSnippet)
|
||||
}
|
||||
|
||||
merged, err := mergeMirrorHook(settings)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if dryRun {
|
||||
return fmt.Sprintf("merged the hook into %s (reformatting it)", rel), nil
|
||||
}
|
||||
body, err := json.MarshalIndent(merged, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := writeFile(path, string(body)+"\n"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("merged the hook into %s (reformatting it)", rel), nil
|
||||
}
|
||||
|
||||
// mergeMirrorHook appends the hook to whatever PreToolUse already holds,
|
||||
// creating the path if it is not there. Nothing existing is removed or
|
||||
// reordered — what is lost is key order, which JSON does not carry, and that
|
||||
// is the whole reason this is behind a flag.
|
||||
func mergeMirrorHook(settings map[string]any) (map[string]any, error) {
|
||||
if settings == nil {
|
||||
settings = map[string]any{}
|
||||
}
|
||||
hooks, _ := settings["hooks"].(map[string]any)
|
||||
if hooks == nil {
|
||||
hooks = map[string]any{}
|
||||
}
|
||||
pre, _ := hooks["PreToolUse"].([]any)
|
||||
pre = append(pre, map[string]any{
|
||||
"matcher": "Bash",
|
||||
"hooks": []any{
|
||||
map[string]any{"type": "command", "command": mirrorHookCommand},
|
||||
},
|
||||
})
|
||||
hooks["PreToolUse"] = pre
|
||||
settings["hooks"] = hooks
|
||||
return settings, nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
)
|
||||
|
||||
// commentsSidecarPath is where an issue's comment thread lives — beside it,
|
||||
// under the same slug.
|
||||
//
|
||||
// A path, not a concept the domain needs: a thread is pulled from the tracker
|
||||
// and never pushed back, so `internal/issue` has no reason to learn that the
|
||||
// file exists. It does not have to — the file is named after the issue, and
|
||||
// issue.SlugFiles takes it away when the issue goes.
|
||||
//
|
||||
// It sits here rather than in either command because `pull` writes it and
|
||||
// `comment` rewrites it, and two spellings of one path is how the two come to
|
||||
// disagree about where a thread is.
|
||||
func commentsSidecarPath(root, id string) string {
|
||||
return filepath.Join(root, id+".comments.md")
|
||||
}
|
||||
|
||||
// The sync commands all start the same way and must fail the same way.
|
||||
//
|
||||
// Every one of them needs a store and a client, and the order matters: 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.
|
||||
// So the store is resolved before a socket is opened, and each failure names
|
||||
// the command that fixes it.
|
||||
|
||||
// syncStart resolves the store and builds a client for it.
|
||||
//
|
||||
// The client is built from the project's own configuration, which is why there
|
||||
// is no --login flag anywhere in this tree: which login a project runs under is
|
||||
// a fact about the project, stated once by `kettle init`, not a thing a caller
|
||||
// gets to differ about per invocation. That the two could disagree is what the
|
||||
// Python version needed a PreToolUse hook to police.
|
||||
func syncStart(out string) (string, *gitea.Client, error) {
|
||||
root, err := storeRoot(out)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
cfg, err := config.Require("")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
client, err := gitea.New(cfg)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return root, client, nil
|
||||
}
|
||||
|
||||
// syncStartExisting is syncStart for the commands that read the store rather
|
||||
// than create it: push, comment, close and the sync form of evict all operate
|
||||
// on issues that are already on disk, and a missing store is a mistake to
|
||||
// report, not a directory to conjure.
|
||||
func syncStartExisting(out string) (string, *gitea.Client, error) {
|
||||
root, client, err := syncStart(out)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if err := issue.RequireStore(root); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return root, client, nil
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
package cmd_test
|
||||
|
||||
// The transport, end to end: the real binary, run as a subprocess against a
|
||||
// throwaway project, talking to an httptest server that speaks enough of the
|
||||
// Gitea REST API to answer it.
|
||||
//
|
||||
// Enough and no more. What is worth proving here is not that JSON round-trips —
|
||||
// internal/mapping has tests for that, without a server anywhere — but the two
|
||||
// rules that cost work when they are wrong: a confirmed push takes the local file
|
||||
// with it, and an unconfirmed one does not touch it.
|
||||
//
|
||||
// The repository is always owner/repo, and the credentials arrive through
|
||||
// KETTLE_URL / KETTLE_TOKEN / KETTLE_REPO, which is also what a CI run does.
|
||||
// KETTLE_CONFIG_HOME points at a temp directory so no fixture can read or
|
||||
// overwrite the developer's own tokens.
|
||||
//
|
||||
// The fake answers /api/v1/version before anything else: the SDK asks an
|
||||
// instance what it is before it hands back a client, so a fake that did not
|
||||
// answer would fail every command at startup — and it is that answer the
|
||||
// dependency gate is decided on, which is why it says a version new enough to
|
||||
// have them.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sdk "code.gitea.io/sdk/gitea"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
||||
)
|
||||
|
||||
// pullGiteaVersion is what both fakes in this package claim to be: new enough
|
||||
// for the issue-dependency endpoints, which is what the transport gates on.
|
||||
const pullGiteaVersion = "1.26.1"
|
||||
|
||||
// pullVersionRoute answers the version handshake and reports whether it did.
|
||||
func pullVersionRoute(w http.ResponseWriter, r *http.Request) bool {
|
||||
if r.URL.Path != "/api/v1/version" {
|
||||
return false
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"version":"`+pullGiteaVersion+`"}`)
|
||||
return true
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// the fake tracker
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// pullFakeGitea is a Gitea instance with one repository in it, owner/repo.
|
||||
type pullFakeGitea struct {
|
||||
mu sync.Mutex
|
||||
issues map[int]*sdk.Issue
|
||||
deps map[int][]int
|
||||
comments map[int][]sdk.Comment
|
||||
labels map[string]int64
|
||||
next int
|
||||
|
||||
// writesFail makes every issue create and edit answer 500 — the failure a
|
||||
// push has to survive without losing a file.
|
||||
writesFail bool
|
||||
}
|
||||
|
||||
func pullNewGitea() *pullFakeGitea {
|
||||
return &pullFakeGitea{
|
||||
issues: map[int]*sdk.Issue{},
|
||||
deps: map[int][]int{},
|
||||
comments: map[int][]sdk.Comment{},
|
||||
labels: map[string]int64{},
|
||||
}
|
||||
}
|
||||
|
||||
// pullAdd puts an issue in the tracker the way the web UI would: it is there
|
||||
// before this project ever hears about it.
|
||||
func (g *pullFakeGitea) pullAdd(p sdk.Issue) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if p.State == "" {
|
||||
p.State = sdk.StateOpen
|
||||
}
|
||||
p.HTMLURL = pullURL(int(p.Index))
|
||||
g.issues[int(p.Index)] = &p
|
||||
if int(p.Index) > g.next {
|
||||
g.next = int(p.Index)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *pullFakeGitea) pullIssue(n int) sdk.Issue {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if p := g.issues[n]; p != nil {
|
||||
return *p
|
||||
}
|
||||
return sdk.Issue{}
|
||||
}
|
||||
|
||||
func (g *pullFakeGitea) pullRetitle(n int, title string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.issues[n].Title = title
|
||||
}
|
||||
|
||||
func (g *pullFakeGitea) pullBlocks(blocked int, blockers ...int) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.deps[blocked] = append(g.deps[blocked], blockers...)
|
||||
}
|
||||
|
||||
func pullURL(n int) string {
|
||||
return fmt.Sprintf("https://git.example.com/owner/repo/issues/%d", n)
|
||||
}
|
||||
|
||||
var (
|
||||
pullIssueRoute = regexp.MustCompile(`^issues/(\d+)$`)
|
||||
pullSubRoute = regexp.MustCompile(`^issues/(\d+)/(dependencies|comments|labels)$`)
|
||||
)
|
||||
|
||||
func (g *pullFakeGitea) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
if pullVersionRoute(w, r) {
|
||||
return
|
||||
}
|
||||
path, ok := strings.CutPrefix(r.URL.Path, "/api/v1/repos/owner/repo/")
|
||||
if !ok {
|
||||
http.Error(w, `{"message":"no such repository"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case path == "labels" && r.Method == http.MethodGet:
|
||||
out := []sdk.Label{}
|
||||
for name, id := range g.labels {
|
||||
out = append(out, sdk.Label{ID: id, Name: name})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
pullJSON(w, out)
|
||||
|
||||
case path == "labels" && r.Method == http.MethodPost:
|
||||
var req sdk.CreateLabelOption
|
||||
pullDecode(r, &req)
|
||||
id := int64(1000 + len(g.labels))
|
||||
g.labels[req.Name] = id
|
||||
pullJSON(w, sdk.Label{ID: id, Name: req.Name, Color: req.Color, Exclusive: req.Exclusive})
|
||||
|
||||
case path == "milestones" && r.Method == http.MethodGet:
|
||||
pullJSON(w, []sdk.Milestone{})
|
||||
|
||||
case path == "issues" && r.Method == http.MethodPost:
|
||||
if g.writesFail {
|
||||
http.Error(w, `{"message":"the tracker is having a bad day"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var req sdk.CreateIssueOption
|
||||
pullDecode(r, &req)
|
||||
g.next++
|
||||
p := &sdk.Issue{
|
||||
Index: int64(g.next), Title: req.Title, Body: req.Body,
|
||||
State: sdk.StateOpen, HTMLURL: pullURL(g.next), Labels: g.pullLabelsFor(req.Labels),
|
||||
}
|
||||
g.issues[g.next] = p
|
||||
pullJSON(w, p)
|
||||
|
||||
case path == "issues" && r.Method == http.MethodGet:
|
||||
g.pullList(w, r)
|
||||
|
||||
case pullIssueRoute.MatchString(path):
|
||||
n := pullNumber(pullIssueRoute, path)
|
||||
p := g.issues[n]
|
||||
if p == nil {
|
||||
http.Error(w, `{"message":"no such issue"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPatch {
|
||||
if g.writesFail {
|
||||
http.Error(w, `{"message":"the tracker is having a bad day"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var req sdk.EditIssueOption
|
||||
pullDecode(r, &req)
|
||||
// An empty title is Gitea's "leave it alone" — the one field of an
|
||||
// edit that says so with a zero value rather than with null.
|
||||
if req.Title != "" {
|
||||
p.Title = req.Title
|
||||
}
|
||||
if req.Body != nil {
|
||||
p.Body = *req.Body
|
||||
}
|
||||
if req.State != nil {
|
||||
p.State = *req.State
|
||||
}
|
||||
// No labels here on purpose: Gitea's edit endpoint takes none, so
|
||||
// an issue whose labels changed gets them through PUT ./labels
|
||||
// below, and a fake that quietly accepted them would hide a push
|
||||
// that never sent them.
|
||||
}
|
||||
pullJSON(w, p)
|
||||
|
||||
case pullSubRoute.MatchString(path):
|
||||
m := pullSubRoute.FindStringSubmatch(path)
|
||||
n, _ := strconv.Atoi(m[1])
|
||||
switch {
|
||||
case m[2] == "dependencies" && r.Method == http.MethodGet:
|
||||
out := []sdk.Issue{}
|
||||
for _, d := range g.deps[n] {
|
||||
if p := g.issues[d]; p != nil {
|
||||
out = append(out, *p)
|
||||
}
|
||||
}
|
||||
pullJSON(w, out)
|
||||
case m[2] == "dependencies" && r.Method == http.MethodPost:
|
||||
var req struct {
|
||||
Index int `json:"index"`
|
||||
}
|
||||
pullDecode(r, &req)
|
||||
g.deps[n] = append(g.deps[n], req.Index)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
case m[2] == "comments" && r.Method == http.MethodGet:
|
||||
out := g.comments[n]
|
||||
if out == nil {
|
||||
out = []sdk.Comment{}
|
||||
}
|
||||
pullJSON(w, out)
|
||||
case m[2] == "labels" && r.Method == http.MethodPut:
|
||||
var req sdk.IssueLabelsOption
|
||||
pullDecode(r, &req)
|
||||
g.issues[n].Labels = g.pullLabelsFor(req.Labels)
|
||||
pullJSON(w, g.issues[n].Labels)
|
||||
default:
|
||||
http.Error(w, `{"message":"not implemented"}`, http.StatusNotFound)
|
||||
}
|
||||
|
||||
default:
|
||||
http.Error(w, `{"message":"not implemented: `+path+`"}`, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
// pullList is the filtered listing, paginated the way the client asks for it.
|
||||
func (g *pullFakeGitea) pullList(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
state, page, limit := q.Get("state"), 1, 50
|
||||
if v, err := strconv.Atoi(q.Get("page")); err == nil && v > 0 {
|
||||
page = v
|
||||
}
|
||||
if v, err := strconv.Atoi(q.Get("limit")); err == nil && v > 0 {
|
||||
limit = v
|
||||
}
|
||||
var want []string
|
||||
if v := q.Get("labels"); v != "" {
|
||||
want = strings.Split(v, ",")
|
||||
}
|
||||
|
||||
numbers := make([]int, 0, len(g.issues))
|
||||
for n := range g.issues {
|
||||
numbers = append(numbers, n)
|
||||
}
|
||||
sort.Ints(numbers)
|
||||
|
||||
out := []sdk.Issue{}
|
||||
for _, n := range numbers {
|
||||
p := g.issues[n]
|
||||
if state != "" && state != "all" && string(p.State) != state {
|
||||
continue
|
||||
}
|
||||
has := map[string]bool{}
|
||||
for _, l := range p.Labels {
|
||||
has[l.Name] = true
|
||||
}
|
||||
missing := false
|
||||
for _, l := range want {
|
||||
missing = missing || !has[l]
|
||||
}
|
||||
if missing {
|
||||
continue
|
||||
}
|
||||
out = append(out, *p)
|
||||
}
|
||||
|
||||
start := (page - 1) * limit
|
||||
if start > len(out) {
|
||||
start = len(out)
|
||||
}
|
||||
end := start + limit
|
||||
if end > len(out) {
|
||||
end = len(out)
|
||||
}
|
||||
pullJSON(w, out[start:end])
|
||||
}
|
||||
|
||||
func (g *pullFakeGitea) pullLabelsFor(ids []int64) []*sdk.Label {
|
||||
if ids == nil {
|
||||
return nil
|
||||
}
|
||||
byID := map[int64]string{}
|
||||
for name, id := range g.labels {
|
||||
byID[id] = name
|
||||
}
|
||||
var out []*sdk.Label
|
||||
for _, id := range ids {
|
||||
if name, ok := byID[id]; ok {
|
||||
out = append(out, &sdk.Label{ID: id, Name: name})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pullNumber(re *regexp.Regexp, path string) int {
|
||||
n, _ := strconv.Atoi(re.FindStringSubmatch(path)[1])
|
||||
return n
|
||||
}
|
||||
|
||||
func pullDecode(r *http.Request, into any) {
|
||||
_ = json.NewDecoder(r.Body).Decode(into)
|
||||
}
|
||||
|
||||
func pullJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// pullEnv starts the fake and returns the environment that points the binary at
|
||||
// it. The credential home is a temp directory: a test run may neither read nor
|
||||
// overwrite the developer's own tokens.
|
||||
func pullEnv(t *testing.T, g *pullFakeGitea) []string {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(g)
|
||||
t.Cleanup(srv.Close)
|
||||
return []string{
|
||||
config.EnvURL + "=" + srv.URL,
|
||||
config.EnvToken + "=t0ken",
|
||||
config.EnvRepo + "=owner/repo",
|
||||
config.EnvHome + "=" + t.TempDir(),
|
||||
}
|
||||
}
|
||||
|
||||
func pullStore(dir string) string { return filepath.Join(dir, ".kettle", "issues") }
|
||||
|
||||
func pullRead(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func pullExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// push
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// The rule the whole design rests on: once the tracker has the issue, the
|
||||
// tracker IS the issue, and the local copy goes — sidecars included.
|
||||
func TestPushCreatesTheIssueAndTakesTheLocalCopyWithIt(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
g := pullNewGitea()
|
||||
env := pullEnv(t, g)
|
||||
|
||||
mustRun(t, dir, "new", "--type", "task", "--title", "Wire sqlc into the appclick layer")
|
||||
const id = "wire-sqlc-into-the-appclick-layer"
|
||||
store := pullStore(dir)
|
||||
sidecar := filepath.Join(store, id+".comments.md")
|
||||
if err := os.WriteFile(sidecar, []byte("# thread\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := runWith(t, dir, env, "", "push")
|
||||
if r.code != 0 {
|
||||
t.Fatalf("push exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
// The number and the URL lead: in a moment they are the only address the
|
||||
// issue has.
|
||||
if !strings.Contains(r.stdout, "created "+id+" #1 "+pullURL(1)) {
|
||||
t.Errorf("the receipt does not say where the issue lives now:\n%s", r.stdout)
|
||||
}
|
||||
|
||||
if pullExists(filepath.Join(store, id+".md")) {
|
||||
t.Error("the local file survived a confirmed push — what is in the store is what has not left")
|
||||
}
|
||||
if pullExists(sidecar) {
|
||||
t.Error("the sidecar was left behind; every file under the slug goes")
|
||||
}
|
||||
|
||||
// The ledger is what makes the slug come back, so it has to hold the number.
|
||||
ledger := pullRead(t, filepath.Join(store, ".remote.json"))
|
||||
if !strings.Contains(ledger, `"owner/repo#1": "`+id+`"`) {
|
||||
t.Errorf("the ledger does not index the number:\n%s", ledger)
|
||||
}
|
||||
// And the slug travelled up in the body, which is what survives a lost ledger.
|
||||
if body := g.pullIssue(1).Body; !strings.Contains(body, "<!-- kettle:id "+id+" -->") {
|
||||
t.Errorf("the id marker did not go up with the issue:\n%s", body)
|
||||
}
|
||||
if !strings.HasPrefix(g.pullIssue(1).Body, "<!-- kettle:id") {
|
||||
t.Error("the marker must be the first line of the tracker-side body")
|
||||
}
|
||||
}
|
||||
|
||||
// Network down, non-2xx, an answer that does not confirm the write: the file
|
||||
// stays and the run stops. Nothing is deleted that was not just accepted.
|
||||
func TestPushLeavesTheFileWhenTheTrackerRefuses(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
g := pullNewGitea()
|
||||
g.writesFail = true
|
||||
env := pullEnv(t, g)
|
||||
|
||||
mustRun(t, dir, "new", "--type", "task", "--title", "Never made it up")
|
||||
const id = "never-made-it-up"
|
||||
path := filepath.Join(pullStore(dir), id+".md")
|
||||
before := pullRead(t, path)
|
||||
|
||||
r := runWith(t, dir, env, "", "push")
|
||||
if r.code == 0 {
|
||||
t.Fatalf("a tracker that refuses the write must fail the run:\n%s", r.out())
|
||||
}
|
||||
if after := pullRead(t, path); after != before {
|
||||
t.Errorf("the file was touched by a push that never landed:\n%s", after)
|
||||
}
|
||||
// The message has to name the file, because "is my only copy still there" is
|
||||
// the question an operator has at that moment.
|
||||
if !strings.Contains(r.stderr, path) {
|
||||
t.Errorf("the failure does not name the file it did not touch:\n%s", r.stderr)
|
||||
}
|
||||
if pullExists(filepath.Join(pullStore(dir), ".remote.json")) {
|
||||
t.Error("a ledger entry was written for an issue the tracker never confirmed")
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// pull
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// A number is an address, not a query. Only filter mode leaves closed issues out.
|
||||
func TestPullByNumberFetchesAClosedIssue(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
g := pullNewGitea()
|
||||
g.pullAdd(sdk.Issue{
|
||||
Index: 7, Title: "Closed but addressable", State: sdk.StateClosed,
|
||||
Body: "## Summary\nДело сделано.\n", Updated: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC),
|
||||
})
|
||||
env := pullEnv(t, g)
|
||||
|
||||
r := runWith(t, dir, env, "", "pull", "7")
|
||||
if r.code != 0 {
|
||||
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
file := pullRead(t, filepath.Join(pullStore(dir), "closed-but-addressable.md"))
|
||||
if !strings.Contains(file, "state: closed") {
|
||||
t.Errorf("the closed state did not land on disk:\n%s", file)
|
||||
}
|
||||
if !strings.Contains(file, "gitea: owner/repo#7") {
|
||||
t.Errorf("the cross-repo handle is missing:\n%s", file)
|
||||
}
|
||||
if !strings.Contains(file, "origin: gitea") {
|
||||
t.Errorf("the issue does not say it exists elsewhere:\n%s", file)
|
||||
}
|
||||
}
|
||||
|
||||
// A pull answers with the unit of work — the issue and what blocks it — and
|
||||
// --no-deps is how you ask for one row of it.
|
||||
func TestPullBringsTheBlockerDownWithIt(t *testing.T) {
|
||||
g := pullNewGitea()
|
||||
g.pullAdd(sdk.Issue{Index: 1, Title: "Migrate the schema", Body: "## Summary\nx\n"})
|
||||
g.pullAdd(sdk.Issue{Index: 2, Title: "Wire sqlc into the layer", Body: "## Summary\nx\n"})
|
||||
g.pullBlocks(2, 1)
|
||||
env := pullEnv(t, g)
|
||||
|
||||
t.Run("by default", func(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
if r := runWith(t, dir, env, "", "pull", "2"); r.code != 0 {
|
||||
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
if !pullExists(filepath.Join(pullStore(dir), "migrate-the-schema.md")) {
|
||||
t.Fatal("the blocker did not come down — a pull returns the unit of work")
|
||||
}
|
||||
dependent := pullRead(t, filepath.Join(pullStore(dir), "wire-sqlc-into-the-layer.md"))
|
||||
if !strings.Contains(dependent, "depends: [migrate-the-schema]") {
|
||||
t.Errorf("depends: was not filled from the tracker's own graph:\n%s", dependent)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("--no-deps", func(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
if r := runWith(t, dir, env, "", "pull", "2", "--no-deps"); r.code != 0 {
|
||||
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
if pullExists(filepath.Join(pullStore(dir), "migrate-the-schema.md")) {
|
||||
t.Error("--no-deps followed a blocker anyway")
|
||||
}
|
||||
dependent := pullRead(t, filepath.Join(pullStore(dir), "wire-sqlc-into-the-layer.md"))
|
||||
if !strings.Contains(dependent, "depends: []") {
|
||||
t.Errorf("--no-deps filled depends: anyway:\n%s", dependent)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The round trip, and the two things that carry the slug through it: the ledger,
|
||||
// and — when the ledger is gone, as it is in a fresh clone — the marker in the
|
||||
// body. A rename in the web UI changes neither.
|
||||
func TestAPushedIssueComesBackUnderItsOriginalSlug(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
g := pullNewGitea()
|
||||
env := pullEnv(t, g)
|
||||
|
||||
mustRun(t, dir, "new", "--type", "task", "--title", "Wire sqlc into the appclick layer")
|
||||
const id = "wire-sqlc-into-the-appclick-layer"
|
||||
store := pullStore(dir)
|
||||
|
||||
if r := runWith(t, dir, env, "", "push"); r.code != 0 {
|
||||
t.Fatalf("push exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
if pullExists(filepath.Join(store, id+".md")) {
|
||||
t.Fatal("push did not drop the local copy")
|
||||
}
|
||||
g.pullRetitle(1, "Somebody retitled this in the web UI")
|
||||
|
||||
// The ledger knows the number, so it wins.
|
||||
if r := runWith(t, dir, env, "", "pull", "1"); r.code != 0 {
|
||||
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
file := pullRead(t, filepath.Join(store, id+".md"))
|
||||
if !strings.Contains(file, "# Somebody retitled this in the web UI") {
|
||||
t.Errorf("the new title did not come down:\n%s", file)
|
||||
}
|
||||
// The marker is transport bookkeeping and never reaches the store.
|
||||
if strings.Contains(file, "kettle:id") {
|
||||
t.Errorf("the id marker was written into the local file:\n%s", file)
|
||||
}
|
||||
|
||||
// Now lose both the file and the ledger, the way a fresh clone has neither.
|
||||
// The marker in the body is all that is left, and it is enough.
|
||||
for _, p := range []string{filepath.Join(store, id+".md"), filepath.Join(store, ".remote.json")} {
|
||||
if err := os.Remove(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if r := runWith(t, dir, env, "", "pull", "1"); r.code != 0 {
|
||||
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
if !pullExists(filepath.Join(store, id+".md")) {
|
||||
names, _ := os.ReadDir(store)
|
||||
var have []string
|
||||
for _, e := range names {
|
||||
have = append(have, e.Name())
|
||||
}
|
||||
t.Fatalf("the issue came back under another name — every depends: pointing at it now "+
|
||||
"dangles; the store holds: %s", strings.Join(have, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// A closed issue is not a unit of work, so a FILTER enumerates it and leaves it
|
||||
// out — the exact opposite of what a key does, and only --state closed changes
|
||||
// it.
|
||||
func TestPullFilterModeLeavesClosedIssuesOut(t *testing.T) {
|
||||
g := pullNewGitea()
|
||||
bug := []*sdk.Label{{ID: 1, Name: "type/bug"}}
|
||||
g.pullAdd(sdk.Issue{Index: 1, Title: "Still broken", Body: "## Summary\nx\n", Labels: bug})
|
||||
g.pullAdd(sdk.Issue{Index: 2, Title: "Fixed last week", State: sdk.StateClosed,
|
||||
Body: "## Summary\nx\n", Labels: bug})
|
||||
env := pullEnv(t, g)
|
||||
|
||||
dir := newProject(t)
|
||||
r := runWith(t, dir, env, "", "pull", "--label", "type/bug", "--state", "all")
|
||||
if r.code != 0 {
|
||||
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
if pullExists(filepath.Join(pullStore(dir), "fixed-last-week.md")) {
|
||||
t.Error("a filter stored a closed issue")
|
||||
}
|
||||
// Nothing is dropped in silence.
|
||||
if !strings.Contains(r.stderr, "1 closed issue(s) enumerated, not stored") {
|
||||
t.Errorf("the closed issue went out without a word:\n%s", r.stderr)
|
||||
}
|
||||
|
||||
// Naming the state is how you ask for one.
|
||||
if r := runWith(t, dir, env, "", "pull", "--label", "type/bug", "--state", "closed"); r.code != 0 {
|
||||
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
if !pullExists(filepath.Join(pullStore(dir), "fixed-last-week.md")) {
|
||||
t.Error("--state closed did not store the closed issue")
|
||||
}
|
||||
}
|
||||
|
||||
// ONE RULE, NO EXCEPTION: a PATCH is a push, and it drops the local copy too.
|
||||
func TestPushUpdateDropsTheLocalCopyAsWell(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
g := pullNewGitea()
|
||||
g.pullAdd(sdk.Issue{
|
||||
Index: 3, Title: "Came down and went back up",
|
||||
Body: "## Summary\nx\n\n## Spec\nnone\n\n## Acceptance criteria\n- [ ] x\n",
|
||||
Labels: []*sdk.Label{{ID: 1, Name: "type/task"}},
|
||||
})
|
||||
env := pullEnv(t, g)
|
||||
|
||||
if r := runWith(t, dir, env, "", "pull", "3"); r.code != 0 {
|
||||
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
const id = "came-down-and-went-back-up"
|
||||
path := filepath.Join(pullStore(dir), id+".md")
|
||||
if !pullExists(path) {
|
||||
t.Fatal("the issue did not arrive")
|
||||
}
|
||||
|
||||
r := runWith(t, dir, env, "", "push", "--update", id)
|
||||
if r.code != 0 {
|
||||
t.Fatalf("push --update exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
if !strings.Contains(r.stdout, "updated "+id+" #3") {
|
||||
t.Errorf("the receipt does not report the PATCH:\n%s", r.stdout)
|
||||
}
|
||||
if pullExists(path) {
|
||||
t.Error("--update kept the local file — two rules would put back the question " +
|
||||
"push exists to remove")
|
||||
}
|
||||
}
|
||||
|
||||
// A dry run makes no request, so it must not need a credential to say what it
|
||||
// would do — no URL, no token, no repository in the environment at all.
|
||||
func TestPushDryRunNeedsNoCredential(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
mustRun(t, dir, "new", "--type", "task", "--title", "Planned but not sent")
|
||||
|
||||
r := run(t, dir, "push", "--dry-run")
|
||||
if r.code != 0 {
|
||||
t.Fatalf("a dry run must not need a tracker:\n%s", r.out())
|
||||
}
|
||||
if !strings.Contains(r.stdout, "ok planned-but-not-sent") ||
|
||||
!strings.Contains(r.stdout, "1 issue(s) would be created") {
|
||||
t.Errorf("the plan was not printed:\n%s", r.stdout)
|
||||
}
|
||||
if !pullExists(filepath.Join(pullStore(dir), "planned-but-not-sent.md")) {
|
||||
t.Error("a dry run deleted the issue")
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// remote
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// Discovery writes nothing: the store is a store, not a search-results folder.
|
||||
func TestRemoteListsWithoutWritingAnything(t *testing.T) {
|
||||
dir := newProject(t)
|
||||
g := pullNewGitea()
|
||||
g.pullAdd(sdk.Issue{Index: 4, Title: "Something open", Body: "x"})
|
||||
g.pullAdd(sdk.Issue{Index: 5, Title: "Something closed", State: sdk.StateClosed, Body: "x"})
|
||||
env := pullEnv(t, g)
|
||||
|
||||
r := runWith(t, dir, env, "", "remote")
|
||||
if r.code != 0 {
|
||||
t.Fatalf("remote exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
if !strings.Contains(r.stdout, "#4") || strings.Contains(r.stdout, "#5") {
|
||||
t.Errorf("the default listing is the open issues:\n%s", r.stdout)
|
||||
}
|
||||
if entries, err := os.ReadDir(pullStore(dir)); err != nil || len(entries) != 0 {
|
||||
t.Errorf("a listing left files in the store: %v", entries)
|
||||
}
|
||||
|
||||
// A number the store already knows about says so, so it is obvious what a
|
||||
// pull would refresh and what it would add.
|
||||
if r := runWith(t, dir, env, "", "pull", "4"); r.code != 0 {
|
||||
t.Fatalf("pull exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
again := runWith(t, dir, env, "", "remote")
|
||||
if !strings.Contains(again.stdout, "└─ local: something-open") {
|
||||
t.Errorf("the local slug was not reported:\n%s", again.stdout)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
package cmd_test
|
||||
|
||||
// The four commands that WRITE — comment, close, labels, sync-evict — end to
|
||||
// end: the real binary, in a throwaway project, against an httptest server
|
||||
// speaking enough of the Gitea API to answer them.
|
||||
//
|
||||
// A fake tracker rather than a mocked client, because what these commands are
|
||||
// trusted to get right is exactly the part a mock would stand in for: what goes
|
||||
// out, and what is believed about the answer. `sync-evict` deletes files on the
|
||||
// strength of a payload, so the payload has to come off a socket.
|
||||
//
|
||||
// Every helper here is named `wr…` so it cannot collide with the read side's.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
)
|
||||
|
||||
const (
|
||||
wrRepo = "kettle/tests"
|
||||
wrToken = "s3cr3t-token"
|
||||
wrWhen = "2026-08-11T12:00:00Z"
|
||||
)
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// the fake tracker
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
type wrLabel struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
Description string `json:"description"`
|
||||
Exclusive bool `json:"exclusive"`
|
||||
}
|
||||
|
||||
type wrIssue struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
State string `json:"state"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type wrUser struct {
|
||||
Login string `json:"login"`
|
||||
}
|
||||
|
||||
type wrComment struct {
|
||||
ID int64 `json:"id"`
|
||||
Body string `json:"body"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
User wrUser `json:"user"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
var (
|
||||
wrIssuePath = regexp.MustCompile(`^issues/(\d+)$`)
|
||||
wrCommentPath = regexp.MustCompile(`^issues/(\d+)/comments$`)
|
||||
wrLabelPath = regexp.MustCompile(`^labels/(\d+)$`)
|
||||
)
|
||||
|
||||
// wrTracker is one repository on a pretend Gitea. It records every call, so a
|
||||
// test can assert that a dry run sent nothing and that a second bootstrap wrote
|
||||
// nothing.
|
||||
type wrTracker struct {
|
||||
mu sync.Mutex
|
||||
labels []wrLabel
|
||||
issues map[int]*wrIssue
|
||||
comments map[int][]wrComment
|
||||
broken map[int]bool // numbers whose GET answers 500
|
||||
calls []string
|
||||
next int64
|
||||
}
|
||||
|
||||
func wrNewTracker() *wrTracker {
|
||||
return &wrTracker{
|
||||
issues: map[int]*wrIssue{},
|
||||
comments: map[int][]wrComment{},
|
||||
broken: map[int]bool{},
|
||||
next: 100,
|
||||
}
|
||||
}
|
||||
|
||||
func (tr *wrTracker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
tr.mu.Lock()
|
||||
defer tr.mu.Unlock()
|
||||
|
||||
// The version handshake, answered before the log: it is not a call any
|
||||
// command made, and counting it would make every "how many requests did
|
||||
// that send" assertion in this file one out.
|
||||
if pullVersionRoute(w, r) {
|
||||
return
|
||||
}
|
||||
tr.calls = append(tr.calls, r.Method+" "+r.URL.Path)
|
||||
|
||||
// The scheme Gitea uses and the client sends: the word `token`.
|
||||
if r.Header.Get("Authorization") != "token "+wrToken {
|
||||
http.Error(w, `{"message":"token required"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
path, ok := strings.CutPrefix(r.URL.Path, "/api/v1/repos/"+wrRepo+"/")
|
||||
if !ok {
|
||||
http.Error(w, `{"message":"no such repository"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case path == "labels" && r.Method == http.MethodGet:
|
||||
wrJSON(w, tr.labels)
|
||||
|
||||
case path == "labels" && r.Method == http.MethodPost:
|
||||
var req wrLabel
|
||||
wrDecode(r, &req)
|
||||
tr.next++
|
||||
req.ID = tr.next
|
||||
tr.labels = append(tr.labels, req)
|
||||
wrJSON(w, req)
|
||||
|
||||
case r.Method == http.MethodPatch && wrLabelPath.MatchString(path):
|
||||
id, _ := strconv.ParseInt(wrLabelPath.FindStringSubmatch(path)[1], 10, 64)
|
||||
var req wrLabel
|
||||
wrDecode(r, &req)
|
||||
for i := range tr.labels {
|
||||
if tr.labels[i].ID == id {
|
||||
req.ID = id
|
||||
tr.labels[i] = req
|
||||
wrJSON(w, req)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Error(w, `{"message":"no such label"}`, http.StatusNotFound)
|
||||
|
||||
case wrIssuePath.MatchString(path):
|
||||
n, _ := strconv.Atoi(wrIssuePath.FindStringSubmatch(path)[1])
|
||||
if tr.broken[n] {
|
||||
http.Error(w, `{"message":"the tracker is having a bad day"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
got := tr.issues[n]
|
||||
if got == nil {
|
||||
http.Error(w, `{"message":"no such issue"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPatch {
|
||||
var req struct {
|
||||
State *string `json:"state"`
|
||||
Title string `json:"title"`
|
||||
Body *string `json:"body"`
|
||||
}
|
||||
wrDecode(r, &req)
|
||||
// A close is state and nothing else; a title or a body arriving
|
||||
// here would be the command editing an issue it was only asked to
|
||||
// close.
|
||||
//
|
||||
// A title of "" is not one. The SDK's edit body has no pointer
|
||||
// there and sends the key whatever happens, so an empty string is
|
||||
// how "no opinion" is spelled for this one field — which is also
|
||||
// how Gitea itself reads it, and Gitea is what this stands in for.
|
||||
// Everything else is a pointer and must arrive as null.
|
||||
if req.Title != "" || req.Body != nil {
|
||||
http.Error(w, `{"message":"close sent more than a state"}`, http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
if req.State != nil {
|
||||
got.State = *req.State
|
||||
}
|
||||
got.UpdatedAt = wrWhen
|
||||
}
|
||||
wrJSON(w, got)
|
||||
|
||||
case wrCommentPath.MatchString(path):
|
||||
n, _ := strconv.Atoi(wrCommentPath.FindStringSubmatch(path)[1])
|
||||
if tr.issues[n] == nil {
|
||||
http.Error(w, `{"message":"no such issue"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost {
|
||||
var req struct {
|
||||
Body string `json:"body"`
|
||||
}
|
||||
wrDecode(r, &req)
|
||||
tr.next++
|
||||
c := wrComment{
|
||||
ID: tr.next,
|
||||
Body: req.Body,
|
||||
HTMLURL: fmt.Sprintf("https://tracker.example/%s/issues/%d#issuecomment-%d", wrRepo, n, tr.next),
|
||||
User: wrUser{Login: "tester"},
|
||||
CreatedAt: wrWhen,
|
||||
}
|
||||
tr.comments[n] = append(tr.comments[n], c)
|
||||
wrJSON(w, c)
|
||||
return
|
||||
}
|
||||
wrJSON(w, tr.comments[n])
|
||||
|
||||
default:
|
||||
http.Error(w, `{"message":"not implemented: `+path+`"}`, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func wrJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func wrDecode(r *http.Request, v any) {
|
||||
_ = json.NewDecoder(r.Body).Decode(v)
|
||||
}
|
||||
|
||||
// --- what the tracker holds, for a test to arrange and to read back ---------
|
||||
|
||||
func (tr *wrTracker) add(number int, title, state string) {
|
||||
tr.mu.Lock()
|
||||
defer tr.mu.Unlock()
|
||||
tr.issues[number] = &wrIssue{
|
||||
Number: number,
|
||||
Title: title,
|
||||
State: state,
|
||||
HTMLURL: fmt.Sprintf("https://tracker.example/%s/issues/%d", wrRepo, number),
|
||||
UpdatedAt: wrWhen,
|
||||
}
|
||||
}
|
||||
|
||||
func (tr *wrTracker) breaks(number int) {
|
||||
tr.mu.Lock()
|
||||
defer tr.mu.Unlock()
|
||||
tr.broken[number] = true
|
||||
}
|
||||
|
||||
func (tr *wrTracker) state(number int) string {
|
||||
tr.mu.Lock()
|
||||
defer tr.mu.Unlock()
|
||||
if got := tr.issues[number]; got != nil {
|
||||
return got.State
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (tr *wrTracker) label(name string) *wrLabel {
|
||||
tr.mu.Lock()
|
||||
defer tr.mu.Unlock()
|
||||
for i := range tr.labels {
|
||||
if tr.labels[i].Name == name {
|
||||
out := tr.labels[i]
|
||||
return &out
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tr *wrTracker) labelCount() int {
|
||||
tr.mu.Lock()
|
||||
defer tr.mu.Unlock()
|
||||
return len(tr.labels)
|
||||
}
|
||||
|
||||
func (tr *wrTracker) thread(number int) []wrComment {
|
||||
tr.mu.Lock()
|
||||
defer tr.mu.Unlock()
|
||||
return append([]wrComment{}, tr.comments[number]...)
|
||||
}
|
||||
|
||||
// mark is where the log has got to, so a test can ask what one run sent.
|
||||
func (tr *wrTracker) mark() int {
|
||||
tr.mu.Lock()
|
||||
defer tr.mu.Unlock()
|
||||
return len(tr.calls)
|
||||
}
|
||||
|
||||
func (tr *wrTracker) since(mark int) []string {
|
||||
tr.mu.Lock()
|
||||
defer tr.mu.Unlock()
|
||||
return append([]string{}, tr.calls[mark:]...)
|
||||
}
|
||||
|
||||
func (tr *wrTracker) count(method string) int {
|
||||
tr.mu.Lock()
|
||||
defer tr.mu.Unlock()
|
||||
n := 0
|
||||
for _, c := range tr.calls {
|
||||
if strings.HasPrefix(c, method+" ") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// the fixture
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// wrProject is an initialized project pointed at a fake tracker.
|
||||
//
|
||||
// The credentials arrive through the environment, which is what they are there
|
||||
// for — and KETTLE_CONFIG_HOME goes at a temp directory so a run can neither
|
||||
// read nor overwrite the developer's own tokens. KETTLE_LOGIN is cleared for the
|
||||
// same reason: a value in the developer's shell would send every fixture
|
||||
// looking for a login that is not in the temp file.
|
||||
func wrProject(t *testing.T) (dir string, tr *wrTracker, env []string) {
|
||||
t.Helper()
|
||||
dir = newProject(t)
|
||||
tr = wrNewTracker()
|
||||
srv := httptest.NewServer(tr)
|
||||
t.Cleanup(srv.Close)
|
||||
return dir, tr, []string{
|
||||
"KETTLE_URL=" + srv.URL,
|
||||
"KETTLE_TOKEN=" + wrToken,
|
||||
"KETTLE_REPO=" + wrRepo,
|
||||
"KETTLE_CONFIG_HOME=" + t.TempDir(),
|
||||
"KETTLE_LOGIN=",
|
||||
}
|
||||
}
|
||||
|
||||
func wrStore(dir string) string { return filepath.Join(dir, ".kettle", "issues") }
|
||||
|
||||
// wrTracked writes an issue the tracker also holds — a working copy, as a pull
|
||||
// would have left it.
|
||||
func wrTracked(t *testing.T, dir, id string, number int, state string) string {
|
||||
t.Helper()
|
||||
return wrWrite(t, dir, id, fmt.Sprintf(
|
||||
"---\nid: %s\nstate: %s\nlabels: [type/task]\nassignees: []\nmilestone: none\ndepends: []\n"+
|
||||
"origin: gitea\ngitea: %s#%d\nsynced: 2026-01-01T00:00:00Z\nurl: https://tracker.example/%s/issues/%d\n---\n"+
|
||||
"# %s\n\n## Summary\nчто-то\n\n## Spec\nnone\n\n## Acceptance criteria\n- [ ] сделано\n",
|
||||
id, state, wrRepo, number, wrRepo, number, id))
|
||||
}
|
||||
|
||||
// wrLocal writes an `origin: local` issue — the only copy of that work.
|
||||
func wrLocal(t *testing.T, dir, id, state string) string {
|
||||
t.Helper()
|
||||
return wrWrite(t, dir, id, fmt.Sprintf(
|
||||
"---\nid: %s\nstate: %s\nlabels: [type/task]\nassignees: []\nmilestone: none\ndepends: []\n"+
|
||||
"origin: local\n---\n# %s\n\n## Summary\nчто-то\n\n## Spec\nnone\n\n## Acceptance criteria\n- [ ] сделано\n",
|
||||
id, state, id))
|
||||
}
|
||||
|
||||
func wrWrite(t *testing.T, dir, id, text string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(wrStore(dir), id+".md")
|
||||
if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func wrRead(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func wrGone(t *testing.T, path, why string) {
|
||||
t.Helper()
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
t.Errorf("%s is still there — %s", filepath.Base(path), why)
|
||||
}
|
||||
}
|
||||
|
||||
func wrThere(t *testing.T, path, why string) {
|
||||
t.Helper()
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("%s is gone — %s", filepath.Base(path), why)
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// labels
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func TestLabelsCreatesTheCanonicalSetAndThenChangesNothing(t *testing.T) {
|
||||
dir, tr, env := wrProject(t)
|
||||
|
||||
r := runWith(t, dir, env, "", "labels")
|
||||
if r.code != 0 {
|
||||
t.Fatalf("labels exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
|
||||
// The set is the domain's, name for name: nothing is spelled out in the
|
||||
// command, so adding a type over there is what adds it here.
|
||||
want := issue.CanonicalLabels()
|
||||
if got := tr.labelCount(); got != len(want) {
|
||||
t.Fatalf("the repository holds %d label(s), want %d:\n%s", got, len(want), r.out())
|
||||
}
|
||||
for _, name := range want {
|
||||
l := tr.label(name)
|
||||
if l == nil {
|
||||
t.Fatalf("%s was not created:\n%s", name, r.out())
|
||||
}
|
||||
if l.Color == "" {
|
||||
t.Errorf("%s was created with no colour", name)
|
||||
}
|
||||
// `exclusive` is the flag no tracker CLI could set, and the whole reason
|
||||
// label creation goes through the API.
|
||||
if !l.Exclusive {
|
||||
t.Errorf("%s is not exclusive", name)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(r.stdout, "created type/bug") {
|
||||
t.Errorf("the receipt does not name what it created:\n%s", r.stdout)
|
||||
}
|
||||
|
||||
// A label belongs to the repository, not to any issue: this must not have
|
||||
// touched the store.
|
||||
if _, err := os.Stat(filepath.Join(wrStore(dir), "INDEX.md")); err == nil {
|
||||
t.Error("a label bootstrap wrote into the issue store")
|
||||
}
|
||||
|
||||
mark := tr.mark()
|
||||
again := runWith(t, dir, env, "", "labels")
|
||||
if again.code != 0 {
|
||||
t.Fatalf("the second run exited %d:\n%s", again.code, again.out())
|
||||
}
|
||||
for _, c := range tr.since(mark) {
|
||||
if !strings.HasPrefix(c, "GET ") {
|
||||
t.Errorf("the second run wrote: %s", c)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(again.stdout, "present type/bug") ||
|
||||
!strings.Contains(again.stdout, "0 created") {
|
||||
t.Errorf("a second run must be a no-op and say so:\n%s", again.stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// close
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func TestCloseChangesTheStateOnTheTrackerAndOnDisk(t *testing.T) {
|
||||
dir, tr, env := wrProject(t)
|
||||
tr.add(42, "Done and elsewhere", "open")
|
||||
tr.add(99, "Never seen here", "open")
|
||||
path := wrTracked(t, dir, "done-and-elsewhere", 42, "open")
|
||||
|
||||
dry := runWith(t, dir, env, "", "close", "--dry-run", "done-and-elsewhere")
|
||||
if dry.code != 0 || !strings.Contains(dry.stdout, "would close") {
|
||||
t.Fatalf("the dry run said nothing:\n%s", dry.out())
|
||||
}
|
||||
if n := tr.count("PATCH"); n != 0 {
|
||||
t.Errorf("a dry run sent %d write(s) — it must make no request at all", n)
|
||||
}
|
||||
if !strings.Contains(wrRead(t, path), "state: open") {
|
||||
t.Error("a dry run wrote to the local file")
|
||||
}
|
||||
|
||||
r := runWith(t, dir, env, "", "close", "done-and-elsewhere")
|
||||
if r.code != 0 {
|
||||
t.Fatalf("close exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
if got := tr.state(42); got != "closed" {
|
||||
t.Errorf("the tracker says %q, want closed", got)
|
||||
}
|
||||
local := wrRead(t, path)
|
||||
if !strings.Contains(local, "state: closed") {
|
||||
t.Errorf("the local copy was not brought along:\n%s", local)
|
||||
}
|
||||
// The answer that authorized the write is also the newest thing the tracker
|
||||
// has said, so the freshness fields are stamped from it.
|
||||
if !strings.Contains(local, "remote-updated: "+wrWhen) || !strings.Contains(local, "synced: 20") {
|
||||
t.Errorf("the freshness fields were not stamped:\n%s", local)
|
||||
}
|
||||
index := filepath.Join(wrStore(dir), "INDEX.md")
|
||||
if !strings.Contains(wrRead(t, index), "closed") {
|
||||
t.Error("INDEX.md was not rebuilt from what is now on disk")
|
||||
}
|
||||
|
||||
// A number this machine has never seen: closed in the tracker, nothing
|
||||
// written here, and the receipt says which is which.
|
||||
byNumber := runWith(t, dir, env, "", "close", "99")
|
||||
if byNumber.code != 0 {
|
||||
t.Fatalf("closing by number exited %d:\n%s", byNumber.code, byNumber.out())
|
||||
}
|
||||
if got := tr.state(99); got != "closed" {
|
||||
t.Errorf("#99 says %q, want closed", got)
|
||||
}
|
||||
if !strings.Contains(byNumber.stdout, "no local copy") {
|
||||
t.Errorf("the receipt hid that there was nothing to write:\n%s", byNumber.stdout)
|
||||
}
|
||||
|
||||
// A number resolves through the file that carries the handle, so the local
|
||||
// copy of #42 is kept honest even when it was named by number.
|
||||
back := runWith(t, dir, env, "", "close", "--reopen", "42")
|
||||
if back.code != 0 {
|
||||
t.Fatalf("reopening by number exited %d:\n%s", back.code, back.out())
|
||||
}
|
||||
if got := tr.state(42); got != "open" {
|
||||
t.Errorf("#42 says %q, want open", got)
|
||||
}
|
||||
if !strings.Contains(wrRead(t, path), "state: open") {
|
||||
t.Errorf("a number named the tracker but not the local copy holding its handle:\n%s", wrRead(t, path))
|
||||
}
|
||||
|
||||
// An issue that has never left this machine has no state in the tracker to
|
||||
// change, and saying so beats editing one field of a local file.
|
||||
wrLocal(t, dir, "never-left-here", "open")
|
||||
refused := runWith(t, dir, env, "", "close", "never-left-here")
|
||||
if refused.code == 0 || !strings.Contains(refused.stderr, "push") {
|
||||
t.Errorf("closing a local issue must stop and say why:\n%s", refused.out())
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// comment
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func TestCommentPostsAndTheThreadLandsBesideTheIssue(t *testing.T) {
|
||||
dir, tr, env := wrProject(t)
|
||||
tr.add(42, "Talk about it", "open")
|
||||
wrTracked(t, dir, "talk-about-it", 42, "open")
|
||||
wrLocal(t, dir, "never-left-here", "open")
|
||||
|
||||
const said = "готово, задеплоено"
|
||||
r := runWith(t, dir, env, "", "comment", "talk-about-it", "--body", said)
|
||||
if r.code != 0 {
|
||||
t.Fatalf("comment exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
thread := tr.thread(42)
|
||||
if len(thread) != 1 || thread[0].Body != said {
|
||||
t.Fatalf("the tracker holds %v", thread)
|
||||
}
|
||||
|
||||
sidecar := filepath.Join(wrStore(dir), "talk-about-it.comments.md")
|
||||
got := wrRead(t, sidecar)
|
||||
if !strings.Contains(got, said) || !strings.Contains(got, "## comment ") {
|
||||
t.Errorf("the thread did not land beside the issue:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(r.stdout, "posted comment") || !strings.Contains(r.stdout, "thread:") {
|
||||
t.Errorf("the receipt does not say what happened:\n%s", r.stdout)
|
||||
}
|
||||
|
||||
// The target is a local id resolved through the `gitea:` handle, so an issue
|
||||
// that carries none cannot be commented on at all.
|
||||
refused := runWith(t, dir, env, "", "comment", "never-left-here", "--body", "x")
|
||||
if refused.code == 0 || !strings.Contains(refused.stderr, "push") {
|
||||
t.Errorf("commenting on a local-only issue must stop and say why:\n%s", refused.out())
|
||||
}
|
||||
if n := tr.count("POST"); n != 1 {
|
||||
t.Errorf("%d comment(s) went out, want 1 — the refused one was sent anyway", n)
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// sync-evict
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// The one thing this command adds to the offline evict: a `state:` that is not
|
||||
// stale. The file says open, the tracker says closed, and the tracker is right.
|
||||
func TestSyncEvictRefreshesTheStateBeforeItDecides(t *testing.T) {
|
||||
dir, tr, env := wrProject(t)
|
||||
tr.add(42, "Closed in the web ui", "closed")
|
||||
path := wrTracked(t, dir, "closed-in-the-web-ui", 42, "open")
|
||||
|
||||
offline := runWith(t, dir, env, "", "evict")
|
||||
if offline.code != 0 || !strings.Contains(offline.stdout, "0 issue(s) evicted") {
|
||||
t.Fatalf("the offline evict must keep an issue whose file reads open:\n%s", offline.out())
|
||||
}
|
||||
wrThere(t, path, "the offline evict asks the file, and the file says open")
|
||||
|
||||
// A dry run asks, reports, and touches nothing.
|
||||
dry := runWith(t, dir, env, "", "sync-evict", "--dry-run")
|
||||
if dry.code != 0 || !strings.Contains(dry.stdout, "would evict") {
|
||||
t.Fatalf("the dry run said nothing:\n%s", dry.out())
|
||||
}
|
||||
wrThere(t, path, "a dry run deleted the issue")
|
||||
if !strings.Contains(wrRead(t, path), "state: open") {
|
||||
t.Error("a dry run wrote the refreshed state to disk")
|
||||
}
|
||||
|
||||
r := runWith(t, dir, env, "", "sync-evict")
|
||||
if r.code != 0 {
|
||||
t.Fatalf("sync-evict exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
if !strings.Contains(r.stdout, "open -> closed") {
|
||||
t.Errorf("the refresh was not reported:\n%s", r.stdout)
|
||||
}
|
||||
wrGone(t, path, "the tracker said it was closed")
|
||||
if index := wrRead(t, filepath.Join(wrStore(dir), "INDEX.md")); strings.Contains(index, "closed-in-the-web-ui") {
|
||||
t.Errorf("INDEX.md still lists the evicted issue:\n%s", index)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncEvictKeepsALocalIssueTheTrackerNeverHeardOf(t *testing.T) {
|
||||
dir, tr, env := wrProject(t)
|
||||
tr.add(42, "Done elsewhere", "closed")
|
||||
tracked := wrTracked(t, dir, "done-elsewhere", 42, "closed")
|
||||
local := wrLocal(t, dir, "only-copy-there-is", "closed")
|
||||
|
||||
r := runWith(t, dir, env, "", "sync-evict")
|
||||
if r.code != 0 {
|
||||
t.Fatalf("sync-evict exited %d:\n%s", r.code, r.out())
|
||||
}
|
||||
wrThere(t, local, "a closed origin: local issue was deleted, and that file IS the work")
|
||||
wrGone(t, tracked, "it is closed and the tracker has it")
|
||||
// It was never asked about either: an issue that has never left this machine
|
||||
// is not a question the tracker has an answer to.
|
||||
if n := tr.count("GET"); n != 1 {
|
||||
t.Errorf("%d issue(s) were asked about, want 1", n)
|
||||
}
|
||||
|
||||
// Naming it explicitly does not make deleting it safe, and the reason is
|
||||
// said out loud rather than left to be inferred from silence.
|
||||
named := runWith(t, dir, env, "", "sync-evict", "only-copy-there-is")
|
||||
if named.code != 0 {
|
||||
t.Fatalf("naming a local issue exited %d:\n%s", named.code, named.out())
|
||||
}
|
||||
if !strings.Contains(named.stdout, "kept") || !strings.Contains(named.stdout, "IS the issue") {
|
||||
t.Errorf("keeping it must be said out loud:\n%s", named.out())
|
||||
}
|
||||
wrThere(t, local, "naming it on the command line deleted it")
|
||||
}
|
||||
|
||||
// A failed answer evicts NOTHING AT ALL — not even the issues whose answers had
|
||||
// already arrived. There is no ordering constraint between evictions, so there
|
||||
// is no reason to start before every answer is in.
|
||||
func TestATrackerFailureDuringSyncEvictEvictsNothing(t *testing.T) {
|
||||
dir, tr, env := wrProject(t)
|
||||
tr.add(42, "First answer", "closed")
|
||||
tr.add(43, "Second answer", "closed")
|
||||
tr.breaks(43)
|
||||
answered := wrTracked(t, dir, "aaa-answered", 42, "closed")
|
||||
unanswered := wrTracked(t, dir, "bbb-unanswered", 43, "closed")
|
||||
|
||||
r := runWith(t, dir, env, "", "sync-evict")
|
||||
if r.code == 0 {
|
||||
t.Fatalf("a tracker failure must stop the run:\n%s", r.out())
|
||||
}
|
||||
if !strings.Contains(r.stderr, "Nothing was evicted") {
|
||||
t.Errorf("the failure must say what it did not do:\n%s", r.stderr)
|
||||
}
|
||||
wrThere(t, answered, "its answer arrived, but another one did not")
|
||||
wrThere(t, unanswered, "the tracker never answered for it")
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "tree",
|
||||
Group: GroupIssue,
|
||||
Args: "[<id>…]",
|
||||
Short: "draw the dependency graph of the local store",
|
||||
Long: `Edges come from the ` + "`depends:`" + ` metadata, which is the authoritative edge list;
|
||||
prose in the body is never walked. Because the graph is slugs all the way down,
|
||||
this works identically for issues that were never pushed anywhere.
|
||||
|
||||
Downwards is what this draws — what an issue depends on. The other direction is
|
||||
a grep, not a flag:
|
||||
|
||||
grep -ln 'depends:.*migrate-schema' .kettle/issues/*.md`,
|
||||
Examples: []Example{
|
||||
{"kettle tree", "every root (nothing depends on it)"},
|
||||
{"kettle tree wire-sqlc-appclick", "one subtree"},
|
||||
{"kettle tree --depth 2 --write", "shallow, and saved beside the issues"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
depth := fs.Int("depth", 6, "maximum depth")
|
||||
write := fs.Bool("write", false, "also write <store>/tree-<slug>.md")
|
||||
out := storeFlag(fs)
|
||||
|
||||
return func(args []string) error {
|
||||
root, err := storeRoot(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := issue.StoreError(root); err != nil {
|
||||
return err
|
||||
}
|
||||
issues, err := issue.LoadAll(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
edges := issue.Graph(issues)
|
||||
|
||||
roots := args
|
||||
for _, r := range roots {
|
||||
if _, ok := issues[r]; !ok {
|
||||
return Fail("no issue %q in %s", r, root)
|
||||
}
|
||||
}
|
||||
if len(roots) == 0 {
|
||||
dependedOn := map[string]bool{}
|
||||
for _, deps := range edges {
|
||||
for _, d := range deps {
|
||||
dependedOn[d] = true
|
||||
}
|
||||
}
|
||||
for id := range issues {
|
||||
if !dependedOn[id] {
|
||||
roots = append(roots, id)
|
||||
}
|
||||
}
|
||||
if len(roots) == 0 { // every issue is somebody's dependency
|
||||
for id := range issues {
|
||||
roots = append(roots, id)
|
||||
}
|
||||
}
|
||||
sort.Strings(roots)
|
||||
}
|
||||
|
||||
text := renderTree(roots, issues, edges, *depth)
|
||||
fmt.Print(text)
|
||||
if *write {
|
||||
slug := "all"
|
||||
if len(roots) == 1 {
|
||||
slug = roots[0]
|
||||
}
|
||||
path := filepath.Join(root, "tree-"+slug+".md")
|
||||
if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("written: %s\n", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func renderTree(roots []string, issues map[string]*issue.Issue, edges map[string][]string, depth int) string {
|
||||
var lines []string
|
||||
seen := map[string]bool{}
|
||||
|
||||
var walk func(id, prefix string, isLast, isRoot bool, level int)
|
||||
walk = func(id, prefix string, isLast, isRoot bool, level int) {
|
||||
connector := ""
|
||||
if !isRoot {
|
||||
connector = "├── "
|
||||
if isLast {
|
||||
connector = "└── "
|
||||
}
|
||||
}
|
||||
lines = append(lines, prefix+connector+treeLabel(id, issues, seen, edges))
|
||||
if seen[id] || level >= depth {
|
||||
return
|
||||
}
|
||||
seen[id] = true
|
||||
kids := edges[id]
|
||||
childPrefix := prefix
|
||||
if !isRoot {
|
||||
childPrefix = prefix + "│ "
|
||||
if isLast {
|
||||
childPrefix = prefix + " "
|
||||
}
|
||||
}
|
||||
for i, k := range kids {
|
||||
walk(k, childPrefix, i == len(kids)-1, false, level+1)
|
||||
}
|
||||
}
|
||||
|
||||
for _, r := range roots {
|
||||
if seen[r] {
|
||||
continue // already drawn as somebody's child — one tree, not two
|
||||
}
|
||||
walk(r, "", true, true, 0)
|
||||
lines = append(lines, "")
|
||||
}
|
||||
|
||||
head := fmt.Sprintf("%d root(s)", len(roots))
|
||||
if len(roots) == 1 {
|
||||
head = roots[0]
|
||||
}
|
||||
out := fmt.Sprintf("# Dependency tree — %s\n\n```\n%s```\n", head, strings.Join(lines, "\n"))
|
||||
if cycles := issue.FindCycles(edges); len(cycles) > 0 {
|
||||
out += "\n## Cycles\n\n"
|
||||
for _, c := range cycles {
|
||||
out += "- " + strings.Join(c, " -> ") + "\n"
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func treeLabel(id string, issues map[string]*issue.Issue, seen map[string]bool, edges map[string][]string) string {
|
||||
i, ok := issues[id]
|
||||
if !ok {
|
||||
return id + " (not in the store)"
|
||||
}
|
||||
tail := ""
|
||||
if seen[id] && len(edges[id]) > 0 {
|
||||
tail = " (see above)"
|
||||
}
|
||||
typ := i.Type()
|
||||
if typ == "" {
|
||||
typ = "-"
|
||||
}
|
||||
return fmt.Sprintf("%s [%s] %s — %s %s.md%s", id, typ, i.Title, i.State, id, tail)
|
||||
}
|
||||
@@ -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,137 @@
|
||||
# AGENTS.md — internal/config
|
||||
|
||||
**Three files: what this project is, who this machine is, and what was last
|
||||
written into the project's agent-harness tree.** The only package in the tree
|
||||
that imports yaml.
|
||||
|
||||
| file | what is in it |
|
||||
|---|---|
|
||||
| `config.go` | `Project`, `Logins` and `Scaffold` (the three 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, scopes, token}]
|
||||
mode 0600
|
||||
|
||||
<project>/.kettle/scaffold.yaml version: v1.4.0 a note, load-bearing
|
||||
out: .claude for nothing
|
||||
```
|
||||
|
||||
The third file is the newest and the least important, which is exactly why it is
|
||||
a file of its own — see *Unknown keys are an error* below. It records what
|
||||
`kettle gen scaffold` last wrote into the project and which build wrote it, so
|
||||
`kettle config` can say when a project's skills are four releases behind the
|
||||
binary reading them. **Nothing resolves from it.** Delete it and you lose the
|
||||
warning and nothing else.
|
||||
|
||||
`user` and `scopes` are **documentation and nothing else** — nothing is checked
|
||||
against either, and no request is refused because of one. `scopes` is what the
|
||||
token was minted with, as Gitea spells it (`write:issue`, `write:repository`),
|
||||
and it is written down because the instance will not answer the question:
|
||||
`GET /user/tokens` needs basic auth rather than token auth, so a token cannot be
|
||||
asked what it may do. What it buys is a 403 that can be read — `kettle auth list`
|
||||
and `kettle config` show what was recorded, and an empty list means "nobody wrote
|
||||
it down", never "none".
|
||||
|
||||
**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.
|
||||
|
||||
**The price is that a field added here is a one-way door for the file that holds
|
||||
it.** `scopes:` was the first one to prove it: a login file written by a binary
|
||||
that has the field cannot be read by one that does not — the older build stops
|
||||
with "unknown field" rather than dropping the line. That is acceptable for
|
||||
`logins.yaml`, which is machine-local and whose reader is the one binary the
|
||||
operator upgrades; it would **not** be acceptable for `config.yaml`, which is
|
||||
committed and read by whatever version each machine happens to have. Adding a
|
||||
field to the project file means answering that first, out loud, here.
|
||||
|
||||
`scaffold.yaml` exists because that answer came back "no". Recording which build
|
||||
wrote a project's `.claude/` tree wanted two keys, and putting them in
|
||||
`config.yaml` would have made every older `kettle` in the world stop reading a
|
||||
committed file. A separate file written and read by one binary about one
|
||||
directory carries the same cost for nothing: an older build never opens it.
|
||||
|
||||
## What does not belong here
|
||||
|
||||
A request, a store path, an issue. This package reads and writes three 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 three files, their fields, the overrides, the file modes.
|
||||
- **Update it when** a field is added to any of them (the block above is 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.
|
||||
@@ -0,0 +1,447 @@
|
||||
// Package config holds the two files kettle reads: what this project is, and
|
||||
// who this machine is.
|
||||
//
|
||||
// The split is the whole design. `<project>/.kettle/config.yaml` says which
|
||||
// tracker repository the issues belong to and which login to reach it under —
|
||||
// facts about the project, written by `kettle init`. The credentials themselves
|
||||
// live in one file per machine, outside any repository, 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 is a secret that has to be rotated. So the project pins a login by
|
||||
// NAME and the name is worth nothing on its own.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
|
||||
)
|
||||
|
||||
// Environment overrides, each winning 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.
|
||||
const (
|
||||
EnvLogin = "KETTLE_LOGIN"
|
||||
EnvURL = "KETTLE_URL"
|
||||
EnvToken = "KETTLE_TOKEN"
|
||||
EnvRepo = "KETTLE_REPO"
|
||||
// EnvHome relocates the machine-wide login file; the test suite sets it so
|
||||
// a run can never read or write the developer's own.
|
||||
EnvHome = "KETTLE_CONFIG_HOME"
|
||||
)
|
||||
|
||||
const projectHeader = `# kettle — project configuration
|
||||
#
|
||||
# login the name of a login in the machine-wide file, NOT a credential.
|
||||
# Manage those with ` + "`kettle auth`" + `; they live outside this tree.
|
||||
# repo the tracker repository these issues belong to, as owner/name.
|
||||
#
|
||||
# Overrides, when you need one: ` + EnvLogin + `, ` + EnvRepo + `, ` + EnvURL + `, ` + EnvToken + `.
|
||||
`
|
||||
|
||||
const scaffoldHeader = `# kettle — what was last written into this project's agent-harness tree.
|
||||
#
|
||||
# Written by ` + "`kettle init`" + ` and ` + "`kettle gen scaffold`" + `; read by
|
||||
# ` + "`kettle config`" + `, which says so when the build that wrote the tree is not the
|
||||
# build that is installed now. Nothing resolves from this file — deleting it
|
||||
# costs the warning and nothing else.
|
||||
`
|
||||
|
||||
// Project is `<project>/.kettle/config.yaml`.
|
||||
type Project struct {
|
||||
// Login names an entry in the machine-wide login file. Never a token.
|
||||
Login string `yaml:"login"`
|
||||
// Repo is the tracker repository, as owner/name.
|
||||
Repo string `yaml:"repo"`
|
||||
}
|
||||
|
||||
// Scaffold is `<project>/.kettle/scaffold.yaml`: what `kettle` last wrote into
|
||||
// this project's agent-harness tree, and which build wrote it.
|
||||
//
|
||||
// A file of its own rather than two more keys in Project, and that is the rule
|
||||
// in "Unknown keys are an error" being obeyed rather than worked around: a field
|
||||
// added to config.yaml is a one-way door for a file that may be committed and
|
||||
// read by whatever version each machine happens to have. This one is written and
|
||||
// read by a single binary about a single directory, so it can carry that cost.
|
||||
//
|
||||
// It exists to answer one question — "were these documents written by the kettle
|
||||
// that is installed now?" — because the documents are generated whole and an
|
||||
// operator has no other way to tell a current tree from one four releases old.
|
||||
type Scaffold struct {
|
||||
// Version is what `kettle version` reported when the tree was written. A
|
||||
// hand build says `dev` and means it.
|
||||
Version string `yaml:"version"`
|
||||
// Out is where the tree went, relative to the project root when it is
|
||||
// underneath it.
|
||||
Out string `yaml:"out"`
|
||||
}
|
||||
|
||||
// Login is one set of credentials for one Gitea instance.
|
||||
type Login struct {
|
||||
Name string `yaml:"name"`
|
||||
URL string `yaml:"url"`
|
||||
User string `yaml:"user,omitempty"`
|
||||
// Scopes is what the token was minted with, as Gitea spells it —
|
||||
// `write:issue`, `read:repository`. DOCUMENTATION ONLY, exactly like User:
|
||||
// nothing is checked against it and nothing is refused because of it. It is
|
||||
// written down because the instance will not say. `GET /user/tokens` needs
|
||||
// basic auth, not token auth, so a token cannot be asked what it may do —
|
||||
// and the failure that costs an afternoon is a 403 on a release from a token
|
||||
// somebody minted for issues a year ago.
|
||||
Scopes []string `yaml:"scopes,omitempty"`
|
||||
Token string `yaml:"token"`
|
||||
}
|
||||
|
||||
// Logins is the machine-wide file.
|
||||
type Logins struct {
|
||||
Logins []Login `yaml:"logins"`
|
||||
}
|
||||
|
||||
// ErrNoConfig means the project has no config.yaml yet.
|
||||
var ErrNoConfig = errors.New("no project configuration")
|
||||
|
||||
// ProjectPath is where this project's config.yaml is, or "" with no project.
|
||||
func ProjectPath(start string) string { return project.ConfigPath(start) }
|
||||
|
||||
// LoadProject reads the project configuration.
|
||||
//
|
||||
// A missing file is ErrNoConfig, not an empty config: "this project has not
|
||||
// been told which tracker it belongs to" and "it belongs to no tracker" are
|
||||
// different answers and only one of them is fixable by running init.
|
||||
func LoadProject(start string) (*Project, error) {
|
||||
path := ProjectPath(start)
|
||||
if path == "" {
|
||||
return nil, project.NotFoundError(start)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("%w at %s — run `kettle init` there", ErrNoConfig, path)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var p Project
|
||||
if err := strictUnmarshal(raw, &p); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// ReadProjectFile reads a config.yaml at a path already known, reporting
|
||||
// whether the file was there.
|
||||
//
|
||||
// LoadProject resolves the path by walking for a marker, which is the right
|
||||
// thing everywhere except inside `kettle init` — the command that is creating
|
||||
// the marker, and on a dry run may not have created it at all.
|
||||
func ReadProjectFile(path string) (*Project, bool, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return &Project{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
var p Project
|
||||
if err := strictUnmarshal(raw, &p); err != nil {
|
||||
return nil, true, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return &p, true, nil
|
||||
}
|
||||
|
||||
// SaveProject writes the project configuration, header comment and all.
|
||||
func SaveProject(path string, p *Project) error {
|
||||
body, err := yaml.Marshal(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, append([]byte(projectHeader+"\n"), body...), 0o644)
|
||||
}
|
||||
|
||||
// ScaffoldPath is where this project records what it last had written into it,
|
||||
// or "" with no project.
|
||||
func ScaffoldPath(start string) string { return project.ScaffoldPath(start) }
|
||||
|
||||
// ReadScaffoldFile reads a scaffold record at a path already known, reporting
|
||||
// whether the file was there.
|
||||
//
|
||||
// A missing file is the zero value and not an error: a project initialized
|
||||
// before this record existed, or one written with --no-scaffold, has nothing to
|
||||
// say here and that is an ordinary state rather than a fault.
|
||||
func ReadScaffoldFile(path string) (*Scaffold, bool, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return &Scaffold{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
var s Scaffold
|
||||
if err := strictUnmarshal(raw, &s); err != nil {
|
||||
return nil, true, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return &s, true, nil
|
||||
}
|
||||
|
||||
// SaveScaffoldFile writes the scaffold record.
|
||||
func SaveScaffoldFile(path string, s *Scaffold) error {
|
||||
body, err := yaml.Marshal(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, append([]byte(scaffoldHeader+"\n"), body...), 0o644)
|
||||
}
|
||||
|
||||
// LoginsPath is the machine-wide login file.
|
||||
//
|
||||
// One file per machine, deliberately outside every working tree: which tokens
|
||||
// this computer holds is a fact about the computer, the way which issues a tree
|
||||
// holds is a fact about the tree.
|
||||
func LoginsPath() string {
|
||||
if h := os.Getenv(EnvHome); h != "" {
|
||||
return filepath.Join(h, "logins.yaml")
|
||||
}
|
||||
if x := os.Getenv("XDG_CONFIG_HOME"); x != "" {
|
||||
return filepath.Join(x, "kettle", "logins.yaml")
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".config", "kettle", "logins.yaml")
|
||||
}
|
||||
|
||||
// LoadLogins reads the machine-wide login file. A missing file is an empty
|
||||
// list, not an error: a machine with no logins yet is an ordinary machine.
|
||||
func LoadLogins() (*Logins, error) {
|
||||
path := LoginsPath()
|
||||
if path == "" {
|
||||
return &Logins{}, nil
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return &Logins{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var l Logins
|
||||
if err := strictUnmarshal(raw, &l); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
// SaveLogins writes the machine-wide login file with 0600, and creates its
|
||||
// directory with 0700. The file holds bearer tokens; nothing else on the
|
||||
// machine has any business reading it.
|
||||
func SaveLogins(l *Logins) error {
|
||||
path := LoginsPath()
|
||||
if path == "" {
|
||||
return errors.New("cannot locate a home directory for the login file — set " + EnvHome)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := yaml.Marshal(l)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, body, 0o600)
|
||||
}
|
||||
|
||||
// Find returns the login with this name.
|
||||
func (l *Logins) Find(name string) *Login {
|
||||
for i := range l.Logins {
|
||||
if l.Logins[i].Name == name {
|
||||
return &l.Logins[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Names lists every login on this machine, for an error message that can
|
||||
// actually be acted on.
|
||||
func (l *Logins) Names() []string {
|
||||
out := make([]string, 0, len(l.Logins))
|
||||
for _, e := range l.Logins {
|
||||
out = append(out, e.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Resolved is everything the transport needs, with every override applied.
|
||||
type Resolved struct {
|
||||
Login string
|
||||
URL string
|
||||
Token string
|
||||
Owner string
|
||||
Repo string
|
||||
// Scopes is what the pinned login records its token was minted with.
|
||||
// Documentation, carried this far so `kettle config` can show it beside the
|
||||
// token it belongs to; nothing dials on it. A token out of the environment
|
||||
// records nothing, and an empty list means "not written down", never "none".
|
||||
Scopes []string
|
||||
}
|
||||
|
||||
// Slug is owner/name, the way a tracker writes it.
|
||||
func (r *Resolved) Slug() string { return r.Owner + "/" + r.Repo }
|
||||
|
||||
// Redacted is the same thing with the token replaced, for printing.
|
||||
func (r *Resolved) Redacted() Resolved {
|
||||
out := *r
|
||||
if out.Token != "" {
|
||||
out.Token = "(set)"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Resolve merges the project config, the machine's login file, and the
|
||||
// environment into what 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.
|
||||
func Resolve(start string) (*Resolved, error) {
|
||||
var p Project
|
||||
if loaded, err := LoadProject(start); err == nil {
|
||||
p = *loaded
|
||||
} 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
|
||||
}
|
||||
|
||||
repo := p.Repo
|
||||
if v := os.Getenv(EnvRepo); v != "" {
|
||||
repo = v
|
||||
}
|
||||
if repo != "" {
|
||||
owner, name, ok := strings.Cut(repo, "/")
|
||||
if !ok || owner == "" || name == "" {
|
||||
return nil, fmt.Errorf("repo %q is not owner/name", repo)
|
||||
}
|
||||
out.Owner, out.Repo = owner, name
|
||||
}
|
||||
|
||||
if out.Login != "" {
|
||||
logins, err := LoadLogins()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry := logins.Find(out.Login)
|
||||
if entry == nil {
|
||||
known := "none on this machine"
|
||||
if names := logins.Names(); len(names) > 0 {
|
||||
known = strings.Join(names, ", ")
|
||||
}
|
||||
return nil, fmt.Errorf("no login %q in %s — known: %s; add one with `kettle auth add`",
|
||||
out.Login, LoginsPath(), known)
|
||||
}
|
||||
out.URL, out.Token, out.Scopes = entry.URL, entry.Token, entry.Scopes
|
||||
}
|
||||
|
||||
if v := os.Getenv(EnvURL); v != "" {
|
||||
out.URL = v
|
||||
}
|
||||
if v := os.Getenv(EnvToken); v != "" {
|
||||
out.Token = v
|
||||
}
|
||||
out.URL = strings.TrimRight(out.URL, "/")
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Require is Resolve plus the assertion that the result can actually reach a
|
||||
// tracker.
|
||||
func Require(start string) (*Resolved, error) {
|
||||
r, err := Resolve(start)
|
||||
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+")")
|
||||
}
|
||||
if r.Token == "" {
|
||||
missing = append(missing, "a token (`kettle auth add`, or set "+EnvToken+")")
|
||||
}
|
||||
if r.Owner == "" {
|
||||
missing = append(missing, "a repository (`kettle init --repo owner/name`, or set "+EnvRepo+")")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("this project has no %s", strings.Join(missing, ", and no "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// strictUnmarshal refuses keys the struct does not know.
|
||||
//
|
||||
// The alternative is silence: an older binary reading a newer config would drop
|
||||
// the setting it did not recognize, and rewriting the file would delete it.
|
||||
// Being told "unknown field" beats finding out later.
|
||||
func strictUnmarshal(raw []byte, out any) error {
|
||||
dec := yaml.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.KnownFields(true)
|
||||
if err := dec.Decode(out); err != nil && err.Error() != "EOF" {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
# 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`, `Do` and the endpoint rule, the payload-dumping `RoundTripper`, pagination, `APIError` and `Fail` |
|
||||
| `issues.go` | `GetIssue`, `CreateIssue`, `EditIssue`, `SetLabels`, comments, milestones, `ListIssues` and its budget, 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, `Do` and what it refuses |
|
||||
| `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".
|
||||
|
||||
## `Do` — the requests the SDK cannot express
|
||||
|
||||
`Do(method, path, body, name)` sends one request and returns the status and the
|
||||
body exactly as they came back. It was here before it was general: the dependency
|
||||
endpoint takes a body the SDK's own `IssueMeta` cannot spell, so a hand-rolled
|
||||
request already existed and `post` is now three lines on top of this one.
|
||||
|
||||
Exporting it is what lets [`kettle api`](../cmd/AGENTS.md) reach a release, a pull
|
||||
request or a webhook **without this package growing a method per entity** and
|
||||
without a second client holding the credentials all over again. It goes through
|
||||
the same `http.Client`, so it gets the same three services as everything else: the
|
||||
body is filed by the same `RoundTripper`, the same `token …` header authenticates
|
||||
it, and a non-2xx is the same `*APIError`.
|
||||
|
||||
Three things it deliberately does not do, and each of them is a way of not lying:
|
||||
|
||||
- **it does not paginate** — one call is one HTTP request, and `?page=`/`?limit=`
|
||||
are the caller's. The pagination below exists for a listing with a budget to
|
||||
spend; a passthrough that stitched pages together would report as one answer
|
||||
something that was several;
|
||||
- **it does not parse or reformat the answer** — bytes in, bytes out;
|
||||
- **it does not know what an issue is** — nothing about it names a domain concept,
|
||||
so `TestTransportDoesNotImportTheDomain` needed no change. A generic transport is
|
||||
no more a domain than a specific one was.
|
||||
|
||||
**The endpoint rule** is `tea api`'s, so a table of endpoints written for that tool
|
||||
still works: a bare path is under `/api/v1/`, a path starting `/api/` is taken as
|
||||
it stands (that is how anything outside v1 is reached), and a full URL is a full
|
||||
URL — **but only on this instance**. That last clause is the one place this is
|
||||
stricter than the tool it replaces, and it is not fussiness: every request carries
|
||||
the project's token in a header, so a URL on another host would hand the token to
|
||||
whatever was typed. Another instance is `KETTLE_URL`.
|
||||
|
||||
**A 403 answers with what to do about it.** Gitea scopes a token as
|
||||
`<read|write>:<category>` and names no scope in the refusal, so `APIError.Error`
|
||||
appends the one sentence that helps — everything outside issues is `repository`,
|
||||
and `kettle auth list` shows what each login recorded. It does not guess a
|
||||
specific scope, because the server did not say and a wrong guess is worse than
|
||||
none.
|
||||
|
||||
## 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 it goes out through `Do` — same HTTP client, same payload dump, same
|
||||
`*APIError`. This was the request `Do` was written for, back when it was the only
|
||||
one. 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.
|
||||
@@ -0,0 +1,605 @@
|
||||
// Package gitea is the transport: everything that talks to a tracker, and
|
||||
// nothing else.
|
||||
//
|
||||
// It knows numbers, logins, HTTP verbs, pagination and 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 internal/issue, and internal/issue may not reach in here. A tracker
|
||||
// number is not a domain concept and a checkbox is not a transport one.
|
||||
// Translating between the two is a layer of its own — internal/mapping — and
|
||||
// that layer is not imported here either: it sits above this package, not
|
||||
// beside it.
|
||||
//
|
||||
// The payload shapes are code.gitea.io/sdk/gitea's, aliased `sdk` everywhere it
|
||||
// is imported so that one type has one spelling across the tree. They are not
|
||||
// this package's to own and never were: the bridge needs exactly the same
|
||||
// vocabulary and cannot import a transport to get it, and a copy on each side
|
||||
// is two structs that drift and a command that copies fields between them by
|
||||
// hand. The issue KEYS are still internal/wire's — the SDK addresses an issue
|
||||
// as (owner, repo, int64) and never parses `owner/repo#42` out of anything.
|
||||
//
|
||||
// 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
|
||||
// that no command has to. Every method here is a thin wrapper, and the three
|
||||
// things the wrapping is for are 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 an *APIError carrying the status AND what the
|
||||
// server said, because "500" on its own has never helped anybody;
|
||||
// - a listing stops when the caller has what it asked for, which a client
|
||||
// that fetches whole pages into a slice cannot do.
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"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/project"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
const (
|
||||
// userAgent names this binary in the server's log. A tracker admin looking
|
||||
// at a burst of requests should be able to tell what made them.
|
||||
userAgent = "kettle"
|
||||
// requestTimeout bounds a single call. A hung tracker must not hang a push
|
||||
// half way through a set of issues.
|
||||
requestTimeout = 30 * time.Second
|
||||
// maxErrorBody caps what an error quotes back. A server having a bad day
|
||||
// answers with an HTML page, and an error message is not a place to paste
|
||||
// one.
|
||||
maxErrorBody = 2000
|
||||
)
|
||||
|
||||
// Client talks to one repository on one Gitea instance.
|
||||
type Client struct {
|
||||
// api is the SDK client: one per run, shared by every copy For makes.
|
||||
api *sdk.Client
|
||||
// http is the SDK's transport, kept because Do sends requests by hand — the
|
||||
// dependency endpoint the SDK cannot spell, and every endpoint this package
|
||||
// has no method for.
|
||||
http *http.Client
|
||||
// dump is the RoundTripper that files request bodies. Shared with every
|
||||
// copy For makes, because the scratchpad is one directory per run.
|
||||
dump *dumper
|
||||
|
||||
base string // instance URL, no API prefix and no trailing slash
|
||||
token string
|
||||
repo wire.Repo
|
||||
}
|
||||
|
||||
// New builds a client for the repository this project points at.
|
||||
//
|
||||
// It refuses a half-filled configuration instead of letting the first call come
|
||||
// back 401 or 404: those answers name nothing an operator can act on, and every
|
||||
// field missing here has exactly one command that supplies it. That check comes
|
||||
// first because building the client now DIALS — the SDK asks the instance for
|
||||
// its version before it hands one back — and a missing token reported as a
|
||||
// connection failure sends the operator to the wrong place.
|
||||
func New(cfg *config.Resolved) (*Client, error) {
|
||||
if cfg == nil {
|
||||
return nil, errors.New("no resolved configuration — call config.Require first")
|
||||
}
|
||||
var missing []string
|
||||
if cfg.URL == "" {
|
||||
missing = append(missing, "a URL (pin a login with `kettle init --login`, or set "+config.EnvURL+")")
|
||||
}
|
||||
if cfg.Token == "" {
|
||||
missing = append(missing, "a token (`kettle auth add`, or set "+config.EnvToken+")")
|
||||
}
|
||||
if cfg.Owner == "" || cfg.Repo == "" {
|
||||
missing = append(missing, "a repository (`kettle init --repo owner/name`, or set "+config.EnvRepo+")")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return nil, fmt.Errorf("this project has no %s", strings.Join(missing, ", and no "))
|
||||
}
|
||||
|
||||
base := strings.TrimRight(cfg.URL, "/")
|
||||
dump := &dumper{next: http.DefaultTransport, root: project.PayloadRoot("")}
|
||||
hc := &http.Client{Timeout: requestTimeout, Transport: dump}
|
||||
|
||||
api, err := sdk.NewClient(base,
|
||||
sdk.SetToken(cfg.Token),
|
||||
sdk.SetHTTPClient(hc),
|
||||
sdk.SetUserAgent(userAgent))
|
||||
if err != nil {
|
||||
// A version string the SDK cannot parse is not a cosmetic failure, and
|
||||
// it is refused here rather than shrugged off: the SDK hands back a
|
||||
// usable-looking client that has quietly decided the server is Gitea
|
||||
// 1.11, and its 1.11 compatibility path rewrites an issue's URL from a
|
||||
// `repository` field a modern payload need not carry — a nil
|
||||
// dereference on the first issue read. Saying so at the handshake beats
|
||||
// crashing three calls later.
|
||||
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,
|
||||
http: hc,
|
||||
dump: dump,
|
||||
base: base,
|
||||
token: cfg.Token,
|
||||
repo: wire.Repo{Owner: cfg.Owner, Name: cfg.Repo},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Repo is the repository every call is made against.
|
||||
func (c *Client) Repo() wire.Repo { return c.repo }
|
||||
|
||||
// For returns a copy of this client pointed at another repository, for the run
|
||||
// that was given an explicit owner/name.
|
||||
//
|
||||
// Bookkeeping and not a second connection: the SDK takes the owner and the name
|
||||
// per call, so what changes is which pair this client passes. The credentials,
|
||||
// the negotiated server version and the scratchpad are all shared, which is
|
||||
// what makes `kettle pull owner/repo#42` cost nothing extra.
|
||||
func (c *Client) For(r wire.Repo) *Client {
|
||||
out := *c
|
||||
out.repo = r
|
||||
return &out
|
||||
}
|
||||
|
||||
// owned is the owner and name every SDK call takes, escaped by the SDK itself.
|
||||
func (c *Client) owned() (string, string) { return c.repo.Owner, c.repo.Name }
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// what a failure says
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// APIError is a non-2xx answer, carrying both halves of what happened.
|
||||
//
|
||||
// The status on its own is not a diagnosis. Gitea answers 422 for a label that
|
||||
// already exists, for a milestone id that belongs to another repository, and
|
||||
// for a body missing a field, and the three are told apart only by the message
|
||||
// sent with them — so the body travels with the code, always.
|
||||
type APIError struct {
|
||||
Method string
|
||||
URL string
|
||||
Status int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
body := strings.TrimSpace(e.Body)
|
||||
if body == "" {
|
||||
body = "(the response body was empty)"
|
||||
} else {
|
||||
body = truncate(body)
|
||||
}
|
||||
status := http.StatusText(e.Status)
|
||||
if status != "" {
|
||||
status = " " + status
|
||||
}
|
||||
out := fmt.Sprintf("%s %s: %d%s: %s", e.Method, e.URL, e.Status, status, body)
|
||||
if e.Status == http.StatusForbidden {
|
||||
out += "\n" + scopeHint
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// scopeHint is what a 403 gets said after it.
|
||||
//
|
||||
// Gitea scopes a token as <read|write>:<category>, and a token minted to file
|
||||
// issues carries `write:issue` and nothing more — which is exactly right until
|
||||
// the first request outside issues, where releases, pull requests, branches and
|
||||
// tags all live under `repository` and the answer is a 403 that names no scope
|
||||
// at all. The server will not say which one is missing, so this does not guess
|
||||
// one; it names the two commands that show what was recorded and let it be
|
||||
// re-recorded.
|
||||
//
|
||||
// Blanket rather than per-call, because the transport does not know which
|
||||
// category an arbitrary endpoint belongs to — and a 403 on a request that had
|
||||
// the scope is a permissions problem on the repository, which this sentence does
|
||||
// not contradict.
|
||||
const scopeHint = "a 403 is usually the token's scopes rather than the request: Gitea scopes a token as " +
|
||||
"<read|write>:<category>, and everything outside issues (releases, pull requests, branches, tags, actions) " +
|
||||
"is `repository`. `kettle auth list` shows what each login on this machine records."
|
||||
|
||||
// StatusIs reports whether err is an API answer with this status code, for the
|
||||
// handful of places where one code means something specific — a 409 from a
|
||||
// dependency link that is already there, say.
|
||||
func StatusIs(err error, status int) bool {
|
||||
var apiErr *APIError
|
||||
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
|
||||
// and then closes it, so the body is only ever available through err; the
|
||||
// status and the request line are only ever available through resp. Neither is
|
||||
// a diagnosis on its own, and dropping either is how "the tracker said no"
|
||||
// becomes a message nobody can act on.
|
||||
//
|
||||
// A 2xx that still errored is a decode failure, not an answer the server
|
||||
// refused: it keeps the status out of the message and the shape out of
|
||||
// StatusIs, because a caller asking "was that a 409" must not be told yes by a
|
||||
// body it could not parse.
|
||||
func fail(resp *sdk.Response, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if resp == nil || resp.Response == nil {
|
||||
return err // never reached the server; the URL is already in the error
|
||||
}
|
||||
method, endpoint := "", ""
|
||||
if r := resp.Request; r != nil {
|
||||
method, endpoint = r.Method, r.URL.String()
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return &APIError{Method: method, URL: endpoint, Status: resp.StatusCode, Body: err.Error()}
|
||||
}
|
||||
return fmt.Errorf("%s %s: %d answered with what is not the JSON expected: %w",
|
||||
method, endpoint, resp.StatusCode, err)
|
||||
}
|
||||
|
||||
func truncate(s string) string {
|
||||
if len(s) <= maxErrorBody {
|
||||
return s
|
||||
}
|
||||
cut := s[:maxErrorBody]
|
||||
// Never split a rune: a truncated message that ends in a broken byte is a
|
||||
// message a terminal renders as garbage.
|
||||
for len(cut) > 0 && !utf8.ValidString(cut) {
|
||||
cut = cut[:len(cut)-1]
|
||||
}
|
||||
return fmt.Sprintf("%s… (%d bytes total)", cut, len(s))
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// where request bodies land
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// dumper is the RoundTripper that files a copy of every request body under
|
||||
// `.kettle/payload/`.
|
||||
//
|
||||
// The file survives the call, for a retry or a post-mortem.
|
||||
//
|
||||
// A RoundTripper and not a call site's decision, because a call site can forget
|
||||
// and a RoundTripper cannot: it sees every request the SDK builds, including
|
||||
// the ones no method of this package spelled out. What a call site still
|
||||
// supplies is the NAME — see label — because an issue's slug identifies the
|
||||
// call in a post-mortem and a serial number does not.
|
||||
//
|
||||
// WHERE IT LANDS IS NOT THE CALLER'S BUSINESS, and never was. The directory is
|
||||
// this package's scratchpad — a SIBLING of the issue store under the same
|
||||
// marker, resolved by the same walk, so which command wrote a body cannot
|
||||
// change where it went and the two can never end up in different projects. The
|
||||
// one time it was a caller's argument it got pointed at the store, and a label
|
||||
// bootstrap that touches no issue at all materialized an issue directory on a
|
||||
// fresh checkout: store contents are the thing being tracked, request bodies
|
||||
// are debris of the transport, and when they share a path `ls` starts lying
|
||||
// about what the project holds.
|
||||
//
|
||||
// It is created lazily, by the first write of a run and only then, so a run
|
||||
// with nothing to send — every read-only command, and the version handshake
|
||||
// every run opens with — leaves no directory behind.
|
||||
type dumper struct {
|
||||
next http.RoundTripper
|
||||
// root is resolved once, by New, and is never taken from a caller.
|
||||
root string
|
||||
|
||||
mu sync.Mutex
|
||||
name string
|
||||
}
|
||||
|
||||
// label names the file the next request body lands in. One label serves one
|
||||
// request: it is taken, not read, so a request the SDK makes on its own account
|
||||
// cannot end up filed under the last thing a command was doing.
|
||||
func (d *dumper) label(name string) {
|
||||
d.mu.Lock()
|
||||
d.name = name
|
||||
d.mu.Unlock()
|
||||
}
|
||||
|
||||
func (d *dumper) take() string {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
name := d.name
|
||||
d.name = ""
|
||||
return name
|
||||
}
|
||||
|
||||
// RoundTrip files the body and then sends the request.
|
||||
//
|
||||
// A dump that cannot be written fails the call before it is made, which is the
|
||||
// order the old hand-rolled client had and worth keeping: the point of the file
|
||||
// is to hold what was sent, and one that does not exist for a request that did
|
||||
// is worse than not having sent it.
|
||||
func (d *dumper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if err := d.file(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d.next.RoundTrip(req)
|
||||
}
|
||||
|
||||
func (d *dumper) file(req *http.Request) error {
|
||||
name := d.take()
|
||||
if req.Body == nil || req.GetBody == nil {
|
||||
return nil // a read: nothing to file
|
||||
}
|
||||
body, err := req.GetBody()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer body.Close()
|
||||
raw, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(bytes.TrimSpace(raw)) == 0 {
|
||||
return nil
|
||||
}
|
||||
if d.root == "" {
|
||||
return project.NotFoundError("")
|
||||
}
|
||||
if name == "" {
|
||||
name = derivedName(req)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(d.root, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(d.root, safeName(name)+".json"), readable(raw), 0o644)
|
||||
}
|
||||
|
||||
// readable is the request body as a person reads it: indented, and with the
|
||||
// markup left alone.
|
||||
//
|
||||
// The SDK marshals with encoding/json's defaults, which escape `<`, `>` and `&`
|
||||
// into their \u00xx spellings. An issue body carries `<!-- … -->` markers and
|
||||
// prose full of `&`, and a dump escaped that way is unreadable exactly when
|
||||
// somebody is reading it because something went wrong.
|
||||
//
|
||||
// So the bytes are re-encoded rather than filed verbatim: same JSON VALUE, and
|
||||
// numbers verbatim (UseNumber, so an id is not rounded through a float), but
|
||||
// not the same bytes. What that costs is byte-for-byte fidelity with the wire —
|
||||
// what it buys is a file anybody can read and re-POST. Anything that will not
|
||||
// parse is filed as it came, because a dump of something surprising is exactly
|
||||
// the dump worth having.
|
||||
func readable(raw []byte) []byte {
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.UseNumber()
|
||||
var v any
|
||||
if err := dec.Decode(&v); err != nil {
|
||||
return raw
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
enc := json.NewEncoder(buf)
|
||||
enc.SetIndent("", " ")
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return raw
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// derivedName is what an unnamed request is filed under: its method and its
|
||||
// path. Nobody has to remember to name a call for its body to be kept — a name
|
||||
// only makes the file easier to find.
|
||||
func derivedName(req *http.Request) string {
|
||||
return strings.ToLower(req.Method) + "-" + strings.TrimPrefix(req.URL.Path, "/api/v1/")
|
||||
}
|
||||
|
||||
// safeName is the file stem, with everything that is not plainly a file name
|
||||
// folded away.
|
||||
//
|
||||
// Sanitizing here rather than trusting callers: label names are namespaced
|
||||
// (`type/bug`), and a name passed straight through would write outside the
|
||||
// scratchpad — which is the one thing this directory exists to prevent.
|
||||
func safeName(name string) string {
|
||||
safe := strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
|
||||
return r
|
||||
}
|
||||
return '-'
|
||||
}, name)
|
||||
if safe = strings.Trim(safe, "-"); safe == "" {
|
||||
return "request"
|
||||
}
|
||||
return safe
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// the requests the SDK cannot express
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// Do sends one request to a path under this instance's API and returns the
|
||||
// status and the body exactly as they came back.
|
||||
//
|
||||
// It is the escape hatch, and it was here before it was one: the dependency
|
||||
// endpoint needed a body the SDK's own type cannot spell (see AddDependency),
|
||||
// so a hand-rolled request already existed. What has changed is that it is
|
||||
// exported, which is what lets `kettle api` reach a release, a pull request or a
|
||||
// webhook without this package growing a method per entity — and without a
|
||||
// second client that would hold the credentials all over again.
|
||||
//
|
||||
// It goes through the same http.Client as everything else, which is the whole
|
||||
// point: the same dump-RoundTripper files the body under `.kettle/payload/`, the
|
||||
// same `token …` header authenticates it, and a non-2xx comes back as the same
|
||||
// *APIError carrying the status AND what the server said.
|
||||
//
|
||||
// THREE THINGS IT DELIBERATELY DOES NOT DO:
|
||||
//
|
||||
// - IT DOES NOT PAGINATE. One call is one HTTP request. The pagination in this
|
||||
// package exists for a listing with a budget to spend, and a passthrough that
|
||||
// quietly stitched pages together would report as one answer something that
|
||||
// was several — `?page=` and `?limit=` are the caller's to spell.
|
||||
// - IT DOES NOT PARSE OR REFORMAT THE ANSWER. Bytes in, bytes out. Whoever
|
||||
// asked knows what they asked for; re-indenting it here would only be a
|
||||
// second opinion about somebody else's JSON.
|
||||
// - IT DOES NOT KNOW WHAT AN ISSUE IS. Nothing about it names a domain concept,
|
||||
// so the layering rule holds unchanged — this is still transport, and a
|
||||
// generic one is no more a domain than a specific one was.
|
||||
//
|
||||
// A nil body sends no body at all, which is what a GET and a DELETE want; the
|
||||
// Content-Type goes on only when there is something to type.
|
||||
func (c *Client) Do(method, path string, body []byte, name string) (int, []byte, error) {
|
||||
endpoint, err := c.endpoint(path)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
reader = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequest(method, endpoint, reader)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
// Gitea's own scheme, and what the `tea` CLI this replaces sent: the word
|
||||
// `token`, not `Bearer`. An instance answers 401 to the other spelling.
|
||||
req.Header.Set("Authorization", "token "+c.token)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
|
||||
c.dump.label(name)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
// The token travels in a header and never in the URL, so an error is
|
||||
// free to quote the URL in full.
|
||||
return 0, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
answer, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return resp.StatusCode, nil, fmt.Errorf("%s %s: %d answered with a body that could not be read: %w",
|
||||
method, endpoint, resp.StatusCode, err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return resp.StatusCode, answer, &APIError{
|
||||
Method: method, URL: endpoint, Status: resp.StatusCode, Body: string(answer)}
|
||||
}
|
||||
return resp.StatusCode, answer, nil
|
||||
}
|
||||
|
||||
// endpoint resolves what a caller spelled into an absolute URL on this instance.
|
||||
//
|
||||
// The three spellings are the ones `tea api` accepted, so a table of endpoints
|
||||
// written for that tool still works here: a bare path is under `/api/v1/`, a
|
||||
// path already starting `/api/` is taken as it stands (that is how anything
|
||||
// outside v1 is reached), and a full URL is a full URL.
|
||||
//
|
||||
// A FULL URL MUST BE ON THIS INSTANCE, and that is the one place this is
|
||||
// stricter than the tool it replaces. Every request made here carries the
|
||||
// project's token in a header; a URL pointing somewhere else would hand that
|
||||
// token to whatever host was named, which is a credential leak spelled as a
|
||||
// convenience. Reaching another instance is what KETTLE_URL is for.
|
||||
func (c *Client) endpoint(path string) (string, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(path, "http://"), strings.HasPrefix(path, "https://"):
|
||||
if path != c.base && !strings.HasPrefix(path, c.base+"/") {
|
||||
return "", fmt.Errorf("%s is not on %s — this token belongs to that instance and is sent nowhere else"+
|
||||
" (point %s at the other one instead)", path, c.base, config.EnvURL)
|
||||
}
|
||||
return path, nil
|
||||
case strings.HasPrefix(path, "/api/"):
|
||||
return c.base + path, nil
|
||||
default:
|
||||
return c.base + "/api/v1/" + strings.TrimLeft(path, "/"), nil
|
||||
}
|
||||
}
|
||||
|
||||
// post sends one JSON body to a path under this instance's API and ignores
|
||||
// whatever comes back. AddDependency is what it is for.
|
||||
func (c *Client) post(path string, body any, name string) error {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err = c.Do(http.MethodPost, path, raw, name)
|
||||
return err
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// pagination
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const (
|
||||
// pageLimit is how many rows a list request asks for at a time. Gitea's own
|
||||
// default is smaller and its maximum is larger; 50 is what the Python this
|
||||
// replaces used and what the page-budget arithmetic is written against.
|
||||
pageLimit = 50
|
||||
// maxPages bounds any single listing. A tracker with a runaway number of
|
||||
// rows must not turn one command into an unbounded read.
|
||||
maxPages = 40
|
||||
// PageSlack is how far past the ideal page count a Keep-bounded listing may
|
||||
// scan before it gives up. The ideal is what Limit would need if every
|
||||
// payload counted; the slack pays for the ones that do not. Deliberately
|
||||
// small: "fetch until N are kept" without a bound is "fetch the whole
|
||||
// tracker" on any repository whose filter matches mostly closed issues.
|
||||
PageSlack = 4
|
||||
)
|
||||
|
||||
// pages calls fetch page by page and hands each page to each as it arrives,
|
||||
// stopping when each returns false, when a short page says the list is
|
||||
// exhausted, or when budget pages have been read.
|
||||
//
|
||||
// A callback rather than a slice, because a caller whose budget is spent on
|
||||
// what it KEEPS cannot be served by a function that fetches everything first:
|
||||
// the page after the one that completed the budget must never be requested.
|
||||
// That is the one thing the SDK's own list options cannot do — they describe a
|
||||
// page, and this describes when to stop asking for another.
|
||||
func pages[T any](fetch func(page, limit int) ([]T, error), limit, budget int, each func([]T) (bool, error)) error {
|
||||
for page := 1; page <= budget; page++ {
|
||||
batch, err := fetch(page, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(batch) == 0 {
|
||||
return nil
|
||||
}
|
||||
more, err := each(batch)
|
||||
if err != nil || !more {
|
||||
return err
|
||||
}
|
||||
if len(batch) < limit {
|
||||
return nil // a short page is the last one
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// paginate follows a list endpoint to exhaustion and returns the whole list.
|
||||
func paginate[T any](fetch func(page, limit int) ([]T, error), limit int) ([]T, error) {
|
||||
var out []T
|
||||
err := pages(fetch, limit, maxPages, func(batch []T) (bool, error) {
|
||||
out = append(out, batch...)
|
||||
return true, nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// listOptions is one page, as the SDK asks for it.
|
||||
func listOptions(page, limit int) sdk.ListOptions {
|
||||
return sdk.ListOptions{Page: page, PageSize: limit}
|
||||
}
|
||||
@@ -0,0 +1,716 @@
|
||||
package gitea_test
|
||||
|
||||
// The transport is tested against httptest, never against a tracker: a test
|
||||
// that needs a server somewhere is a test nobody runs.
|
||||
//
|
||||
// Every fixture builds a throwaway project in a temp directory and points the
|
||||
// project walk at it with CLAUDE_PROJECT_DIR. Without that the walk falls
|
||||
// through to the working directory — which during a test run is this repository
|
||||
// — and a request dump would land in the developer's own project. Nothing here
|
||||
// reads a login file either, but KETTLE_CONFIG_HOME is redirected all the same,
|
||||
// so a run can neither read nor overwrite the developer's own tokens.
|
||||
//
|
||||
// EVERY FAKE ANSWERS /api/v1/version, because building a client is now a
|
||||
// request: the SDK asks the instance what it is before it hands one back, and
|
||||
// that answer is what the dependency gate is decided on later. A fake that did
|
||||
// not answer it would be a fake no client can be built against — see
|
||||
// versionRoute.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sdk "code.gitea.io/sdk/gitea"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
// modernGitea is what a fake says it is: new enough for everything this
|
||||
// transport asks for, dependency endpoints included.
|
||||
const modernGitea = "1.26.1"
|
||||
|
||||
// newProject makes an initialized project and points the walk at it. Returns
|
||||
// the project root.
|
||||
func newProject(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".kettle"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("CLAUDE_PROJECT_DIR", dir)
|
||||
t.Setenv(config.EnvHome, filepath.Join(dir, "config"))
|
||||
return dir
|
||||
}
|
||||
|
||||
// versionRoute answers the SDK's version handshake and reports whether it did,
|
||||
// so every other handler can be written as though the request were not there.
|
||||
func versionRoute(w http.ResponseWriter, r *http.Request, version string) bool {
|
||||
if r.URL.Path != "/api/v1/version" {
|
||||
return false
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"version":"`+version+`"}`)
|
||||
return true
|
||||
}
|
||||
|
||||
// serve is an httptest server that speaks the handshake and hands everything
|
||||
// else to next.
|
||||
func serve(t *testing.T, version string, next http.HandlerFunc) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if versionRoute(w, r, version) {
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func newClient(t *testing.T, url string) *gitea.Client {
|
||||
t.Helper()
|
||||
c, err := gitea.New(&config.Resolved{URL: url, Token: "s3cret", Owner: "acme", Repo: "widgets"})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, w http.ResponseWriter, v any) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
t.Errorf("encoding the fake response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A list endpoint is followed to the last page and no further: the short page
|
||||
// ends it, and the page after that is never asked for.
|
||||
func TestPaginationFollowsToTheLastPage(t *testing.T) {
|
||||
newProject(t)
|
||||
|
||||
var asked []string
|
||||
var auth string
|
||||
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||
asked = append(asked, r.URL.RequestURI())
|
||||
auth = r.Header.Get("Authorization")
|
||||
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
n := limit
|
||||
if page == 3 {
|
||||
n = 7 // the short page
|
||||
} else if page > 3 {
|
||||
n = 0
|
||||
}
|
||||
out := []map[string]any{}
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, map[string]any{"id": (page-1)*limit + i + 1, "body": "hello"})
|
||||
}
|
||||
writeJSON(t, w, out)
|
||||
})
|
||||
|
||||
got, err := newClient(t, srv.URL).ListComments(42)
|
||||
if err != nil {
|
||||
t.Fatalf("ListComments: %v", err)
|
||||
}
|
||||
if len(got) != 107 {
|
||||
t.Errorf("got %d comments, want 107 (50 + 50 + 7)", len(got))
|
||||
}
|
||||
if len(asked) != 3 {
|
||||
t.Errorf("made %d requests (%v), want 3 — a short page is the last one", len(asked), asked)
|
||||
}
|
||||
if got[0].ID != 1 || got[106].ID != 107 {
|
||||
t.Errorf("pages arrived out of order: first %d, last %d", got[0].ID, got[106].ID)
|
||||
}
|
||||
// Gitea's own scheme, and what the CLI this replaces sent.
|
||||
if auth != "token s3cret" {
|
||||
t.Errorf("Authorization was %q, want %q", auth, "token s3cret")
|
||||
}
|
||||
want := "/api/v1/repos/acme/widgets/issues/42/comments?limit=50&page=1"
|
||||
if asked[0] != want {
|
||||
t.Errorf("first request was %s, want %s", asked[0], want)
|
||||
}
|
||||
}
|
||||
|
||||
// A non-2xx carries the status AND the body, because the status alone has never
|
||||
// told anybody which of the four things that answer 422 actually happened.
|
||||
func TestErrorNamesTheStatusAndTheBody(t *testing.T) {
|
||||
newProject(t)
|
||||
|
||||
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
io.WriteString(w, `{"message":"label already exists","url":"https://example.test/docs"}`)
|
||||
})
|
||||
|
||||
_, err := newClient(t, srv.URL).GetIssue(7)
|
||||
if err == nil {
|
||||
t.Fatal("a 422 returned no error")
|
||||
}
|
||||
var apiErr *gitea.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("error is %T, want *gitea.APIError: %v", err, err)
|
||||
}
|
||||
if apiErr.Status != http.StatusUnprocessableEntity {
|
||||
t.Errorf("Status is %d, want 422", apiErr.Status)
|
||||
}
|
||||
if !gitea.StatusIs(err, http.StatusUnprocessableEntity) {
|
||||
t.Error("StatusIs did not recognize its own error")
|
||||
}
|
||||
for _, want := range []string{"422", "label already exists", "GET", "/api/v1/repos/acme/widgets/issues/7"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the error does not mention %q:\n%s", want, err)
|
||||
}
|
||||
}
|
||||
// The token is in a header, so quoting the URL is safe — and it had better
|
||||
// stay that way.
|
||||
if strings.Contains(err.Error(), "s3cret") {
|
||||
t.Errorf("the error quotes the token:\n%s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A request body is filed in the scratchpad, which is a SIBLING of the store
|
||||
// and never inside it. A call that touches no issue must not materialize an
|
||||
// issue directory.
|
||||
func TestPayloadLandsBesideTheStoreAndNeverInIt(t *testing.T) {
|
||||
root := newProject(t)
|
||||
|
||||
var sent []byte
|
||||
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||
sent, _ = io.ReadAll(r.Body)
|
||||
writeJSON(t, w, map[string]any{
|
||||
"number": 42, "id": 5, "html_url": "https://example.test/acme/widgets/issues/42"})
|
||||
})
|
||||
|
||||
body := "<!-- kettle:id wire-sqlc --> a & b"
|
||||
got, err := newClient(t, srv.URL).CreateIssue(
|
||||
sdk.CreateIssueOption{Title: "wire sqlc", Body: body}, "issue-wire-sqlc")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateIssue: %v", err)
|
||||
}
|
||||
if got.Index != 42 {
|
||||
t.Errorf("got issue #%d, want #42", got.Index)
|
||||
}
|
||||
|
||||
path := filepath.Join(root, ".kettle", "payload", "issue-wire-sqlc.json")
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("the request body was not filed at %s: %v", path, err)
|
||||
}
|
||||
// The same JSON VALUE as went out, and not the same bytes: the SDK marshals
|
||||
// with encoding/json's defaults, so what is on the wire has its markup
|
||||
// escaped and no indentation. A dump nobody can read is a dump nobody
|
||||
// reads, and a re-POST of this file sends what this call sent.
|
||||
var filed, onTheWire any
|
||||
if err := json.Unmarshal(raw, &filed); err != nil {
|
||||
t.Fatalf("the filed body is not JSON: %v\n%s", err, raw)
|
||||
}
|
||||
if err := json.Unmarshal(sent, &onTheWire); err != nil {
|
||||
t.Fatalf("what was sent is not JSON: %v\n%s", err, sent)
|
||||
}
|
||||
if !reflect.DeepEqual(filed, onTheWire) {
|
||||
t.Errorf("the filed body is not the body that was sent:\nfiled: %s\nsent: %s", raw, sent)
|
||||
}
|
||||
// A dump escaped to \u003c is unreadable exactly when it is being read.
|
||||
if !strings.Contains(string(raw), "<!-- kettle:id wire-sqlc --> a & b") {
|
||||
t.Errorf("the dump escaped the markup it was meant to preserve:\n%s", raw)
|
||||
}
|
||||
if !strings.Contains(string(raw), "\n \"") {
|
||||
t.Errorf("the dump is not indented:\n%s", raw)
|
||||
}
|
||||
// The whole reason the scratchpad is a sibling.
|
||||
if _, err := os.Stat(filepath.Join(root, ".kettle", "issues")); !os.IsNotExist(err) {
|
||||
t.Errorf("writing a request body materialized the issue store (%v)", err)
|
||||
}
|
||||
// A namespaced name must not climb out of the scratchpad.
|
||||
if _, err := newClient(t, srv.URL).CreateLabel(sdk.CreateLabelOption{Name: "type/bug", Color: "#ee0701"}); err != nil {
|
||||
t.Fatalf("CreateLabel: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, ".kettle", "payload", "label-type-bug.json")); err != nil {
|
||||
t.Errorf("a label request body was not filed under a safe name: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, ".kettle", "payload", "type")); !os.IsNotExist(err) {
|
||||
t.Error("a label name with a slash in it made a directory inside the scratchpad")
|
||||
}
|
||||
}
|
||||
|
||||
// A run that sends no body leaves no directory behind — the scratchpad is
|
||||
// created by the first write and only then. The version handshake every client
|
||||
// opens with is a read, so building one is not "a run that sent something".
|
||||
func TestAReadOnlyCallCreatesNoScratchpad(t *testing.T) {
|
||||
root := newProject(t)
|
||||
|
||||
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(t, w, map[string]any{"number": 42})
|
||||
})
|
||||
|
||||
if _, err := newClient(t, srv.URL).GetIssue(42); err != nil {
|
||||
t.Fatalf("GetIssue: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, ".kettle", "payload")); !os.IsNotExist(err) {
|
||||
t.Errorf("a read created the payload directory (%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The milestone filter is re-checked on the client, because Gitea silently
|
||||
// ignores one it cannot resolve and answers with the whole backlog. Pull
|
||||
// requests go the same way.
|
||||
func TestListIssuesRechecksWhatTheServerIgnored(t *testing.T) {
|
||||
newProject(t)
|
||||
|
||||
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/milestones") {
|
||||
writeJSON(t, w, []map[string]any{{"id": 3, "title": "v1"}, {"id": 9, "title": "later"}})
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("page") != "1" {
|
||||
writeJSON(t, w, []map[string]any{})
|
||||
return
|
||||
}
|
||||
writeJSON(t, w, []map[string]any{
|
||||
{"number": 1, "title": "in the milestone", "milestone": map[string]any{"id": 3, "title": "v1"}},
|
||||
{"number": 2, "title": "another milestone", "milestone": map[string]any{"id": 9, "title": "later"}},
|
||||
{"number": 3, "title": "no milestone at all"},
|
||||
{"number": 4, "title": "a pull request", "milestone": map[string]any{"id": 3, "title": "v1"},
|
||||
"pull_request": map[string]any{"merged": false}},
|
||||
})
|
||||
})
|
||||
|
||||
got, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{Milestone: "v1", Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("ListIssues: %v", err)
|
||||
}
|
||||
if got.Milestone != "v1" {
|
||||
t.Errorf("resolved milestone is %q, want v1", got.Milestone)
|
||||
}
|
||||
if len(got.Issues) != 1 || got.Issues[0].Index != 1 {
|
||||
t.Fatalf("got %d issue(s) %v, want only #1 — the backlog was not re-filtered", len(got.Issues), got.Issues)
|
||||
}
|
||||
|
||||
if _, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{Milestone: "typo", Limit: 50}); err == nil {
|
||||
t.Error("an unknown milestone was accepted — that reads as a milestone with the whole backlog in it")
|
||||
} else if !strings.Contains(err.Error(), "have: v1 (id 3)") {
|
||||
t.Errorf("the error does not say what the repo actually has: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A Keep predicate that rejects everything must not turn a bounded read into a
|
||||
// walk of the whole tracker, and coming up short is reported rather than
|
||||
// answered in silence.
|
||||
func TestListIssuesStopsAtThePageBudget(t *testing.T) {
|
||||
newProject(t)
|
||||
|
||||
pages := 0
|
||||
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||
pages++
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
out := []map[string]any{}
|
||||
for i := 0; i < limit; i++ {
|
||||
out = append(out, map[string]any{"number": pages*100 + i, "state": "closed"})
|
||||
}
|
||||
writeJSON(t, w, out)
|
||||
})
|
||||
|
||||
got, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{
|
||||
Limit: 2,
|
||||
Keep: func(i *sdk.Issue) bool { return i.State == sdk.StateOpen },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListIssues: %v", err)
|
||||
}
|
||||
if pages != gitea.PageSlack {
|
||||
t.Errorf("read %d page(s), want %d — one ideal page times the slack", pages, gitea.PageSlack)
|
||||
}
|
||||
if got.Warning == "" {
|
||||
t.Error("stopped short of the limit and said nothing about it")
|
||||
}
|
||||
// Everything enumerated comes back even though none of it counted: a caller
|
||||
// with something to say about the ones that did not still can.
|
||||
if len(got.Issues) != gitea.PageSlack*2 {
|
||||
t.Errorf("got %d issue(s), want every payload that was enumerated", len(got.Issues))
|
||||
}
|
||||
}
|
||||
|
||||
// A Keep-bounded read stops the moment the budget is full: the page after the
|
||||
// one that completed it is never requested.
|
||||
func TestListIssuesStopsAtTheLimit(t *testing.T) {
|
||||
newProject(t)
|
||||
|
||||
pages := 0
|
||||
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||
pages++
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
out := []map[string]any{}
|
||||
for i := 0; i < limit; i++ {
|
||||
out = append(out, map[string]any{"number": pages*100 + i, "state": "open"})
|
||||
}
|
||||
writeJSON(t, w, out)
|
||||
})
|
||||
|
||||
got, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{
|
||||
Limit: 2,
|
||||
Keep: func(i *sdk.Issue) bool { return i.State == sdk.StateOpen },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListIssues: %v", err)
|
||||
}
|
||||
if pages != 1 {
|
||||
t.Errorf("read %d page(s), want 1 — the budget was full after the first", pages)
|
||||
}
|
||||
if len(got.Issues) != 2 || got.Warning != "" {
|
||||
t.Errorf("got %d issue(s), warning %q; want 2 and no warning", len(got.Issues), got.Warning)
|
||||
}
|
||||
}
|
||||
|
||||
// A dependency endpoint the instance has but this repository does not is "no
|
||||
// dependencies", not a failed pull. A dead connection still is one.
|
||||
func TestDependenciesToleratesAnInstanceWithoutThem(t *testing.T) {
|
||||
newProject(t)
|
||||
|
||||
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "not implemented", http.StatusNotImplemented)
|
||||
})
|
||||
|
||||
// Both clients are built while the server is up, because building one is
|
||||
// itself a request now — and the question this asks is what a call does
|
||||
// when the connection dies UNDER it, which is a different failure from a
|
||||
// tracker that was never there.
|
||||
live := newClient(t, srv.URL)
|
||||
dead := newClient(t, srv.URL)
|
||||
|
||||
got, err := live.DependencyKeys(42)
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Errorf("DependencyKeys = %v, %v; want no keys and no error", got, err)
|
||||
}
|
||||
|
||||
srv.Close()
|
||||
if _, err := dead.Dependencies(42); err == nil {
|
||||
t.Error("a dead connection was reported as an instance without dependency support")
|
||||
}
|
||||
}
|
||||
|
||||
// The gate the SDK made possible: an instance too old to have the endpoint at
|
||||
// all is answered from its version, without a request.
|
||||
//
|
||||
// It matters because the alternative is guessing from a status code. An old
|
||||
// Gitea answers a route it does not have with the same 404 it answers for an
|
||||
// issue that does not exist, and a pull that read the second as "no blockers"
|
||||
// would quietly drop half the unit of work.
|
||||
func TestDependenciesAreNotAskedForOnAnInstanceTooOldToHaveThem(t *testing.T) {
|
||||
newProject(t)
|
||||
|
||||
asked := 0
|
||||
srv := serve(t, "1.19.4", func(w http.ResponseWriter, r *http.Request) {
|
||||
asked++
|
||||
writeJSON(t, w, []map[string]any{{"number": 7}})
|
||||
})
|
||||
|
||||
c := newClient(t, srv.URL)
|
||||
got, err := c.Dependencies(42)
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Errorf("Dependencies = %v, %v; want none and no error", got, err)
|
||||
}
|
||||
if asked != 0 {
|
||||
t.Errorf("%d request(s) went out — the version had already answered", asked)
|
||||
}
|
||||
|
||||
// Writing one says so out loud instead: push reports it beside the issue it
|
||||
// could not link, and a warning naming the version is something an operator
|
||||
// can act on.
|
||||
err = c.AddDependency(42, wire.Key{Repo: wire.Repo{Owner: "acme", Name: "widgets"}, Number: 7})
|
||||
if err == nil {
|
||||
t.Fatal("a link was attempted against an instance that has no endpoint for it")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "1.20.0") {
|
||||
t.Errorf("the refusal does not name the version that would work: %v", err)
|
||||
}
|
||||
if asked != 0 {
|
||||
t.Errorf("%d request(s) went out for a write the instance cannot take", asked)
|
||||
}
|
||||
}
|
||||
|
||||
// And the other side of the same gate: a modern instance is asked, and the
|
||||
// answer comes back as cross-repo keys — a dependency is allowed to live
|
||||
// somewhere else, and a bare number would not say where.
|
||||
func TestDependenciesAreAskedForOnAnInstanceThatHasThem(t *testing.T) {
|
||||
newProject(t)
|
||||
|
||||
var body string
|
||||
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
body = string(raw)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
return
|
||||
}
|
||||
writeJSON(t, w, []map[string]any{
|
||||
{"number": 7},
|
||||
{"number": 3, "repository": map[string]any{"full_name": "other/repo"}},
|
||||
})
|
||||
})
|
||||
|
||||
c := newClient(t, srv.URL)
|
||||
got, err := c.DependencyKeys(42)
|
||||
if err != nil {
|
||||
t.Fatalf("DependencyKeys: %v", err)
|
||||
}
|
||||
want := []wire.Key{
|
||||
{Repo: wire.Repo{Owner: "acme", Name: "widgets"}, Number: 7},
|
||||
{Repo: wire.Repo{Owner: "other", Name: "repo"}, Number: 3},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("keys = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// The one endpoint left with a hand-rolled request, because the SDK's
|
||||
// IssueMeta carries an index and nothing else: a link to another repository
|
||||
// needs the owner and the name with it.
|
||||
if err := c.AddDependency(42, want[1]); err != nil {
|
||||
t.Fatalf("AddDependency: %v", err)
|
||||
}
|
||||
for _, part := range []string{`"index":3`, `"owner":"other"`, `"repo":"repo"`} {
|
||||
if !strings.Contains(body, part) {
|
||||
t.Errorf("the link body does not carry %s: %s", part, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The generic request: bytes out, bytes back, and the same three services every
|
||||
// other call in this package gets — the header, the scratchpad, the *APIError.
|
||||
func TestDoAnswersWithWhatTheServerSent(t *testing.T) {
|
||||
root := newProject(t)
|
||||
|
||||
var got struct{ method, uri, auth, ctype string }
|
||||
var sent []byte
|
||||
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||
got.method, got.uri = r.Method, r.URL.RequestURI()
|
||||
got.auth, got.ctype = r.Header.Get("Authorization"), r.Header.Get("Content-Type")
|
||||
sent, _ = io.ReadAll(r.Body)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
io.WriteString(w, `{"tag_name":"v0.2.0"}`)
|
||||
})
|
||||
c := newClient(t, srv.URL)
|
||||
|
||||
// A read: no body out, and nothing filed — the scratchpad holds what was
|
||||
// SENT, and a run that sent nothing leaves no directory behind.
|
||||
code, answer, err := c.Do(http.MethodGet, "repos/acme/widgets/releases?limit=50", nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Do: %v", err)
|
||||
}
|
||||
if code != http.StatusCreated || string(answer) != `{"tag_name":"v0.2.0"}` {
|
||||
t.Errorf("got %d %q, want 201 and the server's bytes", code, answer)
|
||||
}
|
||||
if got.uri != "/api/v1/repos/acme/widgets/releases?limit=50" {
|
||||
t.Errorf("the endpoint was rewritten: %s", got.uri)
|
||||
}
|
||||
if got.auth != "token s3cret" {
|
||||
t.Errorf("Authorization was %q, want %q", got.auth, "token s3cret")
|
||||
}
|
||||
if got.ctype != "" {
|
||||
t.Errorf("a request with no body carried Content-Type %q", got.ctype)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, ".kettle", "payload")); !os.IsNotExist(err) {
|
||||
t.Errorf("a read created the payload directory (%v)", err)
|
||||
}
|
||||
|
||||
// A write: the body goes out verbatim and is filed under the name it was
|
||||
// given, by the same RoundTripper that files every other request.
|
||||
body := []byte(`{"tag_name":"v0.2.0","body":"a & b"}`)
|
||||
if _, _, err := c.Do(http.MethodPost, "/api/v1/repos/acme/widgets/releases", body, "release-v0-2-0"); err != nil {
|
||||
t.Fatalf("Do: %v", err)
|
||||
}
|
||||
if got.method != http.MethodPost || got.ctype != "application/json" {
|
||||
t.Errorf("the write went out as %s %q", got.method, got.ctype)
|
||||
}
|
||||
if string(sent) != string(body) {
|
||||
t.Errorf("the server got %s, want %s — a passthrough reformatted the body", sent, body)
|
||||
}
|
||||
filed, err := os.ReadFile(filepath.Join(root, ".kettle", "payload", "release-v0-2-0.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("the body was not filed: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(filed), `"tag_name": "v0.2.0"`) {
|
||||
t.Errorf("the dump is not the body that was sent:\n%s", filed)
|
||||
}
|
||||
}
|
||||
|
||||
// A refusal comes back as this package's error, with the status and the
|
||||
// server's own words — and the status and body are returned as well, so a
|
||||
// caller that would rather print them than wrap them can.
|
||||
func TestDoReportsAStatusAndTheServersWords(t *testing.T) {
|
||||
newProject(t)
|
||||
|
||||
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
io.WriteString(w, `{"message":"release does not exist"}`)
|
||||
})
|
||||
|
||||
code, answer, err := newClient(t, srv.URL).Do(http.MethodGet, "repos/acme/widgets/releases/9", nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("a 404 came back as success")
|
||||
}
|
||||
var apiErr *gitea.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("error is %T, want *gitea.APIError: %v", err, err)
|
||||
}
|
||||
if code != http.StatusNotFound || !strings.Contains(string(answer), "release does not exist") {
|
||||
t.Errorf("got %d %q; the status and the body are the caller's too", code, answer)
|
||||
}
|
||||
for _, want := range []string{"404", "release does not exist", "GET", "/api/v1/repos/acme/widgets/releases/9"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the error does not mention %q:\n%s", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A 403 is answered with what to do about it, because Gitea's own 403 names no
|
||||
// scope and a token minted for issues is the usual reason.
|
||||
func TestAForbiddenAnswerNamesTheScopeItMightBe(t *testing.T) {
|
||||
newProject(t)
|
||||
|
||||
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
io.WriteString(w, `{"message":"token does not have at least one of required scope(s)"}`)
|
||||
})
|
||||
|
||||
_, _, err := newClient(t, srv.URL).Do(http.MethodPost, "repos/acme/widgets/releases", []byte(`{}`), "")
|
||||
if err == nil {
|
||||
t.Fatal("a 403 came back as success")
|
||||
}
|
||||
for _, want := range []string{"403", "kettle auth list", "repository"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("a 403 does not say %q — the server named no scope, so this has to:\n%s", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The token is this instance's. A full URL somewhere else is refused before a
|
||||
// socket is opened, because sending it would hand the credential to whatever
|
||||
// host was typed.
|
||||
func TestDoRefusesAURLOnAnotherHost(t *testing.T) {
|
||||
newProject(t)
|
||||
|
||||
asked := 0
|
||||
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||
asked++
|
||||
writeJSON(t, w, map[string]any{})
|
||||
})
|
||||
c := newClient(t, srv.URL)
|
||||
|
||||
_, _, err := c.Do(http.MethodGet, "https://gitea.example.invalid/api/v1/user", nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("a request to another host was allowed — that sends this project's token to it")
|
||||
}
|
||||
if strings.Contains(err.Error(), "s3cret") {
|
||||
t.Errorf("the refusal quotes the token:\n%s", err)
|
||||
}
|
||||
if asked != 0 {
|
||||
t.Errorf("%d request(s) went out for an endpoint that was refused", asked)
|
||||
}
|
||||
// A full URL on the instance itself is the same request as the bare path.
|
||||
if _, _, err := c.Do(http.MethodGet, srv.URL+"/api/v1/user", nil, ""); err != nil {
|
||||
t.Errorf("a full URL on this instance was refused: %v", err)
|
||||
}
|
||||
if asked != 1 {
|
||||
t.Errorf("%d request(s) went out, want 1", asked)
|
||||
}
|
||||
}
|
||||
|
||||
// A half-filled configuration is refused here rather than at the first 401,
|
||||
// because a 401 names nothing an operator can act on — and before the client is
|
||||
// built at all, because building one dials.
|
||||
func TestNewRefusesAHalfFilledConfiguration(t *testing.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: "u", Owner: "a", Repo: "b"}, "kettle auth add"},
|
||||
{"no repo", config.Resolved{URL: "u", Token: "t"}, "kettle init --repo"},
|
||||
} {
|
||||
_, err := gitea.New(&tc.cfg)
|
||||
if err == nil {
|
||||
t.Errorf("%s: accepted", tc.what)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("%s: the error does not name the fix (%q): %v", tc.what, tc.want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An instance nobody can reach is named as one. The handshake is the first
|
||||
// request of every run, so this is the failure an operator meets when the URL
|
||||
// is wrong or the tracker is down, and it has to say which instance.
|
||||
func TestNewSaysWhichInstanceItCouldNotReach(t *testing.T) {
|
||||
newProject(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
srv.Close()
|
||||
|
||||
_, err := gitea.New(&config.Resolved{URL: srv.URL, Token: "t", Owner: "a", Repo: "b"})
|
||||
if err == nil {
|
||||
t.Fatal("a client was built against a tracker that is not there")
|
||||
}
|
||||
if !strings.Contains(err.Error(), srv.URL) {
|
||||
t.Errorf("the error does not name the instance: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A version nobody can parse is refused at the handshake, not carried.
|
||||
//
|
||||
// The SDK hands back a client that has silently decided the server is 1.11 and
|
||||
// then rewrites issue URLs from a field a modern payload need not carry, which
|
||||
// is a nil dereference on the first issue read. A refusal that names the
|
||||
// instance is the answer an operator can act on.
|
||||
func TestNewRefusesAnInstanceWhoseVersionIsNotOne(t *testing.T) {
|
||||
newProject(t)
|
||||
|
||||
srv := serve(t, "not-a-version", func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Errorf("a call went out to %s after the handshake had already failed", r.URL.Path)
|
||||
})
|
||||
|
||||
_, err := gitea.New(&config.Resolved{URL: srv.URL, Token: "t", Owner: "a", Repo: "b"})
|
||||
if err == nil {
|
||||
t.Fatal("an unreadable version was accepted — the first issue read would panic inside the SDK")
|
||||
}
|
||||
if !strings.Contains(err.Error(), srv.URL) || !strings.Contains(err.Error(), config.EnvURL) {
|
||||
t.Errorf("the refusal names neither the instance nor the setting that points at it: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The layering rule, from this side. The transport knows numbers, logins, HTTP
|
||||
// and JSON; the domain knows none of those, and neither may reach the other.
|
||||
//
|
||||
// The bridge is out too, and for a reason of its own: it is the layer that
|
||||
// translates between the two, so it sits ABOVE both. A transport that imported
|
||||
// it would be a transport that knows what an issue is, one indirection later —
|
||||
// and the vocabulary both of them share, the SDK's payloads, exists precisely
|
||||
// so that neither has to reach for the other to name one.
|
||||
func TestTransportDoesNotImportTheDomain(t *testing.T) {
|
||||
out, err := exec.Command("go", "list", "-deps", ".").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("go list: %v", err)
|
||||
}
|
||||
for _, dep := range strings.Fields(string(out)) {
|
||||
switch {
|
||||
case strings.HasSuffix(dep, "/internal/issue"):
|
||||
t.Errorf("the transport imports %s — what an issue IS is not a transport concept", dep)
|
||||
case strings.HasSuffix(dep, "/internal/mapping"):
|
||||
t.Errorf("the transport imports %s — translating is a layer of its own, and it sits above this one", dep)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
sdk "code.gitea.io/sdk/gitea"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
// GetIssue fetches one issue by number.
|
||||
//
|
||||
// A number is an address, not a query: this answers for a closed issue exactly
|
||||
// as it does for an open one.
|
||||
func (c *Client) GetIssue(number int) (*sdk.Issue, error) {
|
||||
owner, repo := c.owned()
|
||||
got, resp, err := c.api.GetIssue(owner, repo, int64(number))
|
||||
if err := fail(resp, err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A 200 that carries no number is not this issue. Gitea has answered that
|
||||
// way for a repository whose issue tracker is disabled.
|
||||
if got == nil || got.Index == 0 {
|
||||
return nil, fmt.Errorf("issue #%d not found in %s", number, c.repo)
|
||||
}
|
||||
return got, nil
|
||||
}
|
||||
|
||||
// CreateIssue files a new issue. name labels the request body in the
|
||||
// scratchpad; the issue's slug is what makes that dump worth keeping.
|
||||
func (c *Client) CreateIssue(opt sdk.CreateIssueOption, name string) (*sdk.Issue, error) {
|
||||
owner, repo := c.owned()
|
||||
c.dump.label(name)
|
||||
got, resp, err := c.api.CreateIssue(owner, repo, opt)
|
||||
if err := fail(resp, err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return got, nil
|
||||
}
|
||||
|
||||
// EditIssue patches an existing issue. Only the fields set on opt are sent.
|
||||
//
|
||||
// LABELS DO NOT GO THROUGH HERE. Gitea's edit endpoint takes no label list and
|
||||
// neither does the SDK's EditIssueOption, so an issue whose labels changed
|
||||
// needs SetLabels after this — push does exactly that, and says so.
|
||||
func (c *Client) EditIssue(number int, opt sdk.EditIssueOption, name string) (*sdk.Issue, error) {
|
||||
owner, repo := c.owned()
|
||||
c.dump.label(name)
|
||||
got, resp, err := c.api.EditIssue(owner, repo, int64(number), opt)
|
||||
if err := fail(resp, err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return got, nil
|
||||
}
|
||||
|
||||
// SetLabels replaces an issue's labels with exactly these ids.
|
||||
//
|
||||
// It exists because Gitea occasionally drops labels handed to it on create, and
|
||||
// the answer to that is to re-apply them rather than to trust the echo. It is
|
||||
// also the only way to change the labels of an issue that already exists — see
|
||||
// EditIssue.
|
||||
func (c *Client) SetLabels(number int, ids []int64, name string) ([]*sdk.Label, error) {
|
||||
if ids == nil {
|
||||
ids = []int64{}
|
||||
}
|
||||
owner, repo := c.owned()
|
||||
c.dump.label(name)
|
||||
got, resp, err := c.api.ReplaceIssueLabels(owner, repo, int64(number), sdk.IssueLabelsOption{Labels: ids})
|
||||
if err := fail(resp, err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return got, nil
|
||||
}
|
||||
|
||||
// ListComments is an issue's whole thread, every page of it.
|
||||
func (c *Client) ListComments(number int) ([]*sdk.Comment, error) {
|
||||
owner, repo := c.owned()
|
||||
return paginate(func(page, limit int) ([]*sdk.Comment, error) {
|
||||
got, resp, err := c.api.ListIssueComments(owner, repo, int64(number),
|
||||
sdk.ListIssueCommentOptions{ListOptions: listOptions(page, limit)})
|
||||
return got, fail(resp, err)
|
||||
}, pageLimit)
|
||||
}
|
||||
|
||||
// CreateComment posts a comment on an issue.
|
||||
func (c *Client) CreateComment(number int, text, name string) (*sdk.Comment, error) {
|
||||
owner, repo := c.owned()
|
||||
c.dump.label(name)
|
||||
got, resp, err := c.api.CreateIssueComment(owner, repo, int64(number),
|
||||
sdk.CreateIssueCommentOption{Body: text})
|
||||
if err := fail(resp, err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return got, nil
|
||||
}
|
||||
|
||||
// EditComment rewrites one comment, addressed by its own id and not by the
|
||||
// issue it is on — which is how Gitea addresses it.
|
||||
func (c *Client) EditComment(id int64, text, name string) (*sdk.Comment, error) {
|
||||
owner, repo := c.owned()
|
||||
c.dump.label(name)
|
||||
got, resp, err := c.api.EditIssueComment(owner, repo, id, sdk.EditIssueCommentOption{Body: text})
|
||||
if err := fail(resp, err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return got, nil
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// listing, and the filter the server does not honour
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// IssueFilter is what a listing asks for.
|
||||
type IssueFilter struct {
|
||||
// State is open (the default), closed, or all.
|
||||
State string
|
||||
// Labels are label names; an issue must carry all of them.
|
||||
Labels []string
|
||||
// Query is Gitea's keyword search over title and body.
|
||||
Query string
|
||||
// Milestone is an id or a title. It is resolved against the repository
|
||||
// before it is trusted — see ResolveMilestone.
|
||||
Milestone string
|
||||
// Limit counts the payloads the CALLER cares about, not the ones the server
|
||||
// returned. Must be 1 or more.
|
||||
Limit int
|
||||
// Keep says whether a payload counts against Limit. Without it every
|
||||
// payload counts and a listing behaves as any other. With it, pages keep
|
||||
// coming until Limit have counted, and the returned list carries the ones
|
||||
// that did not count too — they were enumerated, and a caller with
|
||||
// something to say about them ("11 closed, not stored") still can.
|
||||
//
|
||||
// What Keep means is the caller's business; this package only counts.
|
||||
Keep func(*sdk.Issue) bool
|
||||
}
|
||||
|
||||
// IssueListing is what a filtered read found.
|
||||
type IssueListing struct {
|
||||
// Issues are every payload that passed the filter, kept or not.
|
||||
Issues []*sdk.Issue
|
||||
// Milestone is the resolved milestone title, for a receipt.
|
||||
Milestone string
|
||||
// Warning is set when a Keep-bounded read ran out of page budget with the
|
||||
// budget unfilled. Returned rather than printed: the transport does not own
|
||||
// the operator's terminal, and a caller that is rendering JSON needs it as
|
||||
// data.
|
||||
Warning string
|
||||
}
|
||||
|
||||
// ListIssues reads filtered issue payloads.
|
||||
//
|
||||
// One request per page, and a payload already carries the issue body — a whole
|
||||
// milestone costs one call per page, not one per issue.
|
||||
//
|
||||
// Two boundaries hold whatever Keep decides:
|
||||
//
|
||||
// - Stop at the limit. The page after the one that completed the budget is
|
||||
// never requested.
|
||||
// - Stop at the page budget. A predicate that rejects everything must not turn
|
||||
// a bounded read into a walk of the whole tracker, so a filtered read scans
|
||||
// at most PageSlack times the pages Limit would need if every payload
|
||||
// counted. Hitting that with the budget unfilled sets Warning rather than
|
||||
// answering short in silence: the caller asked for N and is told it got
|
||||
// fewer.
|
||||
func (c *Client) ListIssues(f IssueFilter) (*IssueListing, error) {
|
||||
if f.Limit < 1 {
|
||||
return nil, fmt.Errorf("a listing limit must be 1 or more, got %d", f.Limit)
|
||||
}
|
||||
|
||||
out := &IssueListing{}
|
||||
var milestoneID int64
|
||||
if f.Milestone != "" {
|
||||
ms, err := c.ResolveMilestone(f.Milestone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
milestoneID, out.Milestone = ms.ID, ms.Title
|
||||
}
|
||||
|
||||
state := f.State
|
||||
if state == "" {
|
||||
state = "open"
|
||||
}
|
||||
owner, repo := c.owned()
|
||||
fetch := func(page, limit int) ([]*sdk.Issue, error) {
|
||||
opt := sdk.ListIssueOption{
|
||||
ListOptions: listOptions(page, limit),
|
||||
State: sdk.StateType(state),
|
||||
// Issues and not pull requests. The server has been known to
|
||||
// ignore this, which is why matches re-checks it.
|
||||
Type: sdk.IssueTypeIssue,
|
||||
Labels: f.Labels,
|
||||
KeyWord: f.Query,
|
||||
}
|
||||
if out.Milestone != "" {
|
||||
opt.Milestones = []string{out.Milestone}
|
||||
}
|
||||
got, resp, err := c.api.ListRepoIssues(owner, repo, opt)
|
||||
return got, fail(resp, err)
|
||||
}
|
||||
|
||||
perPage := min(f.Limit, pageLimit)
|
||||
ideal := max(1, (f.Limit+perPage-1)/perPage)
|
||||
budget := ideal
|
||||
if f.Keep != nil {
|
||||
budget = ideal * PageSlack
|
||||
}
|
||||
|
||||
kept, seen, lastFull := 0, 0, false
|
||||
err := pages(fetch, perPage, budget, func(batch []*sdk.Issue) (bool, error) {
|
||||
seen++
|
||||
lastFull = len(batch) == perPage
|
||||
for _, p := range batch {
|
||||
if !matches(p, milestoneID, f.Labels) {
|
||||
continue
|
||||
}
|
||||
out.Issues = append(out.Issues, p)
|
||||
if f.Keep == nil || f.Keep(p) {
|
||||
kept++
|
||||
if kept >= f.Limit {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if f.Keep != nil && seen >= budget && lastFull {
|
||||
out.Warning = fmt.Sprintf("scanned %d page(s) and stopped %d short of the limit of %d"+
|
||||
" — there may be more; narrow the filter or raise the limit", budget, f.Limit-kept, f.Limit)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// matches re-checks on the client what the server was already asked for.
|
||||
//
|
||||
// Not paranoia: Gitea silently IGNORES a `milestones=` value it cannot resolve
|
||||
// and answers with the whole backlog, which is why the milestone is resolved to
|
||||
// an id first and every payload is checked against that id here. The same
|
||||
// re-check on labels costs nothing, and `pull_request` is the one filter that
|
||||
// matters most — a pull request rendered as a unit of work is not a bug the
|
||||
// operator can see until it is in the store.
|
||||
//
|
||||
// A function and not a method: the payload is the SDK's, and re-checking a
|
||||
// filter the server ignored is this package's business, not the payload's.
|
||||
func matches(i *sdk.Issue, milestoneID int64, labels []string) bool {
|
||||
if i.PullRequest != nil {
|
||||
return false
|
||||
}
|
||||
if milestoneID != 0 && (i.Milestone == nil || i.Milestone.ID != milestoneID) {
|
||||
return false
|
||||
}
|
||||
have := make(map[string]bool, len(i.Labels))
|
||||
for _, l := range i.Labels {
|
||||
have[l.Name] = true
|
||||
}
|
||||
for _, want := range labels {
|
||||
if !have[want] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// dependencies
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// dependenciesSince is the first Gitea release that answers at
|
||||
// /issues/{index}/dependencies at all.
|
||||
//
|
||||
// Checked against the release tags themselves and not guessed: the routes are
|
||||
// absent from routers/api/v1/api.go through 1.19 and present in 1.20. Asking
|
||||
// the version rather than the endpoint is what turns "some error came back"
|
||||
// into an answer — and it costs no request, because the SDK already negotiated
|
||||
// the version when the client was built.
|
||||
const dependenciesSince = ">= 1.20.0"
|
||||
|
||||
// hasDependencies reports whether this instance is new enough to have the
|
||||
// dependency endpoints.
|
||||
func (c *Client) hasDependencies() bool {
|
||||
return c.api.CheckServerVersionConstraint(dependenciesSince) == nil
|
||||
}
|
||||
|
||||
// Dependencies are the issues that block this one — Gitea's own dependency
|
||||
// links, read in the direction AddDependency writes them.
|
||||
//
|
||||
// TWO WAYS FOR THERE TO BE NO ANSWER, and both are reported as "no
|
||||
// dependencies" rather than as a failure, because a pull must still bring the
|
||||
// issue itself back:
|
||||
//
|
||||
// - the instance predates the endpoint, which the version says before a
|
||||
// request is made;
|
||||
// - the instance has it but this repository does not — dependencies turned
|
||||
// off, a tracker disabled — which only the tracker's own answer can say.
|
||||
//
|
||||
// Deliberately narrower than the Python it replaces, which swallowed every
|
||||
// failure here including a dead connection. "The server said no" and "there was
|
||||
// no server" are different answers, and only the first one means the feature is
|
||||
// missing.
|
||||
func (c *Client) Dependencies(number int) ([]*sdk.Issue, error) {
|
||||
if !c.hasDependencies() {
|
||||
return nil, nil
|
||||
}
|
||||
owner, repo := c.owned()
|
||||
got, resp, err := c.api.ListIssueDependencies(owner, repo, int64(number),
|
||||
sdk.ListIssueDependenciesOptions{ListOptions: listOptions(1, pageLimit)})
|
||||
err = fail(resp, err)
|
||||
var apiErr *APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return got, nil
|
||||
}
|
||||
|
||||
// DependencyKeys is the same links as cross-repo handles — what a repeat push
|
||||
// compares against so it does not POST a link the tracker already has.
|
||||
//
|
||||
// A bare number is ambiguous the moment a dependency lives in another
|
||||
// repository, and Gitea lets it, so the repository travels with it.
|
||||
func (c *Client) DependencyKeys(number int) ([]wire.Key, error) {
|
||||
deps, err := c.Dependencies(number)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]wire.Key, 0, len(deps))
|
||||
for _, d := range deps {
|
||||
out = append(out, keyIn(d, c.repo))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// keyIn is a payload's cross-repo handle. The payload's own repository wins
|
||||
// when it carries one — a dependency listing answers with issues from other
|
||||
// repositories — and fallback is the repository that was asked.
|
||||
func keyIn(p *sdk.Issue, fallback wire.Repo) wire.Key {
|
||||
repo := fallback
|
||||
if p.Repository != nil {
|
||||
if r, err := wire.ParseRepo(p.Repository.FullName); err == nil {
|
||||
repo = r
|
||||
}
|
||||
}
|
||||
return wire.Key{Repo: repo, Number: int(p.Index)}
|
||||
}
|
||||
|
||||
// issueMeta is Gitea's own IssueMeta: how a dependency names another issue.
|
||||
//
|
||||
// The SDK has a type of this name too and it carries only `index`, so it can
|
||||
// only ever link inside one repository. Gitea's has taken an owner and a repo
|
||||
// since the endpoint existed, and a `depends:` entry is allowed to live
|
||||
// somewhere else — so this one struct and the raw POST that sends it are all
|
||||
// that is left of the hand-rolled client.
|
||||
type issueMeta struct {
|
||||
Index int `json:"index"`
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
}
|
||||
|
||||
// AddDependency makes issue number depend on dep.
|
||||
//
|
||||
// Confirmed against an instance's own swagger.v1.json (Gitea 1.26.1):
|
||||
//
|
||||
// POST /repos/{owner}/{repo}/issues/{index}/dependencies
|
||||
// body: IssueMeta — {"index": <int>, "owner": "<owner>", "repo": "<name>"}
|
||||
// "Make the issue in the url depend on the issue in the form."
|
||||
//
|
||||
// So the URL names the blocked issue and the body the blocker, which is the
|
||||
// direction Dependencies reads back. 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.
|
||||
func (c *Client) AddDependency(number int, dep wire.Key) error {
|
||||
if dep.Repo.Zero() {
|
||||
return fmt.Errorf("dependency %s names no repository — a link needs owner/repo#number", dep)
|
||||
}
|
||||
if dep.Number < 1 {
|
||||
return fmt.Errorf("dependency %s names no issue number", dep)
|
||||
}
|
||||
if !c.hasDependencies() {
|
||||
return fmt.Errorf("this Gitea has no issue-dependency API (it is not %s) — link #%d -> %s by hand",
|
||||
strings.TrimPrefix(dependenciesSince, ">= "), number, dep)
|
||||
}
|
||||
// Owner and name are escaped, the way the SDK escapes them for every other
|
||||
// call: they arrive from a config file, and a file is a thing people type
|
||||
// into.
|
||||
return c.post(
|
||||
fmt.Sprintf("repos/%s/%s/issues/%d/dependencies",
|
||||
url.PathEscape(c.repo.Owner), url.PathEscape(c.repo.Name), number),
|
||||
issueMeta{Index: dep.Number, Owner: dep.Repo.Owner, Repo: dep.Repo.Name},
|
||||
fmt.Sprintf("dep-%d-%d", number, dep.Number))
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
sdk "code.gitea.io/sdk/gitea"
|
||||
)
|
||||
|
||||
// ListLabels is every label in the repository, every page of it.
|
||||
//
|
||||
// A bootstrap decides its plan against this and never against a cache: a cache
|
||||
// answers "what did we create last time", and the question is "what does the
|
||||
// repository have right now".
|
||||
func (c *Client) ListLabels() ([]*sdk.Label, error) {
|
||||
owner, repo := c.owned()
|
||||
return paginate(func(page, limit int) ([]*sdk.Label, error) {
|
||||
got, resp, err := c.api.ListRepoLabels(owner, repo,
|
||||
sdk.ListLabelsOptions{ListOptions: listOptions(page, limit)})
|
||||
return got, fail(resp, err)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
// CreateLabel adds a label to the repository.
|
||||
//
|
||||
// Through the API rather than through any CLI wrapper, because `exclusive` —
|
||||
// the flag that makes `type/*` behave like a single choice — is not something
|
||||
// the `tea` client could set.
|
||||
//
|
||||
// What a label MEANS is not decided here either: this creates what it is
|
||||
// handed.
|
||||
func (c *Client) CreateLabel(opt sdk.CreateLabelOption) (*sdk.Label, error) {
|
||||
owner, repo := c.owned()
|
||||
c.dump.label("label-" + opt.Name)
|
||||
got, resp, err := c.api.CreateLabel(owner, repo, opt)
|
||||
if err := fail(resp, err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if got == nil || got.ID == 0 {
|
||||
return nil, fmt.Errorf("creating label %q: the tracker's answer carries no id", opt.Name)
|
||||
}
|
||||
return got, nil
|
||||
}
|
||||
|
||||
// EditLabel patches an existing label by id.
|
||||
//
|
||||
// It takes the same spec a create takes, and sends every field of it. The SDK
|
||||
// spells an edit with pointers, where nil means "leave it alone" — but a label
|
||||
// edit is rare enough that sending the unchanged name and description along
|
||||
// costs nothing and removes a way to lose them on a server that reads an absent
|
||||
// field as empty. The caller decides what the label should BE; this makes the
|
||||
// tracker say that and nothing less.
|
||||
func (c *Client) EditLabel(id int64, opt sdk.CreateLabelOption) (*sdk.Label, error) {
|
||||
owner, repo := c.owned()
|
||||
c.dump.label("label-" + opt.Name)
|
||||
got, resp, err := c.api.EditLabel(owner, repo, id, sdk.EditLabelOption{
|
||||
Name: &opt.Name,
|
||||
Color: &opt.Color,
|
||||
Description: &opt.Description,
|
||||
Exclusive: &opt.Exclusive,
|
||||
})
|
||||
if err := fail(resp, err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return got, nil
|
||||
}
|
||||
|
||||
// ListMilestones is every milestone in the repository, open and closed.
|
||||
//
|
||||
// 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.
|
||||
func (c *Client) ListMilestones() ([]*sdk.Milestone, error) {
|
||||
owner, repo := c.owned()
|
||||
return paginate(func(page, limit int) ([]*sdk.Milestone, error) {
|
||||
got, resp, err := c.api.ListRepoMilestones(owner, repo, sdk.ListMilestoneOption{
|
||||
ListOptions: listOptions(page, limit),
|
||||
State: sdk.StateAll,
|
||||
})
|
||||
return got, fail(resp, err)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
// ResolveMilestone finds a milestone by id or by title, and fails when there is
|
||||
// none.
|
||||
//
|
||||
// It 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. A typo in a milestone name would otherwise read as
|
||||
// "your milestone has 300 issues in it".
|
||||
//
|
||||
// Against the whole listing rather than the SDK's GetMilestoneByName, because a
|
||||
// failure has to say what the repository actually HAS — and because that helper
|
||||
// matches case-insensitively, which would resolve two different milestones to
|
||||
// one on a repository that has both.
|
||||
func (c *Client) ResolveMilestone(value string) (*sdk.Milestone, error) {
|
||||
got, err := c.ListMilestones()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, m := range got {
|
||||
if m.Title == value || strconv.FormatInt(m.ID, 10) == value {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
have := make([]string, 0, len(got))
|
||||
for _, m := range got {
|
||||
have = append(have, fmt.Sprintf("%s (id %d)", m.Title, m.ID))
|
||||
}
|
||||
if len(have) == 0 {
|
||||
have = []string{"none"}
|
||||
}
|
||||
return nil, fmt.Errorf("no milestone %q in %s — have: %s", value, c.repo, strings.Join(have, ", "))
|
||||
}
|
||||
|
||||
// FindMilestone is the milestone with this title, or nil when the repository
|
||||
// has no such milestone.
|
||||
//
|
||||
// The quiet counterpart of ResolveMilestone, for a push: an issue naming a
|
||||
// milestone the tracker does not have is filed without one, because refusing
|
||||
// the whole push over a field the tracker will happily accept as empty helps
|
||||
// nobody. "none" and "" are both "no milestone".
|
||||
func (c *Client) FindMilestone(title string) (*sdk.Milestone, error) {
|
||||
if title == "" || title == "none" {
|
||||
return nil, nil
|
||||
}
|
||||
got, err := c.ListMilestones()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, m := range got {
|
||||
if m.Title == title {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
// RemoteMapName is the ledger's file name, beside the issues it indexes.
|
||||
const RemoteMapName = ".remote.json"
|
||||
|
||||
// RemoteMap is the number -> slug ledger: {"owner/repo#42": "wire-sqlc-appclick"}.
|
||||
//
|
||||
// ITS ENTRIES OUTLIVE THE FILES THEY NAME, and that is deliberate rather than a
|
||||
// leak. 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, because "no file" no longer means "no such
|
||||
// issue"; eviction does not prune it either, for the same reason a push does
|
||||
// not. 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 slug also travels tracker-side, in the issue
|
||||
// body, so losing this file costs a re-pull and not information — which is why
|
||||
// Load never fails and why a rebuild is a MERGE and never a replacement. 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
|
||||
//
|
||||
// The store is a subset of what the ledger knows, so a rebuild that started
|
||||
// from the files alone would throw away every entry it cannot see. Start from
|
||||
// Load, add what the files say, Save.
|
||||
type RemoteMap map[string]string
|
||||
|
||||
// RemoteMapPath is where the ledger lives: inside the issue store, beside the
|
||||
// issues. root is the STORE, not the payload scratchpad — this file is
|
||||
// bookkeeping about issues and belongs where they are.
|
||||
func RemoteMapPath(root string) string { return filepath.Join(root, RemoteMapName) }
|
||||
|
||||
// LoadRemoteMap reads the ledger.
|
||||
//
|
||||
// A missing, unreadable or malformed file is an empty ledger and never an
|
||||
// error. The ledger is a cache of markers the tracker holds, so refusing to run
|
||||
// because it cannot be parsed would block the very pull that would rebuild it —
|
||||
// and the cost of starting empty is one re-pull, never a lost issue.
|
||||
func LoadRemoteMap(root string) RemoteMap {
|
||||
raw, err := os.ReadFile(RemoteMapPath(root))
|
||||
if err != nil {
|
||||
return RemoteMap{}
|
||||
}
|
||||
var got RemoteMap
|
||||
if err := json.Unmarshal(raw, &got); err != nil || got == nil {
|
||||
return RemoteMap{}
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// Save writes the ledger, creating the directory if it is not there.
|
||||
//
|
||||
// The one write in this package 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.
|
||||
//
|
||||
// Indented and key-sorted — encoding/json sorts map keys for us — because this
|
||||
// file is read by people and diffed by git as often as it is read by the
|
||||
// binary.
|
||||
func (m RemoteMap) Save(root string) error {
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(RemoteMapPath(root), append(raw, '\n'), 0o644)
|
||||
}
|
||||
|
||||
// Slug is the local name recorded for a key, or "".
|
||||
func (m RemoteMap) Slug(k wire.Key) string { return m[k.String()] }
|
||||
|
||||
// Set records that a key is known locally under this slug.
|
||||
func (m RemoteMap) Set(k wire.Key, slug string) { m[k.String()] = slug }
|
||||
@@ -0,0 +1,80 @@
|
||||
package gitea_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
func TestRemoteMapRoundTrips(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "issues")
|
||||
key := wire.Key{Repo: wire.Repo{Owner: "acme", Name: "widgets"}, Number: 42}
|
||||
|
||||
m := gitea.RemoteMap{}
|
||||
m.Set(key, "wire-sqlc-appclick")
|
||||
if err := m.Save(root); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
path := gitea.RemoteMapPath(root)
|
||||
if want := filepath.Join(root, ".remote.json"); path != want {
|
||||
t.Errorf("the ledger is at %s, want %s — beside the issues it indexes", path, want)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reading the ledger: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"acme/widgets#42": "wire-sqlc-appclick"`) {
|
||||
t.Errorf("the ledger is not readable by a human:\n%s", raw)
|
||||
}
|
||||
|
||||
back := gitea.LoadRemoteMap(root)
|
||||
if got := back.Slug(key); got != "wire-sqlc-appclick" {
|
||||
t.Errorf("the key came back as %q, want wire-sqlc-appclick", got)
|
||||
}
|
||||
if got := back.Slug(wire.Key{Repo: key.Repo, Number: 7}); got != "" {
|
||||
t.Errorf("an unrecorded key answered %q", got)
|
||||
}
|
||||
|
||||
// A rebuild is a merge and never a replacement: what is already recorded
|
||||
// survives an entry added on top of it. This is what makes a pull of a
|
||||
// number whose file was deleted by a push land on the same slug.
|
||||
second := wire.Key{Repo: key.Repo, Number: 43}
|
||||
back.Set(second, "drop-the-wiki")
|
||||
if err := back.Save(root); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
again := gitea.LoadRemoteMap(root)
|
||||
if again.Slug(key) != "wire-sqlc-appclick" || again.Slug(second) != "drop-the-wiki" {
|
||||
t.Errorf("a second save lost an entry: %v", again)
|
||||
}
|
||||
}
|
||||
|
||||
// The ledger is a cache of markers the tracker holds, so an unreadable one must
|
||||
// not stop the pull that would rebuild it.
|
||||
func TestRemoteMapSurvivesAMissingOrMangledFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
if got := gitea.LoadRemoteMap(filepath.Join(root, "nowhere")); len(got) != 0 {
|
||||
t.Errorf("a missing ledger loaded as %v", got)
|
||||
}
|
||||
if err := os.WriteFile(gitea.RemoteMapPath(root), []byte("{ not json at all"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := gitea.LoadRemoteMap(root)
|
||||
if len(got) != 0 {
|
||||
t.Errorf("a mangled ledger loaded as %v", got)
|
||||
}
|
||||
// Still writable afterwards: an unreadable ledger costs a re-pull, not a run.
|
||||
got.Set(wire.Key{Repo: wire.Repo{Owner: "acme", Name: "widgets"}, Number: 1}, "first")
|
||||
if err := got.Save(root); err != nil {
|
||||
t.Fatalf("Save over a mangled ledger: %v", err)
|
||||
}
|
||||
if gitea.LoadRemoteMap(root).Slug(wire.Key{Repo: wire.Repo{Owner: "acme", Name: "widgets"}, Number: 1}) != "first" {
|
||||
t.Error("the ledger did not come back after being rewritten")
|
||||
}
|
||||
}
|
||||
@@ -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 is embedded in the binary
|
||||
(`cli/internal/scaffold/assets/skills/kettle-issue/references/format.md`) — when
|
||||
the taxonomy moves, both change.
|
||||
- **Do not** document how any of this reaches a tracker.
|
||||
@@ -0,0 +1,185 @@
|
||||
package issue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A checkbox is the one part of a body that is *state* and not prose, so the
|
||||
// format gives it markup of its own. It is item markup, not a property of one
|
||||
// section: `## Acceptance criteria` is the usual home, but a type/feature
|
||||
// keeps its children as checkboxes under `## Issues`. The scan is therefore
|
||||
// over the whole text and the heading is only recorded, never required.
|
||||
var (
|
||||
// The trailing group stands in for a lookahead RE2 does not have: after
|
||||
// the bracket there is either whitespace and then anything, or end of line.
|
||||
checkboxRe = regexp.MustCompile(
|
||||
`^(?P<indent>[ \t]*)(?P<marker>[-*+]|\d+[.)])[ \t]+` +
|
||||
`\[(?P<box>[ xX])\](?P<text>[ \t].*|)$`)
|
||||
// Any list item — a sibling ends the item above it, checkbox or not.
|
||||
listItemRe = regexp.MustCompile(`^[ \t]*([-*+]|\d+[.)])([ \t]|$)`)
|
||||
fenceRe = regexp.MustCompile("^[ \t]{0,3}(`{3,}|~{3,})")
|
||||
)
|
||||
|
||||
// Checkbox is one checkbox item found in a text.
|
||||
type Checkbox struct {
|
||||
// Index is the 1-based position in the list — what a user types to pick it.
|
||||
Index int
|
||||
// Line is the 1-based line of the `- [ ]` marker, in the text given.
|
||||
Line int
|
||||
// EndLine is the 1-based last line of the item, continuations included.
|
||||
EndLine int
|
||||
// Checked is true for [x] / [X].
|
||||
Checked bool
|
||||
// Text is the item's text; continuation lines joined with one space.
|
||||
Text string
|
||||
// Section is the nearest preceding `## ` heading, "" above the first one.
|
||||
Section string
|
||||
}
|
||||
|
||||
// Checkboxes returns every checkbox item in text, in document order.
|
||||
//
|
||||
// A pure function of the string it is given — no I/O, no store, no tracker.
|
||||
// Pass an issue body to get body-relative line numbers, or a whole file to get
|
||||
// file-relative ones; nothing else changes.
|
||||
//
|
||||
// Rules:
|
||||
//
|
||||
// - Only a line matching checkboxRe opens an item. A wrapped ("continuation")
|
||||
// line is part of the item above it, never an item of its own; the item runs
|
||||
// to the next blank line, heading, code fence, or list marker.
|
||||
// - Fenced code blocks are skipped whole: `- [ ]` inside a fence is an example
|
||||
// of the markup, not a box anybody may tick.
|
||||
// - `-`, `*`, `+` and `1.` markers all count, at any indentation, so nested
|
||||
// lists are seen too.
|
||||
func Checkboxes(text string) []Checkbox {
|
||||
lines := splitLines(text)
|
||||
var items []Checkbox
|
||||
section, fence := "", ""
|
||||
|
||||
for n, line := range lines {
|
||||
if m := fenceRe.FindStringSubmatch(line); m != nil {
|
||||
tok := m[1]
|
||||
switch {
|
||||
case fence == "":
|
||||
fence = tok
|
||||
case tok[0] == fence[0] && len(tok) >= len(fence):
|
||||
fence = ""
|
||||
}
|
||||
continue
|
||||
}
|
||||
if fence != "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "## ") {
|
||||
section = strings.TrimSpace(line)
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "# ") {
|
||||
section = ""
|
||||
continue
|
||||
}
|
||||
m := checkboxRe.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
end := n + 1
|
||||
parts := []string{strings.TrimSpace(m[4])}
|
||||
for k := n + 1; k < len(lines); k++ {
|
||||
next := lines[k]
|
||||
if strings.TrimSpace(next) == "" || strings.HasPrefix(next, "#") ||
|
||||
fenceRe.MatchString(next) || listItemRe.MatchString(next) {
|
||||
break
|
||||
}
|
||||
end = k + 1
|
||||
parts = append(parts, strings.TrimSpace(next))
|
||||
}
|
||||
var kept []string
|
||||
for _, p := range parts {
|
||||
if p != "" {
|
||||
kept = append(kept, p)
|
||||
}
|
||||
}
|
||||
items = append(items, Checkbox{
|
||||
Index: len(items) + 1,
|
||||
Line: n + 1,
|
||||
EndLine: end,
|
||||
Checked: m[3] != " ",
|
||||
Text: strings.Join(kept, " "),
|
||||
Section: section,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// SetCheckbox returns text with the checkbox on the given 1-based line set to
|
||||
// checked.
|
||||
//
|
||||
// Pure, and deliberately surgical: exactly one byte of the input changes — the
|
||||
// one between the brackets. Everything else, including trailing whitespace and
|
||||
// the item's own wording, comes back byte for byte. That is the whole point:
|
||||
// ticking a box must not produce a diff wider than the state that changed.
|
||||
//
|
||||
// Already in the requested state is a no-op — text comes back unchanged, and
|
||||
// an existing [X] keeps its capital.
|
||||
func SetCheckbox(text string, line int, checked bool) (string, error) {
|
||||
off := 0
|
||||
for n := 1; off <= len(text); n++ {
|
||||
nl := strings.IndexByte(text[off:], '\n')
|
||||
var raw string
|
||||
if nl == -1 {
|
||||
raw = text[off:]
|
||||
} else {
|
||||
raw = text[off : off+nl]
|
||||
}
|
||||
if n == line {
|
||||
m := checkboxRe.FindStringSubmatchIndex(strings.TrimRight(raw, "\r"))
|
||||
if m == nil {
|
||||
return "", fmt.Errorf("line %d is not a checkbox item", line)
|
||||
}
|
||||
box := off + m[6] // group 3: box
|
||||
if (text[box] != ' ') == checked {
|
||||
return text, nil
|
||||
}
|
||||
c := byte(' ')
|
||||
if checked {
|
||||
c = 'x'
|
||||
}
|
||||
return text[:box] + string(c) + text[box+1:], nil
|
||||
}
|
||||
if nl == -1 {
|
||||
break
|
||||
}
|
||||
off += nl + 1
|
||||
}
|
||||
return "", fmt.Errorf("line %d is past the end of the text", line)
|
||||
}
|
||||
|
||||
// CheckboxProgress is (done, total) over every checkbox in text; (0, 0) when
|
||||
// it has none.
|
||||
//
|
||||
// Computed on the fly, on purpose. Progress is not a metadata field: it is the
|
||||
// body read back, and the body is the only place the state lives.
|
||||
func CheckboxProgress(text string) (done, total int) {
|
||||
items := Checkboxes(text)
|
||||
for _, c := range items {
|
||||
if c.Checked {
|
||||
done++
|
||||
}
|
||||
}
|
||||
return done, len(items)
|
||||
}
|
||||
|
||||
// splitLines is strings.Split minus the phantom final element a trailing
|
||||
// newline produces, matching Python's str.splitlines().
|
||||
func splitLines(text string) []string {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
|
||||
if last := len(lines) - 1; lines[last] == "" {
|
||||
lines = lines[:last]
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package issue
|
||||
|
||||
import "testing"
|
||||
|
||||
const boxes = `## Acceptance criteria
|
||||
- [ ] первый пункт
|
||||
- [x] второй пункт, который
|
||||
переносится на вторую строку
|
||||
* [X] третий
|
||||
1. [ ] четвёртый
|
||||
|
||||
## Notes
|
||||
` + "```" + `
|
||||
- [ ] это пример разметки, а не состояние
|
||||
` + "```" + `
|
||||
`
|
||||
|
||||
func TestCheckboxesReadTheWholeBody(t *testing.T) {
|
||||
items := Checkboxes(boxes)
|
||||
if len(items) != 4 {
|
||||
t.Fatalf("found %d items, want 4: %+v", len(items), items)
|
||||
}
|
||||
if items[1].Text != "второй пункт, который переносится на вторую строку" {
|
||||
t.Errorf("continuation not joined: %q", items[1].Text)
|
||||
}
|
||||
if items[1].EndLine != 4 {
|
||||
t.Errorf("end line = %d, want 4", items[1].EndLine)
|
||||
}
|
||||
if !items[2].Checked {
|
||||
t.Error("[X] must read as checked")
|
||||
}
|
||||
if items[3].Section != ACSection {
|
||||
t.Errorf("section = %q", items[3].Section)
|
||||
}
|
||||
for _, c := range items {
|
||||
if c.Section == "## Notes" {
|
||||
t.Error("a checkbox inside a code fence was counted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckboxProgressIsCountedOffTheBody(t *testing.T) {
|
||||
done, total := CheckboxProgress(boxes)
|
||||
if done != 2 || total != 4 {
|
||||
t.Errorf("progress = %d/%d, want 2/4", done, total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCheckboxChangesExactlyOneByte(t *testing.T) {
|
||||
items := Checkboxes(boxes)
|
||||
got, err := SetCheckbox(boxes, items[0].Line, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != len(boxes) {
|
||||
t.Fatalf("length changed: %d -> %d", len(boxes), len(got))
|
||||
}
|
||||
diff := 0
|
||||
for i := range got {
|
||||
if got[i] != boxes[i] {
|
||||
diff++
|
||||
}
|
||||
}
|
||||
if diff != 1 {
|
||||
t.Errorf("%d bytes changed, want 1", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCheckboxIsANoOpWhenAlreadyInState(t *testing.T) {
|
||||
items := Checkboxes(boxes)
|
||||
// [X] keeps its capital: the state already matches, so nothing is rewritten.
|
||||
got, err := SetCheckbox(boxes, items[2].Line, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != boxes {
|
||||
t.Error("an already-checked box was rewritten")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCheckboxRefusesALineThatIsNotOne(t *testing.T) {
|
||||
if _, err := SetCheckbox(boxes, 1, true); err == nil {
|
||||
t.Error("ticking a heading must fail")
|
||||
}
|
||||
if _, err := SetCheckbox(boxes, 9999, true); err == nil {
|
||||
t.Error("ticking past the end must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBodyDepRefsOnlyReadTheDepSections(t *testing.T) {
|
||||
body := `## Summary
|
||||
смотри также some-other-issue, который не зависимость
|
||||
|
||||
## Depends on
|
||||
- migrate-schema — нужна схема
|
||||
- add-pool-cfg
|
||||
|
||||
## Issues
|
||||
- [ ] wire-sqlc-appclick — часть
|
||||
- [ ] #42
|
||||
`
|
||||
got := BodyDepRefs(body)
|
||||
want := []DepRef{
|
||||
{DependsSection, "migrate-schema"},
|
||||
{DependsSection, "add-pool-cfg"},
|
||||
{IssuesSection, "wire-sqlc-appclick"},
|
||||
{IssuesSection, "#42"},
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %+v, want %+v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("ref %d = %+v, want %+v", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package issue
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A reference is a slug, or `#N` on an issue that came from a tracker.
|
||||
var depRefRe = regexp.MustCompile(`#(\d+)|\b([a-z0-9]+(?:-[a-z0-9]+)+)\b`)
|
||||
|
||||
// DepRef is one dependency reference written in the body prose, carried out
|
||||
// with the section it was found in.
|
||||
//
|
||||
// The section travels with the reference so a caller can name the one the
|
||||
// reader actually has in front of them: a container's children come from
|
||||
// `## Issues`, and pointing at `## Depends on` would name a section that is not
|
||||
// in the file.
|
||||
type DepRef struct {
|
||||
Section string
|
||||
Ref string
|
||||
}
|
||||
|
||||
// BodyDepRefs returns every reference under one of DepSections, deduplicated
|
||||
// on first sight, in order of first appearance.
|
||||
//
|
||||
// Never from prose elsewhere, or a graph walk would drag in half the backlog.
|
||||
func BodyDepRefs(body string) []DepRef {
|
||||
var out []DepRef
|
||||
seen := map[string]bool{}
|
||||
section := ""
|
||||
|
||||
for _, line := range splitLines(body) {
|
||||
if strings.HasPrefix(line, "## ") {
|
||||
head := strings.TrimSpace(line)
|
||||
section = ""
|
||||
for _, s := range DepSections {
|
||||
if head == s {
|
||||
section = head
|
||||
break
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if section == "" {
|
||||
continue
|
||||
}
|
||||
for _, tok := range depRefRe.FindAllStringSubmatch(line, -1) {
|
||||
ref := tok[2]
|
||||
if tok[1] != "" {
|
||||
ref = "#" + tok[1]
|
||||
}
|
||||
if !seen[ref] {
|
||||
seen[ref] = true
|
||||
out = append(out, DepRef{Section: section, Ref: ref})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package issue
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Closed issues leave the store. The store is a working set, not an archive.
|
||||
//
|
||||
// WHAT IS EVICTED, and it is two conditions, both read off the file:
|
||||
//
|
||||
// state: closed the work is done
|
||||
// origin: <tracker> the work is somewhere else too
|
||||
//
|
||||
// THE SECOND CONDITION IS THE WHOLE SAFETY ARGUMENT. `origin: local` means this
|
||||
// file IS the issue — there is no other copy and deleting it deletes the work.
|
||||
// It is therefore never evicted, in any state, not even when named explicitly:
|
||||
// a closed local issue is reported and kept. The only files that go are ones
|
||||
// whose own metadata says the work can be fetched back, which is the same trade
|
||||
// a push makes when it drops a file the tracker has just confirmed.
|
||||
//
|
||||
// That parallel is exact except for where the confirmation comes from. Push has
|
||||
// to ask the tracker, because it is the tracker that just changed. Eviction asks
|
||||
// the file, because state and origin are domain fields and the answer is already
|
||||
// in the store — which is why this lives in the domain and needs no network, no
|
||||
// login, and no tracker. The sync layer's variant refreshes state from the
|
||||
// tracker first and then calls Evict, so there is exactly one implementation of
|
||||
// "what may be evicted" and it is this one.
|
||||
//
|
||||
// NOT A ONE-OFF MIGRATION. A pull by number fetches an issue in any state — a
|
||||
// number is an address, not a query — so a closed issue pulled after an eviction
|
||||
// lands on disk again. That is the tracker being asked a direct question, not a
|
||||
// regression; evict it again when you are done with it.
|
||||
//
|
||||
// `.remote.json` is deliberately NOT pruned. It is the local number -> slug
|
||||
// ledger, its entries outlive the files they name, and an evicted issue is in
|
||||
// exactly that state. INDEX.md is rebuilt, because it IS a view of the
|
||||
// directory.
|
||||
|
||||
const closed = "closed"
|
||||
|
||||
// LocalReason is printed whether or not the issue was named, because "this
|
||||
// closed thing is still here" needs an answer every time.
|
||||
const LocalReason = "origin: " + Local + " — this file IS the issue"
|
||||
|
||||
// Evicted is one issue that left the store, with every file that went with it.
|
||||
type Evicted struct {
|
||||
ID string
|
||||
Paths []string
|
||||
}
|
||||
|
||||
// Kept is one issue that was considered and stayed, with the reason.
|
||||
type Kept struct {
|
||||
ID string
|
||||
Why string
|
||||
Open bool // true when it is simply not closed yet — the normal case
|
||||
}
|
||||
|
||||
// EvictReport is what a run did, or would have done.
|
||||
type EvictReport struct {
|
||||
Evicted []Evicted
|
||||
Kept []Kept
|
||||
DryRun bool
|
||||
IndexPath string
|
||||
IndexCount int
|
||||
}
|
||||
|
||||
// Classify splits the store into what may be evicted, what is protected, and
|
||||
// what is still open.
|
||||
//
|
||||
// Pure — it reads the loaded issues and decides; nothing here touches disk.
|
||||
// ids restricts the question to those issues; empty considers the whole store.
|
||||
// A protected issue is returned as such even when it was named explicitly:
|
||||
// naming a file does not make deleting it safe.
|
||||
func Classify(issues map[string]*Issue, ids []string) (evict, protected, stillOpen []string) {
|
||||
chosen := ids
|
||||
if len(chosen) == 0 {
|
||||
for id := range issues {
|
||||
chosen = append(chosen, id)
|
||||
}
|
||||
sort.Strings(chosen)
|
||||
}
|
||||
for _, id := range chosen {
|
||||
i, ok := issues[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case i.State != closed:
|
||||
stillOpen = append(stillOpen, id)
|
||||
case i.IsLocal():
|
||||
protected = append(protected, id)
|
||||
default:
|
||||
evict = append(evict, id)
|
||||
}
|
||||
}
|
||||
return evict, protected, stillOpen
|
||||
}
|
||||
|
||||
// Remove deletes everything the store holds under one slug and returns the
|
||||
// paths that went.
|
||||
//
|
||||
// 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.
|
||||
func Remove(root, id string) ([]string, error) {
|
||||
var gone []string
|
||||
for _, p := range SlugFiles(root, id) {
|
||||
if err := os.Remove(p); err != nil {
|
||||
return gone, err
|
||||
}
|
||||
gone = append(gone, p)
|
||||
}
|
||||
return gone, nil
|
||||
}
|
||||
|
||||
// Evict classifies, removes, and rebuilds the index. The 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
|
||||
// from the tracker.
|
||||
func Evict(root string, issues map[string]*Issue, ids []string, dryRun bool) (*EvictReport, error) {
|
||||
evict, protected, stillOpen := Classify(issues, ids)
|
||||
rep := &EvictReport{DryRun: dryRun}
|
||||
|
||||
for _, id := range evict {
|
||||
var paths []string
|
||||
if dryRun {
|
||||
paths = SlugFiles(root, id)
|
||||
} else {
|
||||
var err error
|
||||
if paths, err = Remove(root, id); err != nil {
|
||||
return rep, err
|
||||
}
|
||||
}
|
||||
rep.Evicted = append(rep.Evicted, Evicted{ID: id, Paths: paths})
|
||||
}
|
||||
for _, id := range protected {
|
||||
rep.Kept = append(rep.Kept, Kept{ID: id, Why: LocalReason})
|
||||
}
|
||||
for _, id := range stillOpen {
|
||||
rep.Kept = append(rep.Kept, Kept{ID: id, Why: "state: " + issues[id].State, Open: true})
|
||||
}
|
||||
|
||||
// Only when something actually went: the index is a view of the directory,
|
||||
// and rewriting it after a run that changed nothing is a write nobody asked
|
||||
// for.
|
||||
if !dryRun && len(rep.Evicted) > 0 {
|
||||
path, n, err := BuildIndex(root)
|
||||
if err != nil {
|
||||
return rep, err
|
||||
}
|
||||
rep.IndexPath, rep.IndexCount = path, n
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package issue
|
||||
|
||||
import "sort"
|
||||
|
||||
// Graph is the edge list read off the `depends:` metadata — the authoritative
|
||||
// one. Body prose is never walked.
|
||||
func Graph(issues map[string]*Issue) map[string][]string {
|
||||
out := make(map[string][]string, len(issues))
|
||||
for id, i := range issues {
|
||||
out[id] = append([]string{}, i.Depends...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Dependents lists who depends on id — the upward direction.
|
||||
func Dependents(issues map[string]*Issue, id string) []string {
|
||||
var out []string
|
||||
for other, i := range issues {
|
||||
if contains(i.Depends, id) {
|
||||
out = append(out, other)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// TopoOrder puts dependencies first.
|
||||
//
|
||||
// Cycles are broken deterministically rather than raising: a cycle is a data
|
||||
// problem for the caller to report, not a reason to refuse to order the rest.
|
||||
func TopoOrder(ids []string, edges map[string][]string) []string {
|
||||
const (
|
||||
open = 1
|
||||
done = 2
|
||||
)
|
||||
state := map[string]int{}
|
||||
var order []string
|
||||
|
||||
var visit func(string)
|
||||
visit = func(n string) {
|
||||
switch state[n] {
|
||||
case done, open: // open = a back edge; leave it unresolved
|
||||
return
|
||||
}
|
||||
state[n] = open
|
||||
for _, d := range edges[n] {
|
||||
if _, ok := edges[d]; ok {
|
||||
visit(d)
|
||||
}
|
||||
}
|
||||
state[n] = done
|
||||
order = append(order, n)
|
||||
}
|
||||
|
||||
for _, n := range ids {
|
||||
visit(n)
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
// FindCycles returns one id list per cycle. Empty when the graph is a DAG.
|
||||
func FindCycles(edges map[string][]string) [][]string {
|
||||
const (
|
||||
open = 1
|
||||
done = 2
|
||||
)
|
||||
state := map[string]int{}
|
||||
var stack []string
|
||||
var cycles [][]string
|
||||
|
||||
var visit func(string)
|
||||
visit = func(n string) {
|
||||
state[n] = open
|
||||
stack = append(stack, n)
|
||||
for _, d := range edges[n] {
|
||||
if _, ok := edges[d]; !ok {
|
||||
continue
|
||||
}
|
||||
if state[d] == open {
|
||||
for i, s := range stack {
|
||||
if s == d {
|
||||
cycles = append(cycles, append(append([]string{}, stack[i:]...), d))
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if state[d] == 0 {
|
||||
visit(d)
|
||||
}
|
||||
}
|
||||
stack = stack[:len(stack)-1]
|
||||
state[n] = done
|
||||
}
|
||||
|
||||
// Sorted so the report is the same on every run; Go map order is not.
|
||||
ids := make([]string, 0, len(edges))
|
||||
for n := range edges {
|
||||
ids = append(ids, n)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
for _, n := range ids {
|
||||
if state[n] == 0 {
|
||||
visit(n)
|
||||
}
|
||||
}
|
||||
return cycles
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package issue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var treeFileRe = regexp.MustCompile(`^tree-.+\.md$`)
|
||||
|
||||
const indexPreamble = "Every issue this project knows about. `origin: local` means it " +
|
||||
"exists nowhere else — a complete state, not a pending one. Any other value " +
|
||||
"names the tracker it also lives in; the handle is in the file. `progress` " +
|
||||
"counts the body's checkboxes, ticked over total, and is blank for an issue " +
|
||||
"that has none — read off the body at build time, stored nowhere. Rebuild " +
|
||||
"with `kettle index`; tick a box with `kettle ac`."
|
||||
|
||||
// BuildIndex rewrites INDEX.md from what is on disk and returns its path and
|
||||
// the number of issues in it.
|
||||
//
|
||||
// An index of a store that is not there is not an empty index, it is a bad
|
||||
// path: failing beats writing INDEX.md into a directory nobody asked for. An
|
||||
// existing store with nothing in it is a legitimate thing to index and gets an
|
||||
// "_empty_" table.
|
||||
func BuildIndex(root string) (string, int, error) {
|
||||
if err := RequireStore(root); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
issues, err := LoadAll(root)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(issues))
|
||||
for id := range issues {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
|
||||
out := []string{"# Issue store", "", indexPreamble, ""}
|
||||
if len(ids) > 0 {
|
||||
out = append(out,
|
||||
"| id | state | progress | type | labels | title | milestone | depends | origin |",
|
||||
"|---|---|---|---|---|---|---|---|---|")
|
||||
for _, id := range ids {
|
||||
i := issues[id]
|
||||
var rest []string
|
||||
for _, l := range i.Labels {
|
||||
if !strings.HasPrefix(l, "type/") {
|
||||
rest = append(rest, l)
|
||||
}
|
||||
}
|
||||
out = append(out, fmt.Sprintf("| [%s](%s.md) | %s | %s | %s | %s | %s | %s | %s | %s |",
|
||||
id, id, cell(i.State), progress(i.Body), cell(i.Type()),
|
||||
cellList(rest), cell(i.Title), cell(i.Milestone),
|
||||
cellList(i.Depends), cell(i.Origin)))
|
||||
}
|
||||
} else {
|
||||
out = append(out, "_empty_")
|
||||
}
|
||||
|
||||
if trees := treeFiles(root); len(trees) > 0 {
|
||||
out = append(out, "", "## Dependency trees", "")
|
||||
for _, t := range trees {
|
||||
out = append(out, fmt.Sprintf("- [%s](%s)", t, t))
|
||||
}
|
||||
}
|
||||
|
||||
if cycles := FindCycles(Graph(issues)); len(cycles) > 0 {
|
||||
out = append(out, "", "## Dependency cycles", "")
|
||||
for _, c := range cycles {
|
||||
out = append(out, "- "+strings.Join(c, " -> "))
|
||||
}
|
||||
}
|
||||
out = append(out, "")
|
||||
|
||||
path := filepath.Join(root, "INDEX.md")
|
||||
if err := os.WriteFile(path, []byte(strings.Join(out, "\n")), 0o644); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return path, len(ids), nil
|
||||
}
|
||||
|
||||
// progress is `3/7` for a body with checkboxes, "" for one without.
|
||||
//
|
||||
// Counted from the body every time the index is built and stored nowhere — the
|
||||
// boxes are the state, and a second copy of it in a metadata field would be
|
||||
// wrong by the next edit.
|
||||
func progress(body string) string {
|
||||
done, total := CheckboxProgress(body)
|
||||
if total == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d/%d", done, total)
|
||||
}
|
||||
|
||||
func cell(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return "—"
|
||||
}
|
||||
return strings.ReplaceAll(v, "|", `\|`)
|
||||
}
|
||||
|
||||
func cellList(xs []string) string {
|
||||
if len(xs) == 0 {
|
||||
return "—"
|
||||
}
|
||||
return strings.Join(xs, ", ")
|
||||
}
|
||||
|
||||
func treeFiles(root string) []string {
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if treeFileRe.MatchString(e.Name()) {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// Package issue is what an issue IS. The domain layer.
|
||||
//
|
||||
// It knows the canonical markdown format, the label taxonomy, validation, and
|
||||
// the dependency graph. It knows NOTHING about any tracker: no Gitea, no
|
||||
// logins, no HTTP, no issue numbers. The layering rule is mechanically checked
|
||||
// — see TestDomainImportsNothing, which walks this package's transitive
|
||||
// dependencies and fails on anything outside the standard library and
|
||||
// internal/project.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// Identity is a slug derived from the title, and it is the only identity the
|
||||
// domain has. 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 written by the sync
|
||||
// layer; this package carries those keys through load/save verbatim in Extra
|
||||
// and never reads them. 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:
|
||||
//
|
||||
// grep -l 'labels:.*type/bug' .kettle/issues/*.md
|
||||
// grep -ln 'depends:.*migrate-schema' .kettle/issues/*.md # who depends on it
|
||||
package issue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Origin is "does this issue exist anywhere but here" — a fact about the work,
|
||||
// so it is owned here. Its value is Local or a tracker's name; what that name
|
||||
// means, and the handle that goes with it (gitea: owner/repo#42), stay foreign
|
||||
// keys this layer carries but never reads.
|
||||
const Local = "local"
|
||||
|
||||
// DomainKeys are the metadata fields this layer owns, in render order. Foreign
|
||||
// keys render after these, sorted, so the sync layer can add fields without
|
||||
// touching this list.
|
||||
var DomainKeys = []string{"id", "state", "labels", "assignees", "milestone",
|
||||
"depends", "origin"}
|
||||
|
||||
var listKeys = map[string]bool{"labels": true, "assignees": true, "depends": true}
|
||||
|
||||
// States an issue may be in.
|
||||
var States = []string{"open", "closed"}
|
||||
|
||||
var slugOK = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
|
||||
|
||||
var slugPunct = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// Issue is one unit of work. Extra holds metadata this layer does not own.
|
||||
type Issue struct {
|
||||
ID string
|
||||
Title string
|
||||
Body string
|
||||
State string
|
||||
Labels []string
|
||||
Assignees []string
|
||||
Milestone string
|
||||
Depends []string
|
||||
Origin string
|
||||
Extra map[string]string
|
||||
}
|
||||
|
||||
// IsLocal reports whether this issue exists nowhere but here.
|
||||
//
|
||||
// A complete state, not a pending one — and the state in which this file is
|
||||
// the only copy of the work. An issue whose Origin names somewhere else can be
|
||||
// fetched from there again; this one cannot.
|
||||
func (i *Issue) IsLocal() bool { return i.Origin == Local }
|
||||
|
||||
// Type is the value of the mandatory, exclusive type/* label.
|
||||
func (i *Issue) Type() string { return i.namespaced("type/") }
|
||||
|
||||
// Severity is the value of the optional, exclusive severity/* label.
|
||||
func (i *Issue) Severity() string { return i.namespaced("severity/") }
|
||||
|
||||
func (i *Issue) namespaced(prefix string) string {
|
||||
for _, l := range i.Labels {
|
||||
if v, ok := strings.CutPrefix(l, prefix); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// FromText parses a stored issue. A non-empty id overrides the one in the
|
||||
// metadata block, which is how the store makes the file name authoritative.
|
||||
func FromText(text, id string) *Issue {
|
||||
meta, title, body := ParseMeta(text)
|
||||
|
||||
extra := map[string]string{}
|
||||
for k, v := range meta {
|
||||
if !isDomainKey(k) {
|
||||
extra[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
if id == "" {
|
||||
id = meta["id"]
|
||||
}
|
||||
milestone := meta["milestone"]
|
||||
if milestone == "none" {
|
||||
milestone = ""
|
||||
}
|
||||
state := meta["state"]
|
||||
if state == "" {
|
||||
state = "open"
|
||||
}
|
||||
origin := meta["origin"]
|
||||
if origin == "" {
|
||||
origin = Local
|
||||
}
|
||||
|
||||
return &Issue{
|
||||
ID: id,
|
||||
Title: title,
|
||||
Body: strings.TrimSpace(body),
|
||||
State: state,
|
||||
Labels: splitList(meta["labels"]),
|
||||
Assignees: splitList(meta["assignees"]),
|
||||
Milestone: milestone,
|
||||
Depends: splitList(meta["depends"]),
|
||||
Origin: origin,
|
||||
Extra: extra,
|
||||
}
|
||||
}
|
||||
|
||||
// Text renders the issue back to its canonical file form.
|
||||
func (i *Issue) Text() string {
|
||||
meta := map[string]string{}
|
||||
for k, v := range i.Extra {
|
||||
meta[k] = v
|
||||
}
|
||||
milestone := i.Milestone
|
||||
if milestone == "" {
|
||||
milestone = "none"
|
||||
}
|
||||
meta["id"] = i.ID
|
||||
meta["state"] = i.State
|
||||
meta["labels"] = renderList(i.Labels)
|
||||
meta["assignees"] = renderList(i.Assignees)
|
||||
meta["milestone"] = milestone
|
||||
meta["depends"] = renderList(i.Depends)
|
||||
meta["origin"] = i.Origin
|
||||
|
||||
body := strings.TrimSpace(i.Body)
|
||||
if body == "" {
|
||||
body = "(no body)"
|
||||
}
|
||||
return fmt.Sprintf("%s\n# %s\n\n%s\n", RenderMeta(meta), i.Title, body)
|
||||
}
|
||||
|
||||
// Slugify turns a title into an id. Titles are English by format rule, so
|
||||
// ASCII is enough; anything else is dropped rather than transliterated.
|
||||
func Slugify(text string, maxLen int) string {
|
||||
if maxLen <= 0 {
|
||||
maxLen = 48
|
||||
}
|
||||
s := strings.Trim(slugPunct.ReplaceAllString(strings.ToLower(text), "-"), "-")
|
||||
if len(s) > maxLen {
|
||||
cut := s[:maxLen]
|
||||
if i := strings.LastIndex(cut, "-"); i > 0 {
|
||||
cut = cut[:i]
|
||||
}
|
||||
s = cut
|
||||
}
|
||||
s = strings.Trim(s, "-")
|
||||
if s == "" {
|
||||
return "issue"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// IsSlug reports whether id is a well-formed identity.
|
||||
func IsSlug(id string) bool { return slugOK.MatchString(id) }
|
||||
|
||||
// UniqueID is base, or base-2, base-3… when the slug is already used.
|
||||
func UniqueID(root, base string, taken []string) (string, error) {
|
||||
used := map[string]bool{}
|
||||
for _, t := range taken {
|
||||
used[t] = true
|
||||
}
|
||||
for _, t := range AllIDs(root) {
|
||||
used[t] = true
|
||||
}
|
||||
if !used[base] {
|
||||
return base, nil
|
||||
}
|
||||
for n := 2; n < 1000; n++ {
|
||||
cand := fmt.Sprintf("%s-%d", base, n)
|
||||
if !used[cand] {
|
||||
return cand, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("cannot allocate an id for %q", base)
|
||||
}
|
||||
|
||||
func isDomainKey(k string) bool {
|
||||
for _, d := range DomainKeys {
|
||||
if d == k {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package issue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const sample = `---
|
||||
id: wire-sqlc-appclick
|
||||
state: open
|
||||
labels: [type/task, tech/sql]
|
||||
assignees: [naudachu]
|
||||
milestone: v0.2
|
||||
depends: [migrate-schema]
|
||||
origin: gitea
|
||||
gitea: claude-skills/tea#42
|
||||
synced: 2026-08-09T18:40:00Z
|
||||
---
|
||||
# Wire sqlc into the appclick repo layer
|
||||
|
||||
## Summary
|
||||
Проводка sqlc.
|
||||
|
||||
## Spec
|
||||
none
|
||||
|
||||
## Motivation
|
||||
Ручной SQL расходится со схемой.
|
||||
|
||||
## Acceptance criteria
|
||||
- [x] сгенерирован код
|
||||
- [ ] тесты зелёные
|
||||
`
|
||||
|
||||
func TestFromTextReadsTheDomainAndCarriesTheRest(t *testing.T) {
|
||||
i := FromText(sample, "")
|
||||
|
||||
if i.ID != "wire-sqlc-appclick" {
|
||||
t.Errorf("id = %q", i.ID)
|
||||
}
|
||||
if i.Title != "Wire sqlc into the appclick repo layer" {
|
||||
t.Errorf("title = %q", i.Title)
|
||||
}
|
||||
if want := []string{"type/task", "tech/sql"}; !reflect.DeepEqual(i.Labels, want) {
|
||||
t.Errorf("labels = %v, want %v", i.Labels, want)
|
||||
}
|
||||
if i.Type() != "task" {
|
||||
t.Errorf("type = %q", i.Type())
|
||||
}
|
||||
if i.IsLocal() {
|
||||
t.Error("origin gitea must not read as local")
|
||||
}
|
||||
if i.Extra["gitea"] != "claude-skills/tea#42" {
|
||||
t.Errorf("foreign key lost: %v", i.Extra)
|
||||
}
|
||||
// The domain carries foreign keys; it must not learn to read them.
|
||||
if _, ok := i.Extra["labels"]; ok {
|
||||
t.Error("a domain key leaked into Extra")
|
||||
}
|
||||
if strings.Contains(i.Body, "# "+i.Title) {
|
||||
t.Error("the title heading was left in the body")
|
||||
}
|
||||
if !strings.HasPrefix(i.Body, SummarySection) {
|
||||
t.Errorf("body does not start at ## Summary: %q", head(i.Body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextRoundTripsByteForByte(t *testing.T) {
|
||||
if got := FromText(sample, "").Text(); got != sample {
|
||||
t.Errorf("round trip changed the file:\n--- got ---\n%s\n--- want ---\n%s", got, sample)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMilestoneNoneIsTheEmptyMilestone(t *testing.T) {
|
||||
i := FromText("---\nid: x\nmilestone: none\n---\n# T\n\nbody\n", "")
|
||||
if i.Milestone != "" {
|
||||
t.Errorf("milestone = %q, want empty", i.Milestone)
|
||||
}
|
||||
if !strings.Contains(i.Text(), "milestone: none") {
|
||||
t.Error("an empty milestone must render back as none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBareListValueIsTheSameStatementAsABracketedOne(t *testing.T) {
|
||||
i := FromText("---\nid: x\nlabels: type/bug\n---\n# T\n\nbody\n", "")
|
||||
if want := []string{"type/bug"}; !reflect.DeepEqual(i.Labels, want) {
|
||||
t.Errorf("labels = %v, want %v", i.Labels, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileNameWinsOverTheMetadataID(t *testing.T) {
|
||||
// The store names the file after the slug, so a hand-edited `id:` that
|
||||
// disagrees with it is the one that is wrong.
|
||||
if got := FromText(sample, "renamed-by-hand").ID; got != "renamed-by-hand" {
|
||||
t.Errorf("id = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlugify(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
// Truncation cuts back to the last dash, so a slug never ends in half
|
||||
// a word — even when the limit happened to land on a boundary.
|
||||
"Wire sqlc into the appclick repo layer": "wire-sqlc-into-the-appclick",
|
||||
"Fix tea-guard crash": "fix-tea-guard-crash",
|
||||
" Trailing --- dashes ": "trailing-dashes",
|
||||
// Titles are English by format rule; anything else is dropped rather
|
||||
// than transliterated, and an empty result is not an id.
|
||||
"Крашится гвард": "issue",
|
||||
"": "issue",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := Slugify(in, 32); got != want {
|
||||
t.Errorf("Slugify(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func head(s string) string {
|
||||
if len(s) > 40 {
|
||||
return s[:40]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestSectionBodyStopsAtTheNextHeading(t *testing.T) {
|
||||
body := "## Summary\nодин\nдва\n\n## Spec\nnone\n"
|
||||
if got := SectionBody(body, "## Summary"); got != "один\nдва" {
|
||||
t.Errorf("Summary = %q", got)
|
||||
}
|
||||
if got := SectionBody(body, SpecSection); got != "none" {
|
||||
t.Errorf("Spec = %q", got)
|
||||
}
|
||||
if got := SectionBody(body, "## Missing"); got != "" {
|
||||
t.Errorf("missing section = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package issue
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The domain must depend on nothing but the standard library and the one
|
||||
// package that answers "which directory is the project".
|
||||
//
|
||||
// In Python this rule was a grep in a document and a habit; here it is a build
|
||||
// graph, and the test fails the moment a tracker concept — an HTTP client, a
|
||||
// JSON payload, a login — is imported into the layer that must not know a
|
||||
// tracker exists.
|
||||
func TestDomainDependsOnNothing(t *testing.T) {
|
||||
const allowed = "git.noodles.cam/claude-skills/marketplace/cli/internal/project"
|
||||
|
||||
out, err := exec.Command("go", "list", "-deps", ".").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("go list: %v", err)
|
||||
}
|
||||
for _, dep := range strings.Fields(string(out)) {
|
||||
if dep == allowed || dep == "git.noodles.cam/claude-skills/marketplace/cli/internal/issue" {
|
||||
continue
|
||||
}
|
||||
// A standard-library import path has no dot in its first element,
|
||||
// because it has no domain name in front of it.
|
||||
first, _, _ := strings.Cut(dep, "/")
|
||||
if strings.Contains(first, ".") {
|
||||
t.Errorf("the domain imports %s — a tracker concept in the layer that must not know one exists", dep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of the same rule: net/http and its friends are standard
|
||||
// library, so "no third-party imports" would not catch a transport written by
|
||||
// hand. Name them.
|
||||
func TestDomainDoesNotReachTheNetworkOrTheShell(t *testing.T) {
|
||||
forbidden := []string{"net/http", "net", "os/exec", "encoding/json"}
|
||||
|
||||
out, err := exec.Command("go", "list", "-deps", ".").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("go list: %v", err)
|
||||
}
|
||||
deps := map[string]bool{}
|
||||
for _, d := range strings.Fields(string(out)) {
|
||||
deps[d] = true
|
||||
}
|
||||
for _, f := range forbidden {
|
||||
if deps[f] {
|
||||
t.Errorf("the domain reaches %s — that belongs in the transport", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package issue
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var titleRe = regexp.MustCompile(`^#[ \t]+(.+?)[ \t]*\n`)
|
||||
|
||||
// ParseMeta splits a file into its metadata block, title, and body.
|
||||
//
|
||||
// Values come back as the raw text that followed the colon. Lists are not
|
||||
// unpacked here: a foreign key that happens to look like a list must round
|
||||
// trip byte for byte, and the domain's own lists are unpacked by their
|
||||
// accessors. The title is the first `# ` heading below the block and is
|
||||
// stripped out of the body.
|
||||
func ParseMeta(text string) (meta map[string]string, title, body string) {
|
||||
meta = map[string]string{}
|
||||
rest := text
|
||||
if strings.HasPrefix(text, "---") {
|
||||
if end := strings.Index(text[3:], "\n---"); end != -1 {
|
||||
end += 3
|
||||
for _, line := range strings.Split(strings.TrimSpace(text[3:end]), "\n") {
|
||||
k, v, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
meta[strings.TrimSpace(k)] = strings.TrimSpace(v)
|
||||
}
|
||||
rest = text[end+4:]
|
||||
}
|
||||
}
|
||||
rest = strings.TrimLeft(rest, "\n")
|
||||
|
||||
if m := titleRe.FindStringSubmatchIndex(rest); m != nil {
|
||||
title = strings.TrimSpace(rest[m[2]:m[3]])
|
||||
rest = strings.TrimLeft(rest[m[1]:], "\n")
|
||||
}
|
||||
return meta, title, rest
|
||||
}
|
||||
|
||||
// RenderMeta writes the block back: domain keys in DomainKeys order, foreign
|
||||
// keys after them, sorted. Lists stay on one line so grep sees them whole.
|
||||
func RenderMeta(meta map[string]string) string {
|
||||
var foreign []string
|
||||
for k := range meta {
|
||||
if !isDomainKey(k) {
|
||||
foreign = append(foreign, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(foreign)
|
||||
|
||||
lines := []string{"---"}
|
||||
for _, k := range append(append([]string{}, DomainKeys...), foreign...) {
|
||||
if v, ok := meta[k]; ok {
|
||||
lines = append(lines, k+": "+v)
|
||||
}
|
||||
}
|
||||
return strings.Join(append(lines, "---"), "\n")
|
||||
}
|
||||
|
||||
// splitList unpacks the inline `[a, b]` form, and a bare comma-separated value
|
||||
// too: a hand-written `labels: type/bug` is the same statement as
|
||||
// `labels: [type/bug]` and the format does not make an operator care.
|
||||
func splitList(v string) []string {
|
||||
v = strings.TrimSpace(v)
|
||||
if strings.HasPrefix(v, "[") && strings.HasSuffix(v, "]") {
|
||||
v = v[1 : len(v)-1]
|
||||
}
|
||||
var out []string
|
||||
for _, part := range strings.Split(v, ",") {
|
||||
if part = strings.TrimSpace(part); part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func renderList(xs []string) string { return "[" + strings.Join(xs, ", ") + "]" }
|
||||
@@ -0,0 +1,209 @@
|
||||
package issue
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
|
||||
)
|
||||
|
||||
// The store holds two kinds of file, and only one of them is a store.
|
||||
//
|
||||
// An issue whose origin is Local lives here and nowhere else — that file IS the
|
||||
// issue, and losing it loses the work. Anything with a tracker origin is a
|
||||
// cache: the tracker has it, this copy is a working copy, and it is deleted the
|
||||
// moment a push confirms the tracker is up to date.
|
||||
|
||||
// Root resolves the issue store for the current project. An explicit out
|
||||
// overrides it and is used exactly as typed: a relative out stays relative to
|
||||
// the working directory, because that is what the operator asked for.
|
||||
func Root(out string) string {
|
||||
if out != "" {
|
||||
return out
|
||||
}
|
||||
return project.StoreRoot("")
|
||||
}
|
||||
|
||||
// ErrStoreMissing marks the "the store directory is not there" failure.
|
||||
//
|
||||
// Deliberately a different answer from "the store is empty". One is a path that
|
||||
// does not exist, the other is a repository with no issues filed yet, and
|
||||
// conflating the two is exactly what made a missed directory look like an empty
|
||||
// backlog.
|
||||
var ErrStoreMissing = errors.New("store missing")
|
||||
|
||||
// StoreExists reports whether root is a directory that can be read as a store.
|
||||
func StoreExists(root string) bool {
|
||||
if root == "" {
|
||||
return false
|
||||
}
|
||||
fi, err := os.Stat(root)
|
||||
return err == nil && fi.IsDir()
|
||||
}
|
||||
|
||||
// RequireStore asserts the store is there before reading or writing it.
|
||||
//
|
||||
// An empty root means no project was found at all — a different failure from a
|
||||
// project whose store has not been created yet, and the message says so.
|
||||
func RequireStore(root string) error {
|
||||
if root == "" {
|
||||
return fmt.Errorf("%w: %s", ErrStoreMissing, project.NotFoundError(""))
|
||||
}
|
||||
if !StoreExists(root) {
|
||||
return fmt.Errorf("%w: store %s does not exist", ErrStoreMissing, root)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateStore creates the store, reporting whether it made the directory.
|
||||
//
|
||||
// Only the commands that legitimately bootstrap a store call this — `new` and
|
||||
// `pull` — and both announce it. Nothing creates a store as a side effect of a
|
||||
// write: a missing directory is something to report, not something to conjure.
|
||||
// An unresolved root is never conjured either — without a marker there is no
|
||||
// project to create a store IN, and guessing one is how a store once ended up
|
||||
// inside the plugin.
|
||||
func CreateStore(root string) (bool, error) {
|
||||
if root == "" {
|
||||
return false, fmt.Errorf("%w: %s", ErrStoreMissing, project.NotFoundError(""))
|
||||
}
|
||||
if StoreExists(root) {
|
||||
return false, nil
|
||||
}
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// StoreError says why root cannot be read as a store, or nil when it holds
|
||||
// issues.
|
||||
//
|
||||
// The three messages are distinct on purpose — no project at all, a project
|
||||
// with no store, and a store with nothing in it are three different things to
|
||||
// do next.
|
||||
func StoreError(root string) error {
|
||||
switch {
|
||||
case root == "":
|
||||
return project.NotFoundError("")
|
||||
case !StoreExists(root):
|
||||
return fmt.Errorf("store %s does not exist — nothing was created; pass --out to point elsewhere", root)
|
||||
case len(AllIDs(root)) == 0:
|
||||
return fmt.Errorf("store %s exists but is empty", root)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PathOf is where the issue with this id lives.
|
||||
func PathOf(root, id string) string { return filepath.Join(root, id+".md") }
|
||||
|
||||
// AllIDs lists every issue in the store, by slug.
|
||||
//
|
||||
// An issue file is named by its slug and a slug has no dot in it, so
|
||||
// `<id>.comments.md` — the thread the sync layer parks beside an issue — is not
|
||||
// one, and neither is anything else that grew a second extension. Without that
|
||||
// rule `wire-sqlc.comments` reads as an issue called `wire-sqlc.comments`, and
|
||||
// a bare push tries to file the comment thread as a unit of work.
|
||||
func AllIDs(root string) []string {
|
||||
if !StoreExists(root) {
|
||||
return nil
|
||||
}
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(name, ".md") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "INDEX") ||
|
||||
strings.HasPrefix(name, "tree-") {
|
||||
continue
|
||||
}
|
||||
id := name[:len(name)-3]
|
||||
if strings.Contains(id, ".") {
|
||||
continue
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// SlugFiles lists every file the store holds under one slug — the issue and its
|
||||
// sidecars.
|
||||
//
|
||||
// `<id>.md` is the issue. Anything named `<id>.<something>` beside it is a
|
||||
// companion another layer parked there (`<id>.comments.md` is the one that
|
||||
// exists today). AllIDs already refuses to read those as issues because a slug
|
||||
// has no dot in it; this is the same rule read the other way round.
|
||||
//
|
||||
// Which is how the domain can remove an issue completely without learning what
|
||||
// any of those companions are: it does not need to know that a comment thread
|
||||
// exists to know that a file named after this issue belongs to it and goes when
|
||||
// it goes. The issue's own file comes first — it is the headline of any receipt
|
||||
// printed from this list.
|
||||
//
|
||||
// A missing store is an empty list, not an error: nothing is there to remove.
|
||||
func SlugFiles(root, id string) []string {
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
prefix, own := id+".", id+".md"
|
||||
var self, sidecars []string
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !strings.HasPrefix(name, prefix) || e.IsDir() {
|
||||
continue
|
||||
}
|
||||
p := filepath.Join(root, name)
|
||||
if name == own {
|
||||
self = append(self, p)
|
||||
} else {
|
||||
sidecars = append(sidecars, p)
|
||||
}
|
||||
}
|
||||
sort.Strings(sidecars)
|
||||
return append(self, sidecars...)
|
||||
}
|
||||
|
||||
// Load reads one issue. The file name wins over the id in the metadata block.
|
||||
func Load(root, id string) (*Issue, error) {
|
||||
raw, err := os.ReadFile(PathOf(root, id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return FromText(string(raw), id), nil
|
||||
}
|
||||
|
||||
// LoadAll reads the whole store.
|
||||
func LoadAll(root string) (map[string]*Issue, error) {
|
||||
out := map[string]*Issue{}
|
||||
for _, id := range AllIDs(root) {
|
||||
i, err := Load(root, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id] = i
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Save writes an issue to the store, which must already exist.
|
||||
func Save(root string, i *Issue) (string, error) {
|
||||
if err := RequireStore(root); err != nil {
|
||||
return "", err
|
||||
}
|
||||
p := PathOf(root, i.ID)
|
||||
if err := os.WriteFile(p, []byte(i.Text()), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package issue
|
||||
|
||||
import "strings"
|
||||
|
||||
// Four namespaces classify an issue. type/* is mandatory and exclusive,
|
||||
// severity/* is optional and exclusive, tech/* and comp/* are free-form.
|
||||
//
|
||||
// Colors are NOT here — a hex code is how a tracker paints a chip, which makes
|
||||
// it the sync layer's business.
|
||||
|
||||
// Types are the kinds of work, and the order is the order they are offered in.
|
||||
var Types = []struct{ Name, Meaning string }{
|
||||
{"bug", "Something behaves incorrectly in existing code"},
|
||||
{"task", "Implementation of new functionality"},
|
||||
{"refactor", "Internal restructuring; behavior must not change"},
|
||||
{"test", "Writing or fixing tests"},
|
||||
{"feature", "Container: several issues delivering one unit of business value"},
|
||||
{"draft", "Idea captured for later; not ready for work"},
|
||||
}
|
||||
|
||||
// Severities are the business-impact levels, ascending.
|
||||
var Severities = []string{"low", "medium", "high", "showstopper", "critical"}
|
||||
|
||||
// Section headers are fixed English literals in a fixed order; only body prose
|
||||
// is Russian.
|
||||
const (
|
||||
SummarySection = "## Summary"
|
||||
SpecSection = "## Spec"
|
||||
ACSection = "## Acceptance criteria"
|
||||
DependsSection = "## Depends on"
|
||||
IssuesSection = "## Issues"
|
||||
)
|
||||
|
||||
// RequiredSections must be present in every type. type/draft is exempt from
|
||||
// acceptance criteria and only from that.
|
||||
var RequiredSections = []string{SummarySection, SpecSection}
|
||||
|
||||
// DepSections both name what an issue depends on, so both are edge sources and
|
||||
// both point the same way. In a type/feature that reads container -> child:
|
||||
// "the container is closed when its children are closed" IS a dependency.
|
||||
// "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.
|
||||
var DepSections = []string{DependsSection, IssuesSection}
|
||||
|
||||
// ExpectedSections are the per-type sections from the templates. Absence is a
|
||||
// warning, not a stop.
|
||||
var ExpectedSections = map[string][]string{
|
||||
"bug": {"## Steps to reproduce", "## Expected", "## Actual", "## Environment"},
|
||||
"task": {"## Motivation"},
|
||||
"refactor": {"## Motivation", "## Invariants"},
|
||||
"test": {"## Motivation", "## Test cases"},
|
||||
"feature": {"## Motivation", IssuesSection},
|
||||
"draft": {"## Notes"},
|
||||
}
|
||||
|
||||
// KnownType reports whether name is one of Types.
|
||||
func KnownType(name string) bool {
|
||||
for _, t := range Types {
|
||||
if t.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// KnownSeverity reports whether name is one of Severities.
|
||||
func KnownSeverity(name string) bool {
|
||||
for _, s := range Severities {
|
||||
if s == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TypeNames lists the type slugs, for error messages and completion.
|
||||
func TypeNames() []string {
|
||||
out := make([]string, len(Types))
|
||||
for i, t := range Types {
|
||||
out[i] = t.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CanonicalLabels is the label set a tracker needs before a push can attach
|
||||
// anything: the two exclusive namespaces in full. tech/* and comp/* are
|
||||
// project-specific and have no preset.
|
||||
func CanonicalLabels() []string {
|
||||
out := make([]string, 0, len(Types)+len(Severities))
|
||||
for _, t := range Types {
|
||||
out = append(out, "type/"+t.Name)
|
||||
}
|
||||
for _, s := range Severities {
|
||||
out = append(out, "severity/"+s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SectionBody is the text under header, up to the next `## ` heading.
|
||||
func SectionBody(body, header string) string {
|
||||
var out []string
|
||||
active := false
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
if strings.HasPrefix(line, "## ") {
|
||||
if active {
|
||||
break
|
||||
}
|
||||
active = strings.TrimSpace(line) == header
|
||||
continue
|
||||
}
|
||||
if active {
|
||||
out = append(out, line)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(out, "\n"))
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package issue
|
||||
|
||||
import "strings"
|
||||
|
||||
// The type templates, verbatim from references/format.md.
|
||||
//
|
||||
// Section headers are fixed English literals in a fixed order; body prose is
|
||||
// Russian. Both halves of that rule are in the strings below, and the format
|
||||
// document is the source of truth for them.
|
||||
|
||||
const specSection = "## Spec\nnone\n"
|
||||
|
||||
var templates = map[string]string{
|
||||
"bug": `## Summary
|
||||
Что сломано и где проявляется, одно-два предложения.
|
||||
|
||||
` + specSection + `
|
||||
## Steps to reproduce
|
||||
1. …
|
||||
2. …
|
||||
|
||||
## Expected
|
||||
Что должно было произойти.
|
||||
|
||||
## Actual
|
||||
Что происходит на самом деле: вывод команды, лог.
|
||||
|
||||
## Environment
|
||||
Только релевантное: версии, ОС, конфигурация.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] баг не воспроизводится по шагам выше
|
||||
- [ ] добавлена проверка на регрессию (если применимо)
|
||||
`,
|
||||
"task": `## Summary
|
||||
Что нужно сделать, одно-два предложения.
|
||||
|
||||
` + specSection + `
|
||||
## Motivation
|
||||
Какую проблему пользователя/системы это решает.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] проверяемое условие
|
||||
- [ ] …
|
||||
`,
|
||||
"refactor": `## Summary
|
||||
Что перестраиваем и в каких файлах (` + "`path/file:line`" + `).
|
||||
|
||||
` + specSection + `
|
||||
## Motivation
|
||||
Чем плохо текущее состояние: дублирование, связность, читаемость.
|
||||
|
||||
## Invariants
|
||||
Что НЕ должно измениться: поведение, публичные API, форматы данных.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] проверяемое условие (тесты зелёные, старый путь удалён, …)
|
||||
`,
|
||||
"test": `## Summary
|
||||
Что покрываем тестами и где (` + "`path/file:line`" + `).
|
||||
|
||||
` + specSection + `
|
||||
## Motivation
|
||||
Зачем: регрессия после бага, пробел в покрытии, флаки-тест.
|
||||
|
||||
## Test cases
|
||||
- сценарий → ожидаемый результат
|
||||
- …
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] перечисленные кейсы покрыты и зелёные
|
||||
- [ ] тесты проходят в CI
|
||||
`,
|
||||
"feature": `## Summary
|
||||
Бизнес-ценность одним-двумя предложениями.
|
||||
|
||||
` + specSection + `
|
||||
## Motivation
|
||||
Какую проблему пользователя/системы это решает.
|
||||
|
||||
## Issues
|
||||
- [ ] slug-дочернего-issue — краткое описание части
|
||||
- [ ] …
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] все дочерние issues закрыты
|
||||
- [ ] проверяемое условие уровня фичи
|
||||
`,
|
||||
"draft": `## Summary
|
||||
Идея одним-двумя предложениями.
|
||||
|
||||
` + specSection + `
|
||||
## Notes
|
||||
Свободные заметки: что известно, открытые вопросы, варианты.
|
||||
`,
|
||||
}
|
||||
|
||||
// Template is the prefilled body for a type, with `## Depends on` inserted
|
||||
// right after `## Spec` when the issue has dependencies.
|
||||
func Template(typ string, depends []string) string {
|
||||
return withDepends(templates[typ], depends)
|
||||
}
|
||||
|
||||
// withDepends places the section where the format says it goes: after
|
||||
// `## Spec`, before everything else. Appended at the end only when the
|
||||
// template has no third section to sit in front of.
|
||||
func withDepends(body string, depends []string) string {
|
||||
if len(depends) == 0 {
|
||||
return body
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("## Depends on\n")
|
||||
for _, d := range depends {
|
||||
b.WriteString("- " + d + "\n")
|
||||
}
|
||||
block := b.String()
|
||||
|
||||
var out []string
|
||||
placed := false
|
||||
for _, line := range strings.SplitAfter(body, "\n") {
|
||||
if !placed && len(out) > 0 && strings.HasPrefix(line, "## ") &&
|
||||
!strings.HasPrefix(line, SummarySection) && !strings.HasPrefix(line, SpecSection) {
|
||||
out = append(out, block+"\n")
|
||||
placed = true
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
if !placed {
|
||||
out = append(out, "\n"+block)
|
||||
}
|
||||
return strings.Join(out, "")
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package issue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
titlePrefixRe = regexp.MustCompile(
|
||||
`(?i)^\s*(\[[^\]]+\]|(fix|feat|feature|bug|task|test|chore|refactor)\s*:)`)
|
||||
cyrillicRe = regexp.MustCompile(`(?i)[а-яё]`)
|
||||
)
|
||||
|
||||
// Validate reports what is wrong with an issue.
|
||||
//
|
||||
// Errors mean the issue is not well-formed in the canonical format; warnings
|
||||
// mean it deviates from its type template. Pass knownIDs to have dependencies
|
||||
// resolved against a store; pass nil to skip that check.
|
||||
func Validate(i *Issue, knownIDs map[string]bool) (errs, warns []string) {
|
||||
switch {
|
||||
case i.ID == "":
|
||||
errs = append(errs, "no `id:` — the slug is the issue's identity")
|
||||
case !IsSlug(i.ID):
|
||||
errs = append(errs, fmt.Sprintf("id %q is not a slug (lowercase, digits, single dashes)", i.ID))
|
||||
}
|
||||
|
||||
if !contains(States, i.State) {
|
||||
errs = append(errs, fmt.Sprintf("state %q must be one of: %s",
|
||||
i.State, strings.Join(States, ", ")))
|
||||
}
|
||||
|
||||
var types []string
|
||||
severities := 0
|
||||
for _, l := range i.Labels {
|
||||
if strings.HasPrefix(l, "type/") {
|
||||
types = append(types, l)
|
||||
}
|
||||
if strings.HasPrefix(l, "severity/") {
|
||||
severities++
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(types) != 1:
|
||||
found := strings.Join(types, ", ")
|
||||
if found == "" {
|
||||
found = "none"
|
||||
}
|
||||
errs = append(errs, fmt.Sprintf("need exactly one type/* label, found %d: %s",
|
||||
len(types), found))
|
||||
case !KnownType(i.Type()):
|
||||
errs = append(errs, fmt.Sprintf("unknown type %q — known: %s",
|
||||
i.Type(), strings.Join(TypeNames(), ", ")))
|
||||
}
|
||||
if severities > 1 {
|
||||
errs = append(errs, "at most one severity/* label")
|
||||
}
|
||||
if s := i.Severity(); s != "" && !KnownSeverity(s) {
|
||||
warns = append(warns, fmt.Sprintf("unknown severity %q", s))
|
||||
}
|
||||
|
||||
if i.Title == "" {
|
||||
errs = append(errs, "no `# Title` heading below the metadata block")
|
||||
} else {
|
||||
if titlePrefixRe.MatchString(i.Title) {
|
||||
head := i.Title
|
||||
if len(head) > 24 {
|
||||
head = head[:24]
|
||||
}
|
||||
errs = append(errs, fmt.Sprintf(
|
||||
"title carries a type prefix (%q) — the type lives in the label", head))
|
||||
}
|
||||
if cyrillicRe.MatchString(i.Title) {
|
||||
errs = append(errs, "title must be English, imperative mood (prose stays Russian)")
|
||||
}
|
||||
}
|
||||
|
||||
for _, h := range RequiredSections {
|
||||
if !strings.Contains(i.Body, h) {
|
||||
errs = append(errs, "missing section "+h)
|
||||
}
|
||||
}
|
||||
if i.Type() != "draft" && !strings.Contains(i.Body, ACSection) {
|
||||
errs = append(errs, "missing section "+ACSection)
|
||||
}
|
||||
if strings.Contains(i.Body, SpecSection) && SectionBody(i.Body, SpecSection) == "" {
|
||||
errs = append(errs, "## Spec is empty — put a repo path, a URL, or the literal `none`")
|
||||
}
|
||||
|
||||
for _, h := range ExpectedSections[i.Type()] {
|
||||
if !strings.Contains(i.Body, h) {
|
||||
warns = append(warns, fmt.Sprintf("type/%s template usually has %s", i.Type(), h))
|
||||
}
|
||||
}
|
||||
|
||||
if contains(i.Depends, i.ID) {
|
||||
errs = append(errs, "depends on itself")
|
||||
}
|
||||
if knownIDs != nil {
|
||||
for _, d := range i.Depends {
|
||||
if !knownIDs[d] {
|
||||
warns = append(warns, fmt.Sprintf("depends on %q, which is not in the store", d))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `depends:` is the machine-readable graph; the body section is prose for
|
||||
// humans. They drift silently unless something says so. Name the section
|
||||
// the reference actually came from — for a container that is `## Issues`.
|
||||
for _, r := range BodyDepRefs(i.Body) {
|
||||
if !strings.HasPrefix(r.Ref, "#") && !contains(i.Depends, r.Ref) {
|
||||
warns = append(warns, fmt.Sprintf(
|
||||
"%s mentions %q but `depends:` does not list it", r.Section, r.Ref))
|
||||
}
|
||||
}
|
||||
|
||||
// An unticked checkbox is never a finding — neither an error nor a warning.
|
||||
// `- [ ]` is work not done yet, which is the normal state of a perfectly
|
||||
// well-formed issue. Reading that state is the `ac` command's job.
|
||||
|
||||
return errs, warns
|
||||
}
|
||||
|
||||
func contains(xs []string, x string) bool {
|
||||
for _, v := range xs {
|
||||
if v == x {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -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,260 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "code.gitea.io/sdk/gitea"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
// Gitea -> domain.
|
||||
|
||||
// PayloadOptions are the things a caller knows and this package cannot: what
|
||||
// the store already holds, what the tracker's numbers mean locally, and what
|
||||
// time it is.
|
||||
type PayloadOptions struct {
|
||||
// IDForNumber maps a Gitea number to a local slug. A dependency whose
|
||||
// target has not been pulled yet is dropped from `depends:` rather than
|
||||
// invented — the body still names it, so nothing is lost, and a made-up
|
||||
// slug would be an edge to a file that does not exist.
|
||||
IDForNumber map[int]string
|
||||
// ExtraNumbers are dependencies the caller learned somewhere other than the
|
||||
// body, folded in with the ones the body names.
|
||||
ExtraNumbers []int
|
||||
// Synced is the timestamp stamped into `synced:`. The clock belongs to the
|
||||
// caller: a package with a clock in it is not a pure one.
|
||||
Synced string
|
||||
// LocalBody is the body of the copy already in the store, when there is
|
||||
// one. It contributes exactly one thing — its ticked checkboxes survive the
|
||||
// overwrite. Empty is what a first pull passes.
|
||||
LocalBody string
|
||||
}
|
||||
|
||||
// FromPayload builds a domain issue from a Gitea issue payload, and returns the
|
||||
// numbers it could not resolve to a slug.
|
||||
//
|
||||
// The id marker is stripped before anything else looks at the body: it is
|
||||
// transport bookkeeping, and the caller has already read the slug off it to
|
||||
// decide which id to pass. Everything downstream — checkboxes, `#N` references,
|
||||
// what lands on disk — sees the body the author wrote.
|
||||
func FromPayload(p *sdk.Issue, id string, repo wire.Repo, opt PayloadOptions) (*issue.Issue, []int) {
|
||||
body := MergeCheckboxState(StripIDMarker(strings.TrimSpace(p.Body)), opt.LocalBody)
|
||||
|
||||
numbers := NumbersInBody(body)
|
||||
for _, n := range opt.ExtraNumbers {
|
||||
if !slices.Contains(numbers, n) {
|
||||
numbers = append(numbers, n)
|
||||
}
|
||||
}
|
||||
|
||||
// A number that resolves to this issue itself is dropped without a word: a
|
||||
// body may well name its own number, and a self-edge is a cycle the graph
|
||||
// would report as an error the author cannot fix.
|
||||
var deps []string
|
||||
var unresolved []int
|
||||
for _, n := range numbers {
|
||||
slug := opt.IDForNumber[n]
|
||||
switch {
|
||||
case slug != "" && slug != id && !slices.Contains(deps, slug):
|
||||
deps = append(deps, slug)
|
||||
case slug == "":
|
||||
unresolved = append(unresolved, n)
|
||||
}
|
||||
}
|
||||
|
||||
// The repository the caller asked for, never the one the payload names: a
|
||||
// dependency listing answers with issues from elsewhere, and this is the
|
||||
// handle for the copy landing in THIS store.
|
||||
extra := map[string]string{
|
||||
GiteaKey: wire.Key{Repo: repo, Number: int(p.Index)}.String(),
|
||||
URLKey: p.HTMLURL,
|
||||
SyncedKey: opt.Synced,
|
||||
}
|
||||
if p.Ref != "" {
|
||||
extra[BranchKey] = p.Ref
|
||||
}
|
||||
if stamp := Stamp(p.Updated); stamp != "" {
|
||||
extra[RemoteUpdatedKey] = stamp
|
||||
}
|
||||
// Zero comments is not a fact worth a line in the file — every issue that
|
||||
// has never been discussed would carry one.
|
||||
if p.Comments > 0 {
|
||||
extra[CommentsKey] = strconv.Itoa(p.Comments)
|
||||
}
|
||||
|
||||
state := string(p.State)
|
||||
if state == "" {
|
||||
state = "open"
|
||||
}
|
||||
|
||||
return &issue.Issue{
|
||||
ID: id,
|
||||
Title: p.Title,
|
||||
Body: body,
|
||||
State: state,
|
||||
Labels: LabelNames(p),
|
||||
Assignees: AssigneeLogins(p),
|
||||
Milestone: MilestoneTitle(p),
|
||||
Depends: deps,
|
||||
Origin: Origin,
|
||||
Extra: extra,
|
||||
}, unresolved
|
||||
}
|
||||
|
||||
// LabelNames are a payload's label names, in the order the tracker listed them.
|
||||
//
|
||||
// Appended into a nil slice, so an issue with no labels is the same value as
|
||||
// one loaded from a file — the store's own parser yields nothing, not an empty
|
||||
// list, and two spellings of "none" is a comparison bug waiting. The same goes
|
||||
// for the two below.
|
||||
func LabelNames(p *sdk.Issue) []string {
|
||||
var out []string
|
||||
for _, l := range p.Labels {
|
||||
if l != nil {
|
||||
out = append(out, l.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AssigneeLogins are a payload's assignees, as logins.
|
||||
//
|
||||
// Only the login crosses this boundary — it is the one field of a Gitea user
|
||||
// that means anything to a command, it is what `assignees:` holds, and a
|
||||
// display name is not an identity anything can be pushed against.
|
||||
func AssigneeLogins(p *sdk.Issue) []string {
|
||||
var out []string
|
||||
for _, a := range p.Assignees {
|
||||
if a != nil {
|
||||
out = append(out, a.UserName)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MilestoneTitle is a payload's milestone title, or "" when it has none. The
|
||||
// domain carries the title; the id exists only long enough to be sent back.
|
||||
func MilestoneTitle(p *sdk.Issue) string {
|
||||
if p.Milestone == nil {
|
||||
return ""
|
||||
}
|
||||
return p.Milestone.Title
|
||||
}
|
||||
|
||||
// Stamp is how a tracker timestamp is written into an issue's metadata, and ""
|
||||
// for a time the payload did not carry.
|
||||
//
|
||||
// The zero time is not a date: an issue whose `updated_at` was absent would
|
||||
// otherwise be stamped `0001-01-01`, which reads as a fact and is not one.
|
||||
//
|
||||
// RFC3339 both ways. These values are written into a file, compared as opaque
|
||||
// strings and handed back; the SDK parses them into a time.Time on the way in,
|
||||
// so something has to spell them out again, and the format Gitea sends is the
|
||||
// format they go back out in. What was true when this was a string end to end —
|
||||
// that no round trip could change the spelling — is not any more: a timestamp
|
||||
// with a fraction of a second in it comes back without one.
|
||||
func Stamp(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// NumbersInBody is every `#N` referenced from the body's dependency sections.
|
||||
// Used only to seed `depends:` on the first pull — after that the metadata
|
||||
// field is the graph and the prose is prose.
|
||||
func NumbersInBody(body string) []int {
|
||||
var out []int
|
||||
for _, ref := range issue.BodyDepRefs(body) {
|
||||
if !strings.HasPrefix(ref.Ref, "#") {
|
||||
continue
|
||||
}
|
||||
if n, err := strconv.Atoi(ref.Ref[1:]); err == nil {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MergeCheckboxState is the remote body with every tick the local copy already
|
||||
// had put back.
|
||||
//
|
||||
// The one exception to "a pull overwrites the body", and deliberately the
|
||||
// narrowest one that works. A tick is MONOTONE — an item only ever travels
|
||||
// `[ ]` -> `[x]` — so the two sides are joined by a set union, not reconciled:
|
||||
// no base version, no drift tracking, no conflict to resolve. The set is a set
|
||||
// of item TEXTS, and 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 tick stays with the wording it was put on.
|
||||
//
|
||||
// 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. The alternative — pairing
|
||||
// duplicates up by order — is the reading that can still drop a tick (local
|
||||
// `[ ]` then `[x]`, remote a single line: the ticked one pairs with nothing),
|
||||
// and dropping a tick is the bug this exists to fix. Two items whose text is
|
||||
// identical are the same item to whoever reads them.
|
||||
//
|
||||
// The price, accepted explicitly: UNticking is not monotone, so a box unticked
|
||||
// in the web UI comes back on the next pull. Untick locally, push.
|
||||
func MergeCheckboxState(remoteBody, localBody string) string {
|
||||
ticked := map[string]bool{}
|
||||
for _, c := range issue.Checkboxes(localBody) {
|
||||
if c.Checked {
|
||||
ticked[c.Text] = true
|
||||
}
|
||||
}
|
||||
if len(ticked) == 0 {
|
||||
return remoteBody
|
||||
}
|
||||
body := remoteBody
|
||||
// SetCheckbox trades one character for one character, so line numbers read
|
||||
// off remoteBody stay valid against the partially rewritten body.
|
||||
for _, c := range issue.Checkboxes(remoteBody) {
|
||||
if c.Checked || !ticked[c.Text] {
|
||||
continue
|
||||
}
|
||||
// The line was just read off remoteBody by the same parser, so this
|
||||
// cannot fail; if it ever did, one unticked item is a smaller loss than
|
||||
// abandoning the merge and dropping every other tick with it.
|
||||
if next, err := issue.SetCheckbox(body, c.Line, true); err == nil {
|
||||
body = next
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// RenderComments flattens a comment thread to markdown. Read-only: nothing
|
||||
// writes it back, which is why it may be as lossy as a reader needs.
|
||||
func RenderComments(comments []*sdk.Comment) string {
|
||||
var out []string
|
||||
for _, c := range comments {
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
day := Stamp(c.Created)
|
||||
if len(day) > 10 {
|
||||
day = day[:10]
|
||||
}
|
||||
who := ""
|
||||
if c.Poster != nil {
|
||||
who = c.Poster.UserName
|
||||
}
|
||||
body := strings.TrimSpace(c.Body)
|
||||
if body == "" {
|
||||
body = "(empty)"
|
||||
}
|
||||
out = append(out,
|
||||
"## comment "+strconv.FormatInt(c.ID, 10)+" — "+who+" — "+day,
|
||||
"", body, "")
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
sdk "code.gitea.io/sdk/gitea"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
)
|
||||
|
||||
// How the taxonomy is painted in Gitea's UI. A hex code says nothing about what
|
||||
// an issue IS, which is exactly why the table lives here and not in the domain
|
||||
// — internal/issue/taxonomy.go says as much where the labels themselves are.
|
||||
//
|
||||
// The keys are the canonical set and nothing else. TestEveryCanonicalLabelHasA
|
||||
// Color walks issue.CanonicalLabels() and fails on a gap, so a type or a
|
||||
// severity added over there cannot quietly arrive here as grey.
|
||||
var labelColors = map[string]string{
|
||||
"type/bug": "#ee0701",
|
||||
"type/task": "#0e8a16",
|
||||
"type/refactor": "#1d76db",
|
||||
"type/test": "#fbca04",
|
||||
"type/feature": "#5319e7",
|
||||
"type/draft": "#cccccc",
|
||||
"severity/low": "#c2e0c6",
|
||||
"severity/medium": "#fbca04",
|
||||
"severity/high": "#eb6420",
|
||||
"severity/showstopper": "#ee0701",
|
||||
"severity/critical": "#b60205",
|
||||
}
|
||||
|
||||
// DefaultColor paints everything outside the canonical set. `tech/*` and
|
||||
// `comp/*` are project-specific and have no preset, so guessing a color for one
|
||||
// would be inventing a meaning it does not have.
|
||||
const DefaultColor = "#ededed"
|
||||
|
||||
// LabelColor is the hex code a label is painted with in the tracker.
|
||||
func LabelColor(name string) string {
|
||||
if c, ok := labelColors[name]; ok {
|
||||
return c
|
||||
}
|
||||
return DefaultColor
|
||||
}
|
||||
|
||||
// LabelSpecs is the request body for each name, in the order given.
|
||||
//
|
||||
// The SDK's own CreateLabelOption and not a shape of this package's own: it is
|
||||
// field for field what a label create takes, and a second spelling of it would
|
||||
// mean the bootstrap command copying four fields across on its way to the
|
||||
// transport. It is what an EDIT is built from too — see Client.EditLabel — so
|
||||
// one value says what a label should be, whether or not it exists yet.
|
||||
// Exclusivity and meaning come from the domain taxonomy; only the color is
|
||||
// decided here.
|
||||
//
|
||||
// A slice and not a map: the order is the taxonomy's, and a bootstrap prints
|
||||
// its plan in that order — a map would shuffle the plan on every run and make
|
||||
// two identical runs look like different ones.
|
||||
func LabelSpecs(names []string) []sdk.CreateLabelOption {
|
||||
ns := exclusiveNamespaces()
|
||||
out := make([]sdk.CreateLabelOption, 0, len(names))
|
||||
for _, name := range names {
|
||||
out = append(out, sdk.CreateLabelOption{
|
||||
Name: name,
|
||||
Color: LabelColor(name),
|
||||
Description: typeMeaning(name),
|
||||
Exclusive: hasAnyPrefix(name, ns),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CanonicalLabelSpecs is the set a repository needs before a push can attach
|
||||
// anything.
|
||||
//
|
||||
// Derived from the domain's own list rather than restated: add a type over in
|
||||
// the taxonomy and the next bootstrap creates it, with no line changing here
|
||||
// except the color it is painted with.
|
||||
func CanonicalLabelSpecs() []sdk.CreateLabelOption { return LabelSpecs(issue.CanonicalLabels()) }
|
||||
|
||||
// exclusiveNamespaces are the namespaces at most one label may come from, read
|
||||
// off the canonical set rather than listed again — the domain publishes exactly
|
||||
// the exclusive namespaces there, in full, and that is what makes the set
|
||||
// canonical.
|
||||
//
|
||||
// A prefix test and not a membership test, on purpose: a project's own
|
||||
// `type/spike` is still exclusive. Being one of a set of alternatives is a
|
||||
// property of the namespace, not of the members the taxonomy happens to know.
|
||||
func exclusiveNamespaces() []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, name := range issue.CanonicalLabels() {
|
||||
ns, _, ok := strings.Cut(name, "/")
|
||||
if !ok || seen[ns] {
|
||||
continue
|
||||
}
|
||||
seen[ns] = true
|
||||
out = append(out, ns+"/")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// typeMeaning is the description a `type/*` label carries into the tracker, so
|
||||
// the meaning a reader needs is on the chip rather than in this repository.
|
||||
// Nothing else gets one: a severity explains itself, and a project's own
|
||||
// namespaces are not ours to describe.
|
||||
func typeMeaning(name string) string {
|
||||
tail, ok := strings.CutPrefix(name, "type/")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
for _, t := range issue.Types {
|
||||
if t.Name == tail {
|
||||
return t.Meaning
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func hasAnyPrefix(s string, prefixes []string) bool {
|
||||
for _, p := range prefixes {
|
||||
if strings.HasPrefix(s, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
)
|
||||
|
||||
var hexColor = regexp.MustCompile(`^#[0-9a-f]{6}$`)
|
||||
|
||||
// The canonical set is the domain's, and every member of it must have a color
|
||||
// here. A type added over in the taxonomy that arrived as grey would look like
|
||||
// a label somebody created by hand.
|
||||
func TestEveryCanonicalLabelHasAColor(t *testing.T) {
|
||||
for _, name := range issue.CanonicalLabels() {
|
||||
color := LabelColor(name)
|
||||
switch {
|
||||
case color == DefaultColor:
|
||||
t.Errorf("%s has no color of its own", name)
|
||||
case !hexColor.MatchString(color):
|
||||
t.Errorf("%s = %q, want #rrggbb in lower case", name, color)
|
||||
}
|
||||
}
|
||||
// And the other direction: a color left behind after a label was retired
|
||||
// paints nothing and is a lie about what the taxonomy holds.
|
||||
if len(labelColors) != len(issue.CanonicalLabels()) {
|
||||
t.Errorf("%d colors for %d canonical labels — one of the two lists moved without the other",
|
||||
len(labelColors), len(issue.CanonicalLabels()))
|
||||
}
|
||||
// Anything outside the set is project-specific and nobody here can guess
|
||||
// what it means.
|
||||
if got := LabelColor("tech/sql"); got != DefaultColor {
|
||||
t.Errorf("LabelColor(tech/sql) = %q, want the default", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLabelSpecs(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
description string
|
||||
exclusive bool
|
||||
}{
|
||||
{"type/bug", "Something behaves incorrectly in existing code", true},
|
||||
{"type/draft", "Idea captured for later; not ready for work", true},
|
||||
{"severity/critical", "", true},
|
||||
// Exclusivity is a property of the namespace, not of the members the
|
||||
// taxonomy happens to know.
|
||||
{"type/spike", "", true},
|
||||
{"tech/sql", "", false},
|
||||
{"comp/appclick", "", false},
|
||||
}
|
||||
names := make([]string, len(cases))
|
||||
for i, c := range cases {
|
||||
names[i] = c.name
|
||||
}
|
||||
|
||||
specs := LabelSpecs(names)
|
||||
if len(specs) != len(cases) {
|
||||
t.Fatalf("%d specs for %d names", len(specs), len(cases))
|
||||
}
|
||||
for i, c := range cases {
|
||||
got := specs[i]
|
||||
// The order is the taxonomy's: a bootstrap prints its plan in it, and
|
||||
// two identical runs must not look like different ones.
|
||||
if got.Name != c.name {
|
||||
t.Fatalf("spec %d is %s, want %s", i, got.Name, c.name)
|
||||
}
|
||||
if got.Description != c.description {
|
||||
t.Errorf("%s description = %q, want %q", c.name, got.Description, c.description)
|
||||
}
|
||||
if got.Exclusive != c.exclusive {
|
||||
t.Errorf("%s exclusive = %v, want %v", c.name, got.Exclusive, c.exclusive)
|
||||
}
|
||||
if got.Color != LabelColor(c.name) {
|
||||
t.Errorf("%s color = %q", c.name, got.Color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalLabelSpecsAreTheDomainsList(t *testing.T) {
|
||||
specs := CanonicalLabelSpecs()
|
||||
want := issue.CanonicalLabels()
|
||||
if len(specs) != len(want) {
|
||||
t.Fatalf("%d specs, want %d", len(specs), len(want))
|
||||
}
|
||||
for i, name := range want {
|
||||
if specs[i].Name != name {
|
||||
t.Errorf("spec %d = %s, want %s", i, specs[i].Name, name)
|
||||
}
|
||||
if !specs[i].Exclusive {
|
||||
t.Errorf("%s must be exclusive — the canonical set IS the exclusive namespaces", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// sdkPath is the one third-party import this package is allowed: the payload
|
||||
// shapes it translates to and from.
|
||||
const sdkPath = "code.gitea.io/sdk/gitea"
|
||||
|
||||
// The bridge translates values and nothing else: no network, no filesystem, no
|
||||
// clock, no configuration. Every one of those is a caller's to supply, which is
|
||||
// what lets this package be reasoned about and tested without a Gitea anywhere.
|
||||
//
|
||||
// THE RULE THIS TEST USED TO MAKE was stronger and is no longer true. The
|
||||
// shapes lived in internal/wire, which imported the standard library and
|
||||
// nothing else, so "the bridge cannot reach a transport" held by construction:
|
||||
// there was nothing in its dependency graph that could open a socket. The SDK's
|
||||
// types come with the SDK's client attached, so the graph now contains an HTTP
|
||||
// client whatever this package does with it — and a test that claimed otherwise
|
||||
// would be a test that lies.
|
||||
//
|
||||
// So it asserts the part that survives, which is also the part that catches a
|
||||
// real mistake: what THIS package reaches for. DIRECT imports, not the
|
||||
// dependency walk internal/issue does — the domain reaches os through
|
||||
// internal/project and that is the domain's business. A transport that grew a
|
||||
// helper here, or a lookup that quietly opened a config file, is what this
|
||||
// catches, and it still fails on `os`, on `net/http` and on internal/gitea.
|
||||
func TestTheBridgeTranslatesAndNothingElse(t *testing.T) {
|
||||
forbidden := map[string]string{
|
||||
"net/http": "an HTTP call belongs in the transport",
|
||||
"net": "an HTTP call belongs in the transport",
|
||||
"os": "a pure function reads no file and no environment",
|
||||
"os/exec": "nothing here shells out",
|
||||
"io/ioutil": "a pure function reads no file",
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea": "the transport imports this package, never the reverse",
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/config": "credentials and repositories are the transport's",
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/project": "nothing here resolves a path",
|
||||
}
|
||||
allowed := map[string]bool{
|
||||
sdkPath: true,
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue": true,
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire": true,
|
||||
}
|
||||
|
||||
for _, dep := range directImports(t) {
|
||||
if why, bad := forbidden[dep]; bad {
|
||||
t.Errorf("mapping imports %s — %s", dep, why)
|
||||
continue
|
||||
}
|
||||
// A standard-library import path has no dot in its first element,
|
||||
// because it has no domain name in front of it. Everything else has to
|
||||
// be named above: one third party is a decision, two is a habit.
|
||||
first, _, _ := strings.Cut(dep, "/")
|
||||
if strings.Contains(first, ".") && !allowed[dep] {
|
||||
t.Errorf("mapping imports %s — the only payload vocabulary here is %s", dep, sdkPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The clock is the caller's, and `time` alone can no longer say so: the SDK
|
||||
// hands over a time.Time, so this package imports the package to format one
|
||||
// back into the string an issue file holds. What it must never do is ASK what
|
||||
// time it is — a `synced:` stamped here would be stamped at translation rather
|
||||
// than at the write it describes, and two issues pushed in one run would carry
|
||||
// two different times for one run.
|
||||
//
|
||||
// The source, then, rather than the import graph: the difference between
|
||||
// formatting a timestamp and having a clock is not visible in `go list`.
|
||||
func TestTheBridgeHasNoClock(t *testing.T) {
|
||||
for _, path := range sourceFiles(t) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, banned := range []string{"time.Now(", "time.Since(", "time.Until("} {
|
||||
if strings.Contains(string(raw), banned) {
|
||||
t.Errorf("%s calls %s) — the clock belongs to the caller", filepath.Base(path), banned)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func directImports(t *testing.T) []string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("go", "list", "-f", `{{join .Imports "\n"}}`, ".").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("go list: %v", err)
|
||||
}
|
||||
return strings.Fields(string(out))
|
||||
}
|
||||
|
||||
// sourceFiles is this package's own .go files, tests excluded: a test may look
|
||||
// at a clock, and one of them does.
|
||||
func sourceFiles(t *testing.T) []string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("go", "list", "-f", `{{range .GoFiles}}{{.}}
|
||||
{{end}}`, ".").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("go list: %v", err)
|
||||
}
|
||||
var paths []string
|
||||
for _, name := range strings.Fields(string(out)) {
|
||||
paths = append(paths, name)
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
t.Fatal("go list named no source files — this test would pass on an empty package")
|
||||
}
|
||||
return paths
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Package mapping is 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.
|
||||
//
|
||||
// Direction of knowledge: this package imports the domain and the Gitea SDK,
|
||||
// and nothing imports it but the command layer. The domain never imports it,
|
||||
// and TestDomainDependsOnNothing over in internal/issue fails the moment it
|
||||
// does; the transport never imports it either, and
|
||||
// TestTransportDoesNotImportTheDomain over in internal/gitea says so. Both
|
||||
// sides speak the SDK's shapes, which is what lets the two meet without either
|
||||
// one reaching into the other.
|
||||
//
|
||||
// WHAT THAT COSTS, SAID OUT LOUD. The shapes used to be internal/wire's, a
|
||||
// package that imported the standard library and nothing else, so "the bridge
|
||||
// cannot reach the network" was a fact about the import graph. code.gitea.io/
|
||||
// sdk/gitea carries an HTTP client, so it is not any more. What is still true
|
||||
// is that nothing HERE does I/O, and layering_test.go asserts the version of
|
||||
// the rule that can still be checked: no os, no net/http, no transport, no
|
||||
// configuration, no clock, and no third party but the SDK.
|
||||
//
|
||||
// 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
|
||||
//
|
||||
// `depends:` is the authoritative graph and is always slugs. The body's
|
||||
// `## Depends on` section is human prose and 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 ONE thing this package adds to a body is the id marker, and it does so
|
||||
// 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.
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
// Origin is what this bridge writes into the domain's `origin:` field. The
|
||||
// domain records that an issue exists somewhere else; only this layer knows
|
||||
// where, and what the handle beside it means.
|
||||
const Origin = "gitea"
|
||||
|
||||
// The sync-owned metadata fields, named once. Every one of them is bookkeeping
|
||||
// about a tracker, which is why the domain carries them verbatim in
|
||||
// Issue.Extra and never reads them — the format's ownership table draws the
|
||||
// same line. A field spelled in three call sites is a field that gets renamed
|
||||
// in two.
|
||||
const (
|
||||
// GiteaKey is the handle in the tracker: owner/repo#42, a wire.Key written
|
||||
// out. Cross-repo on purpose — a number alone is only unique inside one
|
||||
// repository, and an issue that has been moved, or a store that has ever
|
||||
// pointed at two repositories, needs the answer to say which.
|
||||
GiteaKey = "gitea"
|
||||
// URLKey is the issue's web address, for a receipt a human can click.
|
||||
URLKey = "url"
|
||||
// SyncedKey is when this copy was last written from or to the tracker —
|
||||
// how old the working copy is, and nothing more.
|
||||
SyncedKey = "synced"
|
||||
// RemoteUpdatedKey is the tracker's own updated_at.
|
||||
RemoteUpdatedKey = "remote-updated"
|
||||
// CommentsKey is how many comments the tracker holds, so a reader knows a
|
||||
// thread exists without fetching it.
|
||||
CommentsKey = "comments"
|
||||
// BranchKey is Gitea's `ref` — the branch an issue is pinned to. Its value
|
||||
// is a git branch name and means exactly `ref`, which is what makes it a
|
||||
// sync field rather than a domain one.
|
||||
BranchKey = "branch"
|
||||
)
|
||||
|
||||
// RemoteKeyOf is the handle an issue carries, and whether it carries one at
|
||||
// all.
|
||||
//
|
||||
// ok is false for anything that is not a handle: an empty field on a
|
||||
// never-pushed issue, a line somebody hand-edited, a key written by a format
|
||||
// that predates this one — and a bare `#42`, which names a number without the
|
||||
// repository that makes it mean something. Callers act on ok rather than on a
|
||||
// zero number, because "#0" and "not synced" would otherwise be the same
|
||||
// answer.
|
||||
func RemoteKeyOf(i *issue.Issue) (key wire.Key, ok bool) {
|
||||
k, err := wire.ParseKey(i.Extra[GiteaKey])
|
||||
if err != nil || k.Repo.Zero() {
|
||||
return wire.Key{}, false
|
||||
}
|
||||
return k, true
|
||||
}
|
||||
|
||||
// NumberOf is the Gitea number of an already-synced issue; ok is false for one
|
||||
// that has never been pushed.
|
||||
func NumberOf(i *issue.Issue) (number int, ok bool) {
|
||||
k, ok := RemoteKeyOf(i)
|
||||
return k.Number, ok
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sdk "code.gitea.io/sdk/gitea"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
// The repository the fixtures are pushed to. A wire.Repo and not a string: the
|
||||
// handle in `gitea:` is a key, and a key is a repository and a number.
|
||||
var tea = wire.Repo{Owner: "claude-skills", Name: "tea"}
|
||||
|
||||
// when parses a tracker timestamp the way the SDK hands one over, so a fixture
|
||||
// can be written in the spelling Gitea actually sends.
|
||||
func when(t *testing.T, s string) time.Time {
|
||||
t.Helper()
|
||||
got, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
t.Fatalf("parsing %q: %v", s, err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// A file exactly as the store holds it: domain fields, then the sync fields the
|
||||
// domain carries and never reads.
|
||||
const stored = `---
|
||||
id: wire-sqlc-appclick
|
||||
state: open
|
||||
labels: [type/task, tech/sql]
|
||||
assignees: [naudachu]
|
||||
milestone: v0.2
|
||||
depends: [migrate-schema]
|
||||
origin: gitea
|
||||
branch: feat/wire-sqlc
|
||||
gitea: claude-skills/tea#42
|
||||
synced: 2026-08-09T18:40:00Z
|
||||
---
|
||||
# Wire sqlc into the appclick repo layer
|
||||
|
||||
## Summary
|
||||
Проводка sqlc в слой репозиториев.
|
||||
|
||||
## Spec
|
||||
none
|
||||
|
||||
## Depends on
|
||||
- #7 — нужна схема БД из этого issue
|
||||
|
||||
## Acceptance criteria
|
||||
- [x] сгенерирован код
|
||||
- [ ] тесты зелёные
|
||||
`
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
func roundTripOptions() RequestOptions {
|
||||
return RequestOptions{
|
||||
LabelIDs: map[string]int64{"type/task": 11, "tech/sql": 12},
|
||||
MilestoneID: ptr(int64(5)),
|
||||
IncludeState: true,
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of the package in one test: everything the format says is
|
||||
// preserved comes back, and the body comes back byte for byte.
|
||||
func TestRoundTripPreservesEveryFieldTheFormatKeeps(t *testing.T) {
|
||||
local := issue.FromText(stored, "wire-sqlc-appclick")
|
||||
req := ToCreate(local, roundTripOptions())
|
||||
|
||||
if req.Title != local.Title {
|
||||
t.Errorf("title = %q, want %q", req.Title, local.Title)
|
||||
}
|
||||
if req.Body == "" {
|
||||
t.Fatal("the request carries no body — a create would file an empty issue")
|
||||
}
|
||||
if got := IDInBody(req.Body); got != local.ID {
|
||||
t.Errorf("the request body does not claim the slug: %q", got)
|
||||
}
|
||||
if got := StripIDMarker(req.Body); got != strings.TrimSpace(local.Body) {
|
||||
t.Errorf("the prose was rewritten on the way up:\n--- got ---\n%s\n--- want ---\n%s",
|
||||
got, strings.TrimSpace(local.Body))
|
||||
}
|
||||
if want := []int64{11, 12}; !reflect.DeepEqual(req.Labels, want) {
|
||||
t.Errorf("labels = %v, want %v", req.Labels, want)
|
||||
}
|
||||
if want := []string{"naudachu"}; !reflect.DeepEqual(req.Assignees, want) {
|
||||
t.Errorf("assignees = %v, want %v", req.Assignees, want)
|
||||
}
|
||||
if req.Milestone != 5 {
|
||||
t.Errorf("milestone = %v, want 5", req.Milestone)
|
||||
}
|
||||
if req.Ref != "feat/wire-sqlc" {
|
||||
t.Errorf("ref = %q — branch: is a sync field and must ride along", req.Ref)
|
||||
}
|
||||
// An edit is the other half of the same translation, and the two must not
|
||||
// disagree about the issue they describe.
|
||||
edit := ToEdit(local, roundTripOptions())
|
||||
if edit.Body == nil || *edit.Body != req.Body || edit.Title != req.Title {
|
||||
t.Errorf("a create and an edit describe different issues: %q / %v", edit.Title, edit.Body)
|
||||
}
|
||||
if edit.State == nil || *edit.State != sdk.StateOpen {
|
||||
t.Errorf("state = %v", edit.State)
|
||||
}
|
||||
if edit.Ref == nil || *edit.Ref != "feat/wire-sqlc" {
|
||||
t.Errorf("ref = %v — branch: is a sync field and must ride along", edit.Ref)
|
||||
}
|
||||
|
||||
// What the tracker hands back is the body it was given, plus its own
|
||||
// bookkeeping.
|
||||
echo := &sdk.Issue{
|
||||
Index: 42,
|
||||
Title: req.Title,
|
||||
Body: req.Body,
|
||||
State: sdk.StateOpen,
|
||||
HTMLURL: "https://git.noodles.cam/claude-skills/tea/issues/42",
|
||||
Updated: when(t, "2026-08-09T18:24:01Z"),
|
||||
Ref: req.Ref,
|
||||
Comments: 3,
|
||||
Labels: []*sdk.Label{{Name: "type/task"}, {Name: "tech/sql"}},
|
||||
Assignees: []*sdk.User{{UserName: "naudachu"}},
|
||||
Milestone: &sdk.Milestone{ID: 5, Title: "v0.2"},
|
||||
}
|
||||
back, unresolved := FromPayload(echo, local.ID, tea, PayloadOptions{
|
||||
IDForNumber: map[int]string{7: "migrate-schema"},
|
||||
Synced: "2026-08-09T18:40:00Z",
|
||||
})
|
||||
if len(unresolved) != 0 {
|
||||
t.Errorf("unresolved = %v, want none", unresolved)
|
||||
}
|
||||
|
||||
if back.Body != strings.TrimSpace(local.Body) {
|
||||
t.Errorf("the body did not survive the trip:\n--- got ---\n%s\n--- want ---\n%s",
|
||||
back.Body, strings.TrimSpace(local.Body))
|
||||
}
|
||||
if strings.Contains(back.Body, "kettle:id") || strings.Contains(back.Body, "tea:id") {
|
||||
t.Error("the marker reached the local copy — it is transport bookkeeping and belongs nowhere near disk")
|
||||
}
|
||||
for _, c := range []struct{ name, got, want string }{
|
||||
{"id", back.ID, local.ID},
|
||||
{"title", back.Title, local.Title},
|
||||
{"state", back.State, local.State},
|
||||
{"milestone", back.Milestone, local.Milestone},
|
||||
{"origin", back.Origin, local.Origin},
|
||||
{"gitea", back.Extra[GiteaKey], "claude-skills/tea#42"},
|
||||
{"branch", back.Extra[BranchKey], "feat/wire-sqlc"},
|
||||
{"synced", back.Extra[SyncedKey], "2026-08-09T18:40:00Z"},
|
||||
{"url", back.Extra[URLKey], "https://git.noodles.cam/claude-skills/tea/issues/42"},
|
||||
{"remote-updated", back.Extra[RemoteUpdatedKey], "2026-08-09T18:24:01Z"},
|
||||
{"comments", back.Extra[CommentsKey], "3"},
|
||||
} {
|
||||
if c.got != c.want {
|
||||
t.Errorf("%s = %q, want %q", c.name, c.got, c.want)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(back.Labels, local.Labels) {
|
||||
t.Errorf("labels = %v, want %v", back.Labels, local.Labels)
|
||||
}
|
||||
if !reflect.DeepEqual(back.Assignees, local.Assignees) {
|
||||
t.Errorf("assignees = %v, want %v", back.Assignees, local.Assignees)
|
||||
}
|
||||
// `depends:` is slugs; the `#7` the prose names is translated at this edge
|
||||
// and the prose itself is left alone.
|
||||
if !reflect.DeepEqual(back.Depends, local.Depends) {
|
||||
t.Errorf("depends = %v, want %v", back.Depends, local.Depends)
|
||||
}
|
||||
if !strings.Contains(back.Body, "- #7 — нужна схема БД из этого issue") {
|
||||
t.Error("the ## Depends on prose was rewritten; it is the author's text and passes through unchanged")
|
||||
}
|
||||
|
||||
// And the strongest form of "no churn": pushing what came back sends
|
||||
// exactly what was sent the first time.
|
||||
if again := ToCreate(back, roundTripOptions()); !reflect.DeepEqual(again, req) {
|
||||
t.Errorf("a second push differs from the first:\n--- again ---\n%+v\n--- first ---\n%+v", again, req)
|
||||
}
|
||||
}
|
||||
|
||||
// The other shape an issue comes in: nothing scheduled, nobody assigned.
|
||||
//
|
||||
// On an EDIT that is a statement and not an absence, which is why this asserts
|
||||
// on the bytes. Gitea reads a null as "no opinion" and a value as "make it
|
||||
// this", and the SDK's edit body sends every key — so `"assignees":null` is the
|
||||
// spelling that leaves the tracker's assignees alone, and `"assignees":[]`
|
||||
// would clear them.
|
||||
func TestNoMilestoneAndNoAssignees(t *testing.T) {
|
||||
local := issue.FromText("---\nid: lone\nstate: open\nlabels: [type/task]\n"+
|
||||
"assignees: []\nmilestone: none\ndepends: []\norigin: local\n---\n"+
|
||||
"# A lone issue\n\n## Summary\nОдин.\n", "lone")
|
||||
|
||||
opt := RequestOptions{LabelIDs: map[string]int64{"type/task": 11}}
|
||||
edit := ToEdit(local, opt)
|
||||
if edit.Assignees != nil {
|
||||
t.Errorf("assignees = %v — an empty list would clear whoever the tracker has", edit.Assignees)
|
||||
}
|
||||
if edit.Milestone != nil {
|
||||
t.Errorf("milestone = %v — a missing milestone is no opinion, not a detach", edit.Milestone)
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(edit)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
body := string(raw)
|
||||
for _, key := range []string{`"assignees":null`, `"milestone":null`, `"state":null`, `"ref":null`} {
|
||||
if !strings.Contains(body, key) {
|
||||
t.Errorf("%s is not in the edit body; anything else there overwrites what the tracker holds: %s", key, body)
|
||||
}
|
||||
}
|
||||
// The title is the one field of an edit that is not a pointer. Gitea reads
|
||||
// an empty one as "leave it alone" too, but this issue has a title and it
|
||||
// has to go up.
|
||||
if !strings.Contains(body, `"title":"A lone issue"`) {
|
||||
t.Errorf("the title is missing from %s", body)
|
||||
}
|
||||
|
||||
// A create is the opposite: there is nothing on the tracker's side to
|
||||
// overwrite, so the resolved label ids are sent as they stand.
|
||||
created, err := json.Marshal(ToCreate(local, opt))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(created), `"labels":[11]`) {
|
||||
t.Errorf("labels missing from %s", created)
|
||||
}
|
||||
// A resolved label set that matched nothing is still an answer, and it is
|
||||
// the same answer a PUT sends at an issue that already exists.
|
||||
if got := LabelIDsFor(local, RequestOptions{LabelIDs: map[string]int64{}}); got == nil || len(got) != 0 {
|
||||
t.Errorf("a resolved label set that matched nothing must be an empty list, got %v", got)
|
||||
}
|
||||
if got := LabelIDsFor(local, RequestOptions{}); got != nil {
|
||||
t.Errorf("a caller that resolved no ids has no opinion about labels, got %v", got)
|
||||
}
|
||||
|
||||
back, unresolved := FromPayload(&sdk.Issue{
|
||||
Index: 9,
|
||||
Title: "A lone issue",
|
||||
Body: WithIDMarker("## Summary\nОдин.", "lone"),
|
||||
State: sdk.StateOpen,
|
||||
HTMLURL: "https://git.noodles.cam/claude-skills/tea/issues/9",
|
||||
}, "lone", tea, PayloadOptions{Synced: "2026-08-11T10:00:00Z"})
|
||||
|
||||
if len(unresolved) != 0 {
|
||||
t.Errorf("unresolved = %v", unresolved)
|
||||
}
|
||||
if back.Milestone != "" || back.Assignees != nil || back.Labels != nil {
|
||||
t.Errorf("empty came back as something: milestone=%q assignees=%v labels=%v",
|
||||
back.Milestone, back.Assignees, back.Labels)
|
||||
}
|
||||
if _, ok := back.Extra[BranchKey]; ok {
|
||||
t.Error("an absent ref must not write an empty branch: field")
|
||||
}
|
||||
if _, ok := back.Extra[CommentsKey]; ok {
|
||||
t.Error("zero comments is not a fact worth a line in the file")
|
||||
}
|
||||
if !strings.Contains(back.Text(), "milestone: none") {
|
||||
t.Error("an empty milestone must render back as none")
|
||||
}
|
||||
}
|
||||
|
||||
// A dependency whose target is not in the store yet is reported, never invented:
|
||||
// a made-up slug is an edge to a file that does not exist.
|
||||
func TestUnresolvedNumbersAreReportedNotInvented(t *testing.T) {
|
||||
body := "## Summary\nx\n\n## Depends on\n- #7\n- #8\n"
|
||||
back, unresolved := FromPayload(&sdk.Issue{Index: 1, Title: "T", Body: body},
|
||||
"here", wire.Repo{Owner: "o", Name: "r"}, PayloadOptions{IDForNumber: map[int]string{7: "known"}})
|
||||
|
||||
if want := []string{"known"}; !reflect.DeepEqual(back.Depends, want) {
|
||||
t.Errorf("depends = %v, want %v", back.Depends, want)
|
||||
}
|
||||
if want := []int{8}; !reflect.DeepEqual(unresolved, want) {
|
||||
t.Errorf("unresolved = %v, want %v", unresolved, want)
|
||||
}
|
||||
if !strings.Contains(back.Body, "- #8") {
|
||||
t.Error("the body still names it, which is why dropping it from depends: loses nothing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtraNumbersJoinTheOnesTheBodyNames(t *testing.T) {
|
||||
body := "## Summary\nx\n\n## Depends on\n- #7\n"
|
||||
back, _ := FromPayload(&sdk.Issue{Index: 1, Title: "T", Body: body}, "here", wire.Repo{Owner: "o", Name: "r"},
|
||||
PayloadOptions{
|
||||
IDForNumber: map[int]string{7: "seven", 9: "nine"},
|
||||
ExtraNumbers: []int{7, 9},
|
||||
})
|
||||
if want := []string{"seven", "nine"}; !reflect.DeepEqual(back.Depends, want) {
|
||||
t.Errorf("depends = %v, want %v", back.Depends, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The one exception to "a pull overwrites the body", and the narrowest one that
|
||||
// works: a tick only ever travels one way, so the two sides are a set union.
|
||||
func TestMergeCheckboxState(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
remote, local string
|
||||
want string
|
||||
wantUnchangedRef bool
|
||||
}{
|
||||
{
|
||||
name: "a local tick survives the overwrite",
|
||||
remote: "- [ ] один\n- [ ] два\n",
|
||||
local: "- [x] два\n",
|
||||
want: "- [ ] один\n- [x] два\n",
|
||||
},
|
||||
{
|
||||
name: "rewrapping an item does not cost it its tick",
|
||||
remote: "- [ ] очень длинный\n пункт\n",
|
||||
local: "- [x] очень длинный пункт\n",
|
||||
want: "- [x] очень длинный\n пункт\n",
|
||||
},
|
||||
{
|
||||
name: "the same text twice is the same item to whoever reads it",
|
||||
remote: "- [ ] дубль\n- [ ] дубль\n",
|
||||
local: "- [ ] дубль\n- [x] дубль\n",
|
||||
want: "- [x] дубль\n- [x] дубль\n",
|
||||
},
|
||||
{
|
||||
name: "a first pull has nothing to merge",
|
||||
remote: "- [ ] один\n",
|
||||
local: "",
|
||||
want: "- [ ] один\n",
|
||||
},
|
||||
{
|
||||
name: "unticking is not monotone, so it does not travel",
|
||||
remote: "- [x] один\n",
|
||||
local: "- [ ] один\n",
|
||||
want: "- [x] один\n",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := MergeCheckboxState(c.remote, c.local); got != c.want {
|
||||
t.Errorf("got:\n%q\nwant:\n%q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// What `gitea:` holds is a key, and it round-trips through the one parser.
|
||||
// Anything that is not a key reads as "not synced" — never as issue #0, and
|
||||
// never as the issue -3 a bare strconv.Atoi would have handed back.
|
||||
func TestRemoteKeyRoundTrip(t *testing.T) {
|
||||
cases := []struct {
|
||||
key string
|
||||
repo string
|
||||
number int
|
||||
ok bool
|
||||
}{
|
||||
{"claude-skills/tea#42", "claude-skills/tea", 42, true},
|
||||
{"o/r#1", "o/r", 1, true},
|
||||
// Never pushed, hand-edited, or written by a format that predates this
|
||||
// one — all the same answer, and none of them is issue #0. `#42` is in
|
||||
// the list because a handle without a repository addresses nothing.
|
||||
{"", "", 0, false},
|
||||
{"claude-skills/tea", "", 0, false},
|
||||
{"#42", "", 0, false},
|
||||
{"o/r#", "", 0, false},
|
||||
{"o/r#-3", "", 0, false},
|
||||
{"o/r#4x", "", 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := RemoteKeyOf(&issue.Issue{Extra: map[string]string{GiteaKey: c.key}})
|
||||
if got.Repo.String() != c.repo || got.Number != c.number || ok != c.ok {
|
||||
t.Errorf("RemoteKeyOf(%q) = (%v, %v), want (%q, %d, %v)",
|
||||
c.key, got, ok, c.repo, c.number, c.ok)
|
||||
}
|
||||
if c.ok && got.String() != c.key {
|
||||
t.Errorf("the key formatted back as %q, want %q", got, c.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumberOf(t *testing.T) {
|
||||
synced := &issue.Issue{Extra: map[string]string{GiteaKey: "o/r#42"}}
|
||||
if n, ok := NumberOf(synced); n != 42 || !ok {
|
||||
t.Errorf("NumberOf = (%d, %v), want (42, true)", n, ok)
|
||||
}
|
||||
if _, ok := NumberOf(&issue.Issue{}); ok {
|
||||
t.Error("an issue that has never been pushed has no number")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRemoteStampsTheSyncFields(t *testing.T) {
|
||||
local := &issue.Issue{ID: "x", Origin: issue.Local}
|
||||
ApplyRemote(local, &sdk.Issue{
|
||||
Index: 42,
|
||||
HTMLURL: "https://git.noodles.cam/o/r/issues/42",
|
||||
Updated: when(t, "2026-08-09T18:24:01Z"),
|
||||
}, wire.Repo{Owner: "o", Name: "r"}, "2026-08-11T10:00:00Z")
|
||||
|
||||
if local.IsLocal() {
|
||||
t.Error("origin must move: the work exists somewhere else now")
|
||||
}
|
||||
if local.Extra[GiteaKey] != "o/r#42" || local.Extra[URLKey] == "" ||
|
||||
local.Extra[SyncedKey] != "2026-08-11T10:00:00Z" ||
|
||||
local.Extra[RemoteUpdatedKey] != "2026-08-09T18:24:01Z" {
|
||||
t.Errorf("extra = %v", local.Extra)
|
||||
}
|
||||
}
|
||||
|
||||
// A thread is flattened for a reader, so it may be as lossy as a reader needs —
|
||||
// but a payload that carries no date and no author still renders, because a
|
||||
// comment that is there is worth showing whatever the tracker left out of it.
|
||||
func TestRenderComments(t *testing.T) {
|
||||
got := RenderComments([]*sdk.Comment{
|
||||
{ID: 1, Poster: &sdk.User{UserName: "naudachu"}, Created: when(t, "2026-08-09T18:24:01Z"), Body: " привет "},
|
||||
{ID: 2, Poster: nil, Body: ""},
|
||||
})
|
||||
want := "## comment 1 — naudachu — 2026-08-09\n\nпривет\n\n" +
|
||||
"## comment 2 — — \n\n(empty)\n"
|
||||
if got != want {
|
||||
t.Errorf("got:\n%q\nwant:\n%q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
)
|
||||
|
||||
// The id marker: the slug, kept tracker-side.
|
||||
//
|
||||
// Push deletes the local file once the tracker has confirmed the write, so the
|
||||
// slug — the issue's ONLY identity in the domain — cannot live only on this
|
||||
// machine any more. It rides up in the body as an HTML comment:
|
||||
//
|
||||
// <!-- kettle:id wire-sqlc-appclick -->
|
||||
//
|
||||
// Why the body and not a local number -> slug ledger: the ledger is a local
|
||||
// file, and "the local copy is not the record" is the whole point of deleting
|
||||
// one. A marker in the body survives a rename in the web UI, a lost ledger, a
|
||||
// fresh clone, and a second machine — none of which the ledger does. Why an
|
||||
// HTML comment: Gitea renders markdown, so it is invisible to a human reader,
|
||||
// and it comes back verbatim on every API read.
|
||||
//
|
||||
// WHERE: the first line of the tracker-side body, followed by one blank line.
|
||||
// First because it is the one position that does not depend on what sections
|
||||
// the issue happens to have, and because a human who does look at the raw
|
||||
// markdown finds it before the prose rather than buried in it.
|
||||
//
|
||||
// WHAT THE LOCAL FILE SEES: nothing. FromPayload strips every marker before the
|
||||
// body reaches the store, so `.kettle/issues/<id>.md` holds exactly what the
|
||||
// author wrote — checkbox line numbers, `kettle check`, and diffs are all
|
||||
// unaffected, and the slug is already the file's name, so a copy of it in the
|
||||
// body would be duplicated state.
|
||||
//
|
||||
// WHY IT CANNOT ACCUMULATE: the two operations are strip-all and
|
||||
// strip-all-then-prepend-one. WithIDMarker never appends to what is there, and
|
||||
// StripIDMarker removes EVERY marker line, not the first. So a body that
|
||||
// somehow gained two (a hand-edit in the web UI, a copy-paste) is cleaned on
|
||||
// the next pull and goes back up with exactly one. There is no code path that
|
||||
// adds a marker to a body that has not just been stripped.
|
||||
//
|
||||
// WHY TWO SPELLINGS ARE READ AND ONE IS WRITTEN: this tool was called `tea`
|
||||
// and wrote `<!-- tea:id … -->`. Issues pushed under that name are sitting in
|
||||
// the tracker right now, and their local files are gone — the marker is the
|
||||
// only copy of their slug there is. A rename that stopped reading the old
|
||||
// spelling would orphan every one of them: the pull would fall back to the
|
||||
// title, allocate a fresh slug, and every `depends:` pointing at the old one
|
||||
// would dangle. So the writer moved and the reader did not.
|
||||
var markerRe = regexp.MustCompile(
|
||||
`^[ \t]*<!--[ \t]*(?:kettle|tea):id[ \t]+(\S+)[ \t]*-->[ \t]*$`)
|
||||
|
||||
// IDMarker is the marker line for a slug. One place formats it, one regex
|
||||
// reads it — and what that regex accepts is deliberately wider than this.
|
||||
func IDMarker(id string) string { return "<!-- kettle:id " + id + " -->" }
|
||||
|
||||
// IDInBody is the slug a tracker-side body claims, or "" when it claims none.
|
||||
//
|
||||
// The FIRST valid marker wins; a second one is ignored here and removed by
|
||||
// StripIDMarker on the way in. The captured text must be a slug by the domain's
|
||||
// own rule — a marker holding anything else is not a slug and is treated as if
|
||||
// it were not there, so a mangled comment falls back to the title instead of
|
||||
// naming a file after garbage.
|
||||
func IDInBody(body string) string {
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
if m := markerRe.FindStringSubmatch(strings.TrimSuffix(line, "\r")); m != nil {
|
||||
if issue.IsSlug(m[1]) {
|
||||
return m[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// StripIDMarker is body with every marker line removed, in either spelling.
|
||||
// Idempotent.
|
||||
//
|
||||
// A body that carries no marker is returned byte for byte — the common case (an
|
||||
// issue filed in the web UI) costs nothing and is not reformatted. When a marker
|
||||
// is removed from the top, the blank line it was written with goes with it, so
|
||||
// the round trip is exact: StripIDMarker(WithIDMarker(b, id)) == b.
|
||||
func StripIDMarker(body string) string {
|
||||
lines := strings.Split(body, "\n")
|
||||
found := false
|
||||
for _, line := range lines {
|
||||
if markerRe.MatchString(strings.TrimSuffix(line, "\r")) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return body
|
||||
}
|
||||
kept := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if !markerRe.MatchString(strings.TrimSuffix(line, "\r")) {
|
||||
kept = append(kept, line)
|
||||
}
|
||||
}
|
||||
return strings.TrimLeft(strings.Join(kept, "\n"), "\n")
|
||||
}
|
||||
|
||||
// WithIDMarker is body with exactly one marker, as its first line.
|
||||
//
|
||||
// Strip-then-prepend, always — that is the guarantee that a body can never end
|
||||
// up with two, however many it arrived with, and it is what quietly rewrites a
|
||||
// `tea:id` marker into the current spelling the next time the issue is pushed.
|
||||
func WithIDMarker(body, id string) string {
|
||||
return IDMarker(id) + "\n\n" + StripIDMarker(body)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sdk "code.gitea.io/sdk/gitea"
|
||||
)
|
||||
|
||||
func TestIDInBodyReadsBothSpellings(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{"the spelling this tool writes", "<!-- kettle:id wire-sqlc -->\n\n## Summary\nx", "wire-sqlc"},
|
||||
// The whole reason the reader is wider than the writer.
|
||||
{"the spelling already in the tracker", "<!-- tea:id wire-sqlc -->\n\n## Summary\nx", "wire-sqlc"},
|
||||
{"indented and loosely spaced", " <!-- tea:id wire-sqlc --> \n", "wire-sqlc"},
|
||||
{"no marker at all", "## Summary\nx", ""},
|
||||
// A mangled comment falls back to the title rather than naming a file
|
||||
// after garbage.
|
||||
{"not a slug", "<!-- kettle:id Wire_SQLC -->\n", ""},
|
||||
{"not on a line of its own", "text <!-- kettle:id wire-sqlc -->\n", ""},
|
||||
{"the first valid marker wins", "<!-- tea:id first-one -->\n<!-- kettle:id second-one -->\n", "first-one"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := IDInBody(c.body); got != c.want {
|
||||
t.Errorf("IDInBody = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An issue pushed under the old name is sitting in the tracker with its local
|
||||
// file long since deleted — the marker is the only copy of its slug there is.
|
||||
// It has to keep resolving, and it has to come back up in the new spelling.
|
||||
func TestAnIssuePushedByTheOldNameStillResolves(t *testing.T) {
|
||||
const inTracker = "<!-- tea:id wire-sqlc-appclick -->\n\n## Summary\nПроводка sqlc.\n"
|
||||
|
||||
id := IDInBody(inTracker)
|
||||
if id != "wire-sqlc-appclick" {
|
||||
t.Fatalf("IDInBody = %q — every issue pushed under the old name would be orphaned", id)
|
||||
}
|
||||
|
||||
iss, _ := FromPayload(&sdk.Issue{Index: 42, Title: "Wire sqlc", Body: inTracker},
|
||||
id, tea, PayloadOptions{})
|
||||
if strings.Contains(iss.Body, "tea:id") {
|
||||
t.Errorf("the old marker reached the local copy: %q", iss.Body)
|
||||
}
|
||||
if iss.Body != "## Summary\nПроводка sqlc." {
|
||||
t.Errorf("body = %q", iss.Body)
|
||||
}
|
||||
|
||||
// And the next push rewrites it into the current spelling, without ever
|
||||
// having two.
|
||||
up := ToCreate(iss, RequestOptions{}).Body
|
||||
if !strings.HasPrefix(up, "<!-- kettle:id wire-sqlc-appclick -->\n\n") {
|
||||
t.Errorf("the marker was not rewritten: %q", up)
|
||||
}
|
||||
if strings.Contains(up, "tea:id") {
|
||||
t.Errorf("both spellings went up: %q", up)
|
||||
}
|
||||
if n := strings.Count(up, ":id "); n != 1 {
|
||||
t.Errorf("%d markers in the body, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkersCannotAccumulate(t *testing.T) {
|
||||
body := "## Summary\nx"
|
||||
// Whatever it arrived with — one, the other, several — it goes up with one.
|
||||
messy := "<!-- tea:id old-one -->\n\n<!-- kettle:id other-one -->\n\n" + body
|
||||
got := WithIDMarker(messy, "real-one")
|
||||
|
||||
if want := IDMarker("real-one") + "\n\n" + body; got != want {
|
||||
t.Errorf("got:\n%q\nwant:\n%q", got, want)
|
||||
}
|
||||
if got := WithIDMarker(got, "real-one"); strings.Count(got, "<!--") != 1 {
|
||||
t.Errorf("a second pass added one: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripIsTheExactInverseOfWith(t *testing.T) {
|
||||
bodies := []string{
|
||||
"## Summary\nx",
|
||||
"## Summary\nx\n\n## Acceptance criteria\n- [ ] один\n",
|
||||
"",
|
||||
}
|
||||
for _, b := range bodies {
|
||||
if got := StripIDMarker(WithIDMarker(b, "an-id")); got != b {
|
||||
t.Errorf("StripIDMarker(WithIDMarker(%q)) = %q", b, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The common case — an issue filed in the web UI — costs nothing and is not
|
||||
// reformatted.
|
||||
func TestStripLeavesAnUnmarkedBodyByteForByte(t *testing.T) {
|
||||
body := "\n\n## Summary\nx\n\n\n"
|
||||
if got := StripIDMarker(body); got != body {
|
||||
t.Errorf("StripIDMarker rewrote a body with no marker in it: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
sdk "code.gitea.io/sdk/gitea"
|
||||
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||
)
|
||||
|
||||
// domain -> Gitea.
|
||||
|
||||
// RequestOptions are what the transport resolved before the call: label names
|
||||
// are ids by then, and a milestone title is a number.
|
||||
//
|
||||
// Both are lookups against one repository, which is why they cannot be done
|
||||
// here — this package never learns which repository it is translating for
|
||||
// beyond the name it is handed.
|
||||
type RequestOptions struct {
|
||||
// LabelIDs is name -> id for the labels this repository holds. A label the
|
||||
// repository does not have is silently left off rather than failing the
|
||||
// write — an unknown label is a bootstrap that has not run, not a reason to
|
||||
// lose the issue.
|
||||
//
|
||||
// It is read by ToCreate and ignored by ToEdit, because Gitea's edit
|
||||
// endpoint carries no labels at all. Changing them on an issue that exists
|
||||
// is a PUT of its own; push makes it.
|
||||
LabelIDs map[string]int64
|
||||
// MilestoneID is the resolved milestone. nil leaves the key out, which on
|
||||
// an edit means "leave whatever is attached alone".
|
||||
MilestoneID *int64
|
||||
// IncludeState sends `state` on an edit. An edit that means to open or
|
||||
// close says so; a create takes the tracker's default.
|
||||
IncludeState bool
|
||||
}
|
||||
|
||||
// ToCreate is the body of a create.
|
||||
//
|
||||
// The prose is sent verbatim — see the package doc on why slugs in
|
||||
// `## Depends on` are not rewritten to `#N`. The one addition is the id marker,
|
||||
// prepended (never appended) so the tracker remembers the slug after push has
|
||||
// deleted the local file. FromPayload takes it straight back off, so the body
|
||||
// still round-trips byte for byte.
|
||||
//
|
||||
// Every field of a create is a value and every one of them is sent, which is
|
||||
// safe in a way an edit is not: there is nothing on the tracker's side yet for
|
||||
// an empty field to overwrite.
|
||||
func ToCreate(i *issue.Issue, opt RequestOptions) sdk.CreateIssueOption {
|
||||
out := sdk.CreateIssueOption{
|
||||
Title: i.Title,
|
||||
Body: WithIDMarker(strings.TrimSpace(i.Body), i.ID),
|
||||
// Copied, so the request body and the issue it came from cannot alias
|
||||
// one slice: whatever a caller does to either afterwards is not a
|
||||
// change to what was sent.
|
||||
Assignees: slices.Clone(i.Assignees),
|
||||
Labels: LabelIDsFor(i, opt),
|
||||
Ref: strings.TrimSpace(i.Extra[BranchKey]),
|
||||
}
|
||||
if opt.MilestoneID != nil {
|
||||
out.Milestone = *opt.MilestoneID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ToEdit is the body of an edit.
|
||||
//
|
||||
// EVERY FIELD IS A POINTER AND MOST OF THEM ARE LEFT NIL, because Gitea reads
|
||||
// an absent value as "no opinion" and a present one as "make it this", and the
|
||||
// difference is not academic: an empty `ref` CLEARS the branch an issue is
|
||||
// pinned to, and an empty `assignees` clears its assignees. A caller meaning to
|
||||
// change only the state would do both by accident with plain zero values.
|
||||
//
|
||||
// The exception is the title, which the SDK spells as a plain string and always
|
||||
// sends. Gitea reads an EMPTY title as "leave it alone" — it is the one field
|
||||
// of an edit where the zero value already means no opinion — so a caller that
|
||||
// wants to rename says so by filling it, and `kettle close` stays a change of
|
||||
// state and nothing else.
|
||||
func ToEdit(i *issue.Issue, opt RequestOptions) sdk.EditIssueOption {
|
||||
out := sdk.EditIssueOption{
|
||||
Title: i.Title,
|
||||
Body: sdk.OptionalString(WithIDMarker(strings.TrimSpace(i.Body), i.ID)),
|
||||
Milestone: opt.MilestoneID,
|
||||
}
|
||||
if len(i.Assignees) > 0 {
|
||||
out.Assignees = slices.Clone(i.Assignees)
|
||||
}
|
||||
if opt.IncludeState {
|
||||
state := sdk.StateType(i.State)
|
||||
out.State = &state
|
||||
}
|
||||
// An empty `branch:` is "no opinion", not "no branch": sending ref="" would
|
||||
// clear whatever is set on the Gitea side, so the key is left out instead.
|
||||
if branch := strings.TrimSpace(i.Extra[BranchKey]); branch != "" {
|
||||
out.Ref = sdk.OptionalString(branch)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// LabelIDsFor is the ids this issue's labels resolve to, in the issue's own
|
||||
// order — what a create sends, and what a push PUTs at an issue whose labels
|
||||
// have to be made to match afterwards.
|
||||
//
|
||||
// One answer to one question, exported so those two cannot derive it
|
||||
// differently: an edit carries no labels at all, so every 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 for a caller that resolved no ids, and an empty list — never nil — for
|
||||
// one that resolved some and matched none. The difference is a statement to the
|
||||
// tracker: `[]` clears every label on the issue.
|
||||
func LabelIDsFor(i *issue.Issue, opt RequestOptions) []int64 {
|
||||
if opt.LabelIDs == nil {
|
||||
return nil
|
||||
}
|
||||
ids := []int64{}
|
||||
for _, name := range i.Labels {
|
||||
if id, ok := opt.LabelIDs[name]; ok {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// ApplyRemote stamps the sync-owned fields onto an issue after a successful
|
||||
// write. Mutates and returns it; `origin` is the one domain field this touches,
|
||||
// and it touches it because "this work exists somewhere else now" is exactly
|
||||
// what has just become true.
|
||||
func ApplyRemote(i *issue.Issue, p *sdk.Issue, repo wire.Repo, synced string) *issue.Issue {
|
||||
if i.Extra == nil {
|
||||
i.Extra = map[string]string{}
|
||||
}
|
||||
i.Origin = Origin
|
||||
i.Extra[GiteaKey] = wire.Key{Repo: repo, Number: int(p.Index)}.String()
|
||||
i.Extra[URLKey] = p.HTMLURL
|
||||
i.Extra[SyncedKey] = synced
|
||||
if stamp := Stamp(p.Updated); stamp != "" {
|
||||
i.Extra[RemoteUpdatedKey] = stamp
|
||||
}
|
||||
return i
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
# AGENTS.md — internal/mirror
|
||||
|
||||
**One filesystem invariant, in every directory of a tree:**
|
||||
|
||||
```
|
||||
AGENTS.md is the real file; CLAUDE.md is a symlink pointing at it.
|
||||
```
|
||||
|
||||
**Imports the standard library and nothing else** — no issue, no tracker, no
|
||||
configuration, no login. That is not a stylistic preference: this walks whatever
|
||||
directory it is pointed at, on any machine, and a package that reached for a
|
||||
project's configuration could not be run outside a project. Two tests hold it,
|
||||
see [`internal/AGENTS.md`](../AGENTS.md).
|
||||
|
||||
| file | what is in it |
|
||||
|---|---|
|
||||
| `mirror.go` | `Sync`, `Check`, `Result`, and `fixDir` — the seven branches |
|
||||
| `mirror_test.go` | one case per branch, plus the walk, the skip list and idempotence |
|
||||
| `layering_test.go` | the two tests that keep this package at the bottom |
|
||||
|
||||
## Why the invariant
|
||||
|
||||
Two agent harnesses read two different filenames for the same document. A
|
||||
repository that keeps both as real files keeps **two documents**, and they drift —
|
||||
silently, until somebody reads the stale one and believes it. One real file with a
|
||||
link beside it is the only arrangement where that cannot happen.
|
||||
|
||||
`AGENTS.md` is the real file rather than `CLAUDE.md` because the convention is not
|
||||
one vendor's: a repository that names its documents after a single tool has picked
|
||||
a side it did not need to pick.
|
||||
|
||||
## The seven branches
|
||||
|
||||
Every one of them is either lossless or a refusal. **Nothing here deletes content.**
|
||||
|
||||
| starting state | what happens |
|
||||
|---|---|
|
||||
| `AGENTS.md` real, no `CLAUDE.md` | create the symlink |
|
||||
| `CLAUDE.md` real, no `AGENTS.md` | rename to `AGENTS.md`, link back |
|
||||
| `CLAUDE.md` symlink → `AGENTS.md` | canonical; nothing to do |
|
||||
| `CLAUDE.md` symlink elsewhere | re-point it |
|
||||
| `AGENTS.md` symlink → real `CLAUDE.md` | reversed layout; swap it round |
|
||||
| both real, identical content | replace `CLAUDE.md` with the symlink |
|
||||
| both real, **different** content | **refuse**, and name the directory |
|
||||
|
||||
The last row is the reason the other six can be automatic. One of those two files
|
||||
is somebody's writing and no rule here knows which, so a merge is not attempted and
|
||||
not offered — the conflict is reported and the directory is left exactly as it was.
|
||||
|
||||
Two smaller refusals sit beside it, both about a symlink with no target: a broken
|
||||
`AGENTS.md` with no `CLAUDE.md` beside it, and a `CLAUDE.md` pointing at something
|
||||
that is gone. Neither is repairable without inventing content.
|
||||
|
||||
**The link target is relative.** `CLAUDE.md -> AGENTS.md`, never an absolute path:
|
||||
a tree that is moved, copied, cloned or mounted somewhere else keeps working, and
|
||||
an absolute link would point at wherever the repair happened to run.
|
||||
|
||||
## Two properties the tests hold
|
||||
|
||||
**A repair that fails halfway reports nothing.** An unwritable directory, a race
|
||||
with an editor — the fix is abandoned and no line is added. Claiming a repair that
|
||||
did not happen is worse than silence: the next run finds the same state, and the
|
||||
operator has now been told twice that it was handled.
|
||||
|
||||
**Sync converges in one pass.** Every case in the table runs `Sync` twice and fails
|
||||
if the second run still has work. This matters more here than it looks: the command
|
||||
that wraps this package runs on `PreToolUse(Bash)`, so a state that reported itself
|
||||
fixed without converging would re-report on every Bash call, forever.
|
||||
|
||||
`Check` is the same walk with the writes turned off — one code path, not a second
|
||||
implementation that might disagree — so a check that says nothing is a promise
|
||||
about the run that follows it.
|
||||
|
||||
## What is skipped
|
||||
|
||||
`node_modules`, `__pycache__`, `venv`, `vendor`, and every dot-directory.
|
||||
|
||||
Somebody else's tree is somebody else's business: a vendored dependency's
|
||||
`AGENTS.md` rewritten here is a diff nobody asked for. `.git` gets the same
|
||||
treatment for a second reason — it is not a place to be creating symlinks.
|
||||
|
||||
## What does not belong here
|
||||
|
||||
The JSON a `PreToolUse` hook reads and writes, the decision to run at all, and the
|
||||
exit status. Those are [`cmd`](../cmd/AGENTS.md)'s, in `mirror.go` there; this
|
||||
package takes a directory and returns a `Result`. That split is what lets the
|
||||
repair be tested against a temp directory without a hook payload anywhere near it.
|
||||
|
||||
## Keeping this file true
|
||||
|
||||
- **Scope:** `mirror.go` and its tests — the invariant, the seven branches, the
|
||||
skip list, and the two properties above.
|
||||
- **Update it when** a branch is added or its outcome changes (the table is the
|
||||
contract), a directory is added to or dropped from the skip list, or the link
|
||||
stops being relative.
|
||||
- **Do not** document the command, the hook payload or the exit codes here.
|
||||
[`internal/cmd`](../cmd/AGENTS.md) owns those.
|
||||
@@ -0,0 +1,57 @@
|
||||
package mirror
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// This package repairs a filesystem layout and knows nothing else. It has no
|
||||
// business with an issue, a tracker, a login or a configuration file, and the
|
||||
// moment it imports one of them it stops being a thing that can be run over any
|
||||
// directory on the machine.
|
||||
//
|
||||
// The dependency walk, so a helper pulled in three packages deep is caught as
|
||||
// the same violation as one written at the top of a file.
|
||||
func TestMirrorDependsOnNothing(t *testing.T) {
|
||||
out, err := exec.Command("go", "list", "-deps", ".").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("go list: %v", err)
|
||||
}
|
||||
for _, dep := range strings.Fields(string(out)) {
|
||||
if dep == "git.noodles.cam/claude-skills/marketplace/cli/internal/mirror" {
|
||||
continue
|
||||
}
|
||||
// A standard-library import path has no dot in its first element,
|
||||
// because it has no domain name in front of it.
|
||||
if first, _, _ := strings.Cut(dep, "/"); strings.Contains(first, ".") {
|
||||
t.Errorf("mirror imports %s — this walks a directory, and nothing else belongs here", dep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The other half: os and net/http are standard library, so "no third-party
|
||||
// imports" would not catch a request or a shell-out written by hand here. os
|
||||
// itself is the point of this package, so it is the one that is allowed.
|
||||
//
|
||||
// DIRECT imports, not the dependency walk — fmt reaches os on its own, and the
|
||||
// question this asks is what THIS package reaches for.
|
||||
func TestMirrorNeitherDialsNorShellsOut(t *testing.T) {
|
||||
forbidden := map[string]string{
|
||||
"net/http": "a documentation convention is not fetched from anywhere",
|
||||
"net": "a documentation convention is not fetched from anywhere",
|
||||
"os/exec": "the repair is syscalls, not a shell — that is the whole reason it left bash",
|
||||
"encoding/json": "the hook's JSON is the command layer's business, not this one's",
|
||||
"time": "nothing here has a clock in it",
|
||||
}
|
||||
|
||||
out, err := exec.Command("go", "list", "-f", `{{join .Imports "\n"}}`, ".").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("go list: %v", err)
|
||||
}
|
||||
for _, dep := range strings.Fields(string(out)) {
|
||||
if why, bad := forbidden[dep]; bad {
|
||||
t.Errorf("mirror imports %s — %s", dep, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// Package mirror enforces one filesystem invariant, in every directory of a
|
||||
// tree:
|
||||
//
|
||||
// AGENTS.md is the real file; CLAUDE.md is a symlink pointing at it.
|
||||
//
|
||||
// Two agent harnesses read two different filenames for the same document, and a
|
||||
// repository that keeps both as real files keeps two documents — which drift,
|
||||
// silently, until somebody reads the stale one and believes it. One real file
|
||||
// with a link beside it is the only arrangement where that cannot happen.
|
||||
//
|
||||
// This package depends on nothing but the standard library. It performs no
|
||||
// merge and DELETES NO CONTENT: every branch is either a lossless repair or a
|
||||
// report, and the one case it refuses to resolve — two real files whose contents
|
||||
// differ — is the one where a wrong guess would destroy somebody's writing.
|
||||
package mirror
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The two names, and the link's target. The target is written relative on
|
||||
// purpose: a tree that is moved, copied or mounted somewhere else keeps working,
|
||||
// and an absolute link would point at wherever the repair happened to run.
|
||||
const (
|
||||
Agents = "AGENTS.md"
|
||||
Claude = "CLAUDE.md"
|
||||
)
|
||||
|
||||
// skipDirs are never descended into. Each holds somebody else's tree — a
|
||||
// vendored dependency's AGENTS.md is that dependency's business, and rewriting
|
||||
// it would show up as a diff nobody asked for. Dot-directories are skipped by
|
||||
// the same argument and by a second one: `.git` is not a place to be creating
|
||||
// symlinks.
|
||||
var skipDirs = map[string]bool{
|
||||
"node_modules": true,
|
||||
"__pycache__": true,
|
||||
"venv": true,
|
||||
"vendor": true,
|
||||
}
|
||||
|
||||
// Result is what one walk found. Both halves are ordered by directory, because
|
||||
// the walk is, so two runs over the same tree report in the same order.
|
||||
type Result struct {
|
||||
// Fixes are the repairs made — or, from Check, the repairs that would be.
|
||||
Fixes []string
|
||||
// Conflicts are the directories this package refuses to resolve. A conflict
|
||||
// is reported identically by both entry points: nothing about it is a write.
|
||||
Conflicts []string
|
||||
}
|
||||
|
||||
// Clean reports whether the tree was already canonical.
|
||||
func (r Result) Clean() bool { return len(r.Fixes) == 0 && len(r.Conflicts) == 0 }
|
||||
|
||||
// Sync walks root and repairs every directory under it.
|
||||
func Sync(root string) Result { return walk(root, true) }
|
||||
|
||||
// Check walks root and reports what Sync would do, writing nothing.
|
||||
//
|
||||
// The two share one code path with the writes turned off, so a check that says
|
||||
// nothing is a promise about the run that follows it rather than a second
|
||||
// implementation that might disagree.
|
||||
func Check(root string) Result { return walk(root, false) }
|
||||
|
||||
func walk(root string, apply bool) Result {
|
||||
var res Result
|
||||
abs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return res
|
||||
}
|
||||
|
||||
_ = filepath.WalkDir(abs, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
// An unreadable directory is skipped, never fatal: this runs over
|
||||
// somebody's whole working tree and one bad mode must not stop it.
|
||||
if d != nil && d.IsDir() {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if path != abs {
|
||||
if name := d.Name(); skipDirs[name] || strings.HasPrefix(name, ".") {
|
||||
return fs.SkipDir
|
||||
}
|
||||
}
|
||||
fixDir(path, abs, apply, &res)
|
||||
return nil
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
// fixDir applies the invariant to one directory.
|
||||
//
|
||||
// The seven cases, and every one of them is either lossless or a refusal:
|
||||
//
|
||||
// AGENTS.md real, no CLAUDE.md ........ create the symlink
|
||||
// CLAUDE.md real, no AGENTS.md ........ rename to AGENTS.md, link back
|
||||
// CLAUDE.md symlink -> AGENTS.md ...... canonical, nothing to do
|
||||
// CLAUDE.md symlink elsewhere ......... re-point it
|
||||
// AGENTS.md symlink -> real CLAUDE.md . reversed layout, swap it round
|
||||
// both real, identical content ........ replace CLAUDE.md with the symlink
|
||||
// both real, different content ........ REFUSE, and say which directory
|
||||
//
|
||||
// A repair that fails halfway — an unwritable directory, a race with an editor —
|
||||
// reports nothing rather than a fix it did not make. Claiming a repair that did
|
||||
// not happen is worse than silence, because the next run would find the same
|
||||
// state and the operator would have been told twice that it was handled.
|
||||
func fixDir(dir, root string, apply bool, res *Result) {
|
||||
agents := filepath.Join(dir, Agents)
|
||||
claude := filepath.Join(dir, Claude)
|
||||
|
||||
aInfo, aErr := os.Lstat(agents)
|
||||
cInfo, cErr := os.Lstat(claude)
|
||||
a, c := aErr == nil, cErr == nil
|
||||
if !a && !c {
|
||||
return
|
||||
}
|
||||
aLink := a && aInfo.Mode()&os.ModeSymlink != 0
|
||||
cLink := c && cInfo.Mode()&os.ModeSymlink != 0
|
||||
|
||||
rel := func(p string) string {
|
||||
r, err := filepath.Rel(root, p)
|
||||
if err != nil {
|
||||
return p
|
||||
}
|
||||
if r == "." {
|
||||
return "<root>"
|
||||
}
|
||||
return r
|
||||
}
|
||||
conflict := func(format string, v ...any) {
|
||||
res.Conflicts = append(res.Conflicts, fmt.Sprintf(format, v...))
|
||||
}
|
||||
// fix runs the repair unless this is a check, and records it only if every
|
||||
// step of it succeeded.
|
||||
fix := func(msg string, steps ...func() error) {
|
||||
if apply {
|
||||
for _, step := range steps {
|
||||
if err := step(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
res.Fixes = append(res.Fixes, msg)
|
||||
}
|
||||
link := func() error { return os.Symlink(Agents, claude) }
|
||||
|
||||
switch {
|
||||
case a && !c:
|
||||
if aLink && !exists(agents) {
|
||||
conflict("%s: broken symlink and no %s", rel(agents), Claude)
|
||||
return
|
||||
}
|
||||
fix(fmt.Sprintf("%s: created symlink -> %s", rel(claude), Agents), link)
|
||||
|
||||
case c && !a:
|
||||
if cLink {
|
||||
target, _ := os.Readlink(claude)
|
||||
conflict("%s: symlink to missing target (%s)", rel(claude), target)
|
||||
return
|
||||
}
|
||||
fix(fmt.Sprintf("%s: renamed to %s, symlink left in place", rel(claude), Agents),
|
||||
func() error { return os.Rename(claude, agents) }, link)
|
||||
|
||||
case cLink:
|
||||
if sameFile(claude, agents) {
|
||||
return // canonical
|
||||
}
|
||||
old, _ := os.Readlink(claude)
|
||||
fix(fmt.Sprintf("%s: re-pointed symlink (%s -> %s)", rel(claude), old, Agents),
|
||||
func() error { return os.Remove(claude) }, link)
|
||||
|
||||
case aLink:
|
||||
// Reversed layout: AGENTS.md is the link and CLAUDE.md the real file.
|
||||
if !sameFile(agents, claude) {
|
||||
conflict("%s: symlink elsewhere while %s is a real file", rel(agents), Claude)
|
||||
return
|
||||
}
|
||||
fix(fmt.Sprintf("%s: swapped — %s is now the real file", rel(agents), Agents),
|
||||
func() error { return os.Remove(agents) },
|
||||
func() error { return os.Rename(claude, agents) }, link)
|
||||
|
||||
default:
|
||||
// Both are real files, and only their contents decide what happens.
|
||||
if !identical(agents, claude) {
|
||||
conflict("%s: %s and %s are different real files — merge manually",
|
||||
rel(dir), Agents, Claude)
|
||||
return
|
||||
}
|
||||
fix(fmt.Sprintf("%s: identical to %s, replaced with symlink", rel(claude), Agents),
|
||||
func() error { return os.Remove(claude) }, link)
|
||||
}
|
||||
}
|
||||
|
||||
func exists(p string) bool {
|
||||
_, err := os.Stat(p)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// sameFile reports whether two paths resolve to one file.
|
||||
func sameFile(a, b string) bool {
|
||||
ra, err := filepath.EvalSymlinks(a)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
rb, err := filepath.EvalSymlinks(b)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return ra == rb
|
||||
}
|
||||
|
||||
// identical compares two files by content, not by size or mtime. The whole
|
||||
// point of the comparison is to decide whether one of them may be deleted.
|
||||
func identical(a, b string) bool {
|
||||
ba, err := os.ReadFile(a)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
bb, err := os.ReadFile(b)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return bytes.Equal(ba, bb)
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package mirror
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// state is what one directory looks like after a walk: what kind of thing each
|
||||
// name is, and what the real file holds. It is the assertion for every case
|
||||
// below, because every case is a statement about exactly this.
|
||||
type state struct {
|
||||
agentsIsFile bool
|
||||
claudeIsLink bool
|
||||
linkTarget string
|
||||
body string
|
||||
}
|
||||
|
||||
func read(t *testing.T, dir string) state {
|
||||
t.Helper()
|
||||
var s state
|
||||
if fi, err := os.Lstat(filepath.Join(dir, Agents)); err == nil {
|
||||
s.agentsIsFile = fi.Mode().IsRegular()
|
||||
}
|
||||
if fi, err := os.Lstat(filepath.Join(dir, Claude)); err == nil {
|
||||
if fi.Mode()&os.ModeSymlink != 0 {
|
||||
s.claudeIsLink = true
|
||||
s.linkTarget, _ = os.Readlink(filepath.Join(dir, Claude))
|
||||
}
|
||||
}
|
||||
if b, err := os.ReadFile(filepath.Join(dir, Agents)); err == nil {
|
||||
s.body = string(b)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// canonical is the one arrangement this package exists to produce.
|
||||
func canonical(body string) state {
|
||||
return state{agentsIsFile: true, claudeIsLink: true, linkTarget: Agents, body: body}
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func symlink(t *testing.T, target, path string) {
|
||||
t.Helper()
|
||||
if err := os.Symlink(target, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// The seven branches, each named by the state it starts in.
|
||||
func TestEveryBranchIsLosslessOrARefusal(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
// setup builds one directory in the state under test.
|
||||
setup func(t *testing.T, dir string)
|
||||
// want is the state afterwards; a conflict case wants no change at all.
|
||||
want state
|
||||
// fixes and conflicts are how many of each the walk reports.
|
||||
fixes, conflicts int
|
||||
// says is a fragment the one report has to contain, so a message that
|
||||
// stops naming what happened fails here rather than in somebody's
|
||||
// terminal a year from now.
|
||||
says string
|
||||
}{
|
||||
{
|
||||
name: "AGENTS.md alone gets a link beside it",
|
||||
setup: func(t *testing.T, dir string) {
|
||||
writeFile(t, filepath.Join(dir, Agents), "doc\n")
|
||||
},
|
||||
want: canonical("doc\n"),
|
||||
fixes: 1,
|
||||
says: "created symlink",
|
||||
},
|
||||
{
|
||||
name: "CLAUDE.md alone is renamed and linked back",
|
||||
setup: func(t *testing.T, dir string) {
|
||||
writeFile(t, filepath.Join(dir, Claude), "doc\n")
|
||||
},
|
||||
want: canonical("doc\n"),
|
||||
fixes: 1,
|
||||
says: "renamed to " + Agents,
|
||||
},
|
||||
{
|
||||
name: "already canonical is left completely alone",
|
||||
setup: func(t *testing.T, dir string) {
|
||||
writeFile(t, filepath.Join(dir, Agents), "doc\n")
|
||||
symlink(t, Agents, filepath.Join(dir, Claude))
|
||||
},
|
||||
want: canonical("doc\n"),
|
||||
},
|
||||
{
|
||||
name: "a link pointing elsewhere is re-pointed",
|
||||
setup: func(t *testing.T, dir string) {
|
||||
writeFile(t, filepath.Join(dir, Agents), "doc\n")
|
||||
writeFile(t, filepath.Join(dir, "OTHER.md"), "other\n")
|
||||
symlink(t, "OTHER.md", filepath.Join(dir, Claude))
|
||||
},
|
||||
want: canonical("doc\n"),
|
||||
fixes: 1,
|
||||
says: "re-pointed symlink",
|
||||
},
|
||||
{
|
||||
name: "the reversed layout is swapped round",
|
||||
setup: func(t *testing.T, dir string) {
|
||||
writeFile(t, filepath.Join(dir, Claude), "doc\n")
|
||||
symlink(t, Claude, filepath.Join(dir, Agents))
|
||||
},
|
||||
want: canonical("doc\n"),
|
||||
fixes: 1,
|
||||
says: "swapped",
|
||||
},
|
||||
{
|
||||
name: "two real files with one content lose the copy",
|
||||
setup: func(t *testing.T, dir string) {
|
||||
writeFile(t, filepath.Join(dir, Agents), "doc\n")
|
||||
writeFile(t, filepath.Join(dir, Claude), "doc\n")
|
||||
},
|
||||
want: canonical("doc\n"),
|
||||
fixes: 1,
|
||||
says: "replaced with symlink",
|
||||
},
|
||||
{
|
||||
// The one case that must never be resolved: one of the two is
|
||||
// somebody's writing, and no rule here knows which.
|
||||
name: "two real files with different content are refused",
|
||||
setup: func(t *testing.T, dir string) {
|
||||
writeFile(t, filepath.Join(dir, Agents), "mine\n")
|
||||
writeFile(t, filepath.Join(dir, Claude), "theirs\n")
|
||||
},
|
||||
want: state{agentsIsFile: true, body: "mine\n"},
|
||||
conflicts: 1,
|
||||
says: "merge manually",
|
||||
},
|
||||
{
|
||||
name: "a broken link with nothing beside it is reported, not replaced",
|
||||
setup: func(t *testing.T, dir string) {
|
||||
symlink(t, "GONE.md", filepath.Join(dir, Agents))
|
||||
},
|
||||
want: state{},
|
||||
conflicts: 1,
|
||||
says: "broken symlink",
|
||||
},
|
||||
{
|
||||
name: "a CLAUDE.md link to nothing is reported",
|
||||
setup: func(t *testing.T, dir string) {
|
||||
symlink(t, "GONE.md", filepath.Join(dir, Claude))
|
||||
},
|
||||
want: state{claudeIsLink: true, linkTarget: "GONE.md"},
|
||||
conflicts: 1,
|
||||
says: "missing target",
|
||||
},
|
||||
{
|
||||
name: "a directory holding neither is not touched",
|
||||
setup: func(t *testing.T, dir string) {},
|
||||
want: state{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tc.setup(t, dir)
|
||||
|
||||
res := Sync(dir)
|
||||
if len(res.Fixes) != tc.fixes {
|
||||
t.Errorf("fixes = %d (%v), want %d", len(res.Fixes), res.Fixes, tc.fixes)
|
||||
}
|
||||
if len(res.Conflicts) != tc.conflicts {
|
||||
t.Errorf("conflicts = %d (%v), want %d", len(res.Conflicts), res.Conflicts, tc.conflicts)
|
||||
}
|
||||
if tc.says != "" {
|
||||
all := strings.Join(append(res.Fixes, res.Conflicts...), "\n")
|
||||
if !strings.Contains(all, tc.says) {
|
||||
t.Errorf("no report mentions %q; got:\n%s", tc.says, all)
|
||||
}
|
||||
}
|
||||
if got := read(t, dir); got != tc.want {
|
||||
t.Errorf("after Sync:\n got %+v\nwant %+v", got, tc.want)
|
||||
}
|
||||
|
||||
// A second run must find nothing left to do. A repair that reported
|
||||
// itself fixed and did not converge would loop forever inside a
|
||||
// PreToolUse hook, once per Bash call.
|
||||
if again := Sync(dir); len(again.Fixes) != 0 {
|
||||
t.Errorf("not idempotent — a second run still fixes %v", again.Fixes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check is the same walk with the writes turned off, and the promise it makes is
|
||||
// that the run after it does exactly what it said.
|
||||
func TestCheckReportsWithoutWriting(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFile(t, filepath.Join(dir, Agents), "doc\n")
|
||||
|
||||
before := read(t, dir)
|
||||
res := Check(dir)
|
||||
if len(res.Fixes) != 1 {
|
||||
t.Fatalf("Check found %d fixes, want 1", len(res.Fixes))
|
||||
}
|
||||
if after := read(t, dir); after != before {
|
||||
t.Errorf("Check wrote to the tree: %+v -> %+v", before, after)
|
||||
}
|
||||
|
||||
sync := Sync(dir)
|
||||
if len(sync.Fixes) != len(res.Fixes) || sync.Fixes[0] != res.Fixes[0] {
|
||||
t.Errorf("Sync did not do what Check said:\n check %v\n sync %v", res.Fixes, sync.Fixes)
|
||||
}
|
||||
if !Check(dir).Clean() {
|
||||
t.Error("the tree is still not canonical after Sync")
|
||||
}
|
||||
}
|
||||
|
||||
// Somebody else's tree is somebody else's business. A vendored dependency's
|
||||
// AGENTS.md rewritten here is a diff nobody asked for, and `.git` is not a place
|
||||
// to be creating symlinks.
|
||||
func TestSkippedDirectoriesAreNotTouched(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, skip := range []string{"vendor", "node_modules", ".git", ".claude"} {
|
||||
sub := filepath.Join(dir, skip, "pkg")
|
||||
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, filepath.Join(sub, Agents), "theirs\n")
|
||||
}
|
||||
|
||||
if res := Sync(dir); !res.Clean() {
|
||||
t.Errorf("walked into a skipped directory: %+v", res)
|
||||
}
|
||||
for _, skip := range []string{"vendor", "node_modules", ".git", ".claude"} {
|
||||
p := filepath.Join(dir, skip, "pkg", Claude)
|
||||
if _, err := os.Lstat(p); err == nil {
|
||||
t.Errorf("%s was created inside %s", Claude, skip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The walk is a walk: a document six directories down is as canonical as one at
|
||||
// the top, and the report says where it was.
|
||||
func TestTheWholeTreeIsWalked(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
deep := filepath.Join(dir, "cli", "internal", "issue")
|
||||
if err := os.MkdirAll(deep, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, Agents), "root\n")
|
||||
writeFile(t, filepath.Join(deep, Agents), "issue\n")
|
||||
|
||||
res := Sync(dir)
|
||||
if len(res.Fixes) != 2 {
|
||||
t.Fatalf("fixes = %v, want one per directory", res.Fixes)
|
||||
}
|
||||
joined := strings.Join(res.Fixes, "\n")
|
||||
if !strings.Contains(joined, filepath.Join("cli", "internal", "issue", Claude)) {
|
||||
t.Errorf("the report does not name the nested directory:\n%s", joined)
|
||||
}
|
||||
if got := read(t, deep); got != canonical("issue\n") {
|
||||
t.Errorf("nested directory not canonical: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
# 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`, `ScaffoldPath` — 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`.
|
||||
|
||||
`MainWorktree` has a second caller now, and it is the one that made the function
|
||||
worth having in public: `kettle init` refuses to run where it answers, and names
|
||||
the main checkout instead. That rule used to be a paragraph in a skill somebody
|
||||
had to read, which stopped being good enough the moment an interactive wizard
|
||||
became the front door — a front door cannot assume anybody read anything.
|
||||
|
||||
## 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,206 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Initializing is a statement, and the only one that matters here: *this*
|
||||
// directory is the project whose issues live in it. It is answered once, by a
|
||||
// person, and everything downstream reads the answer instead of guessing.
|
||||
//
|
||||
// The marker is deliberately something an operator makes, not something
|
||||
// inferred from the tree: `.git` is in every clone including this repository's
|
||||
// own, so a plugin that inferred its root from one wrote issues into itself.
|
||||
|
||||
// Layouts this has been through, migrated in on init in the order listed —
|
||||
// oldest first, so a tree that skipped a generation still lands in one place.
|
||||
//
|
||||
// Each 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.
|
||||
var legacy = map[string][]string{
|
||||
"issues": {
|
||||
filepath.Join("tmp", "issues"),
|
||||
filepath.Join(".tea", "issues"),
|
||||
},
|
||||
"payload": {
|
||||
filepath.Join("tmp", "payload"),
|
||||
filepath.Join(".tea", "payload"),
|
||||
},
|
||||
}
|
||||
|
||||
// ClashError reports that a migration found the same name on both sides.
|
||||
//
|
||||
// Two versions of one issue, and which one survives is not a decision a
|
||||
// migration gets to make quietly.
|
||||
type ClashError struct {
|
||||
Src, Dst string
|
||||
Names []string
|
||||
}
|
||||
|
||||
func (e *ClashError) Error() string {
|
||||
names := e.Names
|
||||
suffix := ""
|
||||
if len(names) > 5 {
|
||||
suffix = fmt.Sprintf(" (+%d more)", len(names)-5)
|
||||
names = names[:5]
|
||||
}
|
||||
return fmt.Sprintf("%s and %s both hold %s%s — move or delete one side first; nothing was changed",
|
||||
e.Src, e.Dst, strings.Join(names, ", "), suffix)
|
||||
}
|
||||
|
||||
// Init makes root a project. Everything it does is idempotent:
|
||||
//
|
||||
// - creates .kettle/issues/ and .kettle/payload/
|
||||
// - moves an older store in, if it finds one — see `legacy`, oldest first
|
||||
// - adds .kettle/ to .gitignore
|
||||
//
|
||||
// The move is the migration off an older layout and it is a move, not a copy:
|
||||
// two stores is the state the marker exists to prevent, and a store left behind
|
||||
// at the old path is a store somebody will edit by accident.
|
||||
//
|
||||
// .kettle/ is gitignored because an `origin: local` issue is the only copy of that
|
||||
// work and the operator, not this command, decides what goes in a shared
|
||||
// history. Committing the store is a legitimate choice — drop the line if you
|
||||
// make it.
|
||||
//
|
||||
// Returns one line per thing done, for the receipt.
|
||||
func Init(root string, dryRun bool) ([]string, error) {
|
||||
var done []string
|
||||
marker := filepath.Join(root, Marker)
|
||||
fresh := !isDir(marker)
|
||||
|
||||
for _, name := range []string{"issues", "payload"} {
|
||||
d := filepath.Join(marker, name)
|
||||
if isDir(d) {
|
||||
continue
|
||||
}
|
||||
if !dryRun {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
return done, err
|
||||
}
|
||||
}
|
||||
done = append(done, "created "+filepath.Join(Marker, name))
|
||||
}
|
||||
|
||||
for _, name := range []string{"issues", "payload"} {
|
||||
for _, old := range legacy[name] {
|
||||
src := filepath.Join(root, old)
|
||||
moved, err := migrate(src, filepath.Join(marker, name), dryRun)
|
||||
if err != nil {
|
||||
return done, err
|
||||
}
|
||||
switch {
|
||||
case moved == nil:
|
||||
// nothing there to migrate
|
||||
case len(moved) == 0:
|
||||
done = append(done, old+" was empty — nothing to move")
|
||||
default:
|
||||
done = append(done, fmt.Sprintf("moved %d file(s) from %s to %s",
|
||||
len(moved), old, filepath.Join(Marker, name)))
|
||||
}
|
||||
}
|
||||
}
|
||||
// The old marker goes only when the migration emptied it — anything else
|
||||
// parked in there is somebody's, and this is not the command that decides
|
||||
// what.
|
||||
if !dryRun {
|
||||
os.Remove(filepath.Join(root, ".tea"))
|
||||
}
|
||||
|
||||
added, err := addToGitignore(filepath.Join(root, ".gitignore"), Marker+"/", dryRun)
|
||||
if err != nil {
|
||||
return done, err
|
||||
}
|
||||
if added {
|
||||
done = append(done, "added "+Marker+"/ to .gitignore")
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(done) == 0:
|
||||
done = append(done, "already initialized — nothing to do")
|
||||
case fresh:
|
||||
done = append(done, fmt.Sprintf("%s now tracks issues in %s/issues", root, Marker))
|
||||
}
|
||||
return done, nil
|
||||
}
|
||||
|
||||
// migrate moves the CONTENTS of src into dst — contents, not the directory, so
|
||||
// an already-created destination is not a reason to refuse. Returns the names
|
||||
// moved, or nil when there was nothing to migrate.
|
||||
func migrate(src, dst string, dryRun bool) ([]string, error) {
|
||||
if !isDir(src) {
|
||||
return nil, nil
|
||||
}
|
||||
entries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
sort.Strings(names)
|
||||
if len(names) == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
var clashes []string
|
||||
for _, n := range names {
|
||||
if _, err := os.Lstat(filepath.Join(dst, n)); err == nil {
|
||||
clashes = append(clashes, n)
|
||||
}
|
||||
}
|
||||
if len(clashes) > 0 {
|
||||
return nil, &ClashError{Src: src, Dst: dst, Names: clashes}
|
||||
}
|
||||
if dryRun {
|
||||
return names, nil
|
||||
}
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, n := range names {
|
||||
if err := os.Rename(filepath.Join(src, n), filepath.Join(dst, n)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
os.Remove(src) // only succeeds when we emptied it, which is the intent
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// addToGitignore appends entry unless some line already ignores it.
|
||||
func addToGitignore(path, entry string, dryRun bool) (bool, error) {
|
||||
var lines []string
|
||||
if raw, err := os.ReadFile(path); err == nil {
|
||||
lines = strings.Split(strings.TrimSuffix(string(raw), "\n"), "\n")
|
||||
} else if !os.IsNotExist(err) {
|
||||
return false, err
|
||||
}
|
||||
want := strings.TrimSuffix(entry, "/")
|
||||
for _, line := range lines {
|
||||
if strings.TrimSuffix(strings.TrimSpace(line), "/") == want {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
if dryRun {
|
||||
return true, nil
|
||||
}
|
||||
trailer := "\n"
|
||||
if len(lines) == 0 || lines[len(lines)-1] == "" {
|
||||
trailer = ""
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := f.WriteString(trailer + entry + "\n"); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
// Package project answers one question: which directory is the project.
|
||||
//
|
||||
// Everything that is a fact about the project — the issue store, the request
|
||||
// payload scratchpad, the tracker the issues belong to — is resolved from the
|
||||
// answer, and the answer is found by one walk written once. The guard, the
|
||||
// transport and the store used to each have their own copy of that walk in
|
||||
// Python, and they disagreed: in a linked worktree `tea` worked while every
|
||||
// script reported no login pinned.
|
||||
//
|
||||
// This package depends on nothing but the standard library, and nothing in it
|
||||
// resolves from the binary's own location. Where an installation keeps its
|
||||
// files is a fact about the installation; which 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.
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Marker is the directory an operator creates to state "this is a project".
|
||||
// It is never inferred. `.git` was tried and is in every clone, including this
|
||||
// repository's own, so a plugin resolved its store inside itself.
|
||||
const Marker = ".kettle"
|
||||
|
||||
// Everything under the marker, each resolved by the same walk so that which
|
||||
// command wrote a file cannot change where it landed.
|
||||
var (
|
||||
storeParts = []string{Marker, "issues"}
|
||||
payloadParts = []string{Marker, "payload"}
|
||||
configParts = []string{Marker, "config.yaml"}
|
||||
scaffoldParts = []string{Marker, "scaffold.yaml"}
|
||||
)
|
||||
|
||||
// Anchors are the directories a root search starts from, in order, first hit
|
||||
// wins: the project the agent harness was opened on, then the working
|
||||
// directory. A non-empty start overrides both and exists so resolution can be
|
||||
// exercised against a scratch tree.
|
||||
func Anchors(start string) []string {
|
||||
if start != "" {
|
||||
abs, err := filepath.Abs(start)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return []string{abs}
|
||||
}
|
||||
var out []string
|
||||
for _, d := range []string{os.Getenv("CLAUDE_PROJECT_DIR"), cwd()} {
|
||||
if d == "" || !isDir(d) {
|
||||
continue
|
||||
}
|
||||
abs, err := filepath.Abs(d)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !contains(out, abs) {
|
||||
out = append(out, abs)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Parents yields start and every ancestor of it, up to the filesystem root.
|
||||
func Parents(start string) []string {
|
||||
d, err := filepath.Abs(start)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for {
|
||||
out = append(out, d)
|
||||
parent := filepath.Dir(d)
|
||||
if parent == d {
|
||||
return out
|
||||
}
|
||||
d = parent
|
||||
}
|
||||
}
|
||||
|
||||
// GitDirOf is the private git directory `d/.git` points at, or "".
|
||||
//
|
||||
// Only a `.git` FILE is a pointer; in an ordinary clone `.git` is a directory
|
||||
// and there is nothing to follow.
|
||||
func GitDirOf(d string) string {
|
||||
p := filepath.Join(d, ".git")
|
||||
fi, err := os.Stat(p)
|
||||
if err != nil || fi.IsDir() {
|
||||
return ""
|
||||
}
|
||||
head, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(string(head), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
target, ok := strings.CutPrefix(line, "gitdir:")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
return ""
|
||||
}
|
||||
if !filepath.IsAbs(target) {
|
||||
target = filepath.Join(d, target)
|
||||
}
|
||||
abs, err := filepath.Abs(target)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return abs
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// MainWorktree is the main working tree of d's repository when d is a linked
|
||||
// worktree, or "".
|
||||
//
|
||||
// `<worktree>/.git` -> `<main>/.git/worktrees/<name>`, whose `commondir` file
|
||||
// holds a path to `<main>/.git`; the main working tree is its parent. The
|
||||
// `.git` basename check keeps this to worktrees: a submodule's `.git` is a
|
||||
// pointer too, but it points into `<super>/.git/modules/…`, and the tree it
|
||||
// belongs to is already on the parent chain.
|
||||
func MainWorktree(d string) string {
|
||||
gitdir := GitDirOf(d)
|
||||
if gitdir == "" || !isDir(gitdir) {
|
||||
return ""
|
||||
}
|
||||
common := gitdir
|
||||
if raw, err := os.ReadFile(filepath.Join(gitdir, "commondir")); err == nil {
|
||||
if rel := strings.TrimSpace(string(raw)); rel != "" {
|
||||
if abs, err := filepath.Abs(filepath.Join(gitdir, rel)); err == nil {
|
||||
common = abs
|
||||
}
|
||||
}
|
||||
}
|
||||
if filepath.Base(common) != ".git" {
|
||||
return ""
|
||||
}
|
||||
root := filepath.Dir(common)
|
||||
abs, err := filepath.Abs(d)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if root != "" && isDir(root) && root != abs {
|
||||
return root
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Root is the nearest ancestor of an anchor (inclusive) holding the marker, or
|
||||
// "" when there is no project.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// A linked worktree is the same project on another branch, and the marker is
|
||||
// gitignored, so it is only ever in the main checkout: the chain is searched
|
||||
// first and always wins, then the main working tree of any worktree met on it.
|
||||
func Root(start string) string {
|
||||
for _, anchor := range Anchors(start) {
|
||||
var hops []string
|
||||
for _, d := range Parents(anchor) {
|
||||
if isDir(filepath.Join(d, Marker)) {
|
||||
return d
|
||||
}
|
||||
if main := MainWorktree(d); main != "" && !contains(hops, main) {
|
||||
hops = append(hops, main)
|
||||
}
|
||||
}
|
||||
// One level of indirection, never two: a main checkout is not itself a
|
||||
// linked worktree, so this cannot chain and cannot cycle.
|
||||
for _, hop := range hops {
|
||||
for _, d := range Parents(hop) {
|
||||
if isDir(filepath.Join(d, Marker)) {
|
||||
return d
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// StoreRoot is the absolute path of the issue store, or "" with no project.
|
||||
func StoreRoot(start string) string { return under(start, storeParts) }
|
||||
|
||||
// PayloadRoot is the absolute path of the request-payload scratchpad, or "".
|
||||
//
|
||||
// A sibling of the store under the same marker, resolved by the same walk, so
|
||||
// the scratchpad and the store can never end up in two different projects —
|
||||
// and so a scratchpad can never sit INSIDE a store, where a call that touched
|
||||
// no issue would still materialize the issue directory.
|
||||
func PayloadRoot(start string) string { return under(start, payloadParts) }
|
||||
|
||||
// ConfigPath is the absolute path of the project's tracker config, or "".
|
||||
func ConfigPath(start string) string { return under(start, configParts) }
|
||||
|
||||
// ScaffoldPath is the absolute path of the record of what was written into the
|
||||
// project's `.claude/` tree, or "".
|
||||
//
|
||||
// A file of its own rather than a field in config.yaml, and the reason is a rule
|
||||
// stated in [config]: an unknown key in config.yaml is an error rather than a
|
||||
// silent drop, so a field added there is a one-way door for a file that may be
|
||||
// committed and read by whatever version each machine happens to have. This
|
||||
// record is written and read by one binary about one directory, so it can carry
|
||||
// that cost where the shared file cannot.
|
||||
func ScaffoldPath(start string) string { return under(start, scaffoldParts) }
|
||||
|
||||
func under(start string, parts []string) string {
|
||||
root := Root(start)
|
||||
if root == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(append([]string{root}, parts...)...)
|
||||
}
|
||||
|
||||
// NotFoundError explains why no project could be resolved, naming every
|
||||
// directory the search began from.
|
||||
//
|
||||
// The anchors, not the whole chain above them: an operator who sees the two
|
||||
// places the search started knows immediately whether it started where they
|
||||
// meant it to.
|
||||
func NotFoundError(start string) error {
|
||||
dirs := strings.Join(Anchors(start), " and ")
|
||||
if dirs == "" {
|
||||
dirs = "nowhere"
|
||||
}
|
||||
return fmt.Errorf("no %s/ found — searched up from %s. Run `kettle init` in the project you mean to track issues in", Marker, dirs)
|
||||
}
|
||||
|
||||
func isDir(p string) bool {
|
||||
fi, err := os.Stat(p)
|
||||
return err == nil && fi.IsDir()
|
||||
}
|
||||
|
||||
func cwd() string {
|
||||
d, err := os.Getwd()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func contains(xs []string, x string) bool {
|
||||
for _, v := range xs {
|
||||
if v == x {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Every test here strips CLAUDE_PROJECT_DIR: it is the first anchor of the
|
||||
// walk, so the harness's own value would point every fixture at whatever
|
||||
// repository the suite happens to run in.
|
||||
func fixture(t *testing.T) string {
|
||||
t.Helper()
|
||||
t.Setenv("CLAUDE_PROJECT_DIR", "")
|
||||
dir := t.TempDir()
|
||||
// macOS hands out /var/… , a symlink to /private/var, and the walk works
|
||||
// in resolved paths.
|
||||
real, err := filepath.EvalSymlinks(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return real
|
||||
}
|
||||
|
||||
func chdir(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
t.Chdir(dir)
|
||||
}
|
||||
|
||||
func TestRootFindsTheNearestMarkerUpFromTheWorkingDirectory(t *testing.T) {
|
||||
root := fixture(t)
|
||||
deep := filepath.Join(root, "a", "b", "c")
|
||||
if err := os.MkdirAll(deep, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, Marker), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chdir(t, deep)
|
||||
|
||||
if got := Root(""); got != root {
|
||||
t.Errorf("Root() = %q, want %q", got, root)
|
||||
}
|
||||
if got := StoreRoot(""); got != filepath.Join(root, Marker, "issues") {
|
||||
t.Errorf("StoreRoot() = %q", got)
|
||||
}
|
||||
// The scratchpad is a sibling of the store, never inside it: a call that
|
||||
// touches no issue must not materialize the issue directory.
|
||||
if got := PayloadRoot(""); got != filepath.Join(root, Marker, "payload") {
|
||||
t.Errorf("PayloadRoot() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoMarkerIsAnAnswerNotAFallback(t *testing.T) {
|
||||
dir := fixture(t)
|
||||
chdir(t, dir)
|
||||
|
||||
if got := Root(""); got != "" {
|
||||
t.Errorf("Root() = %q, want empty — a plausible-looking directory is the failure this replaces", got)
|
||||
}
|
||||
if got := StoreRoot(""); got != "" {
|
||||
t.Errorf("StoreRoot() = %q, want empty", got)
|
||||
}
|
||||
if err := NotFoundError(""); err == nil {
|
||||
t.Fatal("NotFoundError must explain itself")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheNearestMarkerWinsOverAnAncestorOne(t *testing.T) {
|
||||
outer := fixture(t)
|
||||
inner := filepath.Join(outer, "vendored")
|
||||
for _, d := range []string{filepath.Join(outer, Marker), filepath.Join(inner, Marker)} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
chdir(t, inner)
|
||||
|
||||
if got := Root(""); got != inner {
|
||||
t.Errorf("Root() = %q, want the nearest marker %q", got, inner)
|
||||
}
|
||||
}
|
||||
|
||||
// A linked worktree is a SIBLING of the main checkout, so the gitignored
|
||||
// marker is never on its parent chain. The whole sync layer once died there
|
||||
// while the tracker CLI in the same directory worked.
|
||||
func TestALinkedWorktreeResolvesToTheMainCheckout(t *testing.T) {
|
||||
base := fixture(t)
|
||||
main := filepath.Join(base, "repo")
|
||||
wt := filepath.Join(base, "repo-feat")
|
||||
gitdir := filepath.Join(main, ".git", "worktrees", "feat")
|
||||
|
||||
for _, d := range []string{filepath.Join(main, Marker), gitdir, wt} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// <worktree>/.git points at the private dir; commondir points back at
|
||||
// <main>/.git, whose parent is the main working tree.
|
||||
write(t, filepath.Join(wt, ".git"), "gitdir: "+gitdir+"\n")
|
||||
write(t, filepath.Join(gitdir, "commondir"), "../..\n")
|
||||
|
||||
chdir(t, wt)
|
||||
if got := Root(""); got != main {
|
||||
t.Errorf("Root() = %q, want the main checkout %q", got, main)
|
||||
}
|
||||
}
|
||||
|
||||
// A submodule's .git is a pointer too, but it points into
|
||||
// <super>/.git/modules/…, and the tree it belongs to is already on the parent
|
||||
// chain. Following it would be a hop to nowhere.
|
||||
func TestASubmoduleIsNotAWorktree(t *testing.T) {
|
||||
base := fixture(t)
|
||||
sub := filepath.Join(base, "super", "sub")
|
||||
gitdir := filepath.Join(base, "super", ".git", "modules", "sub")
|
||||
for _, d := range []string{sub, gitdir} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write(t, filepath.Join(sub, ".git"), "gitdir: "+gitdir+"\n")
|
||||
|
||||
if got := MainWorktree(sub); got != "" {
|
||||
t.Errorf("MainWorktree() = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnOrdinaryCloneHasNothingToFollow(t *testing.T) {
|
||||
dir := fixture(t)
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := GitDirOf(dir); got != "" {
|
||||
t.Errorf("GitDirOf() = %q — only a .git FILE is a pointer", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeProjectDirIsTheFirstAnchor(t *testing.T) {
|
||||
base := fixture(t)
|
||||
opened := filepath.Join(base, "opened")
|
||||
elsewhere := filepath.Join(base, "elsewhere")
|
||||
for _, d := range []string{filepath.Join(opened, Marker), filepath.Join(elsewhere, Marker)} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Setenv("CLAUDE_PROJECT_DIR", opened)
|
||||
chdir(t, elsewhere)
|
||||
|
||||
if got := Root(""); got != opened {
|
||||
t.Errorf("Root() = %q, want the opened project %q", got, opened)
|
||||
}
|
||||
}
|
||||
|
||||
func write(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
# AGENTS.md — internal/scaffold
|
||||
|
||||
**The documents kettle writes into a project**: the slash commands an operator
|
||||
invokes, the skills a model loads, and the runner subagent. Embedded with
|
||||
`//go:embed`, handed out as bytes.
|
||||
|
||||
**Imports the standard library and nothing else** — and, unusually, is forbidden
|
||||
`os` as well. These documents travel *inside* the binary; one `os.ReadFile` and
|
||||
they are back to being files on a machine that may not have them, which is the
|
||||
entire failure this package exists to make impossible. Two tests hold it, see
|
||||
[`internal/AGENTS.md`](../AGENTS.md).
|
||||
|
||||
| file | what is in it |
|
||||
|---|---|
|
||||
| `scaffold.go` | `Marker`, `File`, `Files`, `Groups`, `PathFor`, `Dirs`, and the `generated` map |
|
||||
| `assets/` | every document, exactly as it is written out |
|
||||
| `scaffold_test.go` | frontmatter, names, the region declaration, determinism |
|
||||
| `layering_test.go` | the two tests that keep this package at the bottom |
|
||||
|
||||
## Why these are not a plugin
|
||||
|
||||
They were, and the plugin shipped on its own release cadence. **Nothing on an
|
||||
operator's machine ever checked that the plugin they had installed described the
|
||||
binary they had installed** — so a renamed flag could ship with documentation
|
||||
recommending the old one, which is the exact failure the generated block was
|
||||
invented to prevent, one hop further downstream.
|
||||
|
||||
Prose that travels inside the binary cannot be a version behind it. That is the
|
||||
whole argument, and everything else here follows from it: the generator writes
|
||||
files whole rather than splicing a region, `kettle init` writes the tree, and
|
||||
`kettle config` reports which build wrote the one on disk.
|
||||
|
||||
What was lost is worth naming. The marketplace was discovery — `/plugin` is a
|
||||
shop window and a README URL is not — and a plugin's `/kettle:…` prefix came free
|
||||
where a project's does not. The second was recoverable and is recovered below;
|
||||
the first was not, and the trade was made anyway, because a document that
|
||||
describes the wrong binary is worse than one nobody found.
|
||||
|
||||
## The layout, and where the namespace went
|
||||
|
||||
```
|
||||
commands/kettle/*.md → /kettle:init, /kettle:auth, /kettle:issue, …
|
||||
skills/kettle-*/SKILL.md → loaded by description, never by name
|
||||
skills/kettle-issue/references/format.md
|
||||
agents/kettle-runner.md
|
||||
```
|
||||
|
||||
A project's skills have **no namespace**: `.claude/skills/` is flat, and a skill
|
||||
called `issue` would collide with every other tool's. So the prefix is spelled
|
||||
into the directory name — `kettle-issue`, not `issue` — and the `name:` in the
|
||||
frontmatter matches it, because the harness resolves one from the other.
|
||||
|
||||
**Commands do have a namespace, and it is free.** A subdirectory under
|
||||
`.claude/commands/` becomes the prefix, so `commands/kettle/init.md` is
|
||||
`/kettle:init` — the same spelling the plugin had, with no plugin. Every
|
||||
`/kettle:…` in the embedded prose is still true because of this, which is why
|
||||
four of the six command files exist at all: `issue`, `sync`, `api` and `project`
|
||||
are three lines each, pointing at the skill that holds the substance. They are
|
||||
not duplication; they are the reason 1,600 lines of cross-references did not have
|
||||
to be rewritten.
|
||||
|
||||
**`init` and `auth` are commands with no skill.** That is not an omission. They
|
||||
were skills carrying `disable-model-invocation: true` — a frontmatter flag saying
|
||||
"an operator invokes this, not a model". A command *is* operator-invoked, so the
|
||||
flag disappeared into the shape of the thing. `init` in particular must not be
|
||||
routable: which directory is the project is the one question this binary exists
|
||||
to have a person answer.
|
||||
|
||||
## The generated region
|
||||
|
||||
Four documents carry `<!-- kettle:gen -->` markers around a flag table rendered
|
||||
from the command registry. Which four is declared in the `generated` map, and
|
||||
declared rather than derived from the directory name — the group ⇄ skill
|
||||
correspondence is a decision, and one that has never been one-to-one: `project`
|
||||
is a single skill covering five commands, and `init` and `auth` are commands
|
||||
inside it with no skill of their own.
|
||||
|
||||
`scaffold_test.go` asserts the map and the markers agree in both directions, and
|
||||
[`internal/cmd`](../cmd/AGENTS.md) asserts every group in the registry is named
|
||||
here. A group added without a document would have its commands silently written
|
||||
nowhere — the generator walks the documents, not the registry.
|
||||
|
||||
**This package does not render.** It hands out embedded bytes and says which of
|
||||
them carry a region; the splice is `internal/cmd`'s, because the registry is. The
|
||||
split is what keeps the import arrow pointing one way.
|
||||
|
||||
## Bootstrapping, which is circular and is meant to be
|
||||
|
||||
`assets/` holds the block already spliced in — the files here are what a project
|
||||
gets, byte for byte. So:
|
||||
|
||||
```bash
|
||||
make check # gen scaffold --check, against assets/
|
||||
dist/kettle gen scaffold --out internal/scaffold/assets # the fix
|
||||
```
|
||||
|
||||
A flag change makes the render differ from the embedded copy, `--check` exits 1,
|
||||
regenerating updates `assets/`, and the binary has to be **rebuilt** to pick the
|
||||
new bytes up. That loop is the same one the plugin's SKILL.md files were in; what
|
||||
changed is that its output now ships with the binary rather than beside it.
|
||||
|
||||
## Editing a document
|
||||
|
||||
Edit it here, under `assets/`, then `make check`. Never edit the copy in a
|
||||
project: it is replaced whole on the next `kettle gen scaffold`, and the run says
|
||||
so before it does it.
|
||||
|
||||
The one line no generator may write is `description:` — it is what decides
|
||||
whether a model loads a skill at all, and it is prose tuned against real
|
||||
failures. `scaffold_test.go` fails on an empty one; nothing checks that it is
|
||||
*good*, and nothing can.
|
||||
|
||||
## Keeping this file true
|
||||
|
||||
- **Scope:** `scaffold.go`, everything under `assets/`, and the two seams — the
|
||||
`generated` map, and the commands ⇄ skills split.
|
||||
- **Update it when** a document is added or removed, a group gains or loses its
|
||||
file, the output layout changes, or the argument for embedding rather than
|
||||
shipping a plugin stops being the argument.
|
||||
- **Do not** restate what any individual document says. Each one is its own
|
||||
procedure and says it in full.
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
name: kettle-runner
|
||||
description: Runs `kettle` commands and hands back a compact receipt. Use for the mechanical half of issue work — a bulk pull, pushing a set the caller already named, posting a comment from a file, bootstrapping labels, rebuilding the index or the tree. It runs commands; it never decides what an issue should say. Delegate a batch, not a single call.
|
||||
tools: Bash, Read, Grep, Glob, Skill
|
||||
model: haiku
|
||||
---
|
||||
|
||||
# kettle-runner — the execution layer
|
||||
|
||||
You run `kettle` commands and hand back a short receipt. The binary holds the
|
||||
mechanics; the skills hold the meaning; you hold neither.
|
||||
|
||||
**You have no opinion about content.** Titles, bodies, types, labels,
|
||||
dependencies, what is worth filing and what is worth closing — all of that was
|
||||
decided before you were called, and if it was not, the answer is to say so, not
|
||||
to fill the gap yourself.
|
||||
|
||||
## Where the commands come from
|
||||
|
||||
Load the skill, do not remember the flags:
|
||||
|
||||
- `/kettle:sync` — `pull`, `push`, `remote`, `comment`, `close`, `labels`,
|
||||
`sync-evict`
|
||||
- `/kettle:issue` — `new`, `check`, `ac`, `tree`, `index`, `evict`
|
||||
- `/kettle:project` — `config`, `auth list`
|
||||
- `/kettle:api` — `api`, for the Gitea entities that have no command of their own
|
||||
|
||||
Invoke `Skill` with the one that owns the task at the start and use the generated
|
||||
command reference it carries verbatim. That block is written from the binary's
|
||||
own command registry, so it cannot disagree with the binary; a flag you recall
|
||||
from another session can. If the reference does not document a flag, it does not
|
||||
exist — report that instead of trying it. `kettle help <command>` is the same
|
||||
truth if you need it in a hurry.
|
||||
|
||||
## Hard rules
|
||||
|
||||
1. **`kettle` only.** No curl at a tracker, no other CLI, no request you composed
|
||||
yourself. The binary carries the project's credentials; there is no login for
|
||||
you to name and none for you to choose. `kettle api` is a kettle command and
|
||||
is yours to run **as the caller spelled it** — endpoint, method and body come
|
||||
from the task, and a `-X DELETE` is never something you add. An entity nobody
|
||||
named an endpoint for is a finding for the caller, not a request for you to
|
||||
improvise.
|
||||
2. **No writing to issue files.** You have no `Edit` and no `Write`. Commands
|
||||
write files; you do not. If a task needs a body edited or a metadata field
|
||||
changed by hand, stop and say which file and which field. `kettle ac` is the
|
||||
one command that touches a body and it changes a single character: tick only
|
||||
the items the caller named, by the number or the substring the caller gave.
|
||||
Whether a criterion is actually met is a judgement about content, and content
|
||||
is never yours.
|
||||
3. **Push only what you were told to push.** `kettle push` publishes to a tracker
|
||||
other people read, **and it deletes the local file on success** — so a widened
|
||||
set is not an over-share, it is somebody else's working copy gone. Run it with
|
||||
the ids or filter the caller named. Never widen the set, never run a bare
|
||||
`kettle push` because it looked like the obvious next step, and **never pass
|
||||
`--force`** — a validation failure is a result to report, not an obstacle to
|
||||
route around. Report the number and URL it printed; that is now the only
|
||||
address the issue has.
|
||||
4. **Close only the ids the caller named.** Same discipline as push. Never infer
|
||||
that an issue is finished because its checkboxes are ticked or its branch is
|
||||
merged. `--reopen` is the same rule backwards. Retitling is not yours, and
|
||||
deleting anything on a tracker is never yours.
|
||||
|
||||
Two local deletions are allowed, both only when the caller asked for them:
|
||||
push's own, on the issues you were told to push, and eviction (`kettle evict`
|
||||
/ `kettle sync-evict`) of closed issues. Run eviction with `--dry-run` first
|
||||
and report what it named. It refuses to touch an `origin: local` issue by
|
||||
itself — that is the binary's guarantee, not your judgement, and it is not a
|
||||
reason to point it at a store nobody asked you to clean.
|
||||
5. **One retry, maximum.** A command that fails twice is a finding. Do not
|
||||
permute flags looking for one that works.
|
||||
6. **No payload dumps.** Never `cat` a pulled issue body back into your report.
|
||||
`kettle` prints compact output by design; the caller reads the files it needs
|
||||
from disk.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. Load the skill you need.
|
||||
2. Run the commands. Prefer one filtered call over a loop — `kettle pull
|
||||
--milestone 6` pages the list endpoint, `kettle pull 41 42 43 …` is a request
|
||||
per issue and per blocker.
|
||||
3. If a command exits non-zero, capture the last lines of stderr and stop that
|
||||
branch. Keep going on independent branches.
|
||||
4. Report.
|
||||
|
||||
## Report format
|
||||
|
||||
Your final message is the return value. Keep it under ~20 lines. No preamble, no
|
||||
restatement of the request, no advice about what to do next.
|
||||
|
||||
```
|
||||
ran:
|
||||
kettle pull --milestone 6 --state all ok 7 issues, 3 threads
|
||||
kettle index ok INDEX.md rebuilt
|
||||
kettle push wire-sqlc-appclick FAIL exit 1
|
||||
|
||||
touched: .kettle/issues/{a,b,c}.md, .kettle/issues/INDEX.md
|
||||
|
||||
failed: kettle push wire-sqlc-appclick
|
||||
ERROR wire-sqlc-appclick: missing section '## Acceptance criteria'
|
||||
|
||||
blocked: none
|
||||
```
|
||||
|
||||
- `ran` — one line per command: what, ok/FAIL, and the one number that matters.
|
||||
- `touched` — paths only. Never contents.
|
||||
- `failed` — the command, then stderr verbatim, trimmed to the lines that name
|
||||
the cause. Quote it exactly; do not paraphrase an error.
|
||||
- `blocked` — what you refused to decide, phrased as the question the caller has
|
||||
to answer. `none` when there is nothing.
|
||||
|
||||
## Known stops
|
||||
|
||||
Report these and halt; none of them is yours to resolve.
|
||||
|
||||
| Condition | Report |
|
||||
|---|---|
|
||||
| `command not found: kettle` | `blocked: kettle is not installed — operator builds it from cli/ or go install`s it |
|
||||
| `no .kettle/ found — searched up from …` | `blocked: not a project — operator must run /kettle:init here` |
|
||||
| no login pinned, an unknown login name, 401/403 | `blocked: credential — operator runs /kettle:auth`, with the binary's own line |
|
||||
| `kettle check` errors before a push | the validator's own lines, verbatim |
|
||||
| a dependency is still `origin: local` | name the id; the caller decides whether to push it |
|
||||
| a milestone or label does not exist in the repo | the command prints the real ones — pass that list through |
|
||||
| a command asks for a decision (type, label, `--force`) | `blocked:` with the question |
|
||||
| the tracker refuses a close because the issue is still blocked | the tracker's own line and the blocker's number; the caller decides |
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
description: Reach everything Gitea has that is not an issue — pull requests, releases, tags, milestones, branches, commits, actions, webhooks — with `kettle api`.
|
||||
argument-hint: "[the endpoint, or what you want from the tracker]"
|
||||
---
|
||||
|
||||
Load the **`kettle-api`** skill and follow it. Which endpoint answers which
|
||||
question, which of them paginate, and what `-X DELETE` requires are all mapped
|
||||
there.
|
||||
|
||||
$ARGUMENTS
|
||||
|
||||
One request, under the login this project already pins — no second tool and no
|
||||
second credential. Issues are not handled here: `/kettle:issue` works on them
|
||||
offline and `/kettle:sync` moves them.
|
||||
@@ -0,0 +1,130 @@
|
||||
---
|
||||
description: Give `kettle` a Gitea credential and choose which login this project runs under — `kettle auth list/add/remove` manages the machine-wide token file, `kettle init --login <name>` pins one of those names into `.kettle/config.yaml`. Invoke when a sync command reports no login, a 401, or an unknown login name, or to switch the account this project's issues are pushed under. The OPERATOR picks the login; you never type a token.
|
||||
allowed-tools: Bash(kettle auth list:*), Bash(kettle config:*)
|
||||
---
|
||||
|
||||
# /kettle:auth — the credential a project runs under
|
||||
|
||||
Two files, and the split is the whole design.
|
||||
|
||||
| where | what is in it | who writes it |
|
||||
|---|---|---|
|
||||
| `~/.config/kettle/logins.yaml` | the tokens, one file per machine, mode 0600, outside every working tree | `kettle auth add` |
|
||||
| `<project>/.kettle/config.yaml` | the **name** of one of those logins, and the tracker repo | `kettle init --login … --repo …` |
|
||||
|
||||
A name is worth nothing on its own, which is what makes it safe to keep in a
|
||||
file inside a repository. A token in a working tree ends up in a commit
|
||||
eventually, and a secret that has ever been pushed has to be rotated.
|
||||
`$KETTLE_CONFIG_HOME` or `$XDG_CONFIG_HOME` move the machine file;
|
||||
`KETTLE_LOGIN`, `KETTLE_URL` and `KETTLE_TOKEN` override it outright, which is
|
||||
how CI runs with no token on disk.
|
||||
|
||||
**No credential is pinned in `.claude/` anywhere, and no hook rewrites a
|
||||
`--login` argument.** That mechanism is gone with the Python scripts. If you find
|
||||
a `GITEA_LOGIN` in a settings file, it is dead weight from the old plugin — the
|
||||
only thing `kettle` ever writes into `.claude/settings.json` is the optional
|
||||
`AGENTS.md` mirror hook, which holds no identity of any kind.
|
||||
|
||||
## The one hard rule: the operator chooses, and holds the token
|
||||
|
||||
- **Never pick a login.** Not from memory, not from the repo URL, not from a
|
||||
previous session. Present the choice with `AskUserQuestion` — name, url and
|
||||
user out of `kettle auth list` — and let the operator answer. Exactly one login
|
||||
on the machine is the only case where you may propose, and you still confirm.
|
||||
- **Never type, echo, paste or read a token.** `kettle auth add` takes it on
|
||||
stdin precisely so it does not land in shell history; a token that goes through
|
||||
a model's context is a token in a transcript. Adding a login is the operator's
|
||||
own terminal, not a Bash call you make for them.
|
||||
|
||||
## Steps
|
||||
|
||||
1. See what this machine holds. It never prints a token, and there is no flag to
|
||||
make it:
|
||||
|
||||
```bash
|
||||
kettle auth list
|
||||
```
|
||||
|
||||
2. **Nothing there** — stop and hand the operator the command to run themselves:
|
||||
|
||||
```bash
|
||||
kettle init --interactive # prompts for it, echo off
|
||||
kettle auth add --name noodles --url https://git.example.com --user naudachu < token.txt
|
||||
pass show gitea/token | kettle auth add --name noodles --url https://git.example.com
|
||||
```
|
||||
|
||||
The first is the one to suggest to a person at a terminal: it reads the token
|
||||
with the echo turned off, so it is in no history, no file and no scrollback.
|
||||
**It is not a command for you to run** — it refuses a stdin that is not a
|
||||
terminal, and that refusal is correct. `--user` is documentation only, and
|
||||
`kettle auth remove <name>` forgets one.
|
||||
|
||||
3. **Pin the choice into the project.** Ask `kettle config` first and only
|
||||
proceed if it answers with a project — `kettle init` in a directory that is
|
||||
not one would *create* a project there, which is the one statement that is
|
||||
never yours to make (`/kettle:init`):
|
||||
|
||||
```bash
|
||||
kettle config
|
||||
kettle init --login noodles
|
||||
```
|
||||
|
||||
`init` on an initialized project prints `already initialized — nothing to do`
|
||||
and rewrites only the settings it was given, so the repo pinned earlier stays.
|
||||
|
||||
4. Confirm with `kettle config`. Tell the operator which login is pinned and
|
||||
which file it went in. It is live immediately — nothing caches it, no restart.
|
||||
|
||||
```
|
||||
login noodles
|
||||
url https://git.example.com
|
||||
token (set)
|
||||
repo claude-skills/marketplace
|
||||
```
|
||||
|
||||
## When it goes wrong
|
||||
|
||||
| what you see | what it means |
|
||||
|---|---|
|
||||
| `no login "X" in …/logins.yaml — known: …` | the project pins a name this machine does not hold. Either add it (step 2) or pin one that is there |
|
||||
| `no .kettle/ found — searched up from …` | not a project. `/kettle:init`, and it is the operator's to run |
|
||||
| `401` / `403` from a sync command | report it verbatim. Do **not** try another login, and do not edit or remove one to route around it — that is somebody's identity, not a setting |
|
||||
| `token none` in `kettle config` | a name is pinned but no credential answers to it |
|
||||
|
||||
## Scopes: what the token is allowed to do
|
||||
|
||||
Gitea mints a token with scopes, spelled `<read|write>:<category>`. A token made
|
||||
for issues carries `write:issue` — and that is enough for everything
|
||||
`/kettle:issue` and `/kettle:sync` do, and **not** enough for anything
|
||||
`/kettle:api` reaches: releases, pull requests, branches, tags and actions all
|
||||
sit under `repository`.
|
||||
|
||||
| doing | needs |
|
||||
|---|---|
|
||||
| pull, push, comment, close, evict | `write:issue` |
|
||||
| `kettle labels` | `write:issue` |
|
||||
| `kettle api` on releases, PRs, tags, branches, actions | `write:repository` too |
|
||||
| reading any of those without writing | the `read:` half is enough |
|
||||
|
||||
`kettle auth add --scopes write:issue,write:repository` writes that down beside
|
||||
the login. **It is a note and nothing else** — nothing is checked against it and
|
||||
nothing is refused because of it. It is worth writing down because the instance
|
||||
will not answer the question: Gitea's own token listing needs a password rather
|
||||
than a token, so a token cannot be asked what it may do. `kettle auth list` and
|
||||
`kettle config` show what was recorded; `(not recorded)` means nobody wrote it
|
||||
down, never "none".
|
||||
|
||||
A **403** from a sync command or from `kettle api` is usually this and says so.
|
||||
Minting a new token is the operator's job in the web UI — the same flow as step
|
||||
2 above, with both scopes ticked this time. Never remove or re-point a login to
|
||||
route around a 403.
|
||||
|
||||
**No `kettle` on PATH?** `command not found: kettle` is the whole story — and it
|
||||
means the binary that wrote this file has since moved or been removed, because
|
||||
nothing else could have put it here. Stop and tell the operator to reinstall it:
|
||||
`go install git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest`, or
|
||||
`cd cli && make install` in a checkout of that repository (go.mod requires
|
||||
**go 1.26**).
|
||||
|
||||
The full flag table for `auth`, `config` and `init` is the generated block in
|
||||
`/kettle:project`.
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
description: Make THIS directory a project that tracks issues — run `kettle init`, which creates the `.kettle/` marker every other command resolves the store from, migrates an older store in, gitignores it, and writes this command file and its skills. Carries the rules the binary cannot enforce — never an `--at` nobody named, never a migration clash resolved for them.
|
||||
argument-hint: "[--at DIR] [--interactive] [--login NAME] [--repo owner/name] [--dry-run]"
|
||||
allowed-tools: Bash(kettle init:*), Bash(kettle config:*), Bash(git rev-parse:*)
|
||||
---
|
||||
|
||||
# /kettle:init — make this directory a project
|
||||
|
||||
Initializing is a statement, and the operator makes it: *this* directory is the
|
||||
project whose issues live in it. Nothing infers it — `.git` is in every clone,
|
||||
and a tool that inferred its root from one wrote other projects' issues into its
|
||||
own versioned cache. It is answered once, by a person, and every command
|
||||
downstream reads the answer instead of guessing.
|
||||
|
||||
**This is a command, not a skill, and that is the point.** It is invoked by an
|
||||
operator who typed it. There is no `description:` here for a model to match on,
|
||||
because "which directory is the project" is not a question a model gets to answer
|
||||
on its own.
|
||||
|
||||
The binary does the work and is idempotent. What this file carries is what it
|
||||
cannot decide.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Run it, passing the operator's arguments through unchanged:
|
||||
|
||||
```bash
|
||||
kettle init $ARGUMENTS
|
||||
```
|
||||
|
||||
With no `--at` it initializes the current directory. **Never supply an `--at`
|
||||
the operator did not name.** Which directory is the project is the one
|
||||
question this command exists to have a person answer; picking a plausible one
|
||||
is the failure the marker replaces.
|
||||
|
||||
2. Report what it printed, verbatim. `already initialized — nothing to do` is a
|
||||
success, not something to work around.
|
||||
|
||||
`--login` pins a login by name and `--repo` names the tracker repository; both
|
||||
are optional and both can be added later by running `init` again — it writes the
|
||||
config without disturbing settings it was not given. Neither is a credential:
|
||||
the tokens live in one file per machine, `/kettle:auth`.
|
||||
|
||||
## `--interactive` is for a person at a terminal
|
||||
|
||||
It walks the operator through the login, the repository, this `.claude/` tree and
|
||||
the `AGENTS.md` mirror hook, one question at a time.
|
||||
|
||||
**Do not run it.** It refuses a stdin that is not a terminal and says so, which is
|
||||
the right failure — but the right move is not to reach for it in the first place.
|
||||
Every question it asks has a flag: `--login`, `--repo`, `--scaffold`,
|
||||
`--mirror-hook`. Pass the flags the operator named and let the binary answer the
|
||||
rest.
|
||||
|
||||
## When it stops
|
||||
|
||||
- **A linked worktree.** The binary refuses, and it is right to: `.kettle/` is
|
||||
gitignored, so a worktree has no marker by design and reaches the main
|
||||
checkout's store on its own — the walk crosses to it through the `gitdir:` in
|
||||
the `.git` *file*. A marker here would give one project two stores, and the
|
||||
second is deleted with the branch. What needs initializing, if anything, is the
|
||||
main checkout, and the refusal names it.
|
||||
- **A name clash on the migration** — the same file name on both sides. It exits
|
||||
having changed nothing and names the files. Report that. Do **not** move,
|
||||
delete, or merge either side: one of them may be an `origin: local` issue,
|
||||
which *is* the issue and the only copy of that work. The operator decides
|
||||
which survives.
|
||||
- **A marker already exists above this directory.** A second one gives that
|
||||
project a second store and the nearer one wins. Confirm with the operator
|
||||
before going ahead; usually they are standing in a subdirectory and there is
|
||||
nothing to do.
|
||||
|
||||
**No `kettle` on PATH?** `command not found: kettle` is the whole story — and it
|
||||
means the binary that wrote this file has since moved or been removed, because
|
||||
nothing else could have put it here. Stop and tell the operator to reinstall it:
|
||||
`go install git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest`, or
|
||||
`cd cli && make install` in a checkout of that repository (go.mod requires
|
||||
**go 1.26**).
|
||||
|
||||
## After
|
||||
|
||||
- `/kettle:issue` works now — offline, no login, no network.
|
||||
- `/kettle:auth` puts a token on this machine and pins the login this project
|
||||
runs under; needed only for the tracker side, `/kettle:sync`.
|
||||
- `kettle config` prints every path and setting this directory resolved to, and
|
||||
is the first thing to run when something looks like it landed in the wrong
|
||||
place. It also reports when this `.claude/` tree was written by an older
|
||||
`kettle` than the one now installed.
|
||||
|
||||
The full flag table for `init` is the generated block in `/kettle:project`.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user