Merge pull request 'Add agentpr and watchpr CLI tools' (#1) from benvin/init into main
ci/woodpecker/tag/release Pipeline was successful

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-08-12 21:51:27 +10:00
23 changed files with 1839 additions and 1 deletions
+7
View File
@@ -0,0 +1,7 @@
# built binaries (repo root only — not the cmd/ source dirs)
/agentpr
/watchpr
# cross-compiled release artifacts (e.g. agentpr-linux-amd64)
/agentpr-*
/watchpr-*
dist/
+28
View File
@@ -0,0 +1,28 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-merge-conflict
- id: mixed-line-ending
args: [--fix=lf]
- repo: https://github.com/dnephin/pre-commit-golang
rev: v0.5.1
hooks:
- id: go-fmt
- id: go-unit-tests
# go-vet at the module level (dnephin's go-vet runs at repo root, which has no
# .go files here since both tools live under cmd/). The CI pre-commit image
# (almalinux9-gobuilder) has go installed.
- repo: local
hooks:
- id: go-vet-mod
name: go vet (module)
entry: go vet ./...
language: system
types: [go]
pass_filenames: false
+18
View File
@@ -0,0 +1,18 @@
when:
- event: pull_request
steps:
- name: build
image: golang:1.25
commands:
- make build
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+18
View File
@@ -0,0 +1,18 @@
when:
- event: pull_request
steps:
- name: pre-commit
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
commands:
- uvx pre-commit run --all-files
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+153
View File
@@ -0,0 +1,153 @@
when:
- event: tag
steps:
- name: test
image: golang:1.25
commands:
- go test -race ./...
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
# Build both binaries into dist/ (consumed by the RPM step) plus the
# cross-platform binaries attached to the Gitea release. Each tool is a
# separate main package, so they are built individually per os/arch.
- name: build
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
commands:
- make build VERSION=${CI_COMMIT_TAG}
# Shell variables/expansions are escaped as $$ so Woodpecker leaves them
# for the shell instead of substituting them (as pipeline vars) at parse
# time. ${CI_COMMIT_TAG} is a real Woodpecker var and stays single-$.
- |
for entry in "agentpr:./cmd/agentpr" "watchpr:./cmd/watchpr"; do
name="$${entry%%:*}"; pkg="$${entry##*:}"
for osarch in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do
os="$${osarch%/*}"; arch="$${osarch#*/}"
GOOS="$$os" GOARCH="$$arch" \
go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" \
-o "$${name}-$${os}-$${arch}" "$$pkg"
done
done
depends_on: [test]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
# Package the built binaries + generated shell completions into an RPM.
- name: package
image: git.unkin.net/unkin/almalinux9-rpmbuilder:latest
commands:
- ./scripts/build-rpm.sh ${CI_COMMIT_TAG}
depends_on: [build]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
# Publish the RPM to the artifactapi local rpm repo (a real yum repo;
# repodata regenerates automatically).
- name: upload-rpm
image: git.unkin.net/unkin/almalinux9-base:20260606
commands:
- |
HOST="https://artifactapi.k8s.syd1.au.unkin.net"
REPO="rpm-internal"
for rpm in dist/*.rpm; do
FILE=$$(basename "$$rpm")
# artifactapi has no HEAD route (returns 405); probe with GET against
# the served path (RPMs are stored under Packages/) to avoid re-upload.
code=$$(curl -s -o /dev/null -w '%{http_code}' "$$HOST/api/v2/remotes/$$REPO/files/Packages/$$FILE" || true)
if [ "$$code" = "200" ]; then
echo "$$FILE already exists in $$REPO (HTTP $$code); skipping upload"
continue
fi
echo "Uploading $$FILE to $$REPO (existence probe returned $$code)"
curl -f -X PUT \
"$$HOST/api/v2/remotes/$$REPO/files/$$FILE" \
-H "Content-Type: application/x-rpm" \
--data-binary @"$$rpm"
done
depends_on: [package]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 128Mi
cpu: 100m
limits:
memory: 512Mi
cpu: 500m
# Cut a Gitea release with the cross-platform binaries attached.
- name: release
image: git.unkin.net/unkin/almalinux9-base:20260606
environment:
RELEASER_TOKEN:
from_secret: RELEASER_TOKEN
commands:
- |
curl --output /usr/local/bin/tea https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/gitea-dl/tea/0.12.0/tea-0.12.0-linux-amd64 && chmod +x /usr/local/bin/tea
tea logins add --name gitea --url https://git.unkin.net --token "$${RELEASER_TOKEN}" --no-version-check
# $$ escapes shell vars/substitutions so Woodpecker doesn't blank them
# at parse time; ${CI_COMMIT_TAG}/${CI_REPO} are real Woodpecker vars.
# Find the previous release tag for the changelog range. Several tags can
# point at the same commit, so we skip tags on the current commit and
# pick the newest semver tag that is a real ancestor of this one.
CUR_SHA=$$(git rev-list -n1 "${CI_COMMIT_TAG}")
PREV_TAG=""
for t in $$(git tag --sort=-v:refname); do
[ "$$t" = "${CI_COMMIT_TAG}" ] && continue
[ "$$(git rev-list -n1 "$$t")" = "$$CUR_SHA" ] && continue
if git merge-base --is-ancestor "$$t" "${CI_COMMIT_TAG}" 2>/dev/null; then
PREV_TAG="$$t"; break
fi
done
if [ -n "$$PREV_TAG" ]; then
NOTES=$$(git log "$${PREV_TAG}..${CI_COMMIT_TAG}" --pretty=format:"- %s")
else
NOTES=$$(git log --pretty=format:"- %s")
fi
tea releases create --tag "${CI_COMMIT_TAG}" --title "${CI_COMMIT_TAG}" --note "$${NOTES}" --login gitea --repo "${CI_REPO}"
# The build step writes the cross-compiled binaries into the workspace
# root; the package step writes the RPM to dist/. Generate a checksums
# manifest over everything we attach so downloads can be verified.
RPM=$$(ls dist/*.rpm 2>/dev/null | head -1)
ASSETS="agentpr-linux-amd64 agentpr-linux-arm64 agentpr-darwin-amd64 agentpr-darwin-arm64 watchpr-linux-amd64 watchpr-linux-arm64 watchpr-darwin-amd64 watchpr-darwin-arm64"
[ -n "$$RPM" ] && ASSETS="$$ASSETS $$RPM"
sha256sum $$ASSETS > sha256sums.txt
tea releases assets create "${CI_COMMIT_TAG}" $$ASSETS sha256sums.txt \
--login gitea --repo "${CI_REPO}"
depends_on: [upload-rpm]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 128Mi
cpu: 100m
limits:
memory: 512Mi
cpu: 500m
+33
View File
@@ -0,0 +1,33 @@
when:
- event: pull_request
steps:
- name: lint
image: golangci/golangci-lint:latest
commands:
- golangci-lint run ./...
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
- name: test
image: golang:1.25
commands:
- go test -v -race ./...
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+103
View File
@@ -0,0 +1,103 @@
# AGENTS.md
## Project Overview
This repo ships two Gitea-automation CLIs in one RPM (`agent-tools`). Both act
as the `unkin-agent` user by minting a scoped Gitea token from Vault, so
actions are attributed to the agent rather than to whoever runs the tool.
- **`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.
Both 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).
## Structure
```
cmd/agentpr/main.go # agentpr CLI (pr create / pr comment / whoami)
cmd/watchpr/main.go # watchpr CLI (poll + meaningful-change exit)
internal/agent/ # shared plumbing:
token.go # env config + in-process Gitea-token cache
vault.go # AppRole login + read gitea/creds/unkin-agent
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
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 (both 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)
Both 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/gitea/creds/unkin-agent` with `X-Vault-Token``.data.token`.
Config via env (all have defaults):
| Variable | Default | Purpose |
|---|---|---|
| `VAULT_ADDR` | `https://vault.service.consul:8200` | Vault/OpenBao address |
| `AGENT_APPROLE_ROLE_ID` | `ababbcd3-9c77-5c6a-be2d-287fce9214a6` | AppRole role_id |
| `GITEA_URL` | `https://git.unkin.net` | Gitea base URL |
| `AGENT_LOGIN` | `unkin-agent` | login whose comments watchpr ignores |
## Build
```bash
make build # -> dist/agentpr, dist/watchpr (CGO disabled, static)
```
Requires Go 1.21+. Dependency: `github.com/spf13/cobra` (CLI).
## Packaging (RPM)
```bash
make rpm # build both 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` and `/usr/bin/watchpr`.
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.
## 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.
- CI "combined status" comes from `/commits/{sha}/status`; an empty head SHA
yields an empty state without an API call.
+79
View File
@@ -0,0 +1,79 @@
# All shipped binaries and the package path each is built from. Both tools live
# under cmd/; the module root ships no binary of its own.
BINARIES := agentpr watchpr
DIST := dist
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)"
OS ?= $(shell go env GOOS)
ARCH ?= $(shell go env GOARCH)
# The Go package path for a binary. Both tools live under cmd/. Usable inside a
# shell for-loop over $(BINARIES).
pkgpath = ./cmd/$$b
.PHONY: all build test lint fmt clean install completions rpm rpm-package patch minor major _tag
all: build
# Build every binary into dist/ so the nfpm packaging step
# (scripts/build-rpm.sh) can find them. Each main package needs its own -o, so
# they are built individually rather than with a single ./... invocation.
build:
@for b in $(BINARIES); do \
echo "building $$b"; \
CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build $(GOFLAGS) -o $(DIST)/$$b $(pkgpath) || exit 1; \
done
test:
go test -v -race ./...
lint:
golangci-lint run ./...
fmt:
gofmt -w .
clean:
rm -rf $(DIST) $(BINARIES)
install:
go install $(GOFLAGS) ./...
# Generate bash/zsh/fish completions for every binary into dist/completions.
completions: build
@mkdir -p $(DIST)/completions
@for b in $(BINARIES); do \
$(DIST)/$$b completion bash > $(DIST)/completions/$$b.bash; \
$(DIST)/$$b completion zsh > $(DIST)/completions/_$$b; \
$(DIST)/$$b completion fish > $(DIST)/completions/$$b.fish; \
done
# Build the binaries then package them (with completions) into an RPM via nfpm.
rpm: build rpm-package
# Package already-built binaries into an RPM (used by CI after the build step).
rpm-package:
./scripts/build-rpm.sh $(VERSION)
# Bump helpers — reads the latest semver tag and creates the next one.
# If no tag exists yet, starts from v0.0.0.
_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1)
_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0)
_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1)
_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2)
_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3)
patch:
@NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \
git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
minor:
@NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \
git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
major:
@NEW=v$(shell expr $(_MAJ) + 1).0.0; \
git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
_tag:
git push origin $(TAG)
+84 -1
View File
@@ -1,3 +1,86 @@
# agent-tools
CLI tools for orchestrator PR automation as unkin-agent
Two small Gitea-automation CLIs, shipped together in one RPM (`agent-tools`).
Both act as the **`unkin-agent`** user by minting a scoped Gitea token from
Vault, so automated PRs and comments are attributed to the agent — not to
whoever happens to run the command.
- **`agentpr`** — create pull requests and post PR comments as `unkin-agent`.
- **`watchpr`** — poll one or more PRs and exit when one changes in a way worth
acting on.
## 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/unkin-agent` to obtain a short-lived Gitea
token, cached in-process for the run.
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` | `ababbcd3-9c77-5c6a-be2d-287fce9214a6` | AppRole role_id |
| `GITEA_URL` | `https://git.unkin.net` | Gitea base URL |
| `AGENT_LOGIN` | `unkin-agent` | login whose comments `watchpr` ignores |
## agentpr
```bash
# Verify identity (should print: unkin-agent)
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
# 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.
## Build & package
```bash
make build # -> dist/agentpr, dist/watchpr
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
```
+165
View File
@@ -0,0 +1,165 @@
// Command agentpr manages Gitea pull requests and comments as the unkin-agent
// user. It obtains a scoped Gitea token from Vault (AppRole login, then reads
// gitea/creds/unkin-agent) so actions are attributed to the agent rather than
// to whoever runs the tool.
//
// agentpr pr create --repo owner/repo --base main --head feature --title T --body B
// agentpr pr comment --repo owner/repo --pr 12 --body "..."
// agentpr whoami
package main
import (
"fmt"
"os"
"git.unkin.net/unkin/agent-tools/internal/agent"
"github.com/spf13/cobra"
)
var version = "dev"
func main() {
root := &cobra.Command{
Use: "agentpr",
Short: "Manage Gitea PRs and comments as the unkin-agent user.",
Long: "agentpr manages Gitea pull requests and comments as unkin-agent, using a\nGitea token minted from Vault (AppRole login + gitea/creds/unkin-agent).",
Version: version,
SilenceUsage: true,
}
root.SetVersionTemplate("{{.Version}}\n")
root.AddCommand(newPRCmd(), newWhoamiCmd(), newVersionCmd())
if err := root.Execute(); err != nil {
os.Exit(1)
}
}
// client mints a Gitea token via Vault and returns a ready client.
func client() (*agent.GiteaClient, error) {
token, err := agent.GiteaToken()
if err != nil {
return nil, err
}
return agent.NewGiteaClient(token), nil
}
func newPRCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "pr",
Short: "Create PRs and post PR comments",
}
cmd.AddCommand(newPRCreateCmd(), newPRCommentCmd())
return cmd
}
func newPRCreateCmd() *cobra.Command {
var repo, base, head, title, body string
cmd := &cobra.Command{
Use: "create",
Short: "Open a pull request",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
owner, name, err := agent.ParseRepo(repo)
if err != nil {
return err
}
if base == "" || head == "" || title == "" {
return fmt.Errorf("--base, --head and --title are required")
}
c, err := client()
if err != nil {
return err
}
pr, err := c.CreatePR(owner+"/"+name, agent.CreatePROptions{
Base: base,
Head: head,
Title: title,
Body: body,
})
if err != nil {
return err
}
fmt.Printf("#%d %s\n", pr.Number, pr.HTMLURL)
return nil
},
}
f := cmd.Flags()
f.StringVar(&repo, "repo", "", "Repository as owner/repo (required)")
f.StringVar(&base, "base", "", "Base branch (required)")
f.StringVar(&head, "head", "", "Head branch (required)")
f.StringVar(&title, "title", "", "PR title (required)")
f.StringVar(&body, "body", "", "PR body")
_ = cmd.MarkFlagRequired("repo")
return cmd
}
func newPRCommentCmd() *cobra.Command {
var repo, body string
var pr int
cmd := &cobra.Command{
Use: "comment",
Short: "Post a comment on a pull request",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
owner, name, err := agent.ParseRepo(repo)
if err != nil {
return err
}
if pr <= 0 {
return fmt.Errorf("--pr must be a positive PR number")
}
if body == "" {
return fmt.Errorf("--body is required")
}
c, err := client()
if err != nil {
return err
}
cm, err := c.CreateComment(owner+"/"+name, pr, body)
if err != nil {
return err
}
fmt.Printf("comment %d posted on %s/%s#%d\n", cm.ID, owner, name, pr)
return nil
},
}
f := cmd.Flags()
f.StringVar(&repo, "repo", "", "Repository as owner/repo (required)")
f.IntVar(&pr, "pr", 0, "PR number (required)")
f.StringVar(&body, "body", "", "Comment body (required)")
_ = cmd.MarkFlagRequired("repo")
_ = cmd.MarkFlagRequired("pr")
_ = cmd.MarkFlagRequired("body")
return cmd
}
func newWhoamiCmd() *cobra.Command {
return &cobra.Command{
Use: "whoami",
Short: "Print the authenticated Gitea login (should be unkin-agent)",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
u, err := c.Whoami()
if err != nil {
return err
}
fmt.Println(u.Login)
return nil
},
}
}
func newVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print the version",
Run: func(cmd *cobra.Command, args []string) { fmt.Println(version) },
SilenceUsage: true,
}
}
+179
View File
@@ -0,0 +1,179 @@
// Command watchpr polls one or more Gitea pull requests and exits when a
// tracked PR changes in a way worth alerting on: it merges or closes, gets a
// new comment from someone other than the agent, its CI fails, or it loses
// mergeability. Benign transitions (CI pending→success, the agent's own
// comments) are ignored.
//
// watchpr owner/repo#12 owner/repo:15
// watchpr --once --json owner/repo#12
// watchpr --interval 30s owner/repo#12
package main
import (
"encoding/json"
"fmt"
"os"
"time"
"git.unkin.net/unkin/agent-tools/internal/agent"
"github.com/spf13/cobra"
)
var version = "dev"
func main() {
var interval time.Duration
var once, jsonMode bool
root := &cobra.Command{
Use: "watchpr [flags] owner/repo#N [owner/repo#N ...]",
Short: "Poll Gitea PRs and exit when one changes meaningfully.",
Long: "watchpr polls each PR every --interval and exits (reporting what changed)\n" +
"when a PR merges/closes, gets a new non-agent comment, its CI fails, or it\n" +
"loses mergeability. Accepts refs as owner/repo#N or owner/repo:N.",
Version: version,
Args: cobra.ArbitraryArgs,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return fmt.Errorf("no PR references given (e.g. owner/repo#12)")
}
refs := make([]agent.PRRef, 0, len(args))
for _, a := range args {
ref, err := agent.ParsePRRef(a)
if err != nil {
return err
}
refs = append(refs, ref)
}
c, err := clientFor()
if err != nil {
return err
}
if once {
return runOnce(c, refs, jsonMode)
}
return runWatch(c, refs, interval, jsonMode)
},
}
root.SetVersionTemplate("{{.Version}}\n")
f := root.Flags()
f.DurationVar(&interval, "interval", 60*time.Second, "Polling interval")
f.BoolVar(&once, "once", false, "Check once, print current state, and exit")
f.BoolVar(&jsonMode, "json", false, "Emit JSON")
root.AddCommand(&cobra.Command{
Use: "version",
Short: "Print the version",
Run: func(cmd *cobra.Command, args []string) { fmt.Println(version) },
SilenceUsage: true,
})
if err := root.Execute(); err != nil {
os.Exit(1)
}
}
func clientFor() (*agent.GiteaClient, error) {
token, err := agent.GiteaToken()
if err != nil {
return nil, err
}
return agent.NewGiteaClient(token), nil
}
// runOnce fetches and prints the current state of each PR, then exits 0.
func runOnce(c *agent.GiteaClient, refs []agent.PRRef, jsonMode bool) error {
login := agent.AgentLogin()
states := make([]agent.PRState, 0, len(refs))
for _, ref := range refs {
st, err := agent.FetchState(c, ref, login)
if err != nil {
return err
}
states = append(states, st)
}
if jsonMode {
return json.NewEncoder(os.Stdout).Encode(states)
}
for _, st := range states {
printState(st)
}
return nil
}
// runWatch establishes a baseline then polls until a tracked PR changes
// meaningfully, at which point it reports the change and returns.
func runWatch(c *agent.GiteaClient, refs []agent.PRRef, interval time.Duration, jsonMode bool) error {
login := agent.AgentLogin()
prev := make(map[string]agent.PRState, len(refs))
for _, ref := range refs {
st, err := agent.FetchState(c, ref, login)
if err != nil {
return err
}
prev[ref.String()] = st
}
if !jsonMode {
fmt.Fprintf(os.Stderr, "watching %d PR(s) every %s; baseline established\n", len(refs), interval)
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
for _, ref := range refs {
key := ref.String()
cur, err := agent.FetchState(c, ref, login)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: polling %s: %v\n", key, err)
continue
}
changed, reason := agent.MeaningfulChange(prev[key], cur)
if changed {
report(key, reason, cur, jsonMode)
return nil
}
prev[key] = cur
}
}
return nil
}
// report emits the change that ended the watch.
func report(key, reason string, st agent.PRState, jsonMode bool) {
if jsonMode {
_ = json.NewEncoder(os.Stdout).Encode(struct {
Changed bool `json:"changed"`
Reason string `json:"reason"`
State agent.PRState `json:"state"`
}{true, reason, st})
return
}
fmt.Printf("%s changed: %s\n", key, reason)
printState(st)
}
func printState(st agent.PRState) {
fmt.Printf("%s state=%s merged=%t mergeable=%t ci=%s head=%s comments(non-agent)=%d\n",
st.Ref.String(), st.State, st.Merged, st.Mergeable, ciOrNone(st.CIStatus), shortSHA(st.HeadSHA), st.NonAgentComments)
}
func ciOrNone(s string) string {
if s == "" {
return "none"
}
return s
}
func shortSHA(s string) string {
if len(s) > 8 {
return s[:8]
}
if s == "" {
return "-"
}
return s
}
+10
View File
@@ -0,0 +1,10 @@
module git.unkin.net/unkin/agent-tools
go 1.25.7
require github.com/spf13/cobra v1.10.2
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
)
+10
View File
@@ -0,0 +1,10 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+179
View File
@@ -0,0 +1,179 @@
package agent
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
// fakeVault serves the AppRole login and gitea creds endpoints.
func fakeVault(t *testing.T, wantRoleID, giteaToken string) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/v1/auth/approle/login", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("approle login method = %s, want POST", r.Method)
}
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
if body["role_id"] != wantRoleID {
t.Errorf("role_id = %q, want %q", body["role_id"], wantRoleID)
}
if _, ok := body["secret_id"]; ok {
t.Errorf("secret_id must not be sent")
}
_, _ = io.WriteString(w, `{"auth":{"client_token":"s.vaulttoken"}}`)
})
mux.HandleFunc("/v1/"+GiteaCredsPath, func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Vault-Token"); got != "s.vaulttoken" {
t.Errorf("X-Vault-Token = %q, want s.vaulttoken", got)
}
_, _ = io.WriteString(w, `{"data":{"token":"`+giteaToken+`"}}`)
})
return httptest.NewServer(mux)
}
func TestFetchGiteaToken(t *testing.T) {
srv := fakeVault(t, "role-xyz", "gitea-abc")
defer srv.Close()
tok, err := fetchGiteaToken(srv.URL, "role-xyz")
if err != nil {
t.Fatalf("fetchGiteaToken: %v", err)
}
if tok != "gitea-abc" {
t.Errorf("token = %q, want gitea-abc", tok)
}
}
func TestFetchGiteaTokenLoginError(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/v1/auth/approle/login", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = io.WriteString(w, `{"errors":["permission denied"]}`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
if _, err := fetchGiteaToken(srv.URL, "role-xyz"); err == nil {
t.Fatal("expected error on 403 login")
}
}
func TestCreatePRRequestBody(t *testing.T) {
var gotPath, gotAuth string
var gotBody CreatePROptions
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls", func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
_ = json.NewDecoder(r.Body).Decode(&gotBody)
_, _ = io.WriteString(w, `{"number":7,"state":"open","html_url":"https://git.unkin.net/unkin/repo/pulls/7","head":{"sha":"deadbeef"}}`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
c := &GiteaClient{BaseURL: srv.URL, Token: "gitea-abc", HTTP: srv.Client()}
pr, err := c.CreatePR("unkin/repo", CreatePROptions{Base: "main", Head: "feature", Title: "T", Body: "B"})
if err != nil {
t.Fatalf("CreatePR: %v", err)
}
if gotPath != "/api/v1/repos/unkin/repo/pulls" {
t.Errorf("path = %q", gotPath)
}
if gotAuth != "token gitea-abc" {
t.Errorf("auth header = %q, want 'token gitea-abc'", gotAuth)
}
if gotBody.Base != "main" || gotBody.Head != "feature" || gotBody.Title != "T" || gotBody.Body != "B" {
t.Errorf("request body = %+v", gotBody)
}
if pr.Number != 7 || pr.HTMLURL == "" {
t.Errorf("parsed PR = %+v", pr)
}
}
func TestCreateComment(t *testing.T) {
var gotBody map[string]string
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/unkin/repo/issues/7/comments", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&gotBody)
_, _ = io.WriteString(w, `{"id":99,"user":{"login":"unkin-agent"},"body":"hi"}`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
c := &GiteaClient{BaseURL: srv.URL, Token: "t", HTTP: srv.Client()}
cm, err := c.CreateComment("unkin/repo", 7, "hi")
if err != nil {
t.Fatalf("CreateComment: %v", err)
}
if gotBody["body"] != "hi" {
t.Errorf("comment body = %q", gotBody["body"])
}
if cm.ID != 99 {
t.Errorf("comment id = %d, want 99", cm.ID)
}
}
func TestWhoami(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/user", func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{"login":"unkin-agent","id":42}`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
c := &GiteaClient{BaseURL: srv.URL, Token: "t", HTTP: srv.Client()}
u, err := c.Whoami()
if err != nil {
t.Fatalf("Whoami: %v", err)
}
if u.Login != "unkin-agent" {
t.Errorf("login = %q, want unkin-agent", u.Login)
}
}
func TestFetchState(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls/7", func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{"number":7,"state":"open","merged":false,"mergeable":true,"title":"feat","html_url":"u","head":{"sha":"cafebabecafebabe"}}`)
})
mux.HandleFunc("/api/v1/repos/unkin/repo/commits/cafebabecafebabe/status", func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{"state":"success"}`)
})
mux.HandleFunc("/api/v1/repos/unkin/repo/issues/7/comments", func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `[{"id":1,"user":{"login":"unkin-agent"}},{"id":2,"user":{"login":"ben"}}]`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
c := &GiteaClient{BaseURL: srv.URL, Token: "t", HTTP: srv.Client()}
ref := PRRef{Owner: "unkin", Repo: "repo", Number: 7}
st, err := FetchState(c, ref, "unkin-agent")
if err != nil {
t.Fatalf("FetchState: %v", err)
}
if st.State != "open" || st.CIStatus != "success" || st.HeadSHA != "cafebabecafebabe" {
t.Errorf("state = %+v", st)
}
if st.NonAgentComments != 1 {
t.Errorf("NonAgentComments = %d, want 1 (agent comment excluded)", st.NonAgentComments)
}
}
func TestGiteaAPIError(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = io.WriteString(w, `{"message":"head and base are the same"}`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
c := &GiteaClient{BaseURL: srv.URL, Token: "t", HTTP: srv.Client()}
if _, err := c.CreatePR("unkin/repo", CreatePROptions{Base: "main", Head: "main", Title: "x"}); err == nil {
t.Fatal("expected error on 422")
}
}
+148
View File
@@ -0,0 +1,148 @@
package agent
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
// GiteaClient talks to the Gitea REST API as the agent user.
type GiteaClient struct {
BaseURL string
Token string
HTTP *http.Client
}
// NewGiteaClient builds a client from the configured base URL and a Vault-minted
// token.
func NewGiteaClient(token string) *GiteaClient {
return &GiteaClient{BaseURL: GiteaURL(), Token: token, HTTP: httpClient}
}
func (c *GiteaClient) do(method, path string, body any, out any) error {
var reader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
reader = bytes.NewReader(b)
}
url := strings.TrimRight(c.BaseURL, "/") + path
req, err := http.NewRequest(method, url, reader)
if err != nil {
return err
}
req.Header.Set("Authorization", "token "+c.Token)
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
data, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("gitea %s %s: HTTP %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(data)))
}
if out != nil && len(data) > 0 {
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("gitea %s %s: decoding response: %w", method, path, err)
}
}
return nil
}
// User is the subset of the Gitea user object we care about.
type User struct {
Login string `json:"login"`
ID int64 `json:"id"`
}
// Whoami returns the authenticated user (GET /api/v1/user).
func (c *GiteaClient) Whoami() (User, error) {
var u User
err := c.do(http.MethodGet, "/api/v1/user", nil, &u)
return u, err
}
// PullRequest is the subset of Gitea's PR object we track.
type PullRequest struct {
Number int `json:"number"`
State string `json:"state"`
Title string `json:"title"`
Merged bool `json:"merged"`
Mergeable bool `json:"mergeable"`
HTMLURL string `json:"html_url"`
Head struct {
Sha string `json:"sha"`
} `json:"head"`
}
// CreatePROptions are the fields for opening a PR.
type CreatePROptions struct {
Base string `json:"base"`
Head string `json:"head"`
Title string `json:"title"`
Body string `json:"body"`
}
// CreatePR opens a pull request (POST /api/v1/repos/{owner}/{repo}/pulls).
func (c *GiteaClient) CreatePR(repoPath string, opts CreatePROptions) (PullRequest, error) {
var pr PullRequest
err := c.do(http.MethodPost, "/api/v1/repos/"+repoPath+"/pulls", opts, &pr)
return pr, err
}
// GetPR fetches a single pull request.
func (c *GiteaClient) GetPR(repoPath string, number int) (PullRequest, error) {
var pr PullRequest
err := c.do(http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/pulls/%d", repoPath, number), nil, &pr)
return pr, err
}
// Comment is the subset of an issue comment we track.
type Comment struct {
ID int64 `json:"id"`
User User `json:"user"`
Body string `json:"body"`
}
// CreateComment posts a comment on the PR's issue thread
// (POST /api/v1/repos/{owner}/{repo}/issues/{n}/comments).
func (c *GiteaClient) CreateComment(repoPath string, number int, body string) (Comment, error) {
var cm Comment
payload := map[string]string{"body": body}
err := c.do(http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/issues/%d/comments", repoPath, number), payload, &cm)
return cm, err
}
// ListComments lists the PR's issue comments.
func (c *GiteaClient) ListComments(repoPath string, number int) ([]Comment, error) {
var out []Comment
err := c.do(http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/issues/%d/comments", repoPath, number), nil, &out)
return out, err
}
// CombinedStatus is the combined commit status for a ref.
type CombinedStatus struct {
State string `json:"state"`
}
// CommitStatus returns the combined CI status for a commit SHA
// (GET /api/v1/repos/{owner}/{repo}/commits/{sha}/status). An empty ref yields
// an empty state without an API call.
func (c *GiteaClient) CommitStatus(repoPath, sha string) (string, error) {
if sha == "" {
return "", nil
}
var cs CombinedStatus
err := c.do(http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/commits/%s/status", repoPath, sha), nil, &cs)
return cs.State, err
}
+59
View File
@@ -0,0 +1,59 @@
package agent
import (
"fmt"
"strconv"
"strings"
)
// PRRef identifies a single pull request by repository and number.
type PRRef struct {
Owner string
Repo string
Number int
}
// String renders the ref in canonical owner/repo#N form.
func (r PRRef) String() string {
return fmt.Sprintf("%s/%s#%d", r.Owner, r.Repo, r.Number)
}
// RepoPath returns the "owner/repo" portion used in Gitea API URLs.
func (r PRRef) RepoPath() string {
return r.Owner + "/" + r.Repo
}
// ParsePRRef parses "owner/repo#N" or "owner/repo:N" into a PRRef.
func ParsePRRef(s string) (PRRef, error) {
s = strings.TrimSpace(s)
sep := strings.IndexAny(s, "#:")
if sep < 0 {
return PRRef{}, fmt.Errorf("invalid PR reference %q: expected owner/repo#N or owner/repo:N", s)
}
repoPart := s[:sep]
numPart := s[sep+1:]
owner, repo, ok := strings.Cut(repoPart, "/")
if !ok || owner == "" || repo == "" {
return PRRef{}, fmt.Errorf("invalid PR reference %q: repo must be owner/repo", s)
}
if strings.Contains(repo, "/") {
return PRRef{}, fmt.Errorf("invalid PR reference %q: repo must be owner/repo", s)
}
n, err := strconv.Atoi(numPart)
if err != nil || n <= 0 {
return PRRef{}, fmt.Errorf("invalid PR reference %q: PR number must be a positive integer", s)
}
return PRRef{Owner: owner, Repo: repo, Number: n}, nil
}
// ParseRepo validates and splits an "owner/repo" string.
func ParseRepo(s string) (owner, repo string, err error) {
s = strings.TrimSpace(s)
owner, repo, ok := strings.Cut(s, "/")
if !ok || owner == "" || repo == "" || strings.Contains(repo, "/") {
return "", "", fmt.Errorf("invalid repo %q: expected owner/repo", s)
}
return owner, repo, nil
}
+79
View File
@@ -0,0 +1,79 @@
package agent
import "testing"
func TestParsePRRef(t *testing.T) {
tests := []struct {
in string
wantErr bool
owner string
repo string
num int
}{
{"unkin/argocd-apps#42", false, "unkin", "argocd-apps", 42},
{"unkin/argocd-apps:42", false, "unkin", "argocd-apps", 42},
{" unkin/repo#1 ", false, "unkin", "repo", 1},
{"unkin/repo#0", true, "", "", 0},
{"unkin/repo#-3", true, "", "", 0},
{"unkin/repo#abc", true, "", "", 0},
{"unkin/repo", true, "", "", 0},
{"unkinrepo#3", true, "", "", 0},
{"unkin/a/b#3", true, "", "", 0},
{"/repo#3", true, "", "", 0},
{"unkin/#3", true, "", "", 0},
}
for _, tt := range tests {
got, err := ParsePRRef(tt.in)
if tt.wantErr {
if err == nil {
t.Errorf("ParsePRRef(%q): expected error, got %+v", tt.in, got)
}
continue
}
if err != nil {
t.Errorf("ParsePRRef(%q): unexpected error: %v", tt.in, err)
continue
}
if got.Owner != tt.owner || got.Repo != tt.repo || got.Number != tt.num {
t.Errorf("ParsePRRef(%q) = %+v, want %s/%s#%d", tt.in, got, tt.owner, tt.repo, tt.num)
}
}
}
func TestPRRefString(t *testing.T) {
r := PRRef{Owner: "unkin", Repo: "repo", Number: 7}
if got := r.String(); got != "unkin/repo#7" {
t.Errorf("String() = %q, want unkin/repo#7", got)
}
if got := r.RepoPath(); got != "unkin/repo" {
t.Errorf("RepoPath() = %q, want unkin/repo", got)
}
}
func TestParseRepo(t *testing.T) {
tests := []struct {
in string
wantErr bool
owner string
repo string
}{
{"unkin/repo", false, "unkin", "repo"},
{" unkin/repo ", false, "unkin", "repo"},
{"repo", true, "", ""},
{"unkin/a/b", true, "", ""},
{"/repo", true, "", ""},
{"unkin/", true, "", ""},
}
for _, tt := range tests {
o, r, err := ParseRepo(tt.in)
if tt.wantErr {
if err == nil {
t.Errorf("ParseRepo(%q): expected error", tt.in)
}
continue
}
if err != nil || o != tt.owner || r != tt.repo {
t.Errorf("ParseRepo(%q) = (%q,%q,%v), want (%q,%q,nil)", tt.in, o, r, err, tt.owner, tt.repo)
}
}
}
+86
View File
@@ -0,0 +1,86 @@
// Package agent holds the plumbing shared by the agent-tools CLIs (agentpr and
// watchpr): obtaining a Gitea token via Vault AppRole, talking to the Gitea
// API, parsing PR references, and deciding when a watched PR changed
// meaningfully. Both tools acquire their Gitea token the same way, so that
// logic lives here once.
package agent
import (
"os"
"sync"
)
const (
// DefaultVaultAddr is the OpenBao/Vault address used when VAULT_ADDR is unset.
DefaultVaultAddr = "https://vault.service.consul:8200"
// DefaultRoleID is the agent AppRole role_id used when AGENT_APPROLE_ROLE_ID
// is unset. Login uses role_id only (no secret_id).
DefaultRoleID = "ababbcd3-9c77-5c6a-be2d-287fce9214a6"
// GiteaCredsPath is the Vault path that mints a scoped Gitea token.
GiteaCredsPath = "gitea/creds/unkin-agent"
// DefaultGiteaURL is the Gitea base URL used when GITEA_URL is unset.
DefaultGiteaURL = "https://git.unkin.net"
// DefaultAgentLogin is the Gitea login of the agent whose own comments are
// ignored by watchpr. Overridable via AGENT_LOGIN.
DefaultAgentLogin = "unkin-agent"
)
// VaultAddr returns the configured Vault address (env VAULT_ADDR or the default).
func VaultAddr() string {
if v := os.Getenv("VAULT_ADDR"); v != "" {
return v
}
return DefaultVaultAddr
}
// RoleID returns the configured AppRole role_id (env AGENT_APPROLE_ROLE_ID or
// the default).
func RoleID() string {
if v := os.Getenv("AGENT_APPROLE_ROLE_ID"); v != "" {
return v
}
return DefaultRoleID
}
// GiteaURL returns the configured Gitea base URL (env GITEA_URL or the default).
func GiteaURL() string {
if v := os.Getenv("GITEA_URL"); v != "" {
return v
}
return DefaultGiteaURL
}
// AgentLogin returns the login whose comments watchpr ignores (env AGENT_LOGIN
// or the default).
func AgentLogin() string {
if v := os.Getenv("AGENT_LOGIN"); v != "" {
return v
}
return DefaultAgentLogin
}
var (
tokenOnce sync.Once
tokenValue string
tokenErr error
)
// GiteaToken returns a Gitea token, minting it via Vault AppRole on first call
// and caching it in-process for the lifetime of the command.
func GiteaToken() (string, error) {
tokenOnce.Do(func() {
tokenValue, tokenErr = fetchGiteaToken(VaultAddr(), RoleID())
})
return tokenValue, tokenErr
}
// fetchGiteaToken performs the AppRole login and reads the Gitea creds. It is
// separated from GiteaToken so tests can exercise it directly against an
// httptest server without touching the process-wide cache.
func fetchGiteaToken(vaultAddr, roleID string) (string, error) {
clientToken, err := approleLogin(vaultAddr, roleID)
if err != nil {
return "", err
}
return readGiteaCreds(vaultAddr, clientToken)
}
+83
View File
@@ -0,0 +1,83 @@
package agent
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// httpClient is shared by the Vault and Gitea calls. A modest timeout keeps a
// hung endpoint from wedging watchpr's poll loop.
var httpClient = &http.Client{Timeout: 30 * time.Second}
// approleLogin logs in with role_id only (no secret_id) and returns the
// resulting client_token.
func approleLogin(vaultAddr, roleID string) (string, error) {
body, _ := json.Marshal(map[string]string{"role_id": roleID})
url := strings.TrimRight(vaultAddr, "/") + "/v1/auth/approle/login"
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("vault approle login: %w", err)
}
defer func() { _ = resp.Body.Close() }()
data, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("vault approle login: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
}
var out struct {
Auth struct {
ClientToken string `json:"client_token"`
} `json:"auth"`
}
if err := json.Unmarshal(data, &out); err != nil {
return "", fmt.Errorf("vault approle login: decoding response: %w", err)
}
if out.Auth.ClientToken == "" {
return "", fmt.Errorf("vault approle login: no client_token in response")
}
return out.Auth.ClientToken, nil
}
// readGiteaCreds reads the Gitea creds secret and returns the token field.
func readGiteaCreds(vaultAddr, clientToken string) (string, error) {
url := strings.TrimRight(vaultAddr, "/") + "/v1/" + GiteaCredsPath
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return "", err
}
req.Header.Set("X-Vault-Token", clientToken)
resp, err := httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("vault read %s: %w", GiteaCredsPath, err)
}
defer func() { _ = resp.Body.Close() }()
data, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("vault read %s: HTTP %d: %s", GiteaCredsPath, resp.StatusCode, strings.TrimSpace(string(data)))
}
var out struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
if err := json.Unmarshal(data, &out); err != nil {
return "", fmt.Errorf("vault read %s: decoding response: %w", GiteaCredsPath, err)
}
if out.Data.Token == "" {
return "", fmt.Errorf("vault read %s: no token field in secret", GiteaCredsPath)
}
return out.Data.Token, nil
}
+89
View File
@@ -0,0 +1,89 @@
package agent
// PRState is a point-in-time snapshot of the PR attributes watchpr tracks.
type PRState struct {
Ref PRRef `json:"ref"`
State string `json:"state"` // open / closed
Merged bool `json:"merged"`
HeadSHA string `json:"head_sha"`
Mergeable bool `json:"mergeable"`
CIStatus string `json:"ci_status"` // success / pending / failure / error / ""
NonAgentComments int `json:"non_agent_comments"`
Title string `json:"title"`
URL string `json:"url"`
}
// FetchState builds a PRState for the given ref. agentLogin's comments are
// excluded from the non-agent comment count.
func FetchState(c *GiteaClient, ref PRRef, agentLogin string) (PRState, error) {
pr, err := c.GetPR(ref.RepoPath(), ref.Number)
if err != nil {
return PRState{}, err
}
ci, err := c.CommitStatus(ref.RepoPath(), pr.Head.Sha)
if err != nil {
return PRState{}, err
}
comments, err := c.ListComments(ref.RepoPath(), ref.Number)
if err != nil {
return PRState{}, err
}
return PRState{
Ref: ref,
State: pr.State,
Merged: pr.Merged,
HeadSHA: pr.Head.Sha,
Mergeable: pr.Mergeable,
CIStatus: ci,
NonAgentComments: countNonAgentComments(comments, agentLogin),
Title: pr.Title,
URL: pr.HTMLURL,
}, nil
}
// countNonAgentComments counts comments authored by anyone other than agentLogin.
func countNonAgentComments(comments []Comment, agentLogin string) int {
n := 0
for _, cm := range comments {
if cm.User.Login != agentLogin {
n++
}
}
return n
}
// isFailedCI reports whether a combined CI state is a terminal failure.
func isFailedCI(state string) bool {
return state == "failure" || state == "error"
}
// MeaningfulChange compares a previous state to the current one and reports
// whether a change warrants alerting the operator, with a human-readable
// reason. Benign transitions (CI pending→success, the agent's own comments, an
// unchanged snapshot) return false.
//
// Alerting conditions:
// - the PR merged
// - the PR closed without merging
// - a new comment from someone other than the agent
// - CI transitioned into failure/error
// - the PR lost mergeability (a conflict appeared)
func MeaningfulChange(prev, cur PRState) (bool, string) {
if !prev.Merged && cur.Merged {
return true, "PR merged"
}
// Closed (not merged): only alert on the open→closed edge.
if prev.State == "open" && cur.State == "closed" && !cur.Merged {
return true, "PR closed without merging"
}
if cur.NonAgentComments > prev.NonAgentComments {
return true, "new comment from a non-agent user"
}
if isFailedCI(cur.CIStatus) && !isFailedCI(prev.CIStatus) {
return true, "CI failed (" + cur.CIStatus + ")"
}
if prev.Mergeable && !cur.Mergeable && cur.State == "open" {
return true, "PR lost mergeability (conflict)"
}
return false, ""
}
+116
View File
@@ -0,0 +1,116 @@
package agent
import "testing"
func base() PRState {
return PRState{
Ref: PRRef{Owner: "unkin", Repo: "repo", Number: 1},
State: "open",
Merged: false,
HeadSHA: "abc123",
Mergeable: true,
CIStatus: "pending",
NonAgentComments: 0,
}
}
func TestMeaningfulChange(t *testing.T) {
tests := []struct {
name string
mutate func(s *PRState)
wantChange bool
}{
{
name: "no change",
mutate: func(s *PRState) {},
wantChange: false,
},
{
name: "CI pending to success is benign",
mutate: func(s *PRState) { s.CIStatus = "success" },
wantChange: false,
},
{
name: "open to merged alerts",
mutate: func(s *PRState) { s.Merged = true; s.State = "closed" },
wantChange: true,
},
{
name: "open to closed without merge alerts",
mutate: func(s *PRState) { s.State = "closed" },
wantChange: true,
},
{
name: "new non-agent comment alerts",
mutate: func(s *PRState) { s.NonAgentComments = 1 },
wantChange: true,
},
{
name: "CI to failure alerts",
mutate: func(s *PRState) { s.CIStatus = "failure" },
wantChange: true,
},
{
name: "CI to error alerts",
mutate: func(s *PRState) { s.CIStatus = "error" },
wantChange: true,
},
{
name: "lost mergeability alerts",
mutate: func(s *PRState) { s.Mergeable = false },
wantChange: true,
},
{
name: "new head sha alone is benign",
mutate: func(s *PRState) { s.HeadSHA = "def456" },
wantChange: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
prev := base()
cur := base()
tt.mutate(&cur)
got, reason := MeaningfulChange(prev, cur)
if got != tt.wantChange {
t.Errorf("MeaningfulChange() = %v (%q), want %v", got, reason, tt.wantChange)
}
if got && reason == "" {
t.Errorf("change reported without a reason")
}
})
}
}
// A comment that only the agent posts must not alert: the non-agent count is
// unchanged, so MeaningfulChange sees nothing.
func TestMeaningfulChangeAgentCommentIgnored(t *testing.T) {
prev := base()
cur := base() // agent commented, but NonAgentComments stayed 0
if got, _ := MeaningfulChange(prev, cur); got {
t.Errorf("agent-only comment should not alert")
}
}
// Once CI is already failing, staying failed must not re-alert.
func TestMeaningfulChangeStaysFailed(t *testing.T) {
prev := base()
prev.CIStatus = "failure"
cur := base()
cur.CIStatus = "failure"
if got, _ := MeaningfulChange(prev, cur); got {
t.Errorf("CI staying failed should not re-alert")
}
}
func TestCountNonAgentComments(t *testing.T) {
comments := []Comment{
{User: User{Login: "unkin-agent"}},
{User: User{Login: "ben"}},
{User: User{Login: "unkin-agent"}},
{User: User{Login: "reviewer"}},
}
if n := countNonAgentComments(comments, "unkin-agent"); n != 2 {
t.Errorf("countNonAgentComments = %d, want 2", n)
}
}
+64
View File
@@ -0,0 +1,64 @@
---
# nfpm config for building the agent-tools RPM.
# Rendered through envsubst (see scripts/build-rpm.sh) then fed to `nfpm pkg`.
name: ${PACKAGE_NAME}
version: ${PACKAGE_VERSION}
release: ${PACKAGE_RELEASE}
arch: ${PACKAGE_ARCH}
platform: ${PACKAGE_PLATFORM}
section: default
priority: extra
description: "${PACKAGE_DESCRIPTION}"
maintainer: ${PACKAGE_MAINTAINER}
homepage: ${PACKAGE_HOMEPAGE}
license: ${PACKAGE_LICENSE}
disable_globbing: false
replaces:
- agent-tools
provides:
- agent-tools
contents:
# The CLI binaries.
- src: dist/agentpr
dst: /usr/bin/agentpr
file_info:
mode: 0755
owner: root
group: root
- src: dist/watchpr
dst: /usr/bin/watchpr
file_info:
mode: 0755
owner: root
group: root
# Shell completions (generated by scripts/build-rpm.sh before packaging).
- src: dist/completions/agentpr.bash
dst: /usr/share/bash-completion/completions/agentpr
file_info:
mode: 0644
- src: dist/completions/_agentpr
dst: /usr/share/zsh/site-functions/_agentpr
file_info:
mode: 0644
- src: dist/completions/agentpr.fish
dst: /usr/share/fish/vendor_completions.d/agentpr.fish
file_info:
mode: 0644
- src: dist/completions/watchpr.bash
dst: /usr/share/bash-completion/completions/watchpr
file_info:
mode: 0644
- src: dist/completions/_watchpr
dst: /usr/share/zsh/site-functions/_watchpr
file_info:
mode: 0644
- src: dist/completions/watchpr.fish
dst: /usr/share/fish/vendor_completions.d/watchpr.fish
file_info:
mode: 0644
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
#
# Package the (already built) agentpr and watchpr binaries into an RPM with
# nfpm, bundling generated bash/zsh/fish shell completions.
# Usage: scripts/build-rpm.sh [version] (version defaults to $CI_COMMIT_TAG)
#
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}"
VERSION="${VERSION#v}" # strip a leading v
PACKAGE="agent-tools"
BINARIES=(agentpr watchpr)
DIST="dist"
for b in "${BINARIES[@]}"; do
if [ ! -f "${DIST}/${b}" ]; then
echo "ERROR: ${DIST}/${b} not found; run 'make build' first" >&2
exit 1
fi
done
# Generate shell completions from the freshly built binaries so they always
# match the shipped flags/subcommands.
COMP_DIR="${DIST}/completions"
mkdir -p "${COMP_DIR}"
for b in "${BINARIES[@]}"; do
"./${DIST}/${b}" completion bash >"${COMP_DIR}/${b}.bash"
"./${DIST}/${b}" completion zsh >"${COMP_DIR}/_${b}"
"./${DIST}/${b}" completion fish >"${COMP_DIR}/${b}.fish"
done
export PACKAGE_NAME="${PACKAGE}"
export PACKAGE_VERSION="${VERSION}"
export PACKAGE_RELEASE="1"
export PACKAGE_ARCH="amd64"
export PACKAGE_PLATFORM="linux"
export PACKAGE_DESCRIPTION="CLI tools for Gitea automation as the unkin-agent user: agentpr (create PRs/comments) and watchpr (poll PRs and alert on meaningful change)"
export PACKAGE_MAINTAINER="Ben Vincent <ben@unkin.net>"
export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/agent-tools"
export PACKAGE_LICENSE="MIT"
envsubst <packaging/nfpm.yaml >"${DIST}/nfpm.yaml"
nfpm pkg --config "${DIST}/nfpm.yaml" --target "${DIST}" --packager rpm
echo "Built:"
ls -1 "${DIST}"/*.rpm