diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5e507cc --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2e63b82 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +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-vet + - id: go-unit-tests diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml new file mode 100644 index 0000000..74123b5 --- /dev/null +++ b/.woodpecker/build.yaml @@ -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 diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..d57b508 --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -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 diff --git a/.woodpecker/release.yaml b/.woodpecker/release.yaml new file mode 100644 index 0000000..232649b --- /dev/null +++ b/.woodpecker/release.yaml @@ -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 diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml new file mode 100644 index 0000000..5e179a7 --- /dev/null +++ b/.woodpecker/test.yaml @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7abad7e --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e23ec6c --- /dev/null +++ b/Makefile @@ -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) diff --git a/README.md b/README.md index b34114f..0dbc053 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,86 @@ # agent-tools -CLI tools for orchestrator PR automation as unkin-agent \ No newline at end of file +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: # + +# 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--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 +``` diff --git a/cmd/agentpr/main.go b/cmd/agentpr/main.go new file mode 100644 index 0000000..63548d9 --- /dev/null +++ b/cmd/agentpr/main.go @@ -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, + } +} diff --git a/cmd/watchpr/main.go b/cmd/watchpr/main.go new file mode 100644 index 0000000..f9b8cfe --- /dev/null +++ b/cmd/watchpr/main.go @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..05685ae --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module git.unkin.net/unkin/agent-tools + +go 1.26.5 + +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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a6ee3e0 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/agent/client_test.go b/internal/agent/client_test.go new file mode 100644 index 0000000..a06b34f --- /dev/null +++ b/internal/agent/client_test.go @@ -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") + } +} diff --git a/internal/agent/gitea.go b/internal/agent/gitea.go new file mode 100644 index 0000000..74eb809 --- /dev/null +++ b/internal/agent/gitea.go @@ -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 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 +} diff --git a/internal/agent/parse.go b/internal/agent/parse.go new file mode 100644 index 0000000..3816ccf --- /dev/null +++ b/internal/agent/parse.go @@ -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 +} diff --git a/internal/agent/parse_test.go b/internal/agent/parse_test.go new file mode 100644 index 0000000..8449470 --- /dev/null +++ b/internal/agent/parse_test.go @@ -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) + } + } +} diff --git a/internal/agent/token.go b/internal/agent/token.go new file mode 100644 index 0000000..b2ef3f6 --- /dev/null +++ b/internal/agent/token.go @@ -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) +} diff --git a/internal/agent/vault.go b/internal/agent/vault.go new file mode 100644 index 0000000..b7f6b95 --- /dev/null +++ b/internal/agent/vault.go @@ -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 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 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 +} diff --git a/internal/agent/watch.go b/internal/agent/watch.go new file mode 100644 index 0000000..f36181c --- /dev/null +++ b/internal/agent/watch.go @@ -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, "" +} diff --git a/internal/agent/watch_test.go b/internal/agent/watch_test.go new file mode 100644 index 0000000..1a3f273 --- /dev/null +++ b/internal/agent/watch_test.go @@ -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) + } +} diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml new file mode 100644 index 0000000..6f4bbc2 --- /dev/null +++ b/packaging/nfpm.yaml @@ -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 diff --git a/scripts/build-rpm.sh b/scripts/build-rpm.sh new file mode 100755 index 0000000..afc9f3b --- /dev/null +++ b/scripts/build-rpm.sh @@ -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 " +export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/agent-tools" +export PACKAGE_LICENSE="MIT" + +envsubst "${DIST}/nfpm.yaml" +nfpm pkg --config "${DIST}/nfpm.yaml" --target "${DIST}" --packager rpm + +echo "Built:" +ls -1 "${DIST}"/*.rpm