Compare commits

...
Author SHA1 Message Date
claude-botandClaude Opus 5.5 a14bd5a85a feat(review-bot): review on request, read less, log token usage
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/review-bot-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Reviews start only when review-bot is a requested reviewer: the
pull_request webhook with action review_requested for the bot, or the
sweep finding the bot in requested_reviewers. Gitea drops the request
when the bot posts its review, so each request yields one review. Pushes
and opened PRs alone start nothing.

The prompt reads the diff first, opens whole files only where the hunks
are not enough, reads the base side only when mergeable is false or the
base moved, and aims at 10-20 tool calls. Re-reviews read only the
commits since the last marker via compare_commits, now an enabled tool.

Every review logs and exports input, output and cache tokens
(review_bot_tokens_total, review_bot_tool_calls_total).

The selector falls back to the default model when it picks another one
with less than SELECTOR_MIN_CONFIDENCE (0.6), and asks for the cheapest
capable model. Sonnet is the default, Opus the exception, Haiku is gone
(41-50 tool calls on trivial PRs), Mistral stays as a separate budget.
Anthropic effort defaults to medium.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:54:38 +01:00
k.renner 35a387a3c1 Merge pull request 'chore: follow homelab to konairius/homelab (merge inside Cut-over 1 only)' (#1372) from claude/homelab-konairius into master
ci/woodpecker/push/ci-claude-refresh Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1372
2026-10-19 08:52:21 +00:00
k.renner 093857c353 Merge pull request 'feat(rotation): sync claude_code_oauth_token and review_bot_token into konairius' (#1370) from claude/konairius-org-secrets into master
Reviewed-on: k.renner/homelab#1370
2026-10-19 08:50:17 +00:00
claude-botandClaude Opus 5.5 7f29ebb878 chore: follow homelab to konairius/homelab
review-bot reviewed: comment
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/review-bot-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Flux source URL, flux-git rotator verify repo, review-bot REPOS, pipeline API,
clone and push URLs, the heating-can source label and the review-bot and
kuma-sync Go module paths. Merge right after the transfer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:43:23 +02:00
claude-botandClaude Opus 5.5 b07167ae1a feat(rotation): sync claude_code_oauth_token and review_bot_token into konairius
review-bot reviewed: comment
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Homelab moves to konairius/homelab; after the Woodpecker move its pipelines
resolve org secrets in konairius. The k.renner containers stay until the move
is done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:42:20 +02:00
40 changed files with 353 additions and 186 deletions
+4 -4
View File
@@ -30,9 +30,9 @@
# Triggering:
# - cron : a cron NAMED exactly "alert-monitor" (name filter, same
# mechanism as update-audit.yml):
# woodpecker repo cron add --repo k.renner/homelab \
# woodpecker repo cron add --repo konairius/homelab \
# --name alert-monitor --branch master --schedule '*/15 * * * *'
# - deploy : woodpecker-cli pipeline deploy k.renner/homelab <n> alert-monitor
# - deploy : woodpecker-cli pipeline deploy konairius/homelab <n> alert-monitor
# where <n> is an existing pipeline to promote.
when:
- event: cron
@@ -130,7 +130,7 @@ steps:
| select([.receivers[]?.name] | (length == 0) or any(. != "null"))]' \
.ci-alert-monitor/alerts-raw.json > .ci-alert-monitor/alerts-active.json
echo "Active alerts (after heartbeat filter): $$(jq length .ci-alert-monitor/alerts-active.json)"
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
curl -sfL "$$API/issues?state=open&type=issues&limit=100" > .ci-alert-monitor/open-issues.json
TITLES=$$(jq -c '[.[].title]' .ci-alert-monitor/open-issues.json)
NOISE_BODY=$$(jq -r 'map(select(.title == "Alert monitor: noise log")) | .[0].body // ""' \
@@ -238,7 +238,7 @@ steps:
exit 0
fi
jq . /tmp/alert-verdicts.json > /dev/null # fail loudly on malformed output
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
STAMP=$$(date -u +%Y-%m-%d)
gitea_write() {
+5 -5
View File
@@ -48,13 +48,13 @@
# OWNER SETUP — the token + secret above are already provisioned. The one
# remaining step, to be run AFTER this merges to master (a cron references a
# pipeline that must exist on the target branch), is to register the schedule:
# woodpecker repo cron add --repo k.renner/homelab \
# woodpecker repo cron add --repo konairius/homelab \
# --name ci-claude-refresh --branch master --schedule '@weekly'
# Weekly is plenty; the pipelines tolerate being a few days behind a release.
#
# Triggering:
# - cron : the cron NAMED exactly "ci-claude-refresh" (see above).
# - deploy : woodpecker-cli pipeline deploy k.renner/homelab <n> ci-claude-refresh
# - deploy : woodpecker-cli pipeline deploy konairius/homelab <n> ci-claude-refresh
# where <n> is an existing pipeline to promote.
# - push : a change to the Containerfile (or this file) ON master forces a
# rebuild of the current latest version so Containerfile fixes ship
@@ -174,9 +174,9 @@ steps:
# Both were validated non-empty by the decide step; re-assert before a
# sed that would corrupt every pin if either were blank.
[ -n "$$NEW" ] && [ -n "$$CUR" ] || { echo "empty version/current sentinel — aborting"; exit 1; }
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
BRANCH="ci-image/claude-$$NEW"
if git ls-remote --heads "https://gitea.renner.uno/k.renner/homelab.git" "$$BRANCH" | grep -q .; then
if git ls-remote --heads "https://gitea.renner.uno/konairius/homelab.git" "$$BRANCH" | grep -q .; then
echo "Branch $$BRANCH already exists — a PR for $$NEW is already open. Nothing to do."
exit 0
fi
@@ -197,7 +197,7 @@ steps:
git checkout -b "$$BRANCH"
git add $$CONSUMERS
git commit -m "chore(ci): bump ci-claude image to $$NEW"
git push "https://$$PUSH_TOKEN@gitea.renner.uno/k.renner/homelab.git" HEAD:"$$BRANCH"
git push "https://$$PUSH_TOKEN@gitea.renner.uno/konairius/homelab.git" HEAD:"$$BRANCH"
{
printf 'Automated bump of the ci-claude agent image from `%s` to `%s`.\n\n' "$$CUR" "$$NEW"
printf 'The refresh pipeline built and pushed `ci-claude:%s` (and `:latest`) from the\n' "$$NEW"
+1 -1
View File
@@ -7,7 +7,7 @@
# was harmless at daily/weekly cadence — but alert-monitor.yml
# fires every 15 minutes, so piggybacking must be opt-in now.
# A new cron that SHOULD also run e2e gets its name added here.
# - deploy : `woodpecker-cli pipeline deploy k.renner/homelab <n> e2e`, where
# - deploy : `woodpecker-cli pipeline deploy konairius/homelab <n> e2e`, where
# <n> is an existing pipeline to promote.
when:
+6 -6
View File
@@ -32,9 +32,9 @@
#
# Triggering:
# - cron : a cron NAMED exactly "issue-fix":
# woodpecker repo cron add --repo k.renner/homelab \
# woodpecker repo cron add --repo konairius/homelab \
# --name issue-fix --branch master --schedule '41 * * * *'
# - deploy : woodpecker-cli pipeline deploy k.renner/homelab <n> issue-fix
# - deploy : woodpecker-cli pipeline deploy konairius/homelab <n> issue-fix
# where <n> is an existing pipeline to promote.
when:
- event: cron
@@ -82,7 +82,7 @@ steps:
set -eu
TRUSTED_AUTHORS="k.renner" # space-separated; the trust anchor (see header)
mkdir -p /tmp/alert-fix
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
curl -sfL "$$API/issues?state=open&type=issues&limit=100" \
| jq '[.[] | select(.title | startswith("[alert] "))] | sort_by(.number)' \
> /tmp/alert-fix/candidates.json
@@ -163,7 +163,7 @@ steps:
# run skips this issue, and stop cleanly.
[ -f /tmp/alert-fix/issue.json ] || exit 0
set -eu
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
NUM=$$(jq -r '.number' /tmp/alert-fix/issue.json)
if git diff --quiet && git diff --cached --quiet \
&& [ -z "$$(git ls-files --others --exclude-standard)" ]; then
@@ -187,7 +187,7 @@ steps:
[ -f /tmp/alert-fix/issue.json ] || exit 0
if [ -f /tmp/alert-fix/no-edits ]; then exit 0; fi
set -eu
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
NUM=$$(jq -r '.number' /tmp/alert-fix/issue.json)
BRANCH=$$(cat /tmp/alert-fix/branch)
TITLE="fix: $$(jq -r '.title' /tmp/alert-fix/issue.json) (#$$NUM)"
@@ -196,7 +196,7 @@ steps:
printf '\n\nCloses #%s\n' "$$NUM" >> /tmp/alert-fix/summary.md
git add -A
git commit -m "$$TITLE"
git push "https://$$PUSH_TOKEN@gitea.renner.uno/k.renner/homelab.git" HEAD:"$$BRANCH"
git push "https://$$PUSH_TOKEN@gitea.renner.uno/konairius/homelab.git" HEAD:"$$BRANCH"
jq -n --arg title "$$TITLE" --rawfile body /tmp/alert-fix/summary.md \
--arg head "$$BRANCH" \
'{title: $$title, body: $$body, head: $$head, base: "master"}' > /tmp/gitea-pr.json
+3 -3
View File
@@ -2,9 +2,9 @@
# pull requests. This pipeline only starts when triggered by hand and is kept for reference.
#
# PR review bot — per-repo STUB. All logic lives centrally in
# k.renner/homelab: ci/agents/pr-review/run.sh (plus prompt.md,
# konairius/homelab: ci/agents/pr-review/run.sh (plus prompt.md,
# settings.json and scripts/ci/). This file is deployed byte-identical into every
# reviewed repo (k.renner/homelab, shelly-manager, authentik-samba-bridge,
# reviewed repo (konairius/homelab, shelly-manager, authentik-samba-bridge,
# homeassistant) and holds NO logic, so the copies cannot drift apart: change the
# review in homelab and every repo picks it up on its next PR event. Only edit this
# stub if the trigger, the image or the secrets change — then change all copies in
@@ -43,5 +43,5 @@ steps:
from_secret: review_bot_token
commands:
- rm -rf /tmp/pr-review-central
- git clone --quiet --depth 1 https://gitea.renner.uno/k.renner/homelab /tmp/pr-review-central
- git clone --quiet --depth 1 https://gitea.renner.uno/konairius/homelab /tmp/pr-review-central
- sh /tmp/pr-review-central/ci/agents/pr-review/run.sh
+3 -3
View File
@@ -5,7 +5,7 @@
# - cron : the named `daily-security-scan` cron, matched by NAME.
# Crons cannot carry variables — `woodpecker-cli repo cron add`
# has no --var — so a variable gate can never match a cron.
# - deployment : woodpecker-cli pipeline deploy k.renner/homelab <n> security-scan
# - deployment : woodpecker-cli pipeline deploy konairius/homelab <n> security-scan
# The target rides in pipeline metadata as
# CI_PIPELINE_DEPLOY_TARGET, which the workflow-root filter
# does see, unlike manual-run variables on Woodpecker 3.18
@@ -147,7 +147,7 @@ steps:
TITLE="security: agent-proposed fixes ($$(date +%Y-%m-%d))"
git add -A
git commit -m "$$TITLE"
git push "https://$$GITEA_TOKEN@gitea.renner.uno/k.renner/homelab.git" HEAD:"$$BRANCH"
git push "https://$$GITEA_TOKEN@gitea.renner.uno/konairius/homelab.git" HEAD:"$$BRANCH"
jq -n \
--arg title "$$TITLE" \
--arg body "$$BODY" \
@@ -157,5 +157,5 @@ steps:
-H "Authorization: token $$GITEA_TOKEN" \
-H "Content-Type: application/json" \
--data @- \
https://gitea.renner.uno/api/v1/repos/k.renner/homelab/pulls \
https://gitea.renner.uno/api/v1/repos/konairius/homelab/pulls \
| jq '{number, html_url, title}'
+3 -3
View File
@@ -14,9 +14,9 @@
# `repo cron add` takes name, branch, schedule and enabled — so the
# name filter is the only thing that can select a workflow for a
# scheduled run:
# woodpecker repo cron add --repo k.renner/homelab \
# woodpecker repo cron add --repo konairius/homelab \
# --name update-audit --branch master --schedule '@weekly'
# - deploy : woodpecker-cli pipeline deploy k.renner/homelab <n> update-audit
# - deploy : woodpecker-cli pipeline deploy konairius/homelab <n> update-audit
# where <n> is an existing pipeline to promote.
#
# NOTE: e2e.yml runs on an explicit list of cron names that includes this one,
@@ -111,7 +111,7 @@ steps:
set -eu
TITLE="Infrastructure update audit"
STAMP=$$(date -u +%Y-%m-%dT%H:%M:%SZ)
API="https://gitea.renner.uno/api/v1/repos/k.renner/homelab"
API="https://gitea.renner.uno/api/v1/repos/konairius/homelab"
# write <method> <url> <payload-file> -> prints HTTP code, fails non-2xx
gitea_write() {
+2 -2
View File
@@ -1,6 +1,6 @@
#!/bin/sh
# PR review bot — the ONE implementation, run by every reviewed repo's
# .woodpecker/pr-review.yml stub (k.renner/homelab, shelly-manager,
# .woodpecker/pr-review.yml stub (konairius/homelab, shelly-manager,
# authentik-samba-bridge, homeassistant). Edit it here only; the stubs hold no
# logic. Design notes (event-driven trigger, dedup marker, tokens, model
# selection, posture) are in the stub and in prompt.md.
@@ -129,7 +129,7 @@ while IFS= read -r ITEM; do
# # Cross-repo context for the review agent: shallow, UNAUTHENTICATED clones of
# # the other fleet repos at ./.pr-review/context/<name>.
# CTX="$D/context"; mkdir -p "$CTX"
# for REPO in k.renner/homelab k.renner/homeassistant konairius/shelly-manager k.renner/authentik-samba-bridge; do
# for REPO in konairius/homelab konairius/homeassistant konairius/shelly-manager konairius/authentik-samba-bridge; do
# [ "$REPO" = "$SLUG" ] && continue
# GIT_TERMINAL_PROMPT=0 git clone --quiet --depth 1 "https://gitea.renner.uno/$REPO" "$CTX/${REPO#*/}" 2>/dev/null \
# || echo "No public context clone of $REPO (skipped)."
+1 -1
View File
@@ -63,7 +63,7 @@ ARG E3ONCAN_REF=461dcbdf97e8d72d2edb9929c63b39382af6c3a1
LABEL org.opencontainers.image.title="heating-can" \
org.opencontainers.image.description="open3e + E3onCAN, Viessmann E3 CAN bus readers" \
org.opencontainers.image.source="https://gitea.renner.uno/k.renner/homelab" \
org.opencontainers.image.source="https://gitea.renner.uno/konairius/homelab" \
uno.renner.open3e.ref="${OPEN3E_REF}" \
uno.renner.e3oncan.ref="${E3ONCAN_REF}"
+2 -2
View File
@@ -10,8 +10,8 @@ import (
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/app"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/app"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
func main() {
+1 -1
View File
@@ -1,4 +1,4 @@
module gitea.renner.uno/k.renner/homelab/kuma-sync
module gitea.renner.uno/konairius/homelab/kuma-sync
go 1.27
+3 -3
View File
@@ -19,9 +19,9 @@ import (
"sigs.k8s.io/controller-runtime/pkg/manager"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/controller"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/controller"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
// Options configures NewManager.
@@ -25,9 +25,9 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/app"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/app"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
const (
@@ -16,8 +16,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/manager"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get
@@ -15,8 +15,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
type gcEnv struct {
@@ -20,8 +20,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/predicate"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
const (
@@ -17,8 +17,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
// counting records calls on top of the Kuma fake.
@@ -4,8 +4,8 @@ import (
"maps"
"slices"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
// defaultMaxRedirects is Kuma's default for a monitor with no explicit value.
@@ -6,8 +6,8 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
func TestMappingAllTypes(t *testing.T) {
+4 -5
View File
@@ -35,7 +35,7 @@ spec:
image: gitea.renner.uno/gitea_admin/review-bot:2026-10-19-0540 # {"$imagepolicy": "flux-system:review-bot"}
env:
- name: REPOS
value: k.renner/homelab,konairius/homeassistant,konairius/authentik-samba-bridge,konairius/shelly-manager
value: konairius/homelab,konairius/homeassistant,konairius/authentik-samba-bridge,konairius/shelly-manager
- name: GITEA_URL
value: https://gitea.renner.uno
- name: AGENT_NAME
@@ -45,10 +45,9 @@ spec:
# The first model is the default; a backend without its keys is off.
- name: MODELS
value: |
mistral:mistral-large-4-0 = Mistral Large 4. Solid on routine changes of small to medium size.
anthropic:claude-haiku-5-5 = Claude Haiku 5.5. Cheapest and fastest; only for trivial changes such as documentation, comments or version bumps.
anthropic:claude-sonnet-5-5 = Claude Sonnet 5.5. Fast and capable; good for most code and configuration changes.
anthropic:claude-opus-5-5 = Claude Opus 5.5. The strongest reviewer; for large or cross-cutting changes and anything touching security, secrets, network policy or data.
anthropic:claude-sonnet-5-5 = Claude Sonnet 5.5. The standard reviewer; right for almost every code and configuration change.
anthropic:claude-opus-5-5 = Claude Opus 5.5. Expensive; only for large cross-cutting changes or changes to security, secrets, network policy or data.
mistral:mistral-large-4-0 = Mistral Large 4. Separate budget; for routine changes of small to medium size.
- name: SELECTOR
value: typesafe
- name: ANTHROPIC_API_KEY
@@ -11,7 +11,7 @@ spec:
branch: master
secretRef:
name: flux-system
url: https://gitea.renner.uno/k.renner/homelab.git
url: https://gitea.renner.uno/konairius/homelab.git
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
@@ -112,3 +112,36 @@ spec:
memory: 16Mi
limits:
memory: 48Mi
- name: sync-woodpecker-konairius
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-07-30
args: ["woodpecker-secret"]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
env:
- name: BAO_ADDR
value: http://openbao.openbao.svc.cluster.local:8200
- name: BAO_ROLE
value: rotator-claude-oauth
- name: BOOTSTRAP_PATH
value: homelab/infrastructure/secret-rotators-bootstrap/woodpecker-bot
- name: BAO_PATH
value: homelab/infrastructure/claude
- name: BAO_FIELD
value: setup-token
- name: WOODPECKER_URL
value: http://woodpecker.woodpecker.svc.cluster.local:8000
- name: WOODPECKER_ORG
value: konairius
- name: WOODPECKER_SECRET_NAME
value: claude_code_oauth_token
- name: WOODPECKER_EVENTS
value: cron,deployment,manual,push,release,tag,pull_request
resources:
requests:
cpu: 25m
memory: 16Mi
limits:
memory: 48Mi
@@ -78,10 +78,10 @@ spec:
- name: GITEA_URL
value: http://gitea.gitea.svc.cluster.local:3000
- name: GITEA_VERIFY_REPO
value: k.renner/homelab
value: konairius/homelab
- name: GITEA_SCOPES
value: write:repository
# Load-bearing: k.renner/homelab is public, so the pull check
# Load-bearing: konairius/homelab is public, so the pull check
# passes for ANY valid token — push (git-receive-pack, needs
# write) is the only real verification. Pinned explicitly so the
# safety property is visible here, not just a binary default.
@@ -197,3 +197,36 @@ spec:
memory: 16Mi
limits:
memory: 48Mi
- name: sync-woodpecker-konairius
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-07-30
args: ["woodpecker-secret"]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
env:
- name: BAO_ADDR
value: http://openbao.openbao.svc.cluster.local:8200
- name: BAO_ROLE
value: rotator-review-bot
- name: BOOTSTRAP_PATH
value: homelab/infrastructure/secret-rotators-bootstrap/woodpecker-bot
- name: BAO_PATH
value: homelab/infrastructure/review-bot
- name: BAO_FIELD
value: token
- name: WOODPECKER_URL
value: http://woodpecker.woodpecker.svc.cluster.local:8000
- name: WOODPECKER_ORG
value: konairius
- name: WOODPECKER_SECRET_NAME
value: review_bot_token
- name: WOODPECKER_EVENTS
value: cron,manual,pull_request
resources:
requests:
cpu: 25m
memory: 16Mi
limits:
memory: 48Mi
+4
View File
@@ -91,6 +91,10 @@ func (a *Anthropic) Ask(ctx context.Context, model, task string) (Answer, error)
if err := stream.Err(); err != nil {
return Answer{}, anthropicError(err)
}
ans.Usage.Input += msg.Usage.InputTokens
ans.Usage.Output += msg.Usage.OutputTokens
ans.Usage.CacheRead += msg.Usage.CacheReadInputTokens
ans.Usage.CacheWrite += msg.Usage.CacheCreationInputTokens
for _, block := range msg.Content {
switch b := block.AsAny().(type) {
case anthropic.BetaMCPToolUseBlock:
+11 -5
View File
@@ -107,13 +107,16 @@ func TestAnthropicResumesAPausedTurnAndTakesTheFinalText(t *testing.T) {
messageStream("claude-opus-5-5", "end_turn", block(0, toolUse), block(1, toolResult),
textBlock(2, "## Summary\n\nok\n\nVerdict: comment")),
}
a, err := f.backend().Ask(context.Background(), "claude-opus-5-5", "Review pull request #7 of k.renner/homelab.")
a, err := f.backend().Ask(context.Background(), "claude-opus-5-5", "Review pull request #7 of konairius/homelab.")
if err != nil {
t.Fatal(err)
}
if a.Text != "## Summary\n\nok\n\nVerdict: comment" || a.ToolCalls != 2 || len(a.Violations) != 0 {
t.Errorf("answer %+v", a)
}
if a.Usage.Input != 2 || a.Usage.Output != 10 {
t.Errorf("usage must add up over the continued turns: %+v", a.Usage)
}
if len(f.bodies) != 2 {
t.Fatalf("requests %d, want 2", len(f.bodies))
}
@@ -164,8 +167,8 @@ func TestAnthropicRateLimitIsRetryable(t *testing.T) {
}
}
// With the selector on, the review runs on the model TypeSafe picks; when TypeSafe fails, on the
// default model.
// With the selector on, the review runs on the model TypeSafe picks; when TypeSafe fails or is
// unsure, on the default model.
func TestSelectorPicksTheBackend(t *testing.T) {
for _, tc := range []struct {
name string
@@ -175,6 +178,8 @@ func TestSelectorPicksTheBackend(t *testing.T) {
}{
{"chosen", `{"answers":{"model":{"type":"choice","choice":"anthropic:claude-opus-5-5","confidence":0.8,` +
`"probabilities":{"anthropic:claude-opus-5-5":0.9,"mistral:mistral-large-4-0":0.1}}}}`, 200, "claude-opus-5-5"},
{"unsure", `{"answers":{"model":{"type":"choice","choice":"anthropic:claude-opus-5-5","confidence":0.4,` +
`"probabilities":{"anthropic:claude-opus-5-5":0.7,"mistral:mistral-large-4-0":0.3}}}}`, 200, "mistral-large-4-0"},
{"failed", `{"error":"overloaded"}`, 529, "mistral-large-4-0"},
{"unknown model", `{"answers":{"model":{"type":"choice","choice":"x:y"}}}`, 200, "mistral-large-4-0"},
} {
@@ -202,16 +207,17 @@ func TestSelectorPicksTheBackend(t *testing.T) {
{Backend: "anthropic", Name: "claude-opus-5-5", Description: "strong"}}
f.rev.backends["anthropic"] = fa.backend()
f.rev.setReady("anthropic", true)
f.rev.cfg.SelectorMinConfidence = 0.6
f.rev.selector = &TypeSafe{url: ts.URL, key: "ts-key", model: "jev-latest", http: &http.Client{Timeout: 5 * time.Second}}
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
posted := f.postedReviews()
if len(posted) != 1 || !strings.Contains(posted[0]["body"], "model `"+tc.wantModel+"`") {
t.Fatalf("want one review by %s, got %v", tc.wantModel, posted)
}
if pr := state["pull_request"].(map[string]any); pr["title"] != "feat: x" || pr["repository"] != "k.renner/homelab" {
if pr := state["pull_request"].(map[string]any); pr["title"] != "feat: x" || pr["repository"] != "konairius/homelab" {
t.Errorf("selector state: %v", state)
}
})
+7
View File
@@ -28,6 +28,13 @@ type Answer struct {
Text string
ToolCalls int
Violations []string
Usage Usage
}
// Usage is the token count of one review, summed over its requests. A backend that does not
// report it leaves it zero.
type Usage struct {
Input, Output, CacheRead, CacheWrite int64
}
// apiError is a non-2xx answer of a provider API. Whatever limit headers come back are kept so
+26 -11
View File
@@ -13,7 +13,7 @@ var defaultMCPTools = []string{
"get_commit", "get_dir_contents", "get_file_contents", "get_repository_tree", "get_tag", "get_release",
"get_latest_release", "issue_read", "label_read", "list_branches", "list_commits", "list_issues",
"list_pull_requests", "list_releases", "list_tags", "milestone_read", "pull_request_read", "search_issues",
"search_repos",
"search_repos", "compare_commits",
}
// Config is read from the environment. A secret comes from NAME, or from the file named by
@@ -40,11 +40,13 @@ type Config struct {
MCPToken string // read-only Gitea token Anthropic uses on the MCP server
MCPTools []string
// Selector "typesafe" picks the model per pull request; empty uses the default model.
Selector string
TypeSafeURL string
TypeSafeKey string
TypeSafeModel string
// Selector "typesafe" picks the model per pull request; empty uses the default model. A pick
// other than the default below SelectorMinConfidence falls back to the default.
Selector string
SelectorMinConfidence float64
TypeSafeURL string
TypeSafeKey string
TypeSafeModel string
Repos []string
Workers int
@@ -110,6 +112,18 @@ func loadConfig(getenv func(string) string, readFile func(string) ([]byte, error
}
return d
}
fraction := func(name string, def float64) float64 {
v := text(name, "")
if v == "" {
return def
}
f, err := strconv.ParseFloat(v, 64)
if err != nil || f < 0 || f > 1 {
errs = append(errs, fmt.Errorf("%s must be a number from 0 to 1, got %q", name, v))
return def
}
return f
}
list := func(name string, def []string) []string {
v := text(name, "")
if v == "" {
@@ -132,15 +146,16 @@ func loadConfig(getenv func(string) string, readFile func(string) ([]byte, error
AnthropicURL: strings.TrimRight(text("ANTHROPIC_URL", ""), "/"),
AnthropicKey: optional("ANTHROPIC_API_KEY"),
AnthropicEffort: text("ANTHROPIC_EFFORT", "high"),
AnthropicEffort: text("ANTHROPIC_EFFORT", "medium"),
MCPURL: text("MCP_URL", "https://gitea-mcp.renner.uno/mcp"),
MCPToken: optional("MCP_TOKEN"),
MCPTools: list("MCP_TOOLS", defaultMCPTools),
Selector: text("SELECTOR", ""),
TypeSafeURL: text("TYPESAFE_URL", "https://api.typesafe.ai/v1/systemone"),
TypeSafeKey: optional("TYPESAFE_API_KEY"),
TypeSafeModel: text("TYPESAFE_MODEL", "jev-latest"),
Selector: text("SELECTOR", ""),
SelectorMinConfidence: fraction("SELECTOR_MIN_CONFIDENCE", 0.6),
TypeSafeURL: text("TYPESAFE_URL", "https://api.typesafe.ai/v1/systemone"),
TypeSafeKey: optional("TYPESAFE_API_KEY"),
TypeSafeModel: text("TYPESAFE_MODEL", "jev-latest"),
Workers: number("WORKERS", 1),
QueueSize: number("QUEUE_SIZE", 32),
+2 -2
View File
@@ -14,7 +14,7 @@ func noFiles(string) ([]byte, error) { return nil, errors.New("no such file") }
func baseEnv() map[string]string {
return map[string]string{
"REVIEW_TOKEN": "t", "WEBHOOK_SECRET": "s", "MISTRAL_API_KEY": "k",
"CONNECTOR_ID": "c1", "REPOS": "k.renner/homelab, konairius/shelly-manager",
"CONNECTOR_ID": "c1", "REPOS": "konairius/homelab, konairius/shelly-manager",
}
}
@@ -25,7 +25,7 @@ func TestLoadConfigDefaults(t *testing.T) {
}
if c.Listen != ":8080" || c.GiteaURL != "https://gitea.renner.uno" || c.BotLogin != "review-bot" ||
c.Workers != 1 || c.Sweep != 30*time.Minute || c.RateLimitRetries != 3 || c.RateLimitWait != time.Minute ||
c.Selector != "" || c.MCPURL != "https://gitea-mcp.renner.uno/mcp" || c.AnthropicEffort != "high" {
c.Selector != "" || c.MCPURL != "https://gitea-mcp.renner.uno/mcp" || c.AnthropicEffort != "medium" || c.SelectorMinConfidence != 0.6 {
t.Errorf("unexpected defaults: %+v", c)
}
if len(c.Models) != 1 || c.Models[0].ID() != "mistral:mistral-large-4-0" {
+20 -2
View File
@@ -24,6 +24,20 @@ type PR struct {
Head struct {
SHA string `json:"sha"`
} `json:"head"`
RequestedReviewers []struct {
Login string `json:"login"`
} `json:"requested_reviewers"`
}
// requested says whether login is a requested reviewer. Gitea drops the request when that
// reviewer submits a review.
func (p PR) requested(login string) bool {
for _, u := range p.RequestedReviewers {
if u.Login == login {
return true
}
}
return false
}
type Review struct {
@@ -119,7 +133,9 @@ var wipTitle = regexp.MustCompile(`^[Ww][Ii][Pp][: ]`)
func marker(sha string) string { return "pr-review:sha=" + sha }
// eligible says why a PR needs no review ("closed", "draft", "wip", "reviewed") or "ok".
// eligible says why a PR needs no review ("closed", "draft", "wip", "reviewed", "not requested")
// or "ok". The bot reviews only while it is a requested reviewer; a request at an already
// reviewed head asks for that head again.
func eligible(pr PR, reviews []Review, bot string) string {
switch {
case pr.State != "open":
@@ -128,11 +144,13 @@ func eligible(pr PR, reviews []Review, bot string) string {
return "draft"
case wipTitle.MatchString(pr.Title):
return "wip"
case pr.requested(bot):
return "ok"
}
if _, ok := headReview(pr, reviews, bot); ok {
return "reviewed"
}
return "ok"
return "not requested"
}
// headReview is the bot's last submitted review of the PR's head commit. A pending review is
+1 -1
View File
@@ -1,4 +1,4 @@
module gitea.renner.uno/k.renner/homelab/review-bot
module gitea.renner.uno/konairius/homelab/review-bot
go 1.27
+3 -2
View File
@@ -34,10 +34,11 @@ func (m *Metrics) Inc(name string, labels ...string) {
m.vals[series(name, labels)]++
}
func (m *Metrics) Add(name string, delta float64) {
// Add adds delta to a series; labels are key, value pairs.
func (m *Metrics) Add(name string, delta float64, labels ...string) {
m.mu.Lock()
defer m.mu.Unlock()
m.vals[series(name, nil)] += delta
m.vals[series(name, labels)] += delta
}
func (m *Metrics) Set(name string, v float64) {
+3 -3
View File
@@ -14,7 +14,7 @@ func TestBrokenStreamIsAnsweredFromTheHistory(t *testing.T) {
{"type":"message.input","content":"Review pull request #7"},
{"type":"tool.execution","name":"conn_get_file_contents"},
{"type":"message.output","content":"## Summary\n\nfine\n\n## Recommendation\n\nLooks reasonable.\n\nVerdict: comment"}]}`
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
if f.convCalls != 1 || f.historyCalls != 1 || len(f.postedReviews()) != 1 {
@@ -26,7 +26,7 @@ func TestBrokenStreamIsAnsweredFromTheHistory(t *testing.T) {
func TestBrokenStreamWithoutHistoryIsAskedAgain(t *testing.T) {
f := newFixture(t)
f.breakStreams = 1
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
if f.convCalls != 2 || f.historyCalls != 2 || len(f.postedReviews()) != 1 {
@@ -37,7 +37,7 @@ func TestBrokenStreamWithoutHistoryIsAskedAgain(t *testing.T) {
func TestStreamErrorEventIsNotRetried(t *testing.T) {
f := newFixture(t)
f.streamError = "Failed to generate response."
err := f.rev.Review(context.Background(), "k.renner/homelab", 7)
err := f.rev.Review(context.Background(), "konairius/homelab", 7)
if err == nil || !strings.Contains(err.Error(), "Failed to generate response.") {
t.Fatalf("want the stream error, got %v", err)
}
+30 -23
View File
@@ -17,24 +17,31 @@ HARD RULES
read files at the head sha.
## How to work
1. Read the PR (title, body, author, base, head sha) and its diff, and any issue or comment it links.
2. For every changed file, read the full file at the head sha, not only the hunks.
3. Read the repo's `CLAUDE.md` or `README` if present, and neighbouring files for conventions (sibling manifests,
callers, tests, existing policies, RBAC, naming). Look at recent commits and other PRs when the change depends on
history.
4. Check each suspicion against what you read before you write it down. A claim that depends on code you did not
1. In your first step, read the PR (title, body, author, base, head sha, `mergeable`) together with its reviews
and comments. Reviews by `review-bot` carry a marker line `<!-- pr-review:sha=<commit> -->`. If there is one,
this is a re-review: follow "Re-reviews" below instead of the rest of this list.
2. Read the diff and any issue the PR links to.
3. The diff is your main source. Read a whole changed file at the head sha only where the hunks do not show
enough to judge the change: the surrounding function, a caller, a schema the hunk depends on. Do not read files
whose change the diff already shows in full.
4. Read the repo's `CLAUDE.md` or `README`, and neighbouring files (sibling manifests, callers, tests, policies),
only when a finding or the change's correctness depends on a convention you have not seen.
5. Check each suspicion against what you read before you write it down. A claim that depends on code you did not
read, or on the current behaviour of an external product, goes under "Unverified" with what you checked. Your
training data is not evidence for how current releases behave.
5. Before you write "no other code does X", search for it.
6. List the reviews and comments of this pull request. Reviews by `review-bot` carry a marker line
`<!-- pr-review:sha=<commit> -->`. If there is one, this is a re-review, and your earlier review is your own
earlier judgment: read it first, together with the author's replies, and follow "Re-reviews" below.
6. Before you write "no other code does X", search for it.
## Re-reviews
The author may have acted on your earlier review, so judge the pull request against it instead of starting over.
- Open the Summary with what changed since the commit in your newest marker (read those commits) and the state of
every earlier 🔴 and 🟡 point: fixed, still open, or withdrawn with the reason. An earlier 🔴 that is still open
stays in the Blocking section, and the verdict stays `request-changes` until it is fixed.
You were asked again, so the author may have acted on your earlier review. Judge the pull request against it
instead of starting over, and read only what changed.
- Read your newest earlier review and the author's replies after it. Then call `compare_commits` with the sha in
your newest marker as base and the head sha as head, and read the diffs of those commits. Do not read the whole
pull request diff again, and do not re-read files the new commits do not touch.
- If the head sha equals the sha in your newest marker, nothing new was pushed: answer the author's replies and
settle the points they dispute, reading only what that needs.
- Open the Summary with what changed since that commit and the state of every earlier 🔴 and 🟡 point: fixed,
still open, or withdrawn with the reason. An earlier 🔴 that is still open stays in the Blocking section, and the
verdict stays `request-changes` until it is fixed.
- Review the changes since that commit as carefully as a first review. A new 🔴 on them is fine.
- Code and behaviour that were already in the pull request at your last review stay as you judged them. Raise a new
point on them only if it is a 🔴 you can verify and you missed it; then say that you missed it. Never add 🟡, ⚪
@@ -45,19 +52,19 @@ The author may have acted on your earlier review, so judge the pull request agai
- When all earlier 🔴 points are fixed and the new changes have no 🔴, say so in the Recommendation.
## Mandatory checks
Do these for every PR, in addition to reading the diff and the changed files:
1. Read the pull request itself for `mergeable`, its base branch and whether the base moved. Read each changed file
at the base branch tip as well as at the head, and list recent commits on the base branch that touch those files.
State plainly if the change is already on the base branch, superseded by another PR, or conflicts with it. That is
a blocking finding.
Do these for every PR, in addition to reading the diff:
1. Check `mergeable`. Only when it is false, or the PR body or diff suggests the base moved, list recent commits on
the base branch that touch the changed files and read those files at the base tip. State plainly if the change is
already on the base branch, superseded by another PR, or conflicts with it. That is a blocking finding.
2. Read every issue the PR links to.
3. Search the repository for other code that does the same thing before you write that nothing else does.
## Efficiency
Request all independent reads in the same step instead of one at a time (the diff, the changed files at head and
base, neighbouring files, README or CLAUDE.md together). Do not repeat a call whose result you already have. There is
no cap on tool calls; stop reading when every mandatory check is done and every suspicion is settled or listed as
unverified.
Every tool result stays in the conversation and is paid for again on every later step, so each read costs more the
later it comes and the bigger it is. Request all independent reads in the same step instead of one at a time. Do not
repeat a call whose result you already have. Aim for about 10 tool calls on a small change and 20 on a large one;
go beyond that only to settle a suspected 🔴. Stop reading when the mandatory checks are done and every suspicion is
settled or listed as unverified.
## What to look for, in priority order
1. Correctness: real bugs, wrong conditionals, unhandled errors, race conditions, resource leaks, incorrect API
+17 -10
View File
@@ -32,9 +32,14 @@ func TestCleanReport(t *testing.T) {
}
func TestEligible(t *testing.T) {
pr := func(state, title string, draft bool, sha string) PR {
pr := func(state, title string, draft bool, sha string, requested ...string) PR {
p := PR{State: state, Title: title, Draft: draft}
p.Head.SHA = sha
for _, login := range requested {
p.RequestedReviewers = append(p.RequestedReviewers, struct {
Login string `json:"login"`
}{login})
}
return p
}
review := func(login, body string) Review {
@@ -51,15 +56,17 @@ func TestEligible(t *testing.T) {
pr PR
want string
}{
{"open at a new head", pr("open", "feat: x", false, "ccc333"), "ok"},
{"review at an older head does not count", pr("open", "feat: x", false, "ddd444"), "ok"},
{"review at this head by the bot", pr("open", "feat: x", false, "aaa111"), "reviewed"},
{"marker from another user does not count", pr("open", "feat: x", false, "bbb222"), "ok"},
{"closed", pr("closed", "feat: x", false, "ccc333"), "closed"},
{"draft", pr("open", "feat: x", true, "ccc333"), "draft"},
{"wip with colon", pr("open", "WIP: x", false, "ccc333"), "wip"},
{"wip with space", pr("open", "wip x", false, "ccc333"), "wip"},
{"title that only starts with wip", pr("open", "wipe the cache", false, "ccc333"), "ok"},
{"requested at a new head", pr("open", "feat: x", false, "ccc333", "review-bot"), "ok"},
{"requested again at a reviewed head", pr("open", "feat: x", false, "aaa111", "review-bot"), "ok"},
{"not requested at a new head", pr("open", "feat: x", false, "ccc333"), "not requested"},
{"request for someone else", pr("open", "feat: x", false, "ccc333", "someone"), "not requested"},
{"not requested, reviewed at this head", pr("open", "feat: x", false, "aaa111"), "reviewed"},
{"marker from another user does not count", pr("open", "feat: x", false, "bbb222"), "not requested"},
{"closed", pr("closed", "feat: x", false, "ccc333", "review-bot"), "closed"},
{"draft", pr("open", "feat: x", true, "ccc333", "review-bot"), "draft"},
{"wip with colon", pr("open", "WIP: x", false, "ccc333", "review-bot"), "wip"},
{"wip with space", pr("open", "wip x", false, "ccc333", "review-bot"), "wip"},
{"title that only starts with wip", pr("open", "wipe the cache", false, "ccc333", "review-bot"), "ok"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
+15 -2
View File
@@ -77,6 +77,12 @@ func (r *Reviewer) pick(ctx context.Context, slug string, pr PR) (Model, error)
r.log.Warn("selector failed, using the default model", "pr", key(slug, pr.Number), "model", def.ID(), "err", err)
return def, nil
}
if c.Model != def.ID() && c.Confidence < r.cfg.SelectorMinConfidence {
r.metrics.Inc("selections_total", "result", "unsure")
r.log.Info("selector unsure, using the default model", "pr", key(slug, pr.Number), "chosen", c.Model,
"confidence", c.Confidence, "model", def.ID(), "probabilities", c.Probabilities)
return def, nil
}
for _, m := range r.cfg.Models {
if m.ID() == c.Model && r.isReady(m.Backend) {
r.metrics.Inc("selections_total", "result", "chosen")
@@ -129,8 +135,15 @@ func (r *Reviewer) Review(ctx context.Context, slug string, n int) error {
r.metrics.Inc("reviews_total", "result", "failed")
return err
}
u := ans.Usage
r.log.Info("agent answered", "pr", key(slug, n), "model", model.ID(), "tool_calls", ans.ToolCalls,
"seconds", int(time.Since(start).Seconds()))
"seconds", int(time.Since(start).Seconds()), "input_tokens", u.Input, "output_tokens", u.Output,
"cache_read_tokens", u.CacheRead, "cache_write_tokens", u.CacheWrite)
for kind, v := range map[string]int64{"input": u.Input, "output": u.Output, "cache_read": u.CacheRead,
"cache_write": u.CacheWrite} {
r.metrics.Add("tokens_total", float64(v), "model", model.ID(), "kind", kind)
}
r.metrics.Add("tool_calls_total", float64(ans.ToolCalls), "model", model.ID())
if len(ans.Violations) > 0 {
r.metrics.Inc("reviews_total", "result", "violation")
return fmt.Errorf("the agent called tools that are not read-only (%s); nothing is posted", strings.Join(ans.Violations, ", "))
@@ -162,7 +175,7 @@ func (r *Reviewer) Review(ctx context.Context, slug string, n int) error {
event := reviewEvent(verdict, report, pr.Additions+pr.Deletions+pr.ChangedFiles > 0)
body := fmt.Sprintf("🤖 **Automated review** — model `%s`, commit `%s`.\n\n"+
"> Automated review by the `review-bot`: it approves only when it found nothing red or yellow, "+
"and a human still merges. Push new commits to get a fresh review.\n\n---\n\n%s\n\n<!-- %s -->\n",
"and a human still merges. Request a review from `review-bot` again for a fresh one.\n\n---\n\n%s\n\n<!-- %s -->\n",
model.Name, pr.Head.SHA, report, marker(pr.Head.SHA))
url, err := r.gitea.PostReview(ctx, slug, n, body, event, pr.Head.SHA)
if err != nil {
+60 -44
View File
@@ -41,6 +41,7 @@ type fixture struct {
convCalls int
retryAfter string // Retry-After header sent with a simulated 429
head string // head commit of PR 7
requested bool // the bot is a requested reviewer of PR 7; a posted review clears it
pushAfter string // when set, the next conversation answer is followed by a push of this head
// breakStreams breaks the next conversation streams off after their first event.
breakStreams int
@@ -93,12 +94,16 @@ const goodAnswer = `{"outputs":[
{"type":"message.output","content":[{"type":"thinking","thinking":"x"},{"type":"text","text":"I looked.\n\n## Summary\n\nAdds a field.\n\n## 🔴 Blocking\n\n- a.yaml:3 is wrong.\n\n## 🟡 Suggestions\n\nNone.\n\n## Recommendation\n\nRequest changes — 1 blocking issue(s)\n\nVerdict: request-changes"}]}]}`
func newFixture(t *testing.T) *fixture {
f := &fixture{t: t, prState: "open", title: "feat: x", head: "abc123", reviews: "[]", answer: goodAnswer, agents: "[]"}
f := &fixture{t: t, prState: "open", title: "feat: x", head: "abc123", requested: true, reviews: "[]", answer: goodAnswer, agents: "[]"}
pr := func() string {
f.mu.Lock()
defer f.mu.Unlock()
reviewers := "[]"
if f.requested {
reviewers = `[{"login":"review-bot"}]`
}
return `{"number":7,"state":"` + f.prState + `","title":"` + f.title + `","additions":3,"deletions":1,"changed_files":1,` +
`"html_url":"https://gitea/pr/7","head":{"sha":"` + f.head + `"}}`
`"html_url":"https://gitea/pr/7","head":{"sha":"` + f.head + `"},"requested_reviewers":` + reviewers + `}`
}
f.gitea = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "token review-token" {
@@ -109,22 +114,23 @@ func newFixture(t *testing.T) *fixture {
f.mu.Lock()
defer f.mu.Unlock()
switch {
case r.Method == "GET" && r.URL.Path == "/api/v1/repos/k.renner/homelab/pulls/7":
case r.Method == "GET" && r.URL.Path == "/api/v1/repos/konairius/homelab/pulls/7":
_, _ = io.WriteString(w, prJSON)
case r.Method == "GET" && r.URL.Path == "/api/v1/repos/k.renner/homelab/pulls/7/reviews":
case r.Method == "GET" && r.URL.Path == "/api/v1/repos/konairius/homelab/pulls/7/reviews":
_, _ = io.WriteString(w, f.reviews)
case r.Method == "GET" && r.URL.Path == "/api/v1/repos/k.renner/homelab/pulls":
case r.Method == "GET" && r.URL.Path == "/api/v1/repos/konairius/homelab/pulls":
_, _ = io.WriteString(w, "["+prJSON+"]")
case r.Method == "POST" && r.URL.Path == "/api/v1/repos/k.renner/homelab/pulls/7/reviews":
case r.Method == "POST" && r.URL.Path == "/api/v1/repos/konairius/homelab/pulls/7/reviews":
var in map[string]string
_ = json.NewDecoder(r.Body).Decode(&in)
f.posted = append(f.posted, in)
f.requested = false
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, `{"html_url":"https://gitea/pr/7#review"}`)
case r.Method == "POST" && strings.HasPrefix(r.URL.Path, "/api/v1/repos/k.renner/homelab/statuses/"):
case r.Method == "POST" && strings.HasPrefix(r.URL.Path, "/api/v1/repos/konairius/homelab/statuses/"):
var in map[string]string
_ = json.NewDecoder(r.Body).Decode(&in)
in["sha"] = strings.TrimPrefix(r.URL.Path, "/api/v1/repos/k.renner/homelab/statuses/")
in["sha"] = strings.TrimPrefix(r.URL.Path, "/api/v1/repos/konairius/homelab/statuses/")
f.statuses = append(f.statuses, in)
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, "{}")
@@ -152,7 +158,7 @@ func newFixture(t *testing.T) *fixture {
var in map[string]any
_ = json.NewDecoder(r.Body).Decode(&in)
if in["agent_id"] != "ag_1" || in["stream"] != true || in["store"] != true ||
!strings.Contains(in["inputs"].(string), "pull request #7 of k.renner/homelab") {
!strings.Contains(in["inputs"].(string), "pull request #7 of konairius/homelab") {
http.Error(w, "unexpected conversation request", http.StatusBadRequest)
return
}
@@ -197,7 +203,7 @@ func newFixture(t *testing.T) *fixture {
GiteaURL: f.gitea.URL, BotLogin: "review-bot", ReviewToken: "review-token", WebhookSecret: "hook-secret",
MistralURL: f.mistral.URL, MistralKey: "mistral-key", AgentName: "pr-review",
Models: []Model{{Backend: "mistral", Name: "mistral-large-4-0"}},
ConnectorID: "c1", Repos: []string{"k.renner/homelab"}, Workers: 1, QueueSize: 4,
ConnectorID: "c1", Repos: []string{"konairius/homelab"}, Workers: 1, QueueSize: 4,
ReviewTimeout: 5 * time.Second, RateLimitRetries: 2, RateLimitWait: time.Millisecond,
}
f.metrics = newMetrics()
@@ -245,7 +251,7 @@ func (f *fixture) postedReviews() []map[string]string {
func TestReviewPostsTheCleanedReport(t *testing.T) {
f := newFixture(t)
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
got := f.postedReviews()
@@ -271,8 +277,9 @@ func TestReviewPostsTheCleanedReport(t *testing.T) {
func TestReviewSkipsWhatIsAlreadyReviewed(t *testing.T) {
f := newFixture(t)
f.requested = false
f.reviews = `[{"body":"<!-- pr-review:sha=abc123 -->","user":{"login":"review-bot"}}]`
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
if n := len(f.postedReviews()); n != 0 {
@@ -286,7 +293,7 @@ func TestPendingReviewIsReviewedAgain(t *testing.T) {
f := newFixture(t)
f.reviews = `[{"body":"<!-- pr-review:sha=abc123 -->","state":"PENDING","user":{"login":"review-bot"}}]`
f.answer = `{"outputs":[{"type":"message.output","content":"## Summary\n\nfine\n\n## Recommendation\n\nLooks reasonable.\n\nVerdict: approve"}]}`
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
posted := f.postedReviews()
@@ -299,7 +306,7 @@ func TestReviewRefusesWriteToolsAndPostsNothing(t *testing.T) {
f := newFixture(t)
f.answer = `{"outputs":[{"type":"tool.execution","name":"conn_create_or_update_file"},
{"type":"message.output","content":"## Summary\n\nx"}]}`
err := f.rev.Review(context.Background(), "k.renner/homelab", 7)
err := f.rev.Review(context.Background(), "konairius/homelab", 7)
if err == nil || !strings.Contains(err.Error(), "conn_create_or_update_file") {
t.Fatalf("want an error naming the tool, got %v", err)
}
@@ -311,7 +318,7 @@ func TestReviewRefusesWriteToolsAndPostsNothing(t *testing.T) {
func TestReviewWithoutAReportPostsNothing(t *testing.T) {
f := newFixture(t)
f.answer = `{"outputs":[{"type":"message.output","content":"I could not do it."}]}`
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err == nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err == nil {
t.Fatal("want an error")
}
if n := len(f.postedReviews()); n != 0 {
@@ -322,7 +329,7 @@ func TestReviewWithoutAReportPostsNothing(t *testing.T) {
func TestReviewNeedsTheAgent(t *testing.T) {
f := newFixture(t)
f.rev.setReady("mistral", false)
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err == nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err == nil {
t.Fatal("want an error while the agent is not ready")
}
if f.rev.Ready() {
@@ -333,7 +340,7 @@ func TestReviewNeedsTheAgent(t *testing.T) {
func TestReviewAsksAgainAfterTheRateLimit(t *testing.T) {
f := newFixture(t)
f.convStatus = []int{429, 429}
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
if f.convCalls != 3 || len(f.postedReviews()) != 1 {
@@ -348,7 +355,7 @@ func TestReviewHonoursRetryAfter(t *testing.T) {
f.rev.cfg.RateLimitWait = 10 * time.Minute
f.retryAfter = "0"
f.convStatus = []int{429}
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
if f.convCalls != 2 || len(f.postedReviews()) != 1 {
@@ -371,7 +378,7 @@ func TestLimitHeaders(t *testing.T) {
func TestReviewGivesUpWhenTheRateLimitStays(t *testing.T) {
f := newFixture(t)
f.convStatus = []int{429, 429, 429, 429}
err := f.rev.Review(context.Background(), "k.renner/homelab", 7)
err := f.rev.Review(context.Background(), "konairius/homelab", 7)
if err == nil || !strings.Contains(err.Error(), "still failing") {
t.Fatalf("want a rate limit error, got %v", err)
}
@@ -383,7 +390,7 @@ func TestReviewGivesUpWhenTheRateLimitStays(t *testing.T) {
func TestOtherErrorsAreNotRetried(t *testing.T) {
f := newFixture(t)
f.convStatus = []int{500}
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err == nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err == nil {
t.Fatal("want an error")
}
if f.convCalls != 1 {
@@ -440,25 +447,34 @@ func (f *fixture) hook(event, signature string, body string) *httptest.ResponseR
func TestHook(t *testing.T) {
f := newFixture(t)
body := `{"action":"opened","number":7,"repository":{"full_name":"k.renner/homelab"}}`
body := `{"action":"review_requested","number":7,"repository":{"full_name":"konairius/homelab"},` +
`"requested_reviewer":{"login":"review-bot"}}`
good := sign("hook-secret", []byte(body))
signed := func(old, new string) (string, string) {
b := strings.Replace(body, old, new, 1)
return sign("hook-secret", []byte(b)), b
}
closedSig, closedBody := signed("review_requested", "closed")
openedSig, openedBody := signed("review_requested", "opened")
syncSig, syncBody := signed("review_requested", "synchronized")
otherSig, otherBody := signed(`"login":"review-bot"`, `"login":"someone"`)
repoSig, repoBody := signed("konairius/homelab", "x/y")
noNum := `{"action":"review_requested","repository":{"full_name":"konairius/homelab"},"requested_reviewer":{"login":"review-bot"}}`
cases := []struct {
name, event, sig, body string
want int
}{
{"valid", "pull_request", good, body, 202},
{"review requested from the bot", "pull_request", good, body, 202},
{"missing signature", "pull_request", "", body, 401},
{"wrong signature", "pull_request", sign("other", []byte(body)), body, 401},
{"signature that is not hex", "pull_request", "zz", body, 401},
{"other event", "push", good, body, 200},
{"closed action", "pull_request", sign("hook-secret", []byte(strings.Replace(body, "opened", "closed", 1))),
strings.Replace(body, "opened", "closed", 1), 200},
{"synchronized", "pull_request", sign("hook-secret", []byte(strings.Replace(body, "opened", "synchronized", 1))),
strings.Replace(body, "opened", "synchronized", 1), 202},
{"repository that is not configured", "pull_request", sign("hook-secret", []byte(strings.Replace(body, "k.renner/homelab", "x/y", 1))),
strings.Replace(body, "k.renner/homelab", "x/y", 1), 200},
{"payload without a number", "pull_request", sign("hook-secret", []byte(`{"action":"opened","repository":{"full_name":"k.renner/homelab"}}`)),
`{"action":"opened","repository":{"full_name":"k.renner/homelab"}}`, 400},
{"closed action", "pull_request", closedSig, closedBody, 200},
{"opened alone starts nothing", "pull_request", openedSig, openedBody, 200},
{"a push alone starts nothing", "pull_request", syncSig, syncBody, 200},
{"review requested from someone else", "pull_request", otherSig, otherBody, 200},
{"repository that is not configured", "pull_request", repoSig, repoBody, 200},
{"payload without a number", "pull_request", sign("hook-secret", []byte(noNum)), noNum, 400},
{"payload that is not json", "pull_request", sign("hook-secret", []byte("nope")), "nope", 400},
}
for _, c := range cases {
@@ -473,10 +489,10 @@ func TestHook(t *testing.T) {
func TestQueueRunsAReviewAndDeduplicates(t *testing.T) {
f := newFixture(t)
q := newQueue(f.rev, 4, f.rev.log, f.metrics)
if r := q.Enqueue("k.renner/homelab", 7, "abc123"); r != "queued" {
if r := q.Enqueue("konairius/homelab", 7, "abc123"); r != "queued" {
t.Fatalf("first enqueue: %s", r)
}
if r := q.Enqueue("k.renner/homelab", 7, "abc123"); r != "duplicate" {
if r := q.Enqueue("konairius/homelab", 7, "abc123"); r != "duplicate" {
t.Fatalf("second enqueue of the same PR: %s", r)
}
q.Start(context.Background(), 1)
@@ -484,7 +500,7 @@ func TestQueueRunsAReviewAndDeduplicates(t *testing.T) {
if n := len(f.postedReviews()); n != 1 {
t.Errorf("posted %d reviews, want 1", n)
}
if r := q.Enqueue("k.renner/homelab", 8, ""); r != "full" {
if r := q.Enqueue("konairius/homelab", 8, ""); r != "full" {
t.Errorf("a stopped queue takes nothing, got %s", r)
}
}
@@ -498,12 +514,12 @@ func TestSweepQueuesOnlyWhatNeedsAReview(t *testing.T) {
}
f.mu.Lock()
f.reviews = `[{"body":"<!-- pr-review:sha=abc123 -->","user":{"login":"review-bot"}}]`
f.requested = false
f.mu.Unlock()
q2 := newQueue(f.rev, 4, f.rev.log, f.metrics)
f.rev.Sweep(context.Background(), q2)
if len(q2.jobs) != 0 {
t.Errorf("queued %d for an already reviewed PR, want 0", len(q2.jobs))
t.Errorf("queued %d for a PR that asks for no review, want 0", len(q2.jobs))
}
}
@@ -529,7 +545,7 @@ func TestFirstRetryWaitsTheConfiguredTime(t *testing.T) {
f.rev.cfg.RateLimitWait = 300 * time.Millisecond
f.convStatus = []int{429}
start := time.Now()
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
if got := time.Since(start); got < 250*time.Millisecond {
@@ -566,7 +582,7 @@ func TestReviewOfAFileAboutRateLimitsIsNotDropped(t *testing.T) {
f.answer = `{"outputs":[
{"type":"tool.execution","name":"conn_get_file_contents","info":{"result":"var rateLimitText = rate limit handling"}},
{"type":"message.output","content":"## Summary\n\nreviewed\n\n## Recommendation\n\nApprove."}]}`
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
if f.convCalls != 1 || len(f.postedReviews()) != 1 {
@@ -579,7 +595,7 @@ func TestReviewOfAFileAboutRateLimitsIsNotDropped(t *testing.T) {
func TestReviewDoesNotPostForAStaleHead(t *testing.T) {
f := newFixture(t)
f.pushAfter = "def456"
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
posted := f.postedReviews()
@@ -595,18 +611,18 @@ func TestReviewDoesNotPostForAStaleHead(t *testing.T) {
func TestQueueReviewsAgainWhenAskedForDuringTheReview(t *testing.T) {
f := newFixture(t)
q := newQueue(f.rev, 4, f.rev.log, f.metrics)
if r := q.Enqueue("k.renner/homelab", 7, "abc123"); r != "queued" {
if r := q.Enqueue("konairius/homelab", 7, "abc123"); r != "queued" {
t.Fatalf("enqueue: %s", r)
}
q.mu.Lock()
q.running["k.renner/homelab#7"] = true // as if a worker had picked it up
q.running["konairius/homelab#7"] = true // as if a worker had picked it up
q.mu.Unlock()
if r := q.Enqueue("k.renner/homelab", 7, "abc123"); r != "duplicate" {
if r := q.Enqueue("konairius/homelab", 7, "abc123"); r != "duplicate" {
t.Fatalf("enqueue while running: %s", r)
}
q.mu.Lock()
again := q.again["k.renner/homelab#7"]
delete(q.running, "k.renner/homelab#7")
again := q.again["konairius/homelab#7"]
delete(q.running, "konairius/homelab#7")
q.mu.Unlock()
if !again {
t.Error("the request during the review was forgotten")
@@ -617,7 +633,7 @@ func TestReviewAsksAgainAfterAGatewayFailure(t *testing.T) {
for _, status := range []int{502, 503, 504} {
f := newFixture(t)
f.convStatus = []int{status}
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatalf("%d: %v", status, err)
}
if f.convCalls != 2 || len(f.postedReviews()) != 1 {
+5 -5
View File
@@ -26,11 +26,11 @@ type Choice struct {
// selectInstructions is the question the selector answers for every pull request.
const selectInstructions = "Pick the model that should review the pull request in `pull_request`. " +
"The review reads the repository, checks the change for bugs, security problems and broken behaviour, " +
"and must be thorough. Prefer a cheaper or faster model when the change is small and low-risk " +
"(documentation, version bumps, simple configuration). Pick a stronger model when the change is large, " +
"spans several components, or touches security, authentication, secrets, network policy, concurrency, " +
"data migrations or anything that is hard to undo."
"The review reads the repository and checks the change for bugs, security problems and broken behaviour. " +
"Pick the cheapest model that can review it well; most changes need no more than the cheapest one. " +
"Pick a more expensive model only when the change clearly needs it: it is large, spans several " +
"components, or touches security, authentication, secrets, network policy, concurrency, data migrations " +
"or anything that is hard to undo."
// TypeSafe picks the review model with a System One Choice question.
type TypeSafe struct {
+9 -2
View File
@@ -74,6 +74,9 @@ type hookPayload struct {
Repository struct {
FullName string `json:"full_name"`
} `json:"repository"`
RequestedReviewer struct {
Login string `json:"login"`
} `json:"requested_reviewer"`
}
// validSignature checks Gitea's X-Gitea-Signature: the hex HMAC-SHA256 of the raw body.
@@ -114,11 +117,15 @@ func (s *Server) hook(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad payload", http.StatusBadRequest)
return
}
// Gitea says "synchronized" for new commits on an open PR.
if !slices.Contains([]string{"opened", "reopened", "synchronize", "synchronized"}, p.Action) {
// A review runs only when the bot is asked for one; pushes and new PRs alone start nothing.
if p.Action != "review_requested" {
ignore("action " + p.Action)
return
}
if p.RequestedReviewer.Login != s.cfg.BotLogin {
ignore("review requested from " + p.RequestedReviewer.Login)
return
}
n := p.Number
if n == 0 {
n = p.PullRequest.Number
+18 -17
View File
@@ -13,10 +13,10 @@ import (
func TestQueueReportsQueuedRunningAndPosted(t *testing.T) {
f := newFixture(t)
q := newQueue(f.rev, 4, f.rev.log, f.metrics)
if r := q.Enqueue("k.renner/homelab", 7, "abc123"); r != "queued" {
if r := q.Enqueue("konairius/homelab", 7, "abc123"); r != "queued" {
t.Fatalf("enqueue: %s", r)
}
if st, _ := f.status.Get("k.renner/homelab", 7); st.Phase != PhaseQueued || st.Position != 1 {
if st, _ := f.status.Get("konairius/homelab", 7); st.Phase != PhaseQueued || st.Position != 1 {
t.Errorf("before the worker runs: %+v, want queued at position 1", st)
}
q.Start(context.Background(), 1)
@@ -30,7 +30,7 @@ func TestQueueReportsQueuedRunningAndPosted(t *testing.T) {
if got := f.commitStatuses(); !slices.Equal(got, want) {
t.Errorf("commit statuses\n got %q\nwant %q", got, want)
}
st, _ := f.status.Get("k.renner/homelab", 7)
st, _ := f.status.Get("konairius/homelab", 7)
if st.Phase != PhasePosted || st.URL != "https://gitea/pr/7#review" || st.Head != "abc123" {
t.Errorf("after the review: %+v", st)
}
@@ -40,7 +40,7 @@ func TestAFailedReviewIsReportedAsAnError(t *testing.T) {
f := newFixture(t)
f.answer = `{"outputs":[{"type":"message.output","content":"I could not do it."}]}`
q := newQueue(f.rev, 4, f.rev.log, f.metrics)
q.Enqueue("k.renner/homelab", 7, "abc123")
q.Enqueue("konairius/homelab", 7, "abc123")
q.Start(context.Background(), 1)
q.Stop(5 * time.Second)
@@ -48,7 +48,7 @@ func TestAFailedReviewIsReportedAsAnError(t *testing.T) {
if last := got[len(got)-1]; last != "abc123 error: review failed: the agent's answer holds no report" {
t.Errorf("last commit status %q", last)
}
if st, _ := f.status.Get("k.renner/homelab", 7); st.Phase != PhaseFailed || st.Detail != "the agent's answer holds no report" {
if st, _ := f.status.Get("konairius/homelab", 7); st.Phase != PhaseFailed || st.Detail != "the agent's answer holds no report" {
t.Errorf("after the failure: %+v", st)
}
}
@@ -56,7 +56,7 @@ func TestAFailedReviewIsReportedAsAnError(t *testing.T) {
func TestARetryIsReported(t *testing.T) {
f := newFixture(t)
f.convStatus = []int{429}
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
got := f.commitStatuses()
@@ -70,12 +70,13 @@ func TestARetryIsReported(t *testing.T) {
func TestSkipsResolveTheQueuedStatus(t *testing.T) {
f := newFixture(t)
f.title = "WIP: x"
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
f.title = "feat: x"
f.requested = false
f.reviews = `[{"body":"<!-- pr-review:sha=abc123 -->","state":"APPROVED","html_url":"https://gitea/r/1","user":{"login":"review-bot"}}]`
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
want := []string{"abc123 success: skipped: wip", "abc123 success: reviewed: approve"}
@@ -87,7 +88,7 @@ func TestSkipsResolveTheQueuedStatus(t *testing.T) {
func TestAStaleHeadIsMarkedSuperseded(t *testing.T) {
f := newFixture(t)
f.pushAfter = "def456"
if err := f.rev.Review(context.Background(), "k.renner/homelab", 7); err != nil {
if err := f.rev.Review(context.Background(), "konairius/homelab", 7); err != nil {
t.Fatal(err)
}
got := f.commitStatuses()
@@ -101,7 +102,7 @@ func TestStatusEndpoints(t *testing.T) {
f := newFixture(t)
q := newQueue(f.rev, 4, f.rev.log, f.metrics)
s := &Server{cfg: f.cfg, queue: q, reviewer: f.rev, metrics: f.metrics, log: f.rev.log}
q.Enqueue("k.renner/homelab", 7, "abc123")
q.Enqueue("konairius/homelab", 7, "abc123")
get := func(path string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
s.routes().ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
@@ -114,14 +115,14 @@ func TestStatusEndpoints(t *testing.T) {
t.Errorf("/status: %d %s", rec.Code, rec.Body)
}
var one PRStatus
if rec := get("/status/k.renner/homelab/7"); rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &one) != nil ||
one.Repo != "k.renner/homelab" || one.Number != 7 || one.Head != "abc123" {
t.Errorf("/status/k.renner/homelab/7: %d %s", rec.Code, rec.Body)
if rec := get("/status/konairius/homelab/7"); rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &one) != nil ||
one.Repo != "konairius/homelab" || one.Number != 7 || one.Head != "abc123" {
t.Errorf("/status/konairius/homelab/7: %d %s", rec.Code, rec.Body)
}
if rec := get("/status/k.renner/homelab/8"); rec.Code != 404 {
if rec := get("/status/konairius/homelab/8"); rec.Code != 404 {
t.Errorf("untracked PR: %d, want 404", rec.Code)
}
if rec := get("/status/k.renner/homelab/x"); rec.Code != 400 {
if rec := get("/status/konairius/homelab/x"); rec.Code != 400 {
t.Errorf("bad number: %d, want 400", rec.Code)
}
}
@@ -130,8 +131,8 @@ func TestFinishedReviewsAreForgottenAfterADay(t *testing.T) {
f := newFixture(t)
now := time.Now()
f.status.now = func() time.Time { return now }
f.status.Set("k.renner/homelab", 7, "", PhasePosted, "comment", "")
f.status.Set("k.renner/homelab", 8, "", PhaseQueued, "", "")
f.status.Set("konairius/homelab", 7, "", PhasePosted, "comment", "")
f.status.Set("konairius/homelab", 8, "", PhaseQueued, "", "")
now = now.Add(keepFinished + time.Minute)
got := f.status.List()
if len(got) != 1 || got[0].Number != 8 {