62aeaf063b
Any git error on a directory under the worktree root was classified orphan, and orphan deletes the directory outright, so a transient failure reading the source root became data loss on a plain `agentws prune --yes`. - prove a backing repo gone by stat before calling a directory an orphan - classify an unexplained git failure as keep, naming the error - refuse to remove a worktree whose git state is unknown, even with --include-keep - spell out that --include-keep discards uncommitted and in-progress work
304 lines
13 KiB
Markdown
304 lines
13 KiB
Markdown
# agent-tools
|
|
|
|
Small Gitea-automation CLIs, shipped together in one RPM (`agent-tools`). They
|
|
act as an agent user (**`unkin-agent`** by default) by minting a scoped Gitea
|
|
token from Vault, so automated PRs, comments and pushes are attributed to the
|
|
agent — not to whoever happens to run the command. Set `AGENT_LOGIN` to act as a
|
|
different agent identity.
|
|
|
|
- **`agentpr`** — create pull requests and post PR comments as the agent user.
|
|
- **`watchpr`** — poll one or more PRs and exit when one changes in a way worth
|
|
acting on.
|
|
- **`agentws`** — manage per-branch git worktrees for `unkin-agent`, cloning
|
|
into Ben's source checkout and isolating agent work under the XDG cache.
|
|
- **`agentvault`** — run deterministic Vault flows in one invocation, so agents
|
|
never plumb secret material through a shell.
|
|
|
|
## How it gets a token
|
|
|
|
On first use each tool performs a Vault AppRole login (`role_id` only, no
|
|
`secret_id`), then reads `gitea/creds/$AGENT_LOGIN` — or `GITEA_CREDS_PATH` when
|
|
set — to obtain a short-lived Gitea token, cached in-process for the run. With
|
|
neither variable set that is `gitea/creds/unkin-agent`, as before.
|
|
|
|
Everything is configured by environment variables, all with 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>`, and the login whose comments `watchpr` ignores |
|
|
| `GITEA_CREDS_PATH` | `gitea/creds/$AGENT_LOGIN` | Vault path minting the Gitea token (wins over `AGENT_LOGIN`) |
|
|
| `AGENTWS_SRC_ROOT` | `~/src/prodenv` | source-of-truth checkout root (`agentws`) |
|
|
| `AGENTWS_ROOT` | `~/.cache/agentws` | worktree root (`agentws`) |
|
|
| `AGENTWS_OWNER` | `unkin` | Gitea org that owns the repos (`agentws`) |
|
|
| `AUTHENTIK_URL` | `https://identity.k8s.syd1.au.unkin.net` | Authentik base URL (`agentvault`) |
|
|
|
|
## agentpr
|
|
|
|
```bash
|
|
# Verify identity (prints the agent login, unkin-agent by default)
|
|
agentpr whoami
|
|
|
|
# Open a PR
|
|
agentpr pr create --repo unkin/argocd-apps \
|
|
--base main --head benvin/my-change \
|
|
--title "Add woodpecker SA" --body "Adds the ServiceAccount ..."
|
|
# prints: #<number> <html_url>
|
|
|
|
# Comment on a PR
|
|
agentpr pr comment --repo unkin/argocd-apps --pr 42 --body "Rebased, CI green."
|
|
|
|
agentpr --version
|
|
agentpr --help
|
|
```
|
|
|
|
Non-zero exit on any API error.
|
|
|
|
## watchpr
|
|
|
|
Poll PRs and exit (reporting what changed) when a tracked PR **merges/closes**,
|
|
gets a **new comment from someone other than the agent**, its **CI fails**
|
|
(failure/error), or it **loses mergeability** (a conflict appears). Benign
|
|
transitions — CI `pending`→`success`, the agent's own comments — are ignored.
|
|
|
|
```bash
|
|
# Watch until something meaningful happens (default interval 60s)
|
|
watchpr unkin/argocd-apps#42
|
|
|
|
# Multiple PRs, custom interval; refs accept #N or :N
|
|
watchpr --interval 30s unkin/argocd-apps#42 unkin/terraform-vault:98
|
|
|
|
# --interval takes a duration (30s, 2m, 1h30m) or a bare number of seconds
|
|
watchpr --interval 30 unkin/argocd-apps#42
|
|
|
|
# One-shot: print current state and exit 0 (great for scripts)
|
|
watchpr --once unkin/argocd-apps#42
|
|
watchpr --once --json unkin/argocd-apps#42
|
|
```
|
|
|
|
On a meaningful change `watchpr` prints the reason and the PR's current state,
|
|
then exits 0. Use `--json` for machine-readable output.
|
|
|
|
## agentws
|
|
|
|
`agentws` gives an agent an isolated git worktree per branch without disturbing
|
|
Ben's shared checkouts. Repos are cloned into the **source root**
|
|
(`~/src/prodenv/<repo>`) so branches created here are visible in the main
|
|
checkout too; the worktrees themselves live under the **worktree root**
|
|
(`~/.cache/agentws/<repo>__<branch>`).
|
|
|
|
```bash
|
|
# Clone unkin/argocd-apps into ~/src/prodenv if missing, then add a worktree for
|
|
# a new branch off the remote default branch. Prints the worktree path.
|
|
agentws new argocd-apps --branch benvin/my-change
|
|
|
|
# Branch off a specific base instead of the remote default
|
|
agentws new argocd-apps --branch benvin/hotfix --from release-1.2
|
|
|
|
# List managed worktrees (repo, branch, path)
|
|
agentws list
|
|
|
|
# Remove a worktree (by path or branch); refreshes the source repo afterwards
|
|
agentws rm benvin/my-change
|
|
agentws rm ~/.cache/agentws/argocd-apps__benvin-my-change --delete-branch
|
|
|
|
# Classify every worktree found; dry run unless --yes is given
|
|
agentws prune
|
|
agentws prune --json
|
|
agentws prune --no-fetch
|
|
agentws prune --yes
|
|
agentws prune --yes --keep-branches
|
|
|
|
# Remove every managed worktree and prune each source repo
|
|
agentws clean
|
|
|
|
# Print a fresh Gitea token for the agent login
|
|
agentws token
|
|
```
|
|
|
|
### prune
|
|
|
|
`agentws prune` finds worktrees two ways and merges the results: the managed
|
|
directories under the worktree root, and `git worktree list` on every source
|
|
checkout — so hand-made worktrees, stale registrations whose directory is gone,
|
|
and leftover directories whose source repo was deleted all show up too.
|
|
|
|
It then decides, per worktree, whether its work is safely upstream:
|
|
|
|
| Signal (first match wins) | Verdict |
|
|
|---|---|
|
|
| working tree gone (registration only) | prune the registration |
|
|
| backing repo gone | delete the leftover directory |
|
|
| locked by `git worktree lock` | keep |
|
|
| rebase, merge, cherry-pick, revert or bisect in progress | keep |
|
|
| uncommitted, staged or untracked changes | keep |
|
|
| branch has an open PR | keep |
|
|
| tip contained in `origin/<default>` | remove worktree + local branch |
|
|
| every commit patch-equivalent to one in `origin/<default>`'s history | remove worktree + local branch |
|
|
| PR merged **and** HEAD contained in the PR's head commit (or in a verified `origin/<branch>`) | remove worktree + local branch |
|
|
| PR closed **and** HEAD contained in a verified `origin/<branch>` | remove worktree + local branch |
|
|
| detached HEAD carrying commits on no remote | keep |
|
|
| anything else | remove worktree, keep the branch |
|
|
|
|
A branch is deleted only where git proves its commits survive elsewhere. PR
|
|
state alone never authorises that: a merged or closed PR whose branch picked up
|
|
commits since keeps its branch, because those commits exist nowhere but here.
|
|
The delete runs `git branch -d` first so git's own unmerged check is a backstop,
|
|
falling back to `-D` only for a proven branch — squash merges keep the guard
|
|
tripping even once the work has landed.
|
|
|
|
Patch equivalence comes from `git cherry`, which these squash-merging repos need
|
|
because a merged branch's commits carry different SHAs upstream. It proves the
|
|
patches reached the default branch's history at some point — a later revert
|
|
still counts — not that they stand at its tip.
|
|
|
|
`origin/<branch>` counts as evidence only when this run's `git fetch --prune`
|
|
succeeded. A tracking ref left over from an earlier fetch may name a branch that
|
|
is already gone upstream and is itself due for deletion, so a failed fetch
|
|
downgrades those verdicts to `remove` and keeps the branch. Proofs that read
|
|
only local objects — containment in `origin/<default>`, patch equivalence, and
|
|
containment in a merged PR's head SHA — stand on their own.
|
|
|
|
Gitea PR state only adds to the git answer: when it cannot be reached, prune
|
|
says so and never deletes a branch it could not prove, and a PR listing that
|
|
hits the pagination cap is reported rather than read as "no PR". Matching a
|
|
branch to its PR uses `head.label`, since Gitea rewrites `head.ref` to
|
|
`refs/pull/<n>/head` once the branch is deleted on merge.
|
|
|
|
The last row is safe only because the local branch keeps the commits, so the
|
|
reason names the branch it is relying on. A detached HEAD has no such branch, so
|
|
unique commits there are kept instead.
|
|
|
|
Output is a table (`REPO BRANCH PATH VERDICT REASON`) with every verdict's reason
|
|
spelled out, or `--json` for scripting. Neither form needs a terminal.
|
|
|
|
| Flag | Effect |
|
|
|---|---|
|
|
| `--yes` | apply the plan; without it nothing is touched |
|
|
| `--keep-branches` | remove worktrees only; verdicts print as `remove` |
|
|
| `--no-fetch` | judge against the refs already on disk, for offline use |
|
|
| `--json` | emit the report as JSON on stdout, notes on stderr |
|
|
| `--include-unmanaged` | also remove worktrees outside the worktree root |
|
|
| `--include-keep` | dangerous: also remove worktrees classified `keep`, destroying uncommitted and in-progress work |
|
|
|
|
Without `--include-unmanaged` a hand-made worktree is reported and then skipped,
|
|
naming the flag that would remove it. `--include-keep` is the only way past a
|
|
`keep`. It leaves the branch, so committed work outlives the worktree, but
|
|
`git worktree remove --force` discards a dirty working tree and a paused
|
|
rebase's sequencer state without a word, and no branch was carrying those.
|
|
|
|
A directory under the worktree root is deleted outright only when its git dir
|
|
and the repo's shared `.git` are both proven absent by `stat`. When git merely
|
|
fails to answer for a checkout, the verdict is `keep` with the error as its
|
|
reason: an unread state is never a dead one.
|
|
|
|
### Auth / credential-helper design
|
|
|
|
Gitea tokens minted from Vault are short-lived (~1h), so `agentws` never
|
|
persists one in a remote URL or in git config. Instead it wires itself as an
|
|
**ephemeral git credential helper**:
|
|
|
|
- `agentws token` prints a fresh token to stdout (handy for scripts).
|
|
- `agentws credential get` speaks the git credential protocol on stdin and, for
|
|
the configured Gitea host only, emits `username=$AGENT_LOGIN` +
|
|
`password=<fresh token>`.
|
|
|
|
`agentws new` sets this up per worktree without touching the shared checkout: it
|
|
enables `extensions.worktreeConfig` on the repo once, then writes
|
|
`user.name` / `user.email` and `credential.helper = !<agentws> credential` to
|
|
the **per-worktree** config. Clone/fetch use the same helper via a transient
|
|
`-c credential.helper=...`; the shared `origin` URL is left clean. On worktree
|
|
removal `agentws` fetches in `~/src/prodenv/<repo>` so its default branch stays
|
|
current.
|
|
|
|
## agentvault
|
|
|
|
Deterministic Vault flows, each a single self-contained invocation: the tool
|
|
reads and writes the secrets itself, and prints only identifiers.
|
|
|
|
### seed-outpost
|
|
|
|
Copy an Authentik outpost's token into Vault KV-v2. `agentvault` reads the
|
|
Authentik API token from `kv/service/authentik/agent-api-token`, resolves the
|
|
named outpost's `token_identifier`, fetches its key via
|
|
`/api/v3/core/tokens/<identifier>/view_key/` and writes it to the destination
|
|
KV path. The token value is never printed or logged.
|
|
|
|
```bash
|
|
agentvault seed-outpost \
|
|
--outpost k8s-outpost \
|
|
--dest-path kubernetes/namespace/authentik/default/outpost-token
|
|
```
|
|
|
|
```
|
|
outpost: k8s-outpost
|
|
token_identifier: ak-outpost-k8s-outpost
|
|
dest: kv/kubernetes/namespace/authentik/default/outpost-token
|
|
version: 3
|
|
```
|
|
|
|
Re-running is safe: it writes a new KV version. Flags: `--outpost` and
|
|
`--dest-path` are required; `--dest-key` (default `token`), `--kv-mount`
|
|
(default `kv`), `--token-path` (default `service/authentik/agent-api-token`) and
|
|
`--authentik-url` override the rest.
|
|
|
|
Errors name the failing stage: AppRole login, KV read denied (policy not
|
|
applied), outpost not found (terraform not applied), `view_key` failure, or KV
|
|
write denied.
|
|
|
|
### seed-oauth
|
|
|
|
Make a Vault KV-v2 path hold a complete oauth2-proxy credential set. It is a
|
|
read-modify-write: `client_id` is set from the flag, `client_secret` and
|
|
`cookie_secret` are generated (32 bytes from `crypto/rand`) only when missing,
|
|
every other key on the path is written back untouched, and nothing is written
|
|
at all when the secret is already correct. `cookie_secret` is base64url so it
|
|
decodes to exactly the 32 bytes oauth2-proxy requires.
|
|
|
|
```bash
|
|
agentvault seed-oauth \
|
|
--path kubernetes/namespace/repospawner/default/oauth-credentials \
|
|
--client-id 4f1c…
|
|
```
|
|
|
|
```
|
|
path: kv/kubernetes/namespace/repospawner/default/oauth-credentials
|
|
keys: client_id, client_secret, cookie_secret
|
|
client_id: created
|
|
client_secret: kept
|
|
cookie_secret: created
|
|
version: 4
|
|
```
|
|
|
|
That is the common case: the provider's `client_secret` already lives on the
|
|
path, so only the missing keys are added. A run with nothing to do prints
|
|
`version: unchanged` and issues no write.
|
|
|
|
Flags: `--path` and `--client-id` are required; `--kv-mount` (default `kv`) and
|
|
`--rotate` override the rest. `--rotate` regenerates both secrets — only use it
|
|
when the IdP provider's secret is being rotated alongside, since a rotated
|
|
`client_secret` no longer matches the provider.
|
|
|
|
Errors name the failing stage: AppRole login, KV read denied, or KV write
|
|
denied. Only key names, actions and the KV version are printed.
|
|
|
|
## Build & package
|
|
|
|
```bash
|
|
make build # -> dist/agentpr, dist/watchpr, dist/agentws, dist/agentvault
|
|
make test # go test -race ./...
|
|
make rpm # build + package dist/agent-tools-<version>-1.x86_64.rpm
|
|
```
|
|
|
|
Release is tag-driven (`v*`) via Woodpecker: builds the RPM, `PUT`s it to the
|
|
artifactapi `rpm-internal` yum repo, and cuts a Gitea release with
|
|
cross-compiled binaries attached.
|
|
|
|
### Version bump
|
|
|
|
```bash
|
|
make patch # or: make minor / make major — tags vX.Y.Z and pushes the tag
|
|
```
|