Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
3a2bc3e
feat: implement issue #1894 — 10 of 11 open PRs have never received a…
don-petry Sep 23, 2026
951661a
fix(reviews): address review comments [skip ci-relay]
don-petry Sep 23, 2026
f9d7948
fix(reviews): address review comments [skip ci-relay]
don-petry Sep 23, 2026
7eaab4f
Merge branch 'main' into dev-lead/issue-1894-20260923-0159
don-petry Oct 2, 2026
cb2c706
Merge branch 'main' into dev-lead/issue-1894-20260923-0159
don-petry Oct 3, 2026
0d9cab8
Merge branch 'main' into dev-lead/issue-1894-20260923-0159
don-petry Oct 3, 2026
9eeea8e
Merge branch 'main' into dev-lead/issue-1894-20260923-0159
don-petry Oct 3, 2026
4feac90
Merge branch 'main' into dev-lead/issue-1894-20260923-0159
don-petry Oct 3, 2026
074cde5
Merge branch 'main' into dev-lead/issue-1894-20260923-0159
don-petry Oct 4, 2026
23fad9b
docs: add docstrings to log and check_advisory_reviews functions
don-petry Oct 4, 2026
963c48a
fix(reviews): document check_advisory_reviews exit code 2
don-petry Oct 4, 2026
976fef7
fix(reviews): align approval-diagnostic advisory fallback with registry
don-petry Oct 4, 2026
23bf5ae
fix(reviews): classify diagnostic advisory comments exactly like the …
don-petry Oct 4, 2026
e61b23b
Merge branch 'main' into dev-lead/issue-1894-20260923-0159
don-petry Oct 5, 2026
05639f9
Merge branch 'main' into dev-lead/issue-1894-20260923-0159
don-petry Oct 6, 2026
e6bf35b
fix(approval-diagnostic): validate comments field is present in snapshot
don-petry Oct 6, 2026
8404cef
Merge branch 'main' into dev-lead/issue-1894-20260923-0159
don-petry Oct 6, 2026
48eca91
fix(approval-diagnostic): pass FORCE_REVIEW bypass to suppress mainta…
don-petry Oct 6, 2026
c07b90f
Merge branch 'main' into dev-lead/issue-1894-20260923-0159
don-petry Oct 6, 2026
9593b36
fix(approval-diagnostic): apply FORCE_REVIEW bypass to all verdict br…
don-petry Oct 6, 2026
2d51698
Merge branch 'main' into dev-lead/issue-1894-20260923-0159
don-petry Oct 7, 2026
f57758f
fix(approval-diagnostic): model quiescence timeout alongside head-age…
don-petry Oct 7, 2026
dbaffb0
Merge branch 'main' into dev-lead/issue-1894-20260923-0159
don-petry Oct 8, 2026
2844959
Merge branch 'main' into dev-lead/issue-1894-20260923-0159
don-petry Oct 8, 2026
51a280f
Merge branch 'main' into dev-lead/issue-1894-20260923-0159
don-petry Oct 9, 2026
4175bc6
Merge branch 'main' into dev-lead/issue-1894-20260923-0159
don-petry Oct 9, 2026
60e98be
Merge branch 'main' into dev-lead/issue-1894-20260923-0159
don-petry Oct 9, 2026
6c686c5
Merge branch 'main' into dev-lead/issue-1894-20260923-0159
don-petry Oct 9, 2026
1481356
Merge branch 'main' into dev-lead/issue-1894-20260923-0159
don-petry Oct 9, 2026
640a3ee
fix(approval-diagnostic): detect UNSUPPORTED from bot comments, not j…
don-petry Oct 9, 2026
3333ba5
Merge branch 'main' into dev-lead/issue-1894-20260923-0159
don-petry Oct 9, 2026
f530385
Merge branch 'main' into dev-lead/issue-1894-20260923-0159
don-petry Oct 9, 2026
7dd295e
feat(#1894): answer "why isn't this PR approved?" from the real gate …
Oct 9, 2026
103ee82
feat(#1894): ship why-not-approved as an org-wide agent profile, not …
Oct 9, 2026
bdabfb5
test(#1894): make the diagnostic's read-only assertion able to fail
Oct 9, 2026
e47c14a
fix(#1894): diagnose mode refuses every GitHub write; no diagnosis fr…
Oct 9, 2026
35eb11a
fix(#1894): lead with the holding gate on an approved PR; keep adviso…
Oct 9, 2026
a5357e0
fix(#1894): diagnose-mode gh guard fails closed on unknown subcommands
Oct 9, 2026
0f685fa
fix(#1894): an approved PR held by the human escalation leads with th…
Oct 9, 2026
8b5d96b
Merge branch 'main' into dev-lead/issue-1894-20260923-0159
don-petry Oct 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@
tests/test_validate_cases.bats \
tests/test_review_registry.bats \
tests/test_reviewer_sources.bats \
tests/test_pr_approval_diagnostic.bats \
tests/test_deep_specialist.bats \
tests/test_pr_diff_dispatch.bats \
tests/test_plan_review.bats \
Expand Down Expand Up @@ -312,7 +313,7 @@
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# NOTE: persist-credentials is intentionally left at the default (true) —

Check warning on line 316 in .github/workflows/lint.yml

View workflow job for this annotation

GitHub Actions / Lint

316:9 [comments-indentation] comment not indented like content
# vci_resolve_reusable needs `git fetch origin` to authenticate against this
# private repo when resolving same-repo channel tags. Setting
# persist-credentials: false would silently push every such caller into the
Expand Down
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,10 @@ run to `remediate=live` for the configured pilot.

- Scripts must be POSIX-compatible shell (`#!/usr/bin/env bash` with `set -euo pipefail`).
- No hardcoded tokens or secrets — use `$GITHUB_TOKEN` from the environment.
- **"Why isn't this PR approved?"** Run `bash scripts/pr-approval-diagnostic.sh <pr-url>`
(profile: [`agents/why-not-approved.md`](./agents/why-not-approved.md)). It runs the real
pr-review gate chain read-only (`PR_REVIEW_DIAGNOSE=true`) and reports the first gate holding
the PR and what clears it. Do not reconstruct the gates by hand (#1894).

### Model selection — name a family, never pin a version

Expand Down
75 changes: 75 additions & 0 deletions agents/why-not-approved.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
name: why-not-approved
description: >
Explains why pr-review has not approved a petry-projects pull request, and what
would change that, by running the real pr-review gate chain read-only. Invoke on
"why isn't PR N approved?", "what is blocking this PR?", or a PR stuck at
REVIEW_REQUIRED with green CI.
tools: ["read", "search", "execute"]
---

# Why isn't this PR approved?

pr-review approves a PR only after a chain of gates passes, in this order: CI,
advisory-bot reviews, maintainer comments, maintainer review threads, standing
change requests, the already-reviewed-at-head check, the human-escalation hold and
cycle cap, and the per-PR automation budget. Then the model review runs and its verdict decides.
This skill finds the first gate holding the PR by running that real chain, not by
reading the PR and guessing.

## Run the diagnostic

The script lives in `petry-projects/.github-private`. If it is not in your checkout,
make a shallow clone of that repo first, through `gh` so the private repo is
authenticated: `gh repo clone petry-projects/.github-private -- --depth 1`. You need
`gh` authenticated with repo read on the PR's repository.

```bash
bash scripts/pr-approval-diagnostic.sh https://github.com/<owner>/<repo>/pull/<n>
```

It runs `scripts/review-one-pr.sh` in diagnose mode (`PR_REVIEW_DIAGNOSE=true`).
That mode is read-only and never forced, and it stops before any model runs. The
output is a Markdown report:
- what pr-review would do now (`decision` and `reason`);
- what changes that;
- dev-lead hold labels (pr-review itself gates only on `needs-human-review`);
- approvals at the head;
- the gate log.

Add `--json` for a machine-readable object.

## Explain the result

Lead with the blocking gate and the one action that clears it. The report's
"What changes that" line is the authoritative condition; restate it plainly. Only
the first holding gate is reported: say so when the user asks about others.

Common reasons:

| `reason` | What it means | What clears it |
|---|---|---|
| `ci-failing` / `ci-pending` | A required check is red or still running | Fix the check or wait for it; the sweep re-reviews |
| `waiting-for-advisory-bots` | Advisory reviewers have not all reported | Their review event re-triggers pr-review; a head-age timeout eventually proceeds without them |
| `changes-requested` | A reviewer requested changes and that review still stands | The author pushes a new commit (making that review stale), or @mentions the bot for a re-review |
| `undispositioned-pr-comment` | A PR comment has no verified disposition | dev-lead replies with a disposition and the comment is minimized RESOLVED |
| `unaddressed-maintainer-review-thread` | A maintainer review thread is open since the last push | Resolve the thread |
| `already-reviewed-at-head` | pr-review already gave a verdict at this commit | Push a commit, or change the PR metadata if the verdict was metadata-only |
| `human-escalated` | pr-review escalated to a human and `needs-human-review` is on | A human reviews and removes the label, or @mentions the bot |
| `max-cycles-reached` | Three review cycles without converging | A human removes `needs-human-review` for a fresh budget |
| `automation-budget-exhausted` | Too much automated activity since a human last acted | Any human comment or approval resets it |
| `automation-budget-unreadable` | The PR's activity could not be read (a GitHub API error) | Re-run the diagnostic |
| `gates-clear` (`proceed`) | Nothing holds the PR | The next pr-review run reviews it; its verdict decides |

If the PR is already approved but not merging, the report's merge state says why
(for example `BLOCKED` by a required check, or `BEHIND`).

## When the script cannot run

Without a checkout or `gh` credentials, look at the PR's latest pr-review run. A
run that a gate stopped records `decision / reason / would_change` in its log and
step summary (#1552, #1894). A run that reached the model review answers with the
review it posted on the PR instead: read that review. If no review appears, read
that run's log for a model or posting failure. Do not reconstruct the
gates by hand: that copy drifts from the real ones, which is why #1902's first
version was replaced.
40 changes: 22 additions & 18 deletions scripts/lib/advisory-review-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -165,14 +165,20 @@ readonly YELLOW='\033[1;33m'
readonly GREEN='\033[0;32m'
readonly NC='\033[0m' # No Color

# log_info <message...>
# Log informational message to stderr with advisory-gate prefix.
log_info() {
echo "[advisory-gate] $*" >&2
}

# log_warn <message...>
# Log warning message to stderr with yellow color and WARNING label.
log_warn() {
echo -e "${YELLOW}[advisory-gate] WARNING: $*${NC}" >&2
}

# log_success <message...>
# Log success message to stderr with green color.
log_success() {
echo -e "${GREEN}[advisory-gate] $*${NC}" >&2
}
Expand Down Expand Up @@ -452,23 +458,11 @@ Advisory bots were rate-limited; auto-approval is withheld until they recover. p
fi
}

# Instant (non-blocking) check of advisory bot status
#
# DESIGN: This uses a re-trigger pattern instead of blocking waits:
# 1. On first pr-review trigger (check_suite completion): instant check
# 2. If return 0: bots ready → approve immediately
# 3. If return 1: bots not ready → skip (exit 100)
# 4. When bots submit: pull_request_review event fires
# 5. pr-review re-triggered → this check returns 0 → approve
#
# Returns:
# 0 = All detected participating bots have submitted (ready to approve)
# 1 = Waiting for bots (skip, will re-check on next review event)
#
# _record_partial_evidence <submitted> <required> <reason> — record approval on
# PARTIAL advisory evidence so the miss-rate metric counts it (#1596). Gate runs
# BEFORE the write, so with PARTIAL_EVIDENCE_STATE_FILE set we DEFER (record facts,
# post NOTHING); post-pr-review.sh announces only after verifying the review (#1874).
# _record_partial_evidence <submitted> <required> <reason>
# Record approval on PARTIAL advisory evidence so the miss-rate metric counts it
# (#1596). Gate runs BEFORE the write, so with PARTIAL_EVIDENCE_STATE_FILE set we
# DEFER (record facts, post NOTHING); post-pr-review.sh announces only after
# verifying the review (#1874).
_record_partial_evidence() {
if [ -n "${PARTIAL_EVIDENCE_STATE_FILE:-}" ]; then
printf '%s %s %s\n' "$1" "$2" "$3" > "$PARTIAL_EVIDENCE_STATE_FILE" 2>/dev/null \
Expand All @@ -481,6 +475,12 @@ _record_partial_evidence() {
|| echo "::warning::partial-evidence marker post failed on ${PR_URL} — approval may be uncounted by the miss-rate metric (#1596)"
}

# check_advisory_reviews <pr_url>
# Instant (non-blocking) check of advisory bot review status. Returns 0 when all
# detected advisory bots have submitted their reviews (or the head-age/quiescence
# timeouts (#1193) for absent bots apply); returns 1 when still waiting; returns 2
# when the GitHub API query or jq parse fails — callers must fail closed, not treat
# it as "waiting".
check_advisory_reviews() {
local pr_url="${1:-}"
if [[ -z "$pr_url" ]]; then
Expand Down Expand Up @@ -555,7 +555,11 @@ check_advisory_reviews() {
effective_total=$((total_advisory_bots - num_unavailable))
[[ "$effective_total" -lt 1 ]] && effective_total=1
if [[ "$num_unavailable" -gt 0 ]]; then
log_info "${num_unavailable} advisory bot(s) rate-limited/unsupported — required set reduced to ${effective_total}/${total_advisory_bots}"
# Name WHICH bots dropped and why so the reduced denominator is observable — the
Comment thread
don-petry marked this conversation as resolved.
# registry total and effective total must never differ silently (#1894 AC #2).
local unavailable_named
unavailable_named=$(echo "$current_states" | jq -rs '[.[] | select(.state == "RATE_LIMITED" or .state == "UNSUPPORTED") | "\(.bot)=\(.state)"] | unique | join(", ")')
log_info "${num_unavailable} advisory bot(s) rate-limited/unsupported — required set reduced to ${effective_total}/${total_advisory_bots}; dropped: ${unavailable_named}"
fi

# Require the effective advisory set to submit before approving.
Expand Down
118 changes: 118 additions & 0 deletions scripts/pr-approval-diagnostic.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
#!/usr/bin/env bash
# pr-approval-diagnostic.sh — "why isn't this PR approved?" (#1894)
#
# Answers from the REAL gate chain, not a model of it: runs scripts/review-one-pr.sh
# in diagnose mode (PR_REVIEW_DIAGNOSE=true — read-only, never forced, stops before
# any model runs) and reports the single verdict that run reaches — which gate holds
# the PR, and what would change the decision — together with the PR facts a reader
# needs (review decision, approvals at the head, hold labels, merge state).
#
# The previous attempt (#1902) re-implemented each gate in a separate library and
# drifted from the real ones (bot-comment handling, the unsupported-file regex,
# check-run participation, the thread-gate fail-closed path). Running the gate chain
# itself makes that drift impossible: a gate change is a diagnostic change.
#
# Usage: pr-approval-diagnostic.sh <pr-url> [--json]
# Needs the same environment a pr-review run needs to READ the PR (GH_TOKEN with
# repo read; BOT_USER defaults to donpetry-bot). Writes nothing to GitHub.
# Exit: 0 diagnosed · 2 no diagnosis: the gate chain produced no verdict (its log
# tail is printed), the PR facts could not be read, or the head moved while
# diagnosing (re-run)

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/hold-gate.sh
source "$SCRIPT_DIR/lib/hold-gate.sh"
PR_URL="${1:-}"
FORMAT="markdown"
[ "${2:-}" = "--json" ] && FORMAT="json"
if [[ ! "$PR_URL" =~ ^https://github\.com/[^/]+/[^/]+/pull/[0-9]+$ ]]; then
echo "usage: pr-approval-diagnostic.sh https://github.com/<owner>/<repo>/pull/<n> [--json]" >&2
exit 2
fi

LOG="$(mktemp)"
trap 'rm -f "$LOG"' EXIT

set +e
PR_REVIEW_DIAGNOSE=true GITHUB_STEP_SUMMARY="" bash "$SCRIPT_DIR/review-one-pr.sh" "$PR_URL" >"$LOG" 2>&1
rc=$?
set -e

# The run's verdict is its LAST emit_verdict line (a JSON object with .decision).
verdict=$(grep -E '^\{"pr":' "$LOG" | tail -n 1 || true)
if [ -z "$verdict" ] || ! jq -e '.decision' <<<"$verdict" >/dev/null 2>&1; then
echo "pr-approval-diagnostic: the gate chain produced no verdict (exit $rc); last lines:" >&2
tail -n 20 "$LOG" >&2
exit 2
fi

# Facts come from a second read of the PR. A failed read is reported as such —
# never as "no approvals, no holds" — and a head that moved since the gate run
# would describe a different commit than the verdict, so both stop the report.
pr_json=$(gh pr view "$PR_URL" --json state,isDraft,headRefOid,reviewDecision,mergeStateStatus,labels,reviews 2>/dev/null) \
&& jq -e '.headRefOid' <<<"$pr_json" >/dev/null 2>&1 \
|| { echo "pr-approval-diagnostic: could not read the PR's review facts (gh pr view failed)" >&2; exit 2; }
verdict_sha=$(jq -r '.sha // ""' <<<"$verdict")
if [ -n "$verdict_sha" ] && [ "$(jq -r '.headRefOid' <<<"$pr_json")" != "$verdict_sha" ]; then
echo "pr-approval-diagnostic: the PR head moved while diagnosing (gate run saw ${verdict_sha:0:8}); re-run" >&2
exit 2
fi
facts=$(jq -c --arg holds "$(hold_gate_labels | tr '\n' ' ')" '
.headRefOid as $head
Comment thread
don-petry marked this conversation as resolved.
| {state, isDraft, reviewDecision, mergeStateStatus,
holds: [(.labels // [])[].name | select(. as $l | ($holds | split(" ") | index($l)))],
approvals_at_head: [(.reviews // [])[]
| select(.state == "APPROVED" and (.commit.oid // "") == $head)
| (.author.login // "?")] | unique}' <<<"$pr_json")

# The gate log keeps the gates' own lines (indented) and the advisory gate's
# `[advisory-gate]` lines — e.g. which unavailable bots reduced the required set.
gate_log=$(sed 's/\x1b\[[0-9;]*m//g' "$LOG" | grep -E '^ |\[advisory-gate\]' || true)
report=$(jq -cn --argjson v "$verdict" --argjson f "$facts" --arg log "$gate_log" \
'$v + {facts: $f, gate_log: $log}')

if [ "$FORMAT" = "json" ]; then
printf '%s\n' "$report"
exit 0
fi

jq -r '
def code: "`" + (. // "") + "`";
.facts as $f
# GitHub can still show an approval while a gate would now hold the PR: lead
# with the gate, not "Approved". Every verdict holds except `proceed` and the
# one true no-op, `already-reviewed-at-head` (e.g. `noop human-escalated` holds).
| (if ($f.reviewDecision // "") == "APPROVED" and .decision != "proceed" and .reason != "already-reviewed-at-head"
then "**GitHub shows the PR approved, but pr-review would now hold it** at the " + (.reason | code) + " gate."
elif ($f.reviewDecision // "") == "APPROVED"
then "**Approved.** Merge state: " + (($f.mergeStateStatus // "unknown") | code) + "."
else "**Not approved** (review decision: " + (($f.reviewDecision // "none") | if . == "" then "none" else . end | code) + ")."
end) as $status
| [ "## pr-review approval diagnostic: " + .pr,
"",
$status,
"",
"| | |",
"|---|---|",
"| Head | " + ((.sha // "")[0:8] | code) + " |",
"| What pr-review would do now | " + (.decision | code) + " — " + (.reason | code) + " |",
"| What changes that | " + ((.would_change // "") | gsub("\\|"; "\\|")) + " |",
"| dev-lead hold labels | " + (if (($f.holds // []) | length) > 0 then ($f.holds | map(code) | join(", ")) else "none" end) + " |",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"| Approvals at head | " + (if (($f.approvals_at_head // []) | length) > 0 then ($f.approvals_at_head | join(", ")) else "none" end) + " |",
"",
"",
"_dev-lead hold labels stop dev-lead acting on the PR. Of them, pr-review gates only on `needs-human-review`, and when it holds the PR the verdict above names it._",
(if .decision == "proceed"
then "No gate holds this PR: the next pr-review run reviews it, and that review decides."
else "This is the first gate that holds the PR, in the order a pr-review run checks them. Gates after it were not evaluated."
end),
"",
"<details><summary>Gate log</summary>",
"",
"```",
.gate_log,
"```",
"</details>"
] | join("\n")' <<<"$report"
Loading
Loading