6380270ac6
- 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
211 lines
11 KiB
Markdown
211 lines
11 KiB
Markdown
# 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
|
|
|
|
```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 <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.
|