From dc08522bab8beba51dcd3652fbd83cfc9b7ea532 Mon Sep 17 00:00:00 2001 From: Emma Thorpe Date: Thu, 11 Jun 2026 11:57:13 +0100 Subject: [PATCH 1/2] feat(edaas): add daily headless Renovate PR review timer Add a systemd user timer on the EDaaS/WSL host that runs Claude Code headless once a day (08:47) to review Renovate dependency PRs awaiting Emma's review. It queries GitHub via the project-scoped github MCP server, excludes PRs against archived repositories, grades each PR's risk, and writes a recommendation-only summary to the journal (journalctl --user -u renovate-review). It never approves or merges. - lyrathorpe/home/renovate-review.nix: wrapper + service + timer. Auth is Vertex AI via the inherited project/region/model env; Claude Code provisions its own network egress, so no proxy is set. The prompt lives in a store file so its literal backticks/$ don't trip shellcheck in the wrapper. - lyrathorpe/home/work.nix: import the module (host-scoped to EDaaS). - system/machine/EDaaS/configuration.nix: enable user linger so the timer fires without an attached login session. --- lyrathorpe/home/renovate-review.nix | 96 ++++++++++++++++++++++++++ lyrathorpe/home/work.nix | 5 ++ system/machine/EDaaS/configuration.nix | 7 ++ 3 files changed, 108 insertions(+) create mode 100644 lyrathorpe/home/renovate-review.nix diff --git a/lyrathorpe/home/renovate-review.nix b/lyrathorpe/home/renovate-review.nix new file mode 100644 index 0000000..45a7d66 --- /dev/null +++ b/lyrathorpe/home/renovate-review.nix @@ -0,0 +1,96 @@ +# Daily automated review of Renovate dependency PRs awaiting Emma's review. +# +# Host-scoped: imported only from work.nix (the EDaaS/WSL host), so the timer +# exists on this machine alone. A systemd *user* timer runs Claude Code headless +# once a day; it queries GitHub via the project-scoped github MCP server and +# writes a risk-graded summary to the journal (read with +# `journalctl --user -u renovate-review`). +# +# Caveats (the foundation this stands on, none of it owned by this flake): +# * Auth is Vertex AI via gcloud Application Default Credentials +# (~/.config/gcloud/application_default_credentials.json). When that token +# can no longer refresh the run fails; re-auth with `gcloud auth login`. +# * The Vertex project, region and model are hardcoded below, copied from the +# interactive environment (the corporate launcher injects them; they live in +# no config file). If IT changes them, update them here. Claude Code handles +# its own network egress, so no proxy is set. +# * The github MCP server is defined in ~/code/.mcp.json, so the job runs with +# that directory as its working directory. +{ + config, + pkgs, + lib, + ... +}: +let + # The review instructions handed to headless Claude. Mirrors the interactive + # daily review: queue -> drop archived repos -> grade risk -> recommend. + reviewPrompt = '' + Daily Renovate PR review for Emma-Thorpe_citrix. Steps: + 1. github MCP search_pull_requests, query: `is:open is:pr review-requested:Emma-Thorpe_citrix author:app/jenkins-stf-jm` (jenkins-stf-jm[bot] is this org's Renovate bot), perPage 50. + 2. Build the archived-repo exclusion set: github MCP search_repositories with query `org:csg-citrix-storefront archived:true`, perPage 100, paginate all pages (~128). Collect each archived repo full_name. Do NOT use the `archived:false` qualifier on the PR search itself; it is mis-indexed and returns zero. Filter by the repo set instead. + 3. Drop any PR whose repository is in the archived set (e.g. csg-citrix-storefront/traefik-fips is archived; a PR to an archived repo cannot merge and is noise). + 4. For each remaining PR: pull_request_read method=get (diff size, mergeable_state, labels, age) and method=get_status (CI). Read the body's dependency table for what is bumped. + 5. Assess risk per PR: patch/minor/major; tooling/observability/infra vs application logic; CI state; diff size; staleness (old created_at or stale checks). Flag CVE/security fixes as elevated priority. + 6. Output a markdown table: PR (linked), repo, change summary, size, CI, risk (Low/Medium/High), verdict (Approve / Hold / Needs rebase). Below it, terse notes for anything needing action. State how many PRs were excluded as archived. + 7. Do NOT approve or merge anything; recommendation only. + If the post-filter search returns zero PRs, say so in one line. + ''; + + # Hold the prompt in its own store file rather than inline, so its literal + # backticks and `$` don't trip shellcheck (SC2016) in the wrapper below. + promptFile = pkgs.writeText "renovate-review-prompt.md" reviewPrompt; + + # Tools the headless run is permitted to use without interactive prompts. + # Read-only github MCP calls only; nothing that mutates a PR. + allowedTools = lib.concatStringsSep "," [ + "mcp__github-mcp__search_pull_requests" + "mcp__github-mcp__search_repositories" + "mcp__github-mcp__pull_request_read" + ]; + + renovate-review = pkgs.writeShellApplication { + name = "renovate-review"; + runtimeInputs = [ config.programs.claude-code.package ]; + text = '' + # The github MCP server is project-scoped to ~/code; run from there. + cd "$HOME/code" + + # Claude Code auth + endpoint: Vertex AI. These are injected into the + # interactive shell by the corporate launcher (not present in any config + # file), so a systemd-spawned process must set them explicitly. Do NOT set + # HTTP(S)_PROXY: Claude Code self-provisions its own network egress to + # Vertex; forcing a proxy here points it at a per-session socket that does + # not exist outside an interactive launch and breaks connectivity. + export CLAUDE_CODE_USE_VERTEX=1 + export ANTHROPIC_VERTEX_PROJECT_ID=claude-code-citrix + export CLOUD_ML_REGION=global + export ANTHROPIC_MODEL='claude-opus-4-8[1m]' + + claude -p "$(cat ${promptFile})" \ + --allowedTools ${lib.escapeShellArg allowedTools} \ + --output-format text + ''; + }; +in +{ + systemd.user.services.renovate-review = { + Unit.Description = "Daily Renovate PR review (headless Claude Code)"; + Service = { + Type = "oneshot"; + ExecStart = lib.getExe renovate-review; + }; + }; + + systemd.user.timers.renovate-review = { + Unit.Description = "Schedule the daily Renovate PR review"; + Timer = { + OnCalendar = "*-*-* 08:47:00"; + # Run on next boot if the machine was off at the scheduled time. + Persistent = true; + # Avoid firing exactly on the minute boundary. + RandomizedDelaySec = "5m"; + }; + Install.WantedBy = [ "timers.target" ]; + }; +} diff --git a/lyrathorpe/home/work.nix b/lyrathorpe/home/work.nix index 41950ae..6191c5a 100644 --- a/lyrathorpe/home/work.nix +++ b/lyrathorpe/home/work.nix @@ -3,6 +3,11 @@ { pkgs, lib, ... }: { + # Host-scoped extras for this machine only (the EDaaS/WSL host). + imports = [ + ./renovate-review.nix # daily headless Renovate PR review (systemd user timer) + ]; + # The work box keeps its own (corporate) ~/.ssh/config; don't let the personal # programs.ssh (shell.nix) take it over. The ssh-agent below still runs. programs.ssh.enable = lib.mkForce false; diff --git a/system/machine/EDaaS/configuration.nix b/system/machine/EDaaS/configuration.nix index 17ee67d..600d786 100644 --- a/system/machine/EDaaS/configuration.nix +++ b/system/machine/EDaaS/configuration.nix @@ -58,6 +58,13 @@ systemd.services.docker-desktop-proxy.script = lib.mkForce ''${config.wsl.wslConf.automount.root}/wsl/docker-desktop/docker-desktop-user-distro proxy --docker-desktop-root ${config.wsl.wslConf.automount.root}/wsl/docker-desktop "C:\Program Files\Docker\Docker\resources"''; features.swayDesktop.enable = false; + + # Keep this user's systemd --user instance running without an open login + # session, so the home-manager user timer (renovate-review.nix) fires on + # schedule even when no terminal is attached. On WSL the timer still only runs + # while the distro itself is up; Persistent=true catches up a missed run at + # next start. + users.users.emmathorpe.linger = true; # programs.nix-ld is enabled for all NixOS hosts in common-nixos.nix. # This value determines the NixOS release from which the default # settings for stateful data, like file locations and database versions From 783754bda286d2b98d0901a49aeecb515c1e09ae Mon Sep 17 00:00:00 2001 From: Emma Thorpe Date: Thu, 11 Jun 2026 15:46:12 +0100 Subject: [PATCH 2/2] feat(edaas): auto-approve low-risk Renovate PRs + daily shell reminder Extend the daily Renovate review so it triages instead of only advising, and surface results in the interactive shell. - Auto-approve: PRs graded low risk (patch/minor bumps to tooling, infra, test or framework libs; symmetric diff; CI passing; no app logic) that are not already approved get an APPROVE review via pull_request_review_write. These repos automerge on approval, so this merges them with no human in the loop -- intentional. Medium/high risk, failing/pending CI, stale branches and anything needing judgement are left untouched for Emma. No merge tool is granted. - State + reminder: each run records ~/.local/state/renovate-review/ {last-run,needs-review.txt}. A once-a-day interactive zsh reminder (programs.zsh.initContent) warns if the timer hasn't run, lists the PRs needing review, or confirms an all-clear. Verified: nix build (eval + shellcheck) green; triage parsing and the reminder's run/stale/all-clear/throttle branches exercised against synthetic state. The first live auto-approval is left for a supervised scheduled/manual run. --- lyrathorpe/home/renovate-review.nix | 92 +++++++++++++++++++++++++---- 1 file changed, 80 insertions(+), 12 deletions(-) diff --git a/lyrathorpe/home/renovate-review.nix b/lyrathorpe/home/renovate-review.nix index 45a7d66..2598865 100644 --- a/lyrathorpe/home/renovate-review.nix +++ b/lyrathorpe/home/renovate-review.nix @@ -1,4 +1,5 @@ -# Daily automated review of Renovate dependency PRs awaiting Emma's review. +# Daily automated review and triage of Renovate dependency PRs awaiting Emma's +# review. # # Host-scoped: imported only from work.nix (the EDaaS/WSL host), so the timer # exists on this machine alone. A systemd *user* timer runs Claude Code headless @@ -6,6 +7,21 @@ # writes a risk-graded summary to the journal (read with # `journalctl --user -u renovate-review`). # +# Triage policy: +# * PRs that are clearly low risk (patch/minor bumps to tooling, infra, test +# or framework libs; symmetric diff; CI green; no application logic) AND not +# already approved are AUTO-APPROVED headlessly. These repos enable Renovate +# automerge, so an approval lets the PR merge itself with no human in the +# loop. This is intentional and was explicitly requested. +# * Everything else (medium/high risk, failing/pending CI, stale branches, +# anything touching application logic or needing judgement) is left +# untouched and surfaced to Emma. +# +# The run records two state files under $XDG_STATE_HOME/renovate-review for the +# once-a-day interactive-shell reminder defined below (programs.zsh.initContent): +# `last-run` (date of the last successful run) and `needs-review.txt` (the PRs +# that need Emma's eyes). +# # Caveats (the foundation this stands on, none of it owned by this flake): # * Auth is Vertex AI via gcloud Application Default Credentials # (~/.config/gcloud/application_default_credentials.json). When that token @@ -23,18 +39,24 @@ ... }: let - # The review instructions handed to headless Claude. Mirrors the interactive - # daily review: queue -> drop archived repos -> grade risk -> recommend. + # The review instructions handed to headless Claude: queue -> drop archived + # repos -> grade risk -> auto-approve the clearly-safe ones, surface the rest. reviewPrompt = '' - Daily Renovate PR review for Emma-Thorpe_citrix. Steps: + Daily Renovate PR review and triage for Emma-Thorpe_citrix. + 1. github MCP search_pull_requests, query: `is:open is:pr review-requested:Emma-Thorpe_citrix author:app/jenkins-stf-jm` (jenkins-stf-jm[bot] is this org's Renovate bot), perPage 50. 2. Build the archived-repo exclusion set: github MCP search_repositories with query `org:csg-citrix-storefront archived:true`, perPage 100, paginate all pages (~128). Collect each archived repo full_name. Do NOT use the `archived:false` qualifier on the PR search itself; it is mis-indexed and returns zero. Filter by the repo set instead. 3. Drop any PR whose repository is in the archived set (e.g. csg-citrix-storefront/traefik-fips is archived; a PR to an archived repo cannot merge and is noise). - 4. For each remaining PR: pull_request_read method=get (diff size, mergeable_state, labels, age) and method=get_status (CI). Read the body's dependency table for what is bumped. - 5. Assess risk per PR: patch/minor/major; tooling/observability/infra vs application logic; CI state; diff size; staleness (old created_at or stale checks). Flag CVE/security fixes as elevated priority. - 6. Output a markdown table: PR (linked), repo, change summary, size, CI, risk (Low/Medium/High), verdict (Approve / Hold / Needs rebase). Below it, terse notes for anything needing action. State how many PRs were excluded as archived. - 7. Do NOT approve or merge anything; recommendation only. - If the post-filter search returns zero PRs, say so in one line. + 4. For each remaining PR: pull_request_read method=get (diff size, mergeable_state, labels, age), method=get_status (CI), and method=get_reviews (existing approvals). Read the body's dependency table for what is bumped. + 5. Grade risk Low / Medium / High. LOW means ALL of: only patch or minor version bumps; the packages are tooling, observability, infrastructure, test, or framework/runtime libraries (not business logic); the diff is small and symmetric (version strings / lockfiles only); CI is passing; nothing security-policy-loosening. Anything that is a major bump, touches application logic, has failing or pending CI, is a stale branch needing rebase, or that you are not confident about is NOT Low. + 6. AUTO-APPROVE the safe ones: for every PR that is Low risk AND has passing CI AND is not already approved by Emma-Thorpe_citrix, submit an approving review with pull_request_review_write (method=create, event=APPROVE, body: a one-line note that this is an automated approval of a low-risk dependency update). Approve only these. NEVER call merge. NEVER approve a Medium/High PR or one you are unsure about. (Note: these repos automerge on approval, so approval effectively merges it.) + 7. Leave for Emma, without approving: every Medium/High risk PR, anything with failing or pending CI, stale branches, and anything needing human judgement. + 8. Print a markdown table (PR linked, repo, change summary, size, CI, risk, action: Auto-approved / Needs review / Held) and terse notes. State how many PRs were excluded as archived. + 9. As the FINAL lines of your output, emit machine-readable triage lines, one per PR, with these EXACT prefixes and nothing else on the line: + - For each PR you auto-approved: APPROVED> owner/repo#NUMBER short title + - For each PR that needs Emma's review: NEEDS> owner/repo#NUMBER (Risk) one-line reason — https://github.com/owner/repo/pull/NUMBER + If no PR needs Emma's review, emit no NEEDS> lines at all. + If the post-filter search returns zero PRs, say so in one line and emit no NEEDS> lines. ''; # Hold the prompt in its own store file rather than inline, so its literal @@ -42,13 +64,18 @@ let promptFile = pkgs.writeText "renovate-review-prompt.md" reviewPrompt; # Tools the headless run is permitted to use without interactive prompts. - # Read-only github MCP calls only; nothing that mutates a PR. + # Read-only github MCP calls, plus review_write so it can submit APPROVE + # reviews on low-risk PRs. Deliberately NOT included: any merge tool. allowedTools = lib.concatStringsSep "," [ "mcp__github-mcp__search_pull_requests" "mcp__github-mcp__search_repositories" "mcp__github-mcp__pull_request_read" + "mcp__github-mcp__pull_request_review_write" ]; + # Where the run records state for the interactive-shell reminder. + stateDir = "$HOME/.local/state/renovate-review"; + renovate-review = pkgs.writeShellApplication { name = "renovate-review"; runtimeInputs = [ config.programs.claude-code.package ]; @@ -67,9 +94,22 @@ let export CLOUD_ML_REGION=global export ANTHROPIC_MODEL='claude-opus-4-8[1m]' - claude -p "$(cat ${promptFile})" \ + # Capture the run so we can both log it (journal) and persist the triage + # for the shell reminder. If claude exits non-zero, errexit aborts here and + # the state files are left stale, so the reminder will flag a missed run. + out="$(claude -p "$(cat ${promptFile})" \ --allowedTools ${lib.escapeShellArg allowedTools} \ - --output-format text + --output-format text)" + + printf '%s\n' "$out" + + # Persist state for programs.zsh.initContent's daily reminder. needs-review + # gets the PRs Claude flagged for Emma (the NEEDS> lines, prefix stripped); + # it is empty when nothing needs her attention. grep || true: no matches is + # the all-clear case, not an error. + mkdir -p "${stateDir}" + printf '%s\n' "$out" | grep '^NEEDS> ' | sed 's/^NEEDS> //' > "${stateDir}/needs-review.txt" || true + date +%F > "${stateDir}/last-run" ''; }; in @@ -93,4 +133,32 @@ in }; Install.WantedBy = [ "timers.target" ]; }; + + # Interactive-shell reminder: nudge once per calendar day about the daily + # Renovate timer -- whether it actually ran, and any PRs that need Emma's eyes + # (the auto-approved ones need no nudge). Throttled via a `reminded-on` marker + # so it prints in the first shell/tmux pane of the day, not every pane. mkOrder + # 1600 runs after shell.nix's tmux re-exec (order 200), so it fires inside the + # tmux pane where Emma actually reads it. + programs.zsh.initContent = lib.mkOrder 1600 '' + if [[ $- == *i* ]]; then + __rr_dir="$HOME/.local/state/renovate-review" + __rr_today=$(date +%F) + if [[ "$(cat "$__rr_dir/reminded-on" 2>/dev/null)" != "$__rr_today" ]]; then + __rr_last=$(cat "$__rr_dir/last-run" 2>/dev/null) + if [[ "$__rr_last" != "$__rr_today" ]]; then + print -P "%F{yellow}renovate:%f last review ''${__rr_last:-never} (not today) -- check: systemctl --user status renovate-review" + fi + if [[ -s "$__rr_dir/needs-review.txt" ]]; then + print -P "%F{red}renovate:%f $(grep -c . "$__rr_dir/needs-review.txt") PR(s) need your review:" + sed 's/^/ - /' "$__rr_dir/needs-review.txt" + print -P " -> journalctl --user -u renovate-review -e" + elif [[ "$__rr_last" == "$__rr_today" ]]; then + print -P "%F{green}renovate:%f reviewed today -- low-risk auto-approved, nothing for you." + fi + mkdir -p "$__rr_dir" && print -r -- "$__rr_today" > "$__rr_dir/reminded-on" + fi + unset __rr_dir __rr_today __rr_last + fi + ''; }