- discover worktrees from `git worktree list` on each source checkout, not only the worktree root, so hand-made ones, stale registrations and orphaned directories are classified too - normalise each candidate to its main checkout, so a linked worktree in the source root cannot offer up the repo's real checkout - keep locked, mid-rebase and detached-with-unique-commits worktrees, whose removal would destroy state nothing else holds - name the retained branch in every unproven verdict - add --no-fetch, --json, --include-unmanaged and --include-keep
11 KiB
AGENTS.md
Project Overview
This repo ships several Gitea-automation CLIs in one RPM (agent-tools). They
act as an agent user (unkin-agent by default) by minting a scoped Gitea token
from Vault, so actions are attributed to the agent rather than to whoever runs
the tool. Setting AGENT_LOGIN selects a different agent identity, so a service
like repospawner can run these tools as itself.
agentpr— create pull requests and post PR comments asunkin-agent(fixes the "tea posts as Ben" attribution problem). Subcommands:pr create,pr comment,whoami.watchpr— poll one or more PRs and exit when a tracked PR changes meaningfully: it merges/closes, gets a new non-agent comment, its CI fails, or it loses mergeability. Benign transitions (CI pending→success, the agent's own comments) are ignored.agentws— manage per-branch git worktrees forunkin-agent. Clones repos into the source root (~/src/prodenv/<repo>), creates worktrees under the worktree root (~/.cache/agentws/<repo>__<branch>), and authenticates clone/fetch/push via an ephemeral credential helper. Subcommands:new,list,rm,prune,clean,token,credential.
All tools are separate main packages under cmd/ and share the
internal/agent package (Vault AppRole login, Gitea REST client, PR-ref
parsing, watch-state comparison, git worktree helpers).
Structure
cmd/agentpr/main.go # agentpr CLI (pr create / pr comment / whoami)
cmd/watchpr/main.go # watchpr CLI (poll + meaningful-change exit)
cmd/agentws/main.go # agentws CLI (new / list / rm / clean / token / credential)
cmd/agentws/prune.go # agentws prune (classify worktrees, remove the safe ones)
cmd/agentvault/main.go # agentvault CLI (seed-outpost / seed-oauth)
internal/agent/ # shared plumbing:
token.go # env config + in-process Gitea-token cache
vault.go # AppRole login + read the gitea creds path
gitea.go # Gitea REST client (PR create/get, comments, status, whoami)
parse.go # owner/repo#N and owner/repo parsing
watch.go # PRState snapshot + MeaningfulChange comparison
git.go # git worktree/clone/fetch helpers (os/exec, no go-git)
vaultkv.go # AppRole-authenticated Vault client + KV-v2 read/write
authentik.go # Authentik REST client (outpost search, token view_key)
seedoutpost.go # seed-outpost flow (Authentik token -> Vault KV)
seedoauth.go # seed-oauth flow (oauth2-proxy credential set in Vault KV)
go.mod # module git.unkin.net/unkin/agent-tools
Makefile # build / test / lint / completions / rpm / version-bump
packaging/nfpm.yaml # nfpm spec (envsubst-templated) for the RPM (all binaries)
scripts/build-rpm.sh # generates completions + packages the RPM with nfpm
.woodpecker/ # CI: build, test, pre-commit (PR) + release (tag)
dist/ # build output: binaries, completions, RPM (not committed)
Every binary is a separate main package, so make build builds each with its
own -o (a single go build ./... can't emit multiple mains to one file).
Token acquisition (shared)
All tools call agent.GiteaToken(), which (once per process):
- AppRole login:
POST $VAULT_ADDR/v1/auth/approle/loginwithrole_idonly (nosecret_id) →client_token. GET $VAULT_ADDR/v1/<creds path>withX-Vault-Token→.data.token, where the creds path isGITEA_CREDS_PATHif set, elsegitea/creds/$AGENT_LOGIN(so unset env still readsgitea/creds/unkin-agent).
Config via env (all have defaults):
| Variable | Default | Purpose |
|---|---|---|
VAULT_ADDR |
https://vault.service.consul:8200 |
Vault/OpenBao address |
AGENT_APPROLE_ROLE_ID |
built-in default | AppRole role_id (overridable) |
GITEA_URL |
https://git.unkin.net |
Gitea base URL |
AGENT_LOGIN |
unkin-agent |
agent identity: selects gitea/creds/<login>; login whose comments watchpr ignores; agentws git identity |
GITEA_CREDS_PATH |
gitea/creds/$AGENT_LOGIN |
Vault path minting the Gitea token (wins over AGENT_LOGIN) |
AGENTWS_SRC_ROOT |
~/src/prodenv |
agentws source-of-truth checkout root |
AGENTWS_ROOT |
~/.cache/agentws |
agentws worktree root |
AGENTWS_OWNER |
unkin |
Gitea org that owns agentws-managed repos |
AUTHENTIK_URL |
https://identity.k8s.syd1.au.unkin.net |
Authentik base URL (agentvault) |
agentws git auth (ephemeral credential helper)
Gitea tokens are ~1h ephemeral, so agentws never bakes one into a remote URL
or config. agentws token prints a fresh token; agentws credential get
implements the git credential protocol (reads the key=value request on stdin,
and for the configured Gitea host only emits username=$AGENT_LOGIN +
password=<fresh token>). agentws new wires this per worktree — it enables
extensions.worktreeConfig on the repo once, then writes user.name,
user.email and credential.helper = !<agentws> credential to the
per-worktree config so the shared checkout's identity/config is untouched.
Clone/fetch pass the same helper transiently via -c credential.helper=....
Worktrees are created FROM ~/src/prodenv/<repo> (git worktree add) so agent
branches are visible in Ben's main checkout; rm/clean fetch there afterwards
to keep the default branch current.
Build
make build # -> dist/agentpr, dist/watchpr, dist/agentws, dist/agentvault (CGO disabled, static)
Requires Go 1.21+. Dependency: github.com/spf13/cobra (CLI).
Packaging (RPM)
make rpm # build all binaries + package into dist/*.rpm via nfpm
scripts/build-rpm.sh generates bash/zsh/fish completions from the built
binaries and bundles them alongside /usr/bin/agentpr, /usr/bin/watchpr,
/usr/bin/agentws and /usr/bin/agentvault.
On a v* tag the release pipeline builds the RPM and PUTs it to the
artifactapi rpm-internal repo, then cuts a Gitea release.
Shell completions
Cobra provides a completion subcommand for each binary
(agentpr completion bash, etc.). The RPM installs them to the standard system
paths (/usr/share/bash-completion/completions/,
/usr/share/zsh/site-functions/, /usr/share/fish/vendor_completions.d/).
Testing
make test # go test -v -race ./...
internal/agent covers PR-ref parsing, the MeaningfulChange table (benign vs
alerting transitions), request-body construction, and the Vault+Gitea client
against httptest servers (fake AppRole login + gitea creds + PR create /
comment / whoami / status). No live Vault/Gitea access is required for tests.
agentvault seed-outpost
agentvault seed-outpost --outpost <name> --dest-path <kv/path> does the whole
flow in-process:
- AppRole login (shared
approleLogin), then KV-v2 read ofkv/service/authentik/agent-api-token(fieldtoken, falling back toapi_token). GET /api/v3/outposts/instances/?search=<name>— Authentik'ssearchis a substring match, so the exactnameis re-checked client-side.GET /api/v3/core/tokens/<token_identifier>/view_key/for the key.- KV-v2 write to
--dest-pathunder--dest-key(defaulttoken).
Only the outpost name, token identifier, dest path and new KV version are
printed. Errors are wrapped per stage (login / read denied / outpost missing /
view_key / write denied) via the ErrVaultDenied, ErrVaultNotFound and
ErrOutpostNotFound sentinels.
agentvault seed-oauth
agentvault seed-oauth --path <kv/path> --client-id <id> makes a KV-v2 path
hold a complete oauth2-proxy credential set, in-process:
- AppRole login (shared
approleLogin), then a KV-v2 read viaReadKVOptional— a 404 or a deleted version means "empty", not an error, so the first seed of a path works. - Desired keys are computed over the existing map:
client_idfrom the flag (kept/created/updated),client_secretandcookie_secretgenerated from 32crypto/randbytes only when absent or when--rotateis set (kept/created/rotated).cookie_secretis base64url so it decodes to exactly the 32 bytes oauth2-proxy demands;client_secretis standard base64. - Any other key on the path is carried through unchanged (
preserved), which is why the write goes throughWriteKVAnyrather thanWriteKV. - The write is skipped entirely when nothing changed; the command then prints
version: unchanged.
Only key names, per-key actions and the new KV version are printed. Errors are
wrapped per stage (login / read denied / write denied) via ErrVaultDenied.
Gotchas
watchprexits 0 with no output changes on--once(just prints state).- Gitea tokens expire in ~1h, shorter than a watch: the client re-mints once on a
401/403 and replays the request. If the fresh token is rejected too,
watchprexits non-zero rather than polling blind. watchprpolls anonymously when no token can be minted (public repos work fine); only a real 401/403 reaches for Vault.- The token cache is process-wide (mutex-guarded);
RefreshGiteaTokenreplaces it. Tests call the unexportedfetchGiteaTokento avoid the cache. agentvaultnever puts a secret in an error string: Vault decode failures and Authentikview_keyresponses are reported without their bodies, andseed-oauthreports key names only.--rotateregenerates theclient_secrettoo, which then no longer matches the IdP provider unless that is rotated alongside.agentws pruneis a dry run unless--yes. It matches a branch to its PR onhead.label: Gitea rewriteshead.reftorefs/pull/<n>/headonce the branch is deleted, which merging does, sohead.refmatching misses every merged PR. Git signals (merge-base --is-ancestor,git cherry) are authoritative and offline-safe; an unreachable Gitea only means no branch gets deleted without git proof. A PR's state never authorises a branch delete on its own — HEAD must be contained in the PR's head commit or inorigin/<branch>, otherwise the worktree goes and the branch stays.origin/<branch>is only evidence when this run's pruning fetch succeeded; a failed fetch leaves stale tracking refs, so those verdicts fall back to keeping the branch.agentws prunediscovers worktrees from the worktree root and fromgit worktree liston each source checkout, merging the two so git's ownlocked/prunableflags reach entries the directory scan already found. Removing a worktree is only safe because the local branch keeps its commits, so the cases with no branch to fall back on are kept: a detached HEAD carrying commits on no remote, a locked checkout, or one with a sequencer operation half-finished (rebase-merge,MERGE_HEAD,CHERRY_PICK_HEAD, …). A directory whose backing repo is gone is deleted outright, but only ever inside the worktree root.- CI "combined status" comes from
/commits/{sha}/status; an empty head SHA yields an empty state without an API call.