Compare commits

..
Author SHA1 Message Date
claude-botandClaude Opus 5.5 9439e0d58c fix(os-update): reboot when the booted kernel is no longer installed
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/deployment/update-os Pipeline was successful
Review #1811: on Arch the reboot only fired when this run's pacman task
changed something, while the post-reboot check looks at host state. A
host whose newer kernel was installed by an interrupted earlier run (or
by hand) skipped the reboot, failed the kernel check and stopped the
whole run. The play now also reboots when /usr/lib/modules/$(uname -r)
is gone before the reboot step.

Also from the review:
- assert >= 1 GiB free on / before upgrading (edge-1 runs from a
  6.6 GiB eMMC root, 2.1 GiB free on 2026-10-22)
- play comment names the groups it covers instead of 'every host'
- pipeline comment points at the README 02:00-05:00 window

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:55:18 +02:00
claude-botandClaude Opus 5.5 92615c0402 feat(ansible): add update-os play and pipeline for host package upgrades
ci/woodpecker/pr/ci-manifests-test Pipeline is running
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
Since 52899ecb (#625) removed plays/update.yml and the update-all
pipeline together with the Proxmox fleet, nothing upgrades the remaining
Ansible hosts. proxy-1/2 and edge-1 (Arch) and netbird (Ubuntu) all run
kernels affected by CVE-2026-99999.

os-update role, per host:
- records failed systemd units and running docker containers
- Arch: archlinux-keyring first, then pacman -Syu
- Debian/Ubuntu: apt full-upgrade + autoremove
- reboots when pacman changed something / reboot-required is set
- asserts the running kernel is the installed one (Arch) or no reboot
  is pending (Debian)
- waits until every previously running container is back and no new
  failed units appear

update-os.yml runs netbird_proxy, then edge_host, then netbird_server,
serial: 1 with max_fail_percentage: 0, so the first failing host stops
the whole run before the next host is touched.

The pipeline is deployment-only (target update-os): edge-1 is the sole
NTP server and carries Z-Wave and the heating CAN bus, and netbird is
the control plane; neither may reboot on a cron.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:04:28 +02: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
k.renner df0b344f7f Merge pull request 'fix(hermes): push the Hermes image as registry-bot' (#1369) from fix/hermes-image-registry-bot into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline failed
Reviewed-on: k.renner/homelab#1369
2026-10-19 08:31:56 +00:00
hermes-bot 46b7d458b3 fix(hermes): push the Hermes image as registry-bot
review-bot reviewed: request changes
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
registry-bot is the konairius registry identity: write:package PAT,
konairius org secret registry_bot_token, rotated by
openbao/rotation/registry-bot.yaml. That rotation syncs the secret for
the push, tag, release and manual events, so the manual trigger is
`event: manual` instead of a deployment target.

registry_bot_token is a konairius org secret. Woodpecker resolves org
secrets through the repo's org link, so this pipeline gets it only once
homelab has moved to konairius/homelab
(docs/superpowers/plans/2026-10-18-homelab-konairius-main.md, cut-over 1).
2026-10-19 08:29:53 +00:00
security-bot 2d04575c51 chore(image-automation): bump gitea.renner.uno/konairius/hermes:build.1792397861
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-10-19 08:20:50 +00:00
k.renner 97310db78c Merge pull request 'feat(hermes): run the in-house Hermes image' (#1367) from feat/hermes-use-custom-image into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/push/hermes-image Pipeline was successful
Reviewed-on: k.renner/homelab#1367
2026-10-19 08:16:53 +00:00
k.renner 085f969f63 Merge pull request 'feat(hermes): build a Hermes image with the agent's CLI tools' (#1366) from feat/hermes-image into master
ci/woodpecker/push/build-ci-images Pipeline is pending
ci/woodpecker/push/hermes-image Pipeline is pending
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1366
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-19 08:12:26 +00:00
hermes-bot d00ee70b23 fix(hermes): run the Hermes image from the konairius org
review-bot reviewed: request changes
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
2026-10-19 08:10:18 +00:00
hermes-bot 8ea8318c6e Merge branch 'feat/hermes-image' into feat/hermes-use-custom-image 2026-10-19 08:10:13 +00:00
hermes-bot be9898f7b0 chore(renovate): leave gitea_admin/hermes to Flux image automation
review-bot reviewed: request changes
ci/woodpecker/pr/ci-scripts-test Pipeline was canceled
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
2026-10-19 08:06:10 +00:00
hermes-bot 3ed7741c68 docs(hermes): tea is in the image now, still not logged in
review-bot reviewed: request changes
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
2026-10-19 07:58:27 +00:00
hermes-bot c8849b8d9c feat(hermes): run the in-house Hermes image
Switches apps/hermes to gitea.renner.uno/gitea_admin/hermes, which
carries talosctl, kubectl, flux, bao, tea and jq in /usr/local/bin. The
image-automation policy now tracks its build.<unix-ts> tags instead of
upstream nousresearch/hermes-agent; .woodpecker/hermes-image.yml follows
upstream releases.

Drops the kubectl init container and its emptyDir: the binary sat in
/opt/data/.local/bin, which the agent's terminal does not have on PATH.
2026-10-19 07:58:27 +00:00
37 changed files with 391 additions and 147 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() {
+28
View File
@@ -0,0 +1,28 @@
when:
# Manual only: reboots edge-1 (Z-Wave, heating CAN, sole NTP server) and the
# NetBird control plane. Run inside the 02:00-05:00 Europe/Berlin window.
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "update-os"'
timeout: 120
steps:
- name: update-os
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-04-29
environment:
ANSIBLE_FORCE_COLOR: "true"
ANSIBLE_HOST_KEY_CHECKING: "false"
ANSIBLE_ROLES_PATH: "../roles:./roles"
SSH_PRIVATE_KEY:
from_secret: ssh_private_key
VAULT_PASS:
from_secret: vault_pass
commands:
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- printf "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n" > ~/.ssh/config
- cd ansible
- echo "$VAULT_PASS" > .vault_pass && chmod 600 .vault_pass
- export ANSIBLE_VAULT_PASSWORD_FILE=.vault_pass
- ansible-playbook -i inventory/ plays/update-os.yml -v
+30
View File
@@ -0,0 +1,30 @@
---
# Package upgrade + reboot for netbird_proxy, edge_host and netbird_server,
# one host at a time.
# Usage: ansible-playbook -i inventory/ plays/update-os.yml
#
# Plays run in order; a failed host stops the whole run (max_fail_percentage: 0).
- name: Update NetBird proxies
hosts: netbird_proxy
serial: 1
max_fail_percentage: 0
become: true
roles:
- os-update
- name: Update edge hosts
hosts: edge_host
serial: 1
max_fail_percentage: 0
become: true
roles:
- os-update
- name: Update NetBird server
hosts: netbird_server
serial: 1
max_fail_percentage: 0
become: true
roles:
- os-update
+142
View File
@@ -0,0 +1,142 @@
---
- name: Fail on unsupported distribution
ansible.builtin.fail:
msg: >-
os-update supports Debian and Archlinux only;
this host reports os_family={{ ansible_facts['os_family'] }}
when: ansible_facts['os_family'] not in ['Debian', 'Archlinux']
# --- Baseline: what is healthy before the upgrade must be healthy after ---
- name: Record failed units before upgrade
ansible.builtin.command: systemctl list-units --state=failed --plain --no-legend --no-pager
register: os_update_failed_before
changed_when: false
check_mode: false
- name: Check for docker
ansible.builtin.stat:
path: /usr/bin/docker
register: os_update_docker
- name: Record running containers before upgrade
ansible.builtin.command: docker ps --format "{{ '{{' }}.Names{{ '}}' }}"
register: os_update_containers_before
changed_when: false
check_mode: false
when: os_update_docker.stat.exists
- name: Assert at least 1 GiB free on /
ansible.builtin.assert:
that: >-
(ansible_facts['mounts'] | selectattr('mount', 'equalto', '/') | first)['size_available']
>= 1073741824
fail_msg: "Less than 1 GiB free on / -- free space before upgrading"
# --- Arch Linux ---
- name: Upgrade keyring first (Arch)
community.general.pacman:
name: archlinux-keyring
state: latest
update_cache: true
when: ansible_facts['os_family'] == 'Archlinux'
- name: Upgrade all packages (Arch)
community.general.pacman:
upgrade: true
register: os_update_pacman
when: ansible_facts['os_family'] == 'Archlinux'
# --- Debian/Ubuntu ---
- name: Upgrade all packages (Debian)
ansible.builtin.apt:
update_cache: true
upgrade: full
autoremove: true
environment:
DEBIAN_FRONTEND: noninteractive
when: ansible_facts['os_family'] == 'Debian'
- name: Check reboot-required flag (Debian)
ansible.builtin.stat:
path: /var/run/reboot-required
register: os_update_reboot_flag
when: ansible_facts['os_family'] == 'Debian'
# --- Reboot ---
- name: Read running kernel before reboot (Arch)
ansible.builtin.command: uname -r
register: os_update_kernel_before
changed_when: false
check_mode: false
when: ansible_facts['os_family'] == 'Archlinux'
# Missing modules dir: a newer kernel is installed but not booted, including
# from an earlier interrupted run where pacman now reports no change.
- name: Check running kernel is still installed (Arch)
ansible.builtin.stat:
path: "/usr/lib/modules/{{ os_update_kernel_before.stdout }}/vmlinuz"
register: os_update_kernel_present
when: ansible_facts['os_family'] == 'Archlinux'
- name: Reboot
ansible.builtin.reboot:
msg: "Rebooting after package upgrade"
reboot_timeout: 900
post_reboot_delay: 30
when: >-
(ansible_facts['os_family'] == 'Archlinux' and
(os_update_pacman is changed or not os_update_kernel_present.stat.exists)) or
(ansible_facts['os_family'] == 'Debian' and os_update_reboot_flag.stat.exists)
# --- Verify ---
- name: Read running kernel
ansible.builtin.command: uname -r
register: os_update_kernel
changed_when: false
check_mode: false
# Arch keeps only the installed kernel's modules; the directory existing means
# the host booted the kernel pacman installed.
- name: Assert running kernel is the installed one (Arch)
ansible.builtin.stat:
path: "/usr/lib/modules/{{ os_update_kernel.stdout }}/vmlinuz"
register: os_update_kernel_installed
failed_when: not os_update_kernel_installed.stat.exists
when: ansible_facts['os_family'] == 'Archlinux'
- name: Assert no reboot pending (Debian)
ansible.builtin.stat:
path: /var/run/reboot-required
register: os_update_reboot_flag_after
failed_when: os_update_reboot_flag_after.stat.exists
when: ansible_facts['os_family'] == 'Debian'
- name: Wait for previously running containers
ansible.builtin.command: docker ps --format "{{ '{{' }}.Names{{ '}}' }}"
register: os_update_containers_after
changed_when: false
check_mode: false
until: >-
os_update_containers_before.stdout_lines
| difference(os_update_containers_after.stdout_lines)
| length == 0
retries: 20
delay: 15
when: os_update_docker.stat.exists
- name: Wait for no new failed units
ansible.builtin.command: systemctl list-units --state=failed --plain --no-legend --no-pager
register: os_update_failed_after
changed_when: false
check_mode: false
until: >-
os_update_failed_after.stdout_lines
| map('split') | map('first')
| difference(os_update_failed_before.stdout_lines | map('split') | map('first'))
| length == 0
retries: 10
delay: 15
- name: Report kernel
ansible.builtin.debug:
msg: "{{ inventory_hostname }} runs {{ os_update_kernel.stdout }}"
+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) {
+1 -1
View File
@@ -9,7 +9,7 @@ When you use a pronoun for someone whose pronouns haven't been stated, use they/
- The homelab wiki through the `homelab-wiki` skill (read, and deposit facts).
- The web (HTTPS) and LAN hostnames under `renner.uno` through Traefik.
- Gitea (`https://gitea.renner.uno`) as `hermes-bot`: `git` over HTTPS authenticates and sets the commit identity on its own. For the API, send `Authorization: token <contents of /var/run/secrets/gitea/api-token>` (read it inside the command, never print it). There is no `tea`; use the REST API (`/api/v1`). You write only to repos where `hermes-bot` is a collaborator.
- Gitea (`https://gitea.renner.uno`) as `hermes-bot`: `git` over HTTPS authenticates and sets the commit identity on its own. For the API, send `Authorization: token <contents of /var/run/secrets/gitea/api-token>` (read it inside the command, never print it). `tea` is installed but not logged in; use the REST API (`/api/v1`). You write only to repos where `hermes-bot` is a collaborator.
All state of record lives in git: infrastructure in the `homelab` repo (Kubernetes/Flux, Ansible, OpenTofu, ESPHome), Home Assistant config in the `homeassistant` repo. Changes land as a feature branch plus a PR for the owner's review (see below).
+3 -23
View File
@@ -49,7 +49,7 @@ spec:
# connects from the pod network. No env equivalent exists, so it is
# set in config.yaml on every start (idempotent).
- name: config
image: nousresearch/hermes-agent:v0.22.2 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792397861 # {"$imagepolicy": "flux-system:hermes"}
command:
- /opt/hermes/.venv/bin/hermes
- config
@@ -73,7 +73,7 @@ spec:
mountPath: /opt/data
# Skills from git (ConfigMap hermes-skills) beside the PVC's skills/.
- name: skills-config
image: nousresearch/hermes-agent:v0.22.2 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792397861 # {"$imagepolicy": "flux-system:hermes"}
command:
- /opt/hermes/.venv/bin/hermes
- config
@@ -95,25 +95,9 @@ spec:
volumeMounts:
- name: data
mountPath: /opt/data
# kubectl for the agent. /opt/data/.local/bin is on the image's PATH;
# an emptyDir there keeps the binary out of the PVC and current with
# this pin.
- name: kubectl
image: docker.io/alpine/kubectl:1.37.1@sha256:b568e8beb6a79aee269c142343078a278b85e7710f1c0e7d8d6b21d1eb566a48
command: ["cp", "/usr/local/bin/kubectl", "/tools/kubectl"]
securityContext:
runAsUser: 10000
runAsGroup: 10000
runAsNonRoot: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tools
mountPath: /tools
containers:
- name: hermes
image: nousresearch/hermes-agent:v0.22.2 # {"$imagepolicy": "flux-system:hermes"}
image: gitea.renner.uno/konairius/hermes:build.1792397861 # {"$imagepolicy": "flux-system:hermes"}
args: ["gateway", "run"]
env:
- name: HERMES_DASHBOARD
@@ -181,8 +165,6 @@ spec:
volumeMounts:
- name: data
mountPath: /opt/data
- name: tools
mountPath: /opt/data/.local/bin
# Identity and house rules from git (SOUL.md beside this file).
- name: soul
mountPath: /opt/data/SOUL.md
@@ -201,8 +183,6 @@ spec:
- name: data
persistentVolumeClaim:
claimName: hermes-data
- name: tools
emptyDir: {}
- name: soul
configMap:
name: hermes-soul
+1 -1
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
@@ -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
@@ -4,13 +4,10 @@ metadata:
name: hermes
namespace: flux-system
spec:
image: nousresearch/hermes-agent
interval: 1h
image: gitea.renner.uno/konairius/hermes
interval: 10m
---
# v-prefix tags (v0.22.2). The filter skips rc.*, -desktop and per-arch tags.
# The repo also carries the retired date-versioned releases (v2026.4.3 ..
# v2026.9.24, last one 2026-09-24); as semver they sort above 0.x, so the
# range caps below 1.0.0 or the policy picks the old v2026.9.24.
# .woodpecker/hermes-image.yml tags every build `build.<unix-ts>`; latest push wins.
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
@@ -20,8 +17,8 @@ spec:
imageRepositoryRef:
name: hermes
filterTags:
pattern: '^v(?P<ver>\d+\.\d+\.\d+)$'
extract: '$ver'
pattern: '^build\.(?P<ts>\d+)$'
extract: '$ts'
policy:
semver:
range: ">=0.22.2 <1.0.0"
numerical:
order: asc
@@ -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
+1
View File
@@ -45,6 +45,7 @@
"ghcr.io/usetrmnl/terminus",
"ghcr.io/usetrmnl/trmnl-ha-amd64",
"gitea.renner.uno/k.renner/shelly-manager",
"gitea.renner.uno/konairius/hermes",
"gitea.renner.uno/konairius/vsftpd",
"gitea.renner.uno/konairius/wiki",
"gotenberg/gotenberg",
+3 -3
View File
@@ -107,7 +107,7 @@ 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)
}
@@ -204,14 +204,14 @@ func TestSelectorPicksTheBackend(t *testing.T) {
f.rev.setReady("anthropic", true)
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)
}
})
+1 -1
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",
}
}
+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 -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)
}
+35 -35
View File
@@ -109,22 +109,22 @@ 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)
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 +152,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 +197,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 +245,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()
@@ -272,7 +272,7 @@ func TestReviewPostsTheCleanedReport(t *testing.T) {
func TestReviewSkipsWhatIsAlreadyReviewed(t *testing.T) {
f := newFixture(t)
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 +286,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 +299,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 +311,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 +322,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 +333,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 +348,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 +371,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 +383,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,7 +440,7 @@ 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":"opened","number":7,"repository":{"full_name":"konairius/homelab"}}`
good := sign("hook-secret", []byte(body))
cases := []struct {
name, event, sig, body string
@@ -455,10 +455,10 @@ func TestHook(t *testing.T) {
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},
{"repository that is not configured", "pull_request", sign("hook-secret", []byte(strings.Replace(body, "konairius/homelab", "x/y", 1))),
strings.Replace(body, "konairius/homelab", "x/y", 1), 200},
{"payload without a number", "pull_request", sign("hook-secret", []byte(`{"action":"opened","repository":{"full_name":"konairius/homelab"}}`)),
`{"action":"opened","repository":{"full_name":"konairius/homelab"}}`, 400},
{"payload that is not json", "pull_request", sign("hook-secret", []byte("nope")), "nope", 400},
}
for _, c := range cases {
@@ -473,10 +473,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 +484,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)
}
}
@@ -529,7 +529,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 +566,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 +579,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 +595,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 +617,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 {
+17 -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,12 @@ 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.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 +87,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 +101,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 +114,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 +130,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 {