diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 000000000..db1e3f7ed --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,68 @@ +# ───────────────────────────────────────────────────────────────────────────── +# SOURCE OF TRUTH: petry-projects/.github/standards/workflows/claude.yml +# Standard: petry-projects/.github/standards/ci-standards.md#4-claude-code-claudeyml +# Reusable: petry-projects/.github/.github/workflows/claude-code-reusable.yml +# +# AGENTS — READ BEFORE EDITING: +# • This file is a THIN CALLER STUB. All Claude Code logic, the prompt, +# allowedTools, and trigger gating live in the reusable workflow above. +# • You MAY change: nothing in this file in normal use. Adopt verbatim. +# • You MUST NOT change: trigger events, job permissions, the `uses:` line, +# or `secrets: inherit`. These are required for the reusable to work. +# • If you need different behaviour, open a PR against the reusable in the +# central repo. The change will propagate everywhere on next run. +# +# WHY THIS FILE IS IMMUTABLE (agents: read before opening any PR here): +# Anthropic's OIDC token endpoint validates that this file on a PR branch +# is byte-for-byte identical to the file on the default branch. Any diff — +# even a whitespace or comment change — causes the token exchange to fail: +# "401 Unauthorized — Workflow validation failed" +# Claude Code will not run on that PR. Do not open compliance PRs against +# this file. Do not SHA-pin the `uses:` line — internal reusable workflow +# refs are exempt from the Action Pinning Policy (ci-standards.md +# §Action Pinning Policy). The @v1 tag is the correct, stable reference. +# +# NARROW GUARD: The paths-ignore setting (lines 38-39) under pull_request +# prevents the workflow from triggering only when the PR's entire changeset +# is limited to claude.yml alone. PRs that modify claude.yml *plus other +# files* will still trigger the workflow and hit the 401 error at token +# exchange. Other triggers (issue_comment, pull_request_review_comment, +# issues, check_run) are unaffected by paths-ignore and run as configured. +# ───────────────────────────────────────────────────────────────────────────── +# +# Claude Code — thin caller that delegates to the org-level reusable workflow. +# To adopt: copy this file to .github/workflows/claude.yml in your repo. +# Required org/repo secret: CLAUDE_CODE_OAUTH_TOKEN +# Optional org/repo secret: GH_PAT_WORKFLOWS (PAT with `workflow` scope — +# required if Claude needs to push changes to .github/workflows/*.yml) + +name: Claude Code + +on: + pull_request: + branches: [main] + types: [opened, reopened, synchronize] + paths-ignore: + - '.github/workflows/claude.yml' # OIDC invariant — see header above + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [labeled] + check_run: + types: [completed] + +permissions: {} + +jobs: + claude-code: + uses: petry-projects/.github/.github/workflows/claude-code-reusable.yml@v2 + secrets: inherit + permissions: + contents: write + id-token: write + pull-requests: write + issues: write + actions: read + checks: read diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml index 0bb2cbb7f..b41bc07b7 100644 --- a/.github/workflows/pr-review.yml +++ b/.github/workflows/pr-review.yml @@ -85,6 +85,12 @@ jobs: path: ~/.npm-global key: claude-code-${{ env.CLAUDE_CODE_VERSION }}-${{ runner.os }} + - name: Cache claude-code CLI + uses: actions/cache@v5 + with: + path: ~/.npm-global + key: claude-code-${{ env.CLAUDE_CODE_VERSION }}-${{ runner.os }} + - name: Install review engine CLIs run: | set -euo pipefail diff --git a/.gitignore b/.gitignore index 852625937..a51448727 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,392 @@ -# Claude Code worktrees — these are ephemeral scratch directories created by -# Claude Code during agentic runs and must not be committed to the repository. -# The actions/checkout post-job sweep trips on unregistered git directories -# here and emits exit-128 warnings on every run. -.claude/worktrees/ -.claude/scheduled_tasks.lock +# ============================================================================ +# petry-projects baseline .gitignore — SECRETS ONLY +# ---------------------------------------------------------------------------- +# First layer of defense in the Push Protection Standard +# (standards/push-protection.md). Complements GitHub secret scanning + push +# protection, gitleaks pre-commit hooks, and the CI gitleaks job. +# +# Scope: SECRETS ONLY. This file is intentionally language-agnostic. Put +# build artifacts, OS cruft, and editor swap files in per-repo .gitignores or +# the matching language template from https://github.com/github/gitignore. +# +# Ordering note: keep `!` negations IMMEDIATELY AFTER the broad pattern they +# carve out of. Git evaluates rules top-to-bottom; a later ignore can re-hide +# a previously-negated file. A negation inside an already-ignored directory +# does NOT re-include it — always negate by file, never by directory. +# ============================================================================ -# Secret-protection baseline (required by standards/push-protection.md) + +# --------------------------------------------------------------------------- +# 1. Dotenv family +# --------------------------------------------------------------------------- +# .env files are the single most common source of leaked credentials on GitHub. .env +.env.* +.envrc +.env.local +.env.*.local +# Keep committed templates so contributors know which vars to set. +!.env.example +!.env.sample +!.env.template +!.env.*.example +!.env.*.sample +!.env.*.template +# direnv and dotenv-vault artifacts that can contain plaintext values +.direnv/ +.envrc.local +.env.vault.keys + + +# --------------------------------------------------------------------------- +# 2. Cloud provider credential files +# --------------------------------------------------------------------------- +# AWS shared credentials & SSO/CLI token caches (live AccessKey / SessionToken) +.aws/ +aws.credentials +aws_credentials +credentials.csv +# GCP service-account JSON keys and Application Default Credentials +gcp-key.json +gcp-service-account*.json +service-account*.json +service_account*.json +application_default_credentials.json +gha-creds-*.json +# Azure CLI profile / MSAL token cache +.azure/ +azureProfile.json +azureAuth.json +msal_token_cache.* +# DigitalOcean, Linode, Hetzner, Fly, Vercel, Netlify, Cloudflare +.doctl/ +.linode-cli +.hcloud/ +.fly/ +fly.toml.local +.vercel/ +.netlify/ +.wrangler/ +.cloudflared/ + + +# --------------------------------------------------------------------------- +# 3. Kubernetes / container / Helm secrets +# --------------------------------------------------------------------------- +# kubeconfigs embed bearer tokens and client certs +kubeconfig +kubeconfig.* +*.kubeconfig +.kube/ +# Docker registry auth (base64 basic auth for registries) +.docker/config.json +docker-config.json +.dockercfg +# Helm values files that conventionally hold secrets +secrets.yaml +secrets.yml +values.secret.yaml +values.secret.yml +values-secrets.yaml +values-secrets.yml +*.secrets.yaml +*.secrets.yml +# helm-secrets / sops-encrypted values are SAFE to commit — re-allow: +!*.enc.yaml +!*.enc.yml +!secrets.enc.yaml +!secrets.enc.yml +# Skaffold / Tilt local overrides +skaffold.local.yaml +tilt_config.json + + +# --------------------------------------------------------------------------- +# 4. SSH / TLS / GPG key material +# --------------------------------------------------------------------------- +# Private keys — any common format *.pem *.key +*.cer +*.der +*.p12 +*.pfx +*.jks +*.keystore +*.truststore +id_rsa +id_rsa.* +id_dsa +id_dsa.* +id_ecdsa +id_ecdsa.* +id_ed25519 +id_ed25519.* +*_rsa +*_dsa +*_ecdsa +*_ed25519 +known_hosts +authorized_keys +# GPG / PGP +*.gpg +*.pgp +*.asc +secring.* +# Re-allow common PUBLIC artifacts that legitimately live in repos +!*.pub +!*.pub.pem +!public.pem +!public_key.pem +!*.crt +!ca.crt +!*.cert +# NOTE: *.pem / *.key have HIGH false-positive rates (TLS test fixtures, JWT +# libraries, webpack dev certs). Per-repo .gitignores should `!` specific +# fixture FILES — never negate a whole directory. + + +# --------------------------------------------------------------------------- +# 5. Terraform / IaC state and variables +# --------------------------------------------------------------------------- +# State files contain plaintext secrets resolved at apply-time +*.tfstate +*.tfstate.* +*.tfstate.backup +crash.log +crash.*.log +# .tfvars routinely hold secrets — block all, allow only examples +*.tfvars +*.tfvars.json +!*.tfvars.example +!*.tfvars.sample +!example.tfvars +# Local .terraform cache +.terraform/ +.terraform.lock.hcl.bak +# Pulumi stack configs (often hold encrypted + plaintext values) +Pulumi.*.yaml +!Pulumi.yaml +# Ansible vault password files +vault-password +vault_password +.vault_pass +.vault-password-file + + +# --------------------------------------------------------------------------- +# 6. Secret manager local caches & key material +# --------------------------------------------------------------------------- +# SOPS / age — private keys are plaintext files +.sops/ +sops.agekey +age.key +age-key.txt +keys.txt +# HashiCorp Vault token files +.vault-token +.vault_token +vault-token +# Doppler CLI config (contains service tokens) +.doppler.yaml +.doppler/ +doppler.yaml +# 1Password CLI session tokens / service-account tokens +.op/ +op-session-* +.1password/ +# Infisical, Bitwarden CLI, chamber, envchain, teller +.infisical.json +.bw-session +.chamber +.teller.yml + + +# --------------------------------------------------------------------------- +# 7. Database dumps and DB client dotfiles +# --------------------------------------------------------------------------- +# Raw dumps frequently contain PII + embedded credentials in CREATE USER stmts +*.sql.gz +*.sql.bz2 +*.sql.xz +*.sql.zst +*.dump +*.bak +dump.rdb +mongodump/ +# Client config dotfiles that store passwords in plaintext +.pgpass +.my.cnf +.mylogin.cnf +.mongorc.js +.mongoshrc.js +.psqlrc.local +.pg_service.conf + + +# --------------------------------------------------------------------------- +# 8. Package registry / tooling credential dotfiles +# --------------------------------------------------------------------------- +# Recent supply-chain attacks specifically targeted these files. +.npmrc +.yarnrc +.yarnrc.yml +.pypirc +.gem/credentials +.gem/ +.cargo/credentials +.cargo/credentials.toml +.nuget/ +nuget.config +NuGet.Config +.m2/settings.xml +.gradle/gradle.properties +gradle-local.properties +.netrc +_netrc +.curlrc +.wgetrc +# GH CLI / Git Credential Manager +.gh-token +gh_token +.git-credentials +.config/gh/hosts.yml +# NOTE: .npmrc / .yarnrc.yml / nuget.config / gradle.properties also hold +# legitimate non-secret config. The safer pattern is to commit a `.npmrc.example` +# and keep the real file ignored. A per-repo override may re-allow a scrubbed +# root file via `!/.npmrc` if absolutely needed. + + +# --------------------------------------------------------------------------- +# 9. Cloud CLI session / token caches +# --------------------------------------------------------------------------- +# These rarely end up in repos, but when they do they grant live access — +# usually inside a Dockerfile COPY context that swept in a home dir. +.aws/sso/cache/ +.aws/cli/cache/ +.config/gcloud/ +.config/gcloud/application_default_credentials.json +.config/gcloud/legacy_credentials/ +.config/gcloud/access_tokens.db +.boto +.s3cfg +.rclone.conf +rclone.conf +.oci/ +.ibmcloud/ +.snowflake/ +.snowsql/config +.databricks/ +.databrickscfg + + +# --------------------------------------------------------------------------- +# 10. IDE files known to cache credentials +# --------------------------------------------------------------------------- +# JetBrains: workspace.xml can cache DB passwords; dataSources.local.xml +# holds per-user DB state. dataSources.xml (no .local) is intended for +# sharing and should not hold passwords — but review before committing. +.idea/workspace.xml +.idea/tasks.xml +.idea/usage.statistics.xml +.idea/shelf/ +.idea/dataSources.local.xml +.idea/dataSources/ +.idea/sqlDataSources.xml +.idea/dynamic.xml +.idea/**/aws.xml +# VS Code: sftp.json from the popular SFTP extension embeds passwords. +# Block local-variant settings; the shared settings.json stays committable. +.vscode/sftp.json +.vscode/ftp-sync.json +.vscode/settings.local.json +.vscode-server/ +# Eclipse secure storage +.metadata/.plugins/org.eclipse.core.runtime/.settings/org.eclipse.equinox.security* +# Cursor / Windsurf / Zed AI assistant caches (2025+) +.cursor/mcp.json +.cursor-server/ +.windsurf/ +.zed/settings.json +# NOTE: do NOT blanket-ignore .idea/ or .vscode/ here — that's a per-repo +# editor-cruft decision, not a secrets decision. + + +# --------------------------------------------------------------------------- +# 11. Generic "secret" / "credential" / "private" filename conventions +# --------------------------------------------------------------------------- +secrets.json +secrets.toml +secrets.env +secret.yaml +secret.yml +secret.json +credentials +credentials.yaml +credentials.yml +credentials.json +credentials.toml +private.json +private.yaml +private.yml +*.secret +*.secret.* +*.secrets +*.private +*.private.* +# Re-allow obviously-public template variants +!*.secret.example +!*.secrets.example +!secrets.example.* +!credentials.example.* +# Re-allow SOPS/age-encrypted variants (safe to commit) +!*.enc.yaml +!*.enc.yml +!*.enc.json +!*.sops.yaml +!*.sops.yml +!*.sops.json + + +# --------------------------------------------------------------------------- +# 12. Modern (2024-2026) credential-leak hotspots +# --------------------------------------------------------------------------- +# LLM / AI tooling config files where users paste API keys +.anthropic/ +.openai/ +.ollama/id_ed25519 +.continue/config.json +.aider.conf.yml +.aider.model.settings.yml +# GitHub Copilot local state +.github-copilot/ +# Supabase / PlanetScale / Neon / Turso CLI auth +.supabase/ +.pscale/ +.neon/ +.turso/ +# Railway / Render / Fly machine tokens +.railway/ +.render/ +# Stripe CLI, Twilio CLI, SendGrid +.stripe/ +.twilio-cli/ +# Temporal, Dagger, Earthly auth +.temporalio/ +.dagger/ +.earthly/config.yml + +# --------------------------------------------------------------------------- +# 13. Agent / local worktrees (org coding policy) +# --------------------------------------------------------------------------- +# Temporary worktrees created by Claude Code and other agents. Not strictly +# secret material, but the petry-projects coding guidelines require these +# paths to be ignored in every repo so an agent's scratch worktree cannot +# be committed accidentally. +.claude/worktrees/ +.claude/scheduled_tasks.lock +.worktrees/ + +# ============================================================================ +# End of petry-projects secrets baseline +# ============================================================================ diff --git a/prompts/cascade-action.md b/prompts/cascade-action.md index 066718a6e..4e9a42398 100644 --- a/prompts/cascade-action.md +++ b/prompts/cascade-action.md @@ -46,6 +46,9 @@ cat > /tmp/cascade/review-body.txt << 'BODYEOF' ### Summary PLACEHOLDER_SUMMARY +### Cross-engine agreement (if deep+duck) + + ### Findings PLACEHOLDER_FINDINGS_LIST diff --git a/scripts/dev-lead-fix-ci.sh b/scripts/dev-lead-fix-ci.sh index f2ce5e6f9..e58d06d47 100755 --- a/scripts/dev-lead-fix-ci.sh +++ b/scripts/dev-lead-fix-ci.sh @@ -174,6 +174,23 @@ ${details}" fi } +post_exhaustion() { + local reason="$1" + local body="${EXHAUSTION_MARKER} +## Dev-Lead Fix CI — exhausted + +This PR has had **${MAX_FAIL_ATTEMPTS}** consecutive engine failures (timeouts or errors). Automated CI fixing has been paused to avoid consuming further tokens. + +**Reason for last failure:** ${reason} + +To re-enable, delete this comment or push a new commit with a substantially different change." + if [ "${DEV_LEAD_DRY_RUN:-false}" = "true" ]; then + echo "[dry-run] would post exhaustion comment" + else + gh pr comment "$PR_NUMBER" --repo "$REPO" --body "$body" + fi +} + main() { if [ -z "$PR_NUMBER" ] || [ -z "$HEAD_SHA" ]; then echo "::error::PR_NUMBER and HEAD_SHA are required" diff --git a/scripts/engine.sh b/scripts/engine.sh index d76d59aa8..b90cbe0a4 100644 --- a/scripts/engine.sh +++ b/scripts/engine.sh @@ -54,6 +54,18 @@ case "$REVIEW_ENGINE" in DUCK_ENGINE="claude" DUCK_MODEL="claude-sonnet-4-6" ;; + gemini) + ENGINE_TRIAGE_MODEL="gemini-2.0-flash" + ENGINE_DEEP_MODEL="gemini-1.5-pro" + ENGINE_AUDIT_MODEL="gemini-1.5-pro" + ENGINE_ACTION_MODEL="gemini-1.5-pro" + ENGINE_SINGLE_MODEL="gemini-1.5-pro" + ENGINE_LABEL="triage: gemini-2.0-flash → deep: gemini-1.5-pro + duck: sonnet 4.6 → audit: gemini-1.5-pro" + ENGINE_SINGLE_LABEL="single-reviewer mode: gemini-1.5-pro" + # Cross-engine rubber duck: use Claude for diversity + DUCK_ENGINE="claude" + DUCK_MODEL="claude-sonnet-4-6" + ;; copilot) ENGINE_TRIAGE_MODEL="o4-mini" ENGINE_DEEP_MODEL="o4-mini" @@ -240,6 +252,120 @@ print(content, end='') " || return 1 } +# is_transient_failure +# Returns 0 (true) for exit codes suggesting a flaky network/process state: +# 124 (GNU timeout) and 137/143 (signal kills). JSON parse failures and +# generic exit-1s are NOT retried — those are deterministic problems. +is_transient_failure() { + local rc="$1" + case "$rc" in + 124|137|143) return 0 ;; + *) return 1 ;; + esac +} + +# copilot_chat [timeout_sec] +# Calls the GitHub Models REST API (OpenAI-compatible) for text completion. +# +# Replaces the broken `gh copilot suggest -p "$(cat )"` invocation: +# • The -p flag is not valid syntax in modern gh CLI versions (produces +# "Invalid command format" and causes a non-zero exit that the session +# circuit-breaker misclassifies as a rate-limit). +# • gh copilot suggest is a shell-command suggestion tool; it does NOT +# support arbitrary prompt text or return structured JSON. +# • $(cat ) as a shell argument fails for large PR prompts (ARG_MAX). +# +# This function uses curl + the GitHub Models REST API instead: +# https://models.github.ai/inference/chat/completions +# The endpoint is versioned (X-GitHub-Api-Version header) and stable against +# gh CLI version changes. Auth uses COPILOT_GITHUB_TOKEN (user PAT with a +# Copilot subscription). Model is COPILOT_API_MODEL (default: openai/o4-mini). +# +# Rate-limit responses (HTTP 429) are echoed to stdout so the caller's +# is_rate_limited() check can detect them and exit 2 for engine fallback. +copilot_chat() { + local prompt_file="$1" + local timeout_sec="${2:-300}" + + # Build JSON payload via python3 into a temp file — safely encodes arbitrary + # prompt text (special chars, newlines, quotes, Unicode, large files) and + # avoids ARG_MAX limits when passing large diffs to curl via --data-binary. + local _body_file rc=0 + _body_file=$(mktemp) || { echo "copilot_chat: mktemp failed" >&2; return 1; } + python3 -c " +import json, sys +prompt = open(sys.argv[1]).read() +model = sys.argv[2] +sys.stdout.write(json.dumps({ + 'model': model, + 'messages': [{'role': 'user', 'content': prompt}], +})) +" "$prompt_file" "${COPILOT_API_MODEL:-openai/o4-mini}" > "$_body_file" || { + rm -f "$_body_file" + echo "copilot_chat: failed to build JSON payload from $prompt_file" >&2 + return 1 + } + + # Call GitHub Models REST API. -w '\n%{http_code}' appends the HTTP status + # on its own line so we can split body from code in pure shell. + local raw + raw=$( + timeout "$timeout_sec" curl -sSL \ + -H "Authorization: Bearer ${COPILOT_GITHUB_TOKEN:?COPILOT_GITHUB_TOKEN is required for copilot engine}" \ + -H "Content-Type: application/json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + https://models.github.ai/inference/chat/completions \ + --data-binary @"$_body_file" \ + -w '\n%{http_code}' + ) || rc=$? + rm -f "$_body_file" + + if [ "$rc" -ne 0 ]; then + echo "copilot_chat: curl exited $rc (timeout=${timeout_sec}s)" >&2 + return "$rc" + fi + + # Split the appended HTTP code from the response body. + local http_code response_body + http_code=$(printf '%s' "$raw" | tail -n 1) + response_body=$(printf '%s' "$raw" | head -n -1) + + # Rate-limit: echo to stdout so is_rate_limited() in review-one-pr.sh fires. + if [ "$http_code" -eq 429 ]; then + echo "error: GitHub Models API rate limit (HTTP 429 — quota exceeded)" + printf '%s\n' "$response_body" + return 1 + fi + + # Other HTTP errors: log to stderr and fail. + if [ "$http_code" -ge 400 ]; then + echo "copilot_chat: HTTP $http_code from GitHub Models API" >&2 + printf '%s\n' "$response_body" >&2 + return 1 + fi + + # Extract the assistant message from the JSON response. + printf '%s' "$response_body" | python3 -c " +import json, sys +try: + data = json.load(sys.stdin) +except json.JSONDecodeError as e: + print('copilot_chat: invalid JSON response: ' + str(e), file=sys.stderr) + sys.exit(1) +if 'error' in data: + err = data['error'] + msg = err.get('message', str(err)) if isinstance(err, dict) else str(err) + print('copilot_chat: API error: ' + str(msg), file=sys.stderr) + sys.exit(1) +choices = data.get('choices', []) +if not choices: + print('copilot_chat: empty choices in response', file=sys.stderr) + sys.exit(1) +content = choices[0].get('message', {}).get('content', '') +print(content, end='') +" || return 1 +} + # run_triage # No-tool mode. The prompt file already has all PR context inlined by the # caller (review-one-pr.sh builds it). Every tool is denied so the model diff --git a/scripts/list-prs.sh b/scripts/list-prs.sh index 74a1a07b5..076761583 100644 --- a/scripts/list-prs.sh +++ b/scripts/list-prs.sh @@ -24,6 +24,24 @@ # 2. All other repos (priority 1) # Within each priority tier, PRs are sorted oldest-first by createdAt. # +# Filters: +# --draft=false — skip work-in-progress PRs +# +# CI filtering is intentionally omitted here — review-one-pr.sh enforces it +# per-PR as a second layer. Filtering by --checks success would exclude repos +# with no CI configured (GitHub treats "no checks" as not matching --checks +# success), causing their PRs to never enter the candidate pool. +# +# Self-authored PRs (PRs whose author is $BOT_USER) are excluded here, because +# GitHub's GraphQL API rejects self-approval unconditionally — including such a +# PR in the queue previously triggered a fatal session abort that starved every +# subsequent candidate (see issue #96). +# +# Output ordering (stable, deterministic): +# 1. .github and .github-private PRs first (priority 0) +# 2. All other repos (priority 1) +# Within each priority tier, PRs are sorted oldest-first by createdAt. +# # Output: one PR URL per line on stdout. set -euo pipefail diff --git a/scripts/pr_review_health.sh b/scripts/pr_review_health.sh new file mode 100644 index 000000000..f0951aa78 --- /dev/null +++ b/scripts/pr_review_health.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +# Daily telemetry check for the PR Review Agent workflow. +# +# Fetches recent pr-review.yml run data, computes health metrics, and +# writes a structured markdown report to both GITHUB_STEP_SUMMARY and +# pr_review_health_report.md. Sets HAS_FAILURES=true in GITHUB_ENV when +# failed runs are detected. +# +# Env vars consumed: +# GH_TOKEN — must have actions:read on WORKFLOW_REPO +# LOOKBACK_DAYS — days of history to consider (default: 1) +# GITHUB_ENV — written by Actions runner +# GITHUB_STEP_SUMMARY — written by Actions runner + +set -euo pipefail + +LOOKBACK_DAYS="${LOOKBACK_DAYS:-1}" +WORKFLOW_REPO="${AGENT_REPO:-petry-projects/.github-private}" +WORKFLOW_FILE="pr-review.yml" +REPORT_FILE="pr_review_health_report.md" +TODAY=$(date -u +%Y-%m-%d) + +echo "=== PR Review Agent — Daily Health Check ===" +echo " Repo: $WORKFLOW_REPO" +echo " Workflow: $WORKFLOW_FILE" +echo " Lookback: ${LOOKBACK_DAYS} day(s)" +echo " Date: $TODAY" +echo "" + +# --------------------------------------------------------------------------- +# 0. Token selection +# --------------------------------------------------------------------------- +if ! gh api "repos/${WORKFLOW_REPO}/actions/workflows/${WORKFLOW_FILE}/runs?per_page=1" \ + >/dev/null 2>&1; then + if [ -n "${GH_PAT_FALLBACK:-}" ]; then + echo "::warning::GH_TOKEN cannot access ${WORKFLOW_REPO} — using GH_PAT_FALLBACK" + export GH_TOKEN="$GH_PAT_FALLBACK" + else + echo "::error::GH_TOKEN cannot access ${WORKFLOW_REPO} and GH_PAT_FALLBACK is not set." + exit 1 + fi +fi + +# --------------------------------------------------------------------------- +# 1. Fetch run metadata +# --------------------------------------------------------------------------- +CUTOFF=$(date -u -d "${LOOKBACK_DAYS} days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \ + || date -u -v-"${LOOKBACK_DAYS}"d +%Y-%m-%dT%H:%M:%SZ) + +echo "Fetching runs since: $CUTOFF" + +runs_json=$(gh api \ + "repos/${WORKFLOW_REPO}/actions/workflows/${WORKFLOW_FILE}/runs?per_page=100&created=>=${CUTOFF}" \ + --jq '.workflow_runs | map({ + id: .id, + run_number: .run_number, + status: .status, + conclusion: .conclusion, + created_at: .created_at, + html_url: .html_url, + duration_s: ((.updated_at | fromdate) - (.created_at | fromdate)) + })' 2>/dev/null || echo '[]') + +# --------------------------------------------------------------------------- +# 2. Compute aggregate stats +# --------------------------------------------------------------------------- +read -r total_runs failed_runs success_runs cancelled_runs < <(echo "$runs_json" | jq -r ' + [ + length, + ([.[] | select(.conclusion == "failure")] | length), + ([.[] | select(.conclusion == "success")] | length), + ([.[] | select(.conclusion == "cancelled")] | length) + ] | @tsv') + +echo " Total: $total_runs" +echo " Success: $success_runs" +echo " Failed: $failed_runs" +echo " Cancelled: $cancelled_runs" + +if [ "$total_runs" -gt 0 ]; then + failure_rate=$(echo "scale=1; $failed_runs * 100 / $total_runs" | bc) +else + failure_rate="0.0" +fi + +# Duration percentiles across all completed runs +read -r dur_min dur_p50 dur_p95 dur_max < <(echo "$runs_json" | jq -r ' + [.[] | select(.conclusion != null and .duration_s > 0) | .duration_s] | sort | + if length == 0 then "0 0 0 0" + else . as $d | ($d | length) as $n | + "\($d | min) \($d[$n * 50 / 100 | floor]) \($d[$n * 95 / 100 | floor]) \($d | max)" + end') + +# --------------------------------------------------------------------------- +# 3. Helpers +# --------------------------------------------------------------------------- +fmt_dur() { + local s=$1 + if [ "$s" -ge 60 ]; then + printf '%dm%ds' $((s / 60)) $((s % 60)) + else + printf '%ds' "$s" + fi +} + +conclusion_icon() { + case "$1" in + success) echo "✅" ;; + failure) echo "❌" ;; + cancelled) echo "⚪" ;; + skipped) echo "⏭️" ;; + *) echo "⏳" ;; + esac +} + +if [ "$failed_runs" -eq 0 ]; then + overall="HEALTHY" +elif [ "$(echo "$failure_rate > 50" | bc)" -eq 1 ]; then + overall="CRITICAL" +elif [ "$(echo "$failure_rate > 20" | bc)" -eq 1 ]; then + overall="DEGRADED" +else + overall="WARNING" +fi + +# --------------------------------------------------------------------------- +# 4. Build report +# --------------------------------------------------------------------------- +{ + printf '# PR Review Agent Health — %s\n\n' "$TODAY" + printf '**Status:** `%s` | **Lookback:** %s day(s) | **Workflow:** `%s`\n\n' \ + "$overall" "$LOOKBACK_DAYS" "$WORKFLOW_FILE" + + printf '## Summary\n\n' + printf '| Metric | Value |\n|---|---|\n' + printf '| Total runs | %s |\n' "$total_runs" + printf '| Successful | %s |\n' "$success_runs" + printf '| Failed | %s |\n' "$failed_runs" + printf '| Cancelled | %s |\n' "$cancelled_runs" + printf '| Failure rate | %s%% |\n' "$failure_rate" + if [ "$total_runs" -gt 0 ]; then + printf '| Duration min | %s |\n' "$(fmt_dur $dur_min)" + printf '| Duration p50 | %s |\n' "$(fmt_dur $dur_p50)" + printf '| Duration p95 | %s |\n' "$(fmt_dur $dur_p95)" + printf '| Duration max | %s |\n' "$(fmt_dur $dur_max)" + fi + + printf '\n## Runs\n\n' + printf '| Run | Status | Date | Duration | Link |\n|---|---|---|---|---|\n' + while IFS=$'\t' read -r run_num conclusion created_at dur_s url; do + icon=$(conclusion_icon "$conclusion") + date_short="${created_at%%T*}" + printf '| #%s | %s %s | %s | %s | [view](%s) |\n' \ + "$run_num" "$icon" "$conclusion" "$date_short" "$(fmt_dur $dur_s)" "$url" + done < <(echo "$runs_json" | jq -r ' + sort_by(.run_number) | reverse[] | + [(.run_number | tostring), (.conclusion // .status), .created_at, (.duration_s | tostring), .html_url] | @tsv') +} > "$REPORT_FILE" + +# --------------------------------------------------------------------------- +# 5. Emit step summary and export env flags +# --------------------------------------------------------------------------- +[ -n "${GITHUB_STEP_SUMMARY:-}" ] && cat "$REPORT_FILE" >> "$GITHUB_STEP_SUMMARY" + +if [ "$failed_runs" -gt 0 ]; then + [ -n "${GITHUB_ENV:-}" ] && echo "HAS_FAILURES=true" >> "$GITHUB_ENV" +fi + +echo "" +echo "Report written to $REPORT_FILE ($(wc -c < "$REPORT_FILE") bytes)" +echo "=== Health check complete ===" \ No newline at end of file diff --git a/scripts/review-one-pr.sh b/scripts/review-one-pr.sh index 93a00f754..9810347c2 100644 --- a/scripts/review-one-pr.sh +++ b/scripts/review-one-pr.sh @@ -363,6 +363,22 @@ if [ "$TRIAGE_RC" -ne 0 ]; then exit 1 fi +# Hard-fail on triage process exit. Previously this silently synthesized a +# fake "escalate=true, MEDIUM" verdict, which masked real model regressions +# (a broken triage prompt or model endpoint would still cost a deep review on +# every PR while looking healthy). With the session circuit breaker upstream, +# letting this fail loudly is the right call — the workflow aborts the rest +# of the session and the next hourly run retries fresh. +if [ "$TRIAGE_RC" -ne 0 ]; then + echo "::warning::triage exited with code $TRIAGE_RC" + # claude --print writes errors to stdout (TRIAGE_RESULT), not stderr — surface + # both channels so the actual failure message is always visible in CI logs. + [ -n "$TRIAGE_RESULT" ] && echo " triage stdout: $TRIAGE_RESULT" + [ -n "$TRIAGE_STDERR" ] && echo " triage stderr: $TRIAGE_STDERR" + echo "::error::cascade failed at tier 1 (triage process exit $TRIAGE_RC) for $PR_URL" + exit 1 +fi + # Strip ```json ... ``` markdown fences if the model wrapped its JSON in # them. Haiku tends to add fences despite explicit instructions not to. TRIAGE_RESULT=$(printf '%s' "$TRIAGE_RESULT" | sed -E '/^```[a-zA-Z]*$/d; /^```$/d')