Files
agent-tools/AGENTS.md
T
unkin-agent 6380270ac6
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Find every stale worktree, not just the managed ones
- 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
2026-09-12 00:16:32 +10:00

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 as unkin-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 for unkin-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):

  1. AppRole login: POST $VAULT_ADDR/v1/auth/approle/login with role_id only (no secret_id) → client_token.
  2. GET $VAULT_ADDR/v1/<creds path> with X-Vault-Token.data.token, where the creds path is GITEA_CREDS_PATH if set, else gitea/creds/$AGENT_LOGIN (so unset env still reads gitea/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:

  1. AppRole login (shared approleLogin), then KV-v2 read of kv/service/authentik/agent-api-token (field token, falling back to api_token).
  2. GET /api/v3/outposts/instances/?search=<name> — Authentik's search is a substring match, so the exact name is re-checked client-side.
  3. GET /api/v3/core/tokens/<token_identifier>/view_key/ for the key.
  4. KV-v2 write to --dest-path under --dest-key (default token).

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:

  1. AppRole login (shared approleLogin), then a KV-v2 read via ReadKVOptional — a 404 or a deleted version means "empty", not an error, so the first seed of a path works.
  2. Desired keys are computed over the existing map: client_id from the flag (kept/created/updated), client_secret and cookie_secret generated from 32 crypto/rand bytes only when absent or when --rotate is set (kept/created/rotated). cookie_secret is base64url so it decodes to exactly the 32 bytes oauth2-proxy demands; client_secret is standard base64.
  3. Any other key on the path is carried through unchanged (preserved), which is why the write goes through WriteKVAny rather than WriteKV.
  4. 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

  • watchpr exits 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, watchpr exits non-zero rather than polling blind.
  • watchpr polls 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); RefreshGiteaToken replaces it. Tests call the unexported fetchGiteaToken to avoid the cache.
  • agentvault never puts a secret in an error string: Vault decode failures and Authentik view_key responses are reported without their bodies, and seed-oauth reports key names only.
  • --rotate regenerates the client_secret too, which then no longer matches the IdP provider unless that is rotated alongside.
  • agentws prune is a dry run unless --yes. It matches a branch to its PR on head.label: Gitea rewrites head.ref to refs/pull/<n>/head once the branch is deleted, which merging does, so head.ref matching 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 in origin/<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 prune discovers worktrees from the worktree root and from git worktree list on each source checkout, merging the two so git's own locked/prunable flags 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.