Files
nixfiles/lyrathorpe/home/renovate-review.nix
T
Emma Thorpe 783754bda2
CI / flake (pull_request) Successful in 4m0s
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.
2026-06-11 15:46:12 +01:00

165 lines
9.6 KiB
Nix

# 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
# 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`).
#
# 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
# 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: queue -> drop archived
# repos -> grade risk -> auto-approve the clearly-safe ones, surface the rest.
reviewPrompt = ''
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), 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
# 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, 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 ];
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]'
# 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)"
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
{
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" ];
};
# 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
'';
}