# 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/`), creates worktrees under the worktree root (`~/.cache/agentws/__`), and authenticates clone/fetch/push via an ephemeral credential helper. Subcommands: `new`, `list`, `rm`, `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/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/` 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 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=`). `agentws new` wires this per worktree — it enables `extensions.worktreeConfig` on the repo once, then writes `user.name`, `user.email` and `credential.helper = ! 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/` (`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 ```bash 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) ```bash 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 `PUT`s 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 ```bash 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 --dest-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=` — Authentik's `search` is a substring match, so the exact `name` is re-checked client-side. 3. `GET /api/v3/core/tokens//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 --client-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). - The token cache is process-wide (`sync.Once`); tests call the unexported `fetchGiteaToken` to avoid it. - `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. - CI "combined status" comes from `/commits/{sha}/status`; an empty head SHA yields an empty state without an API call.