Add agentpr and watchpr CLI tools
agentpr manages PRs/comments/whoami as unkin-agent (Vault AppRole -> gitea creds -> Gitea API), fixing tea's post-as-Ben default. watchpr polls PRs and alerts only on merge/close, human comment, CI failure, or lost mergeability. - cobra multi-binary layout mirroring node-lookup (cmd/ + internal/) - Makefile (build, patch|minor|major, completions, rpm), nfpm RPM with both binaries + bash/zsh/fish completions, woodpecker CI publishing to rpm-internal - unit tests for parsing, meaningful-change detection, and the Vault+Gitea client
This commit is contained in:
@@ -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/
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
@@ -1,3 +1,86 @@
|
|||||||
# agent-tools
|
# 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
|
||||||
|
```
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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, ""
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Executable
+49
@@ -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
|
||||||
Reference in New Issue
Block a user