Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a14bd5a85a | ||
|
|
35a387a3c1 | ||
|
|
093857c353 | ||
|
|
7f29ebb878 | ||
|
|
b07167ae1a |
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}'
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)."
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
module gitea.renner.uno/k.renner/homelab/kuma-sync
|
||||
module gitea.renner.uno/konairius/homelab/kuma-sync
|
||||
|
||||
go 1.27
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
||||
module gitea.renner.uno/k.renner/homelab/review-bot
|
||||
module gitea.renner.uno/konairius/homelab/review-bot
|
||||
|
||||
go 1.27
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user