feat(triage): add "ready for review" label lifecycle to Agent Shin

Adds review_gate(), a state machine that keeps a `ready for review` label in
sync with whether an external PR clears BOTH gates — the LLM rubric and
Greptile's most recent confidence score:

- pass (untagged)            -> add label + "ready for review" / "all clear" comment
- pass (already tagged)      -> no-op (idempotent across re-runs)
- regress (Greptile < 4/5 or QA proof removed) -> remove label + "what's missing"
  comment, PR stays open
- recover after a regression -> "all clear again" comment + re-add the label
- fail & untagged, < 24h old -> one-time "what's missing" notice (grace window)
- fail & untagged, > 24h old -> close + comment (reopen via @agent-shin reconsider)

The label itself is the persisted state, so comments fire only on transitions
(never on every scheduled run). All side effects are gated behind --close, so
the dry-run contract matches the existing triage flow. Lifecycle comments use
hidden HTML markers and deliberately avoid the auto-close marker so they never
trip the reconsider provenance check.

Relocates the shared Greptile helpers (extract_greptile_score, SCORE_PATTERN,
GREPTILE_BOT_LOGINS, parse_iso8601) into triage_with_llm.py so the daily sweep
and the review gate read the score through one implementation, and adds the
review_gate.yml workflow (dry-run unless AGENT_SHIN_ENABLED=true) plus 18 unit
tests covering every branch and a full pass->regress->recover cycle.

https://claude.ai/code/session_01XyyWa8t2VYmoGd6mKMEqkZ
This commit is contained in:
Claude 2026-05-24 23:36:38 +00:00
parent 0ad6836e31
commit b4d970371e
No known key found for this signature in database
5 changed files with 967 additions and 65 deletions

View file

@ -38,11 +38,9 @@ from __future__ import annotations
import argparse
import datetime as dt
import json
import re
import subprocess
import sys
from pathlib import Path
from typing import Iterable
# Share constants with the sibling Agent Shin script instead of duplicating
# them. `AGENT_SHIN_AUTO_CLOSE_MARKER` is the literal phrase the reconsider
@ -53,23 +51,21 @@ from typing import Iterable
_SCRIPTS_DIR = Path(__file__).resolve().parent
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
# `extract_greptile_score`, `GREPTILE_BOT_LOGINS`, `SCORE_PATTERN`, and
# `parse_iso8601` now live in triage_with_llm so the daily sweep and the
# review gate read the Greptile score through one implementation (drift would
# silently let one path act on a PR the other would spare).
from triage_with_llm import ( # noqa: E402
AGENT_SHIN_AUTO_CLOSE_MARKER,
GREPTILE_BOT_LOGINS,
INTERNAL_ASSOCIATIONS,
SCORE_PATTERN,
extract_greptile_score,
parse_iso8601,
)
# Greptile's GitHub App appears as `greptile-apps[bot]` in REST API comments
# and `greptile-apps` in `gh pr view --json` output. Accept either form.
GREPTILE_BOT_LOGINS = frozenset({"greptile-apps", "greptile-apps[bot]"})
# Matches lines like:
# <h3>Confidence Score: 3/5</h3>
# **Confidence Score: 4/5**
# Confidence Score: 5 / 5
SCORE_PATTERN = re.compile(
r"confidence\s*score\s*[:\-]?\s*(\d+)\s*/\s*5",
re.IGNORECASE,
)
# Re-exported above for any caller that imports them from this module.
__all__ = ["GREPTILE_BOT_LOGINS", "SCORE_PATTERN", "extract_greptile_score"]
# Default labels that exempt a PR from auto-close. Defined at module scope (not
# as a mutable argparse default) so that `--optout-label foo` REPLACES the
@ -197,39 +193,6 @@ def fetch_pr_comments(pr_number: int, repo: str | None) -> list[dict]:
return comments
def extract_greptile_score(comments: Iterable[dict]) -> tuple[int, dict] | None:
"""Return (score, comment) for the most recent Greptile-authored comment
that contains a "Confidence Score: X/5". Returns None if no such comment.
"Most recent" is determined by the comment's `updated_at` (falling back to
`created_at`), so re-reviews override earlier passes.
"""
candidates: list[tuple[str, int, dict]] = []
for comment in comments:
user = (comment.get("user") or {}).get("login", "")
if user not in GREPTILE_BOT_LOGINS:
continue
body = comment.get("body") or ""
match = SCORE_PATTERN.search(body)
if not match:
continue
score = int(match.group(1))
timestamp = comment.get("updated_at") or comment.get("created_at") or ""
candidates.append((timestamp, score, comment))
if not candidates:
return None
candidates.sort(key=lambda triple: triple[0])
_, score, comment = candidates[-1]
return score, comment
def parse_iso8601(value: str) -> dt.datetime:
"""Parse a GitHub ISO-8601 timestamp into a timezone-aware datetime."""
return dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
def has_optout_label(pr: dict, optout_labels: set[str]) -> bool:
labels = {label.get("name", "").lower() for label in pr.get("labels", [])}
return bool(labels & {lbl.lower() for lbl in optout_labels})

View file

@ -30,18 +30,55 @@ Environment:
from __future__ import annotations
import argparse
import datetime as dt
import json
import os
import re
import subprocess
import sys
import textwrap
from typing import Any
import urllib.parse
from typing import Any, Iterable
DEFAULT_MODEL = "gpt-5.4-mini"
INTERNAL_ASSOCIATIONS = frozenset({"OWNER", "MEMBER", "COLLABORATOR"})
# --- Review-gate ("ready for review" label lifecycle) configuration ----------
# The review gate keeps a single label in sync with whether a PR currently
# clears BOTH quality bars: the LLM rubric (clear problem + expected/actual +
# QA proof, or a linked issue) AND Greptile's most recent confidence score.
READY_FOR_REVIEW_LABEL = "ready for review"
DEFAULT_GRACE_DAYS = 1 # 24h before an un-passing, un-tagged PR is auto-closed
DEFAULT_MIN_GREPTILE_SCORE = 4 # Greptile < 4/5 counts as "not passing"
# Hidden HTML-comment markers stamped into review-gate comments. They never
# render in the GitHub UI but let the gate detect its own prior actions so it
# (a) posts the within-grace "what's missing" notice at most once and (b) can
# tell a first-time pass ("ready for review") from a recovery after a
# regression ("all clear again"). They deliberately do NOT contain
# AGENT_SHIN_AUTO_CLOSE_MARKER, so review-gate chatter on an open PR never
# trips the reconsider provenance check (which keys off the close marker).
READY_MARKER = "<!-- agent-shin:ready -->"
REGRESSED_MARKER = "<!-- agent-shin:regressed -->"
WITHIN_GRACE_MARKER = "<!-- agent-shin:within-grace -->"
# Greptile's GitHub App appears as `greptile-apps[bot]` in REST API comments
# and `greptile-apps` in `gh pr view --json` output. Accept either form. These
# live here (rather than in close_low_quality_prs.py) so both the daily sweep
# and the review gate read the score through one implementation — drift would
# silently let one path close/label a PR the other would spare.
GREPTILE_BOT_LOGINS = frozenset({"greptile-apps", "greptile-apps[bot]"})
# Matches lines like:
# <h3>Confidence Score: 3/5</h3>
# **Confidence Score: 4/5**
# Confidence Score: 5 / 5
SCORE_PATTERN = re.compile(
r"confidence\s*score\s*[:\-]?\s*(\d+)\s*/\s*5",
re.IGNORECASE,
)
# Marker phrase Agent Shin always includes in its auto-close comments
# (see `format_pr_close_comment` / `format_issue_close_comment`). The
# provenance check for reconsider matches this marker against a comment
@ -169,6 +206,35 @@ def reopen_issue(repo: str, number: int) -> None:
)
def add_label(repo: str, number: int, label: str) -> None:
"""Add a label to a PR/issue (GitHub creates the label if it's missing)."""
gh(
"api",
f"repos/{repo}/issues/{number}/labels",
"-X",
"POST",
"-f",
f"labels[]={label}",
)
def remove_label(repo: str, number: int, label: str) -> None:
"""Remove a label from a PR/issue. A missing label (404) is not an error."""
encoded = urllib.parse.quote(label, safe="")
try:
gh(
"api",
f"repos/{repo}/issues/{number}/labels/{encoded}",
"-X",
"DELETE",
)
except subprocess.CalledProcessError as exc:
stderr = (exc.stderr or "").lower()
if "404" in stderr or "not found" in stderr:
return
raise
def fetch_issue_comments(repo: str, number: int) -> list[dict]:
"""Fetch all issue-style comments on a PR/issue (paginated).
@ -309,6 +375,43 @@ def is_internal_contributor(item: dict) -> bool:
return False
# ---------------------------------------------------------------------------
# Greptile score + age helpers (shared with close_low_quality_prs.py)
def extract_greptile_score(comments: Iterable[dict]) -> tuple[int, dict] | None:
"""Return (score, comment) for the most recent Greptile-authored comment
that contains a "Confidence Score: X/5". Returns None if no such comment.
"Most recent" is determined by the comment's `updated_at` (falling back to
`created_at`), so re-reviews override earlier passes.
"""
candidates: list[tuple[str, int, dict]] = []
for comment in comments:
user = (comment.get("user") or {}).get("login", "")
if user not in GREPTILE_BOT_LOGINS:
continue
body = comment.get("body") or ""
match = SCORE_PATTERN.search(body)
if not match:
continue
score = int(match.group(1))
timestamp = comment.get("updated_at") or comment.get("created_at") or ""
candidates.append((timestamp, score, comment))
if not candidates:
return None
candidates.sort(key=lambda triple: triple[0])
_, score, comment = candidates[-1]
return score, comment
def parse_iso8601(value: str) -> dt.datetime:
"""Parse a GitHub ISO-8601 timestamp into a timezone-aware datetime."""
return dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
# ---------------------------------------------------------------------------
# Prompt construction
@ -328,8 +431,7 @@ def build_pr_prompt(*, title: str, body: str) -> str:
# Dedent the static template *before* interpolating dynamic fields so that
# multi-line bodies (whose 2nd+ lines start at column 0) don't defeat the
# common-indent computation in textwrap.dedent.
template = textwrap.dedent(
"""
template = textwrap.dedent("""
You are "Agent Shin", the OSS triage bot for the LiteLLM open-source
repository (BerriAI/litellm). Decide whether this external pull request
meets the project's contribution standards.
@ -375,8 +477,7 @@ def build_pr_prompt(*, title: str, body: str) -> str:
---
{cleaned_body}
---
"""
).strip()
""").strip()
return template.format(title=title, cleaned_body=cleaned_body)
@ -385,8 +486,7 @@ def build_issue_prompt(*, title: str, body: str) -> str:
# Dedent the static template *before* interpolating dynamic fields so that
# multi-line bodies (whose 2nd+ lines start at column 0) don't defeat the
# common-indent computation in textwrap.dedent.
template = textwrap.dedent(
"""
template = textwrap.dedent("""
You are "Agent Shin", the OSS triage bot for the LiteLLM open-source
repository (BerriAI/litellm). Decide whether this GitHub issue meets
the project's reporting standards.
@ -429,8 +529,7 @@ def build_issue_prompt(*, title: str, body: str) -> str:
---
{cleaned_body}
---
"""
).strip()
""").strip()
return template.format(title=title, cleaned_body=cleaned_body)
@ -621,6 +720,282 @@ def format_reconsider_still_failing_comment(kind: str, verdict: dict) -> str:
)
# ---------------------------------------------------------------------------
# Review gate — "ready for review" label lifecycle
_UNSET = object()
def _combine_missing(
verdict: dict, greptile_score: int | None, min_score: int
) -> list[str]:
"""Merge the LLM rubric's `missing` list with a Greptile-score shortfall."""
missing = list(verdict.get("missing") or [])
if greptile_score is not None and greptile_score < min_score:
missing.insert(
0,
f"Greptile's most recent review scored this PR {greptile_score}/5 "
f"(below the {min_score}/5 bar)",
)
return missing or ["(see explanation below)"]
def _has_marker(comments: Iterable[dict], marker: str) -> bool:
return any(marker in (comment.get("body") or "") for comment in comments)
def format_ready_for_review_comment(verdict: dict, greptile_score: int | None) -> str:
"""Posted the first time a PR clears the bar (label added)."""
score_line = (
f" Greptile scored it **{greptile_score}/5**."
if greptile_score is not None
else ""
)
explanation = verdict.get("explanation") or ""
return (
"✅ **Triage passed — tagging `ready for review`.**\n"
"\n"
"Agent Shin checked this PR against the "
"[contribution rubric](https://github.com/BerriAI/litellm/blob/main/.github/pull_request_template.md) "
"and it clears the bar (a linked issue, or a clear problem description "
f"+ expected vs. actual + QA proof).{score_line}\n"
"\n"
f"> {explanation}\n"
"\n"
"A maintainer will take it from here. If a later re-check finds the PR "
f"has regressed (Greptile drops below {DEFAULT_MIN_GREPTILE_SCORE}/5, "
"the QA proof is removed, etc.) I'll pull the tag and comment with "
"what's missing — fix it and the tag comes back automatically.\n"
f"{READY_MARKER}"
)
def format_all_clear_comment(verdict: dict, greptile_score: int | None) -> str:
"""Posted when a PR recovers after a regression (label re-added)."""
score_line = (
f" Greptile is back to **{greptile_score}/5**."
if greptile_score is not None
else ""
)
explanation = verdict.get("explanation") or ""
return (
"✅ **All clear again — re-adding `ready for review`.**\n"
"\n"
"Thanks for addressing the earlier feedback. On re-check this PR meets "
f"the contribution bar once more.{score_line}\n"
"\n"
f"> {explanation}\n"
"\n"
"A maintainer will take another look.\n"
f"{READY_MARKER}"
)
def format_regression_comment(missing: list[str], explanation: str) -> str:
"""Posted when a previously-tagged PR regresses (label removed, PR stays open)."""
return (
"⚠️ **Removing the `ready for review` tag.**\n"
"\n"
"On a re-check this PR no longer meets the contribution bar. What's "
"missing now:\n"
"\n"
f"{_format_missing(missing)}\n"
"\n"
f"> {explanation}\n"
"\n"
"The PR stays open — address the points above and Agent Shin will post "
'an "all clear" comment and re-add the tag automatically.\n'
f"{REGRESSED_MARKER}"
)
def format_within_grace_comment(
missing: list[str], explanation: str, grace_days: int
) -> str:
"""Posted once while a failing PR is still inside its grace window."""
window = "24 hours" if grace_days == 1 else f"{grace_days} days"
return (
"👋 Hi, thanks for the PR! This is **Agent Shin**, the automated triage "
"bot. This PR doesn't meet the contribution bar yet:\n"
"\n"
f"{_format_missing(missing)}\n"
"\n"
f"> {explanation}\n"
"\n"
f"You have ~{window} from when this PR was opened to add the missing "
"pieces. Once it passes I'll tag it `ready for review`; otherwise I'll "
"auto-close it (you can always re-open the conversation with "
"`@agent-shin reconsider`).\n"
f"{WITHIN_GRACE_MARKER}"
)
def review_gate(
*,
repo: str,
number: int,
close: bool,
model: str,
judge: Any = None,
greptile_score: Any = _UNSET,
comments: Any = _UNSET,
now: dt.datetime | None = None,
grace_days: int = DEFAULT_GRACE_DAYS,
min_greptile_score: int = DEFAULT_MIN_GREPTILE_SCORE,
label: str = READY_FOR_REVIEW_LABEL,
) -> dict:
"""Reconcile the `ready for review` label with a PR's current quality.
A PR is *passing* when it clears BOTH gates: the LLM rubric (linked issue,
or problem description + expected/actual + QA proof) AND Greptile's most
recent confidence score (>= ``min_greptile_score``; absence of a score is
not held against the PR). The gate then drives a small state machine, using
the label itself as the persisted state so comments fire only on
transitions (never on every scheduled run):
passing, untagged -> add label + "ready for review" / "all clear"
passing, tagged -> noop-passing
not passing, tagged -> remove label + regression comment (stays open)
not passing, untagged, old -> close + comment (past the grace window)
not passing, untagged, new -> one-time "what's missing" notice (within grace)
``close`` gates every destructive side effect: with ``close=False`` the
function returns a ``would-*`` preview and touches nothing, mirroring the
dry-run contract of :func:`triage`. ``judge``/``greptile_score``/
``comments``/``now`` are injectable for tests; in production they are
resolved from the OpenAI judge, the PR's Greptile comment, the live comment
list, and the wall clock respectively.
"""
item = fetch_pr(repo, number)
title = item.get("title") or ""
body = item.get("body") or ""
login = (item.get("user") or {}).get("login") or ""
association = item.get("author_association") or ""
state = item.get("state") or ""
labels_now = {(lbl.get("name") or "") for lbl in (item.get("labels") or [])}
created_raw = item.get("created_at") or ""
base_result = {
"kind": "pr",
"number": number,
"title": title,
"author": login,
"author_association": association,
"state": state,
"labeled": label in labels_now,
"review_gate": True,
}
if state != "open":
return {**base_result, "action": "skip-not-open"}
if is_internal_contributor(item):
return {**base_result, "action": "skip-internal-author"}
# Resolve the comment list once — used for both the Greptile score and the
# marker-based dedup below.
if comments is _UNSET:
comments = fetch_issue_comments(repo, number)
# --- rubric verdict: linked-issue short-circuit, else the LLM judge -------
if has_linked_issue(body):
verdict = {
"verdict": "pass",
"linked_issue": True,
"missing": [],
"explanation": "Linked-issue regex matched; LLM was not called.",
}
rubric_pass = True
else:
prompt = build_pr_prompt(title=title, body=body)
if judge is None:
api_key = os.environ.get("OPENAI_API_KEY")
if not api_key:
return {**base_result, "action": "skip-no-llm-key"}
base_url = os.environ.get("OPENAI_BASE_URL") or None
def judge(p: str) -> str:
return call_llm_judge(
p, model=model, api_key=api_key, base_url=base_url
)
try:
verdict = parse_verdict(judge(prompt))
except Exception as exc: # noqa: BLE001 - judge errors must never act
return {**base_result, "action": "skip-llm-error", "error": str(exc)}
rubric_pass = (verdict.get("verdict") or "").lower() == "pass"
# --- Greptile score -------------------------------------------------------
if greptile_score is _UNSET:
extraction = extract_greptile_score(comments)
greptile_score = extraction[0] if extraction else None
greptile_ok = greptile_score is None or greptile_score >= min_greptile_score
passing = rubric_pass and greptile_ok
# --- age ------------------------------------------------------------------
age_days = None
if created_raw:
reference = now or dt.datetime.now(dt.timezone.utc)
age_days = (reference - parse_iso8601(created_raw)).days
label_present = label in labels_now
explanation = verdict.get("explanation") or ""
base_result = {
**base_result,
"verdict": verdict,
"greptile_score": greptile_score,
"passing": passing,
"age_days": age_days,
}
if passing:
if label_present:
return {**base_result, "action": "noop-passing"}
recovered = _has_marker(comments, REGRESSED_MARKER)
comment = (
format_all_clear_comment(verdict, greptile_score)
if recovered
else format_ready_for_review_comment(verdict, greptile_score)
)
if not close:
return {**base_result, "action": "would-label-ready", "comment": comment}
post_comment(repo, number, comment)
add_label(repo, number, label)
return {**base_result, "action": "labeled-ready", "comment": comment}
missing = _combine_missing(verdict, greptile_score, min_greptile_score)
if label_present:
comment = format_regression_comment(missing, explanation)
if not close:
return {**base_result, "action": "would-remove-label", "comment": comment}
remove_label(repo, number, label)
post_comment(repo, number, comment)
return {**base_result, "action": "label-removed-regressed", "comment": comment}
# Not passing and not tagged: close if past the grace window, else notify once.
if age_days is not None and age_days >= grace_days:
comment = format_pr_close_comment({**verdict, "missing": missing})
if not close:
return {**base_result, "action": "would-close", "comment": comment}
post_comment(repo, number, comment)
close_pr(repo, number)
return {**base_result, "action": "closed", "comment": comment}
if _has_marker(comments, WITHIN_GRACE_MARKER):
return {**base_result, "action": "within-grace-already-notified"}
comment = format_within_grace_comment(missing, explanation, grace_days)
if not close:
return {
**base_result,
"action": "would-notify-within-grace",
"comment": comment,
}
post_comment(repo, number, comment)
return {**base_result, "action": "within-grace-notified", "comment": comment}
def triage(
*,
repo: str,
@ -842,6 +1217,16 @@ def render_summary(result: dict) -> str:
f"- **Author**: `{result.get('author', '')}` ({result.get('author_association', '')})"
)
lines.append(f"- **State**: {result.get('state', '')}")
if result.get("review_gate"):
score = result.get("greptile_score")
lines.append(
f"- **Greptile**: {score}/5"
if score is not None
else "- **Greptile**: (no score yet)"
)
lines.append(f"- **`ready for review` label present**: {result.get('labeled')}")
if result.get("age_days") is not None:
lines.append(f"- **Age**: {result['age_days']}d")
lines.append(f"- **Action**: `{result['action']}`")
verdict = result.get("verdict")
if verdict:
@ -898,20 +1283,60 @@ def main() -> int:
"PR/issue author or an internal collaborator."
),
)
parser.add_argument(
"--review-gate",
action="store_true",
help=(
"Reconcile the `ready for review` label for an OPEN PR: tag on "
"pass, remove the tag + comment on regression, close after the "
"grace window if it never passed. PR-only."
),
)
parser.add_argument(
"--grace-days",
type=int,
default=DEFAULT_GRACE_DAYS,
help=(
"Review-gate only: hours/24 a failing, un-tagged PR may stay open "
f"before auto-close (default: {DEFAULT_GRACE_DAYS} = 24h)."
),
)
parser.add_argument(
"--min-greptile-score",
type=int,
default=DEFAULT_MIN_GREPTILE_SCORE,
choices=range(1, 6),
help=(
"Review-gate only: Greptile score below which a PR counts as not "
f"passing (default: {DEFAULT_MIN_GREPTILE_SCORE} -> <4/5 regresses)."
),
)
args = parser.parse_args()
kind = "pr" if args.pr is not None else "issue"
number = args.pr if args.pr is not None else args.issue
result = triage(
repo=args.repo,
kind=kind,
number=number,
close=args.close,
model=args.model,
print_prompt=args.print_prompt,
reconsider=args.reconsider,
)
if args.review_gate:
if kind != "pr":
parser.error("--review-gate applies to pull requests only (use --pr).")
result = review_gate(
repo=args.repo,
number=number,
close=args.close,
model=args.model,
grace_days=args.grace_days,
min_greptile_score=args.min_greptile_score,
)
else:
result = triage(
repo=args.repo,
kind=kind,
number=number,
close=args.close,
model=args.model,
print_prompt=args.print_prompt,
reconsider=args.reconsider,
)
if result.get("action") == "print-prompt":
print(result["prompt"])

128
.github/workflows/review_gate.yml vendored Normal file
View file

@ -0,0 +1,128 @@
name: Agent Shin — review gate
# Keeps the `ready for review` label in sync with whether an external PR
# currently clears BOTH the LLM rubric AND Greptile's confidence score.
#
# pass -> add `ready for review` + a "passed / all clear" comment
# regress -> remove the label + a "what's missing" comment (PR stays open)
# fail, <24h old -> a one-time "what's missing" notice (grace window)
# fail, >24h old -> close + a comment (reopen via `@agent-shin reconsider`)
#
# DRY-RUN BY DEFAULT. Every side effect (label add/remove, comment, close) is
# gated behind `--close`, which is only added when the repo variable
# `AGENT_SHIN_ENABLED == "true"`. Until then runs only write the verdict to the
# workflow step summary.
#
# Manual single PR: gh workflow run "Agent Shin — review gate" -f pr_number=NNN
# Manual dry-run: gh workflow run "Agent Shin — review gate" -f close=false
#
# We use `pull_request_target` so the workflow can read repo secrets and run
# against fork PRs. Fork code is never checked out — only PR metadata is read
# via `gh api`.
on:
pull_request_target:
types: [opened, reopened, synchronize, ready_for_review]
schedule:
# Daily at 09:30 UTC — re-reconciles labels as Greptile re-reviews land.
- cron: "30 9 * * *"
workflow_dispatch:
inputs:
pr_number:
description: "Single PR to reconcile (omit to sweep all open PRs)."
required: false
close:
description: "If AGENT_SHIN_ENABLED=true, actually act (false = dry run)."
required: false
default: "false"
type: choice
options:
- "true"
- "false"
grace_days:
description: "Hours/24 a failing, un-tagged PR may stay open before close."
required: false
default: "1"
min_greptile_score:
description: "Greptile score below which a PR counts as not passing (1-5)."
required: false
default: "4"
permissions:
contents: read
issues: write
pull-requests: write
jobs:
review-gate:
if: github.repository == 'BerriAI/litellm'
runs-on: ubuntu-latest
steps:
- name: Checkout triage script
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
sparse-checkout: .github/scripts
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Install LLM client
run: pip install --no-cache-dir "openai>=1.40.0"
- name: Run review gate
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Mirror the triage workflow: only expose the LLM key when the bot is
# enabled or a collaborator triggers it manually, so an external user
# can't force paid LLM calls by churning a fork PR while the bot is
# still in dry-run.
OPENAI_API_KEY: ${{ (vars.AGENT_SHIN_ENABLED == 'true' || github.event_name == 'workflow_dispatch') && secrets.OPENAI_API_KEY || '' }}
OPENAI_BASE_URL: ${{ vars.OPENAI_BASE_URL }}
TRIAGE_MODEL: ${{ vars.TRIAGE_MODEL }}
AGENT_SHIN_ENABLED: ${{ vars.AGENT_SHIN_ENABLED }}
CLOSE_FLAG: ${{ github.event.inputs.close || 'false' }}
GRACE_DAYS: ${{ github.event.inputs.grace_days || '1' }}
MIN_GREPTILE_SCORE: ${{ github.event.inputs.min_greptile_score || '4' }}
EVENT_PR: ${{ github.event.pull_request.number }}
INPUT_PR: ${{ github.event.inputs.pr_number }}
run: |
set -euo pipefail
COMMON=(--review-gate --grace-days "${GRACE_DAYS}" --min-greptile-score "${MIN_GREPTILE_SCORE}")
# Fail-safe gating, identical philosophy to the Greptile closer:
# - AGENT_SHIN_ENABLED must be the EXACT string "true" to act at all.
# - A manual dispatch can still preview with close=false.
# - Automatic triggers (PR events, schedule) act once enabled — that
# is the whole point of the gate (re-tag / un-tag automatically).
DO_CLOSE="false"
if [ "${AGENT_SHIN_ENABLED:-false}" != "true" ]; then
echo "::notice::AGENT_SHIN_ENABLED is not 'true' -> dry-run (no labels/comments/closes)."
elif [ "${GITHUB_EVENT_NAME:-}" = "workflow_dispatch" ] && [ "${CLOSE_FLAG:-false}" = "true" ]; then
DO_CLOSE="true"
echo "::notice::Manual run -> acting for real."
elif [ "${GITHUB_EVENT_NAME:-}" != "workflow_dispatch" ]; then
DO_CLOSE="true"
echo "::notice::Enabled automatic trigger (${GITHUB_EVENT_NAME:-}) -> acting for real."
else
echo "::notice::Manual dispatch with close=false -> dry-run."
fi
if [ "${DO_CLOSE}" = "true" ]; then
COMMON+=(--close)
fi
# Single PR (PR event or explicit input) vs. sweep over all open PRs.
TARGET_PR="${EVENT_PR:-${INPUT_PR:-}}"
if [ -n "${TARGET_PR}" ]; then
python3 .github/scripts/triage_with_llm.py --repo "${{ github.repository }}" --pr "${TARGET_PR}" "${COMMON[@]}"
else
echo "::notice::Sweeping all open PRs."
mapfile -t NUMBERS < <(gh pr list --repo "${{ github.repository }}" --state open --limit 1000 --json number --jq '.[].number')
for n in "${NUMBERS[@]}"; do
echo "::group::PR #${n}"
python3 .github/scripts/triage_with_llm.py --repo "${{ github.repository }}" --pr "${n}" "${COMMON[@]}" || echo "::warning::review gate errored on #${n}"
echo "::endgroup::"
done
fi

View file

@ -0,0 +1,385 @@
"""Unit tests for the `ready for review` label lifecycle (Agent Shin review gate).
Exercises `triage_with_llm.review_gate`, the state machine that keeps the
`ready for review` label in sync with whether a PR clears both the LLM rubric
and Greptile's confidence score:
* pass (untagged) -> add label + "ready for review" comment
* pass (untagged, recovered) -> add label + "all clear again" comment
* pass (already tagged) -> noop
* regress (tagged) -> remove label + "what's missing" comment, stays open
* fail (untagged, within 24h)-> one-time "what's missing" notice
* fail (untagged, >24h) -> close + comment
* dry run (close=False) -> would-* previews, no side effects
"""
from __future__ import annotations
import datetime as dt
import importlib.util
import sys
from pathlib import Path
import pytest
SCRIPT_PATH = (
Path(__file__).resolve().parents[2] / ".github" / "scripts" / "triage_with_llm.py"
)
NOW = dt.datetime(2026, 5, 24, 12, 0, 0, tzinfo=dt.timezone.utc)
JUST_NOW = "2026-05-24T11:00:00Z" # 1h old -> within 24h grace
TWO_DAYS_AGO = "2026-05-22T11:00:00Z" # >24h old -> past grace
@pytest.fixture(scope="module")
def triage_module():
spec = importlib.util.spec_from_file_location("triage_with_llm", SCRIPT_PATH)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
sys.modules["triage_with_llm"] = module
spec.loader.exec_module(module)
return module
class _Recorder:
"""Captures every gh mutation review_gate could fire, and fails loudly
on the ones a given scenario forbids."""
def __init__(self, triage_module, monkeypatch):
self.comments: list[str] = []
self.added: list[str] = []
self.removed: list[str] = []
self.closed: list[int] = []
monkeypatch.setattr(
triage_module,
"post_comment",
lambda repo, n, body: self.comments.append(body),
)
monkeypatch.setattr(
triage_module,
"add_label",
lambda repo, n, label: self.added.append(label),
)
monkeypatch.setattr(
triage_module,
"remove_label",
lambda repo, n, label: self.removed.append(label),
)
monkeypatch.setattr(
triage_module,
"close_pr",
lambda repo, n: self.closed.append(n),
)
def _make_pr(**overrides):
base = {
"number": 7,
"title": "feat: do a thing",
"body": "some body without a linked issue or QA proof",
"state": "open",
"author_association": "NONE",
"user": {"login": "outside-dev"},
"labels": [],
"created_at": JUST_NOW,
}
base.update(overrides)
return base
def _pass(prompt):
return '{"verdict": "pass", "missing": [], "explanation": "looks good"}'
def _fail(prompt):
return (
'{"verdict": "fail", "missing": ["QA proof", "expected vs. actual"],'
' "explanation": "thin description"}'
)
def _gate(triage_module, **kwargs):
"""Call review_gate with safe defaults for the injectable hooks."""
params = dict(
repo="o/r",
number=7,
close=True,
model="m",
judge=_pass,
greptile_score=None,
comments=[],
now=NOW,
)
params.update(kwargs)
return triage_module.review_gate(**params)
class TestReviewGatePass:
def test_pass_untagged_adds_label_and_ready_comment(
self, triage_module, monkeypatch
):
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: _make_pr())
rec = _Recorder(triage_module, monkeypatch)
result = _gate(triage_module, judge=_pass, greptile_score=5)
assert result["action"] == "labeled-ready"
assert rec.added == [triage_module.READY_FOR_REVIEW_LABEL]
assert rec.removed == [] and rec.closed == []
assert len(rec.comments) == 1
assert "ready for review" in rec.comments[0].lower()
assert triage_module.READY_MARKER in rec.comments[0]
assert "5/5" in rec.comments[0]
def test_pass_already_tagged_is_noop(self, triage_module, monkeypatch):
pr = _make_pr(labels=[{"name": "ready for review"}])
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
rec = _Recorder(triage_module, monkeypatch)
result = _gate(triage_module, judge=_pass, greptile_score=5)
assert result["action"] == "noop-passing"
assert rec.added == [] and rec.removed == [] and rec.comments == []
def test_pass_after_prior_regression_uses_all_clear_wording(
self, triage_module, monkeypatch
):
# A regression marker in history -> this is a recovery, not a first pass.
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: _make_pr())
rec = _Recorder(triage_module, monkeypatch)
prior = [{"user": {"login": "x"}, "body": triage_module.REGRESSED_MARKER}]
result = _gate(triage_module, judge=_pass, greptile_score=5, comments=prior)
assert result["action"] == "labeled-ready"
assert "all clear" in rec.comments[0].lower()
def test_linked_issue_passes_without_calling_judge(
self, triage_module, monkeypatch
):
pr = _make_pr(body="Fixes #4321\n\nbody")
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
rec = _Recorder(triage_module, monkeypatch)
result = _gate(
triage_module,
judge=lambda p: pytest.fail("LLM must not be called for linked issue"),
greptile_score=5,
)
assert result["action"] == "labeled-ready"
assert rec.added == [triage_module.READY_FOR_REVIEW_LABEL]
class TestReviewGateRegression:
def test_regression_removes_label_and_keeps_pr_open(
self, triage_module, monkeypatch
):
pr = _make_pr(labels=[{"name": "ready for review"}])
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
rec = _Recorder(triage_module, monkeypatch)
result = _gate(triage_module, judge=_fail, greptile_score=5)
assert result["action"] == "label-removed-regressed"
assert rec.removed == [triage_module.READY_FOR_REVIEW_LABEL]
assert rec.closed == [] # regression NEVER closes the PR
assert triage_module.REGRESSED_MARKER in rec.comments[0]
assert "QA proof" in rec.comments[0]
def test_greptile_drop_alone_triggers_regression(self, triage_module, monkeypatch):
# Rubric still passes, but Greptile fell to 2/5 -> not passing.
pr = _make_pr(labels=[{"name": "ready for review"}])
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
rec = _Recorder(triage_module, monkeypatch)
result = _gate(triage_module, judge=_pass, greptile_score=2)
assert result["action"] == "label-removed-regressed"
assert rec.removed == [triage_module.READY_FOR_REVIEW_LABEL]
assert "2/5" in rec.comments[0]
def test_greptile_score_read_from_comments_when_not_injected(
self, triage_module, monkeypatch
):
pr = _make_pr(labels=[{"name": "ready for review"}])
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
rec = _Recorder(triage_module, monkeypatch)
greptile = [
{
"user": {"login": "greptile-apps[bot]"},
"body": "Confidence Score: 2/5",
"created_at": "2026-05-24T10:00:00Z",
}
]
result = _gate(
triage_module,
judge=_pass,
greptile_score=triage_module._UNSET,
comments=greptile,
)
assert result["action"] == "label-removed-regressed"
assert "2/5" in rec.comments[0]
class TestReviewGateGraceAndClose:
def test_within_grace_posts_one_time_notice(self, triage_module, monkeypatch):
monkeypatch.setattr(
triage_module, "fetch_pr", lambda repo, n: _make_pr(created_at=JUST_NOW)
)
rec = _Recorder(triage_module, monkeypatch)
result = _gate(triage_module, judge=_fail, greptile_score=None)
assert result["action"] == "within-grace-notified"
assert rec.closed == [] and rec.added == [] and rec.removed == []
assert triage_module.WITHIN_GRACE_MARKER in rec.comments[0]
assert "QA proof" in rec.comments[0]
def test_within_grace_does_not_double_notify(self, triage_module, monkeypatch):
monkeypatch.setattr(
triage_module, "fetch_pr", lambda repo, n: _make_pr(created_at=JUST_NOW)
)
rec = _Recorder(triage_module, monkeypatch)
prior = [{"user": {"login": "b"}, "body": triage_module.WITHIN_GRACE_MARKER}]
result = _gate(triage_module, judge=_fail, greptile_score=None, comments=prior)
assert result["action"] == "within-grace-already-notified"
assert rec.comments == []
def test_past_grace_closes_with_comment(self, triage_module, monkeypatch):
monkeypatch.setattr(
triage_module,
"fetch_pr",
lambda repo, n: _make_pr(created_at=TWO_DAYS_AGO),
)
rec = _Recorder(triage_module, monkeypatch)
result = _gate(triage_module, judge=_fail, greptile_score=None)
assert result["action"] == "closed"
assert rec.closed == [7]
assert len(rec.comments) == 1
# The close comment must carry the reconsider provenance marker.
assert triage_module.AGENT_SHIN_AUTO_CLOSE_MARKER in rec.comments[0]
class TestReviewGateDryRun:
@pytest.mark.parametrize(
"scenario,labels,judge,score,created,expected",
[
("pass", [], _pass, 5, JUST_NOW, "would-label-ready"),
(
"regress",
[{"name": "ready for review"}],
_fail,
5,
JUST_NOW,
"would-remove-label",
),
("within-grace", [], _fail, None, JUST_NOW, "would-notify-within-grace"),
("past-grace", [], _fail, None, TWO_DAYS_AGO, "would-close"),
],
)
def test_dry_run_previews_without_side_effects(
self,
triage_module,
monkeypatch,
scenario,
labels,
judge,
score,
created,
expected,
):
pr = _make_pr(labels=labels, created_at=created)
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
rec = _Recorder(triage_module, monkeypatch)
result = _gate(triage_module, close=False, judge=judge, greptile_score=score)
assert result["action"] == expected
# Dry run touches nothing.
assert rec.added == [] and rec.removed == [] and rec.closed == []
assert rec.comments == []
assert "comment" in result # preview body still surfaced
class TestReviewGateGuards:
def test_skips_internal_author(self, triage_module, monkeypatch):
pr = _make_pr(author_association="MEMBER", user={"login": "krrish"})
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
result = _gate(
triage_module, judge=lambda p: pytest.fail("no LLM for internal")
)
assert result["action"] == "skip-internal-author"
def test_skips_closed_pr(self, triage_module, monkeypatch):
pr = _make_pr(state="closed")
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
result = _gate(triage_module, judge=lambda p: pytest.fail("no LLM for closed"))
assert result["action"] == "skip-not-open"
def test_llm_error_is_non_destructive(self, triage_module, monkeypatch):
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: _make_pr())
rec = _Recorder(triage_module, monkeypatch)
def boom(prompt):
raise RuntimeError("api down")
result = _gate(triage_module, judge=boom, greptile_score=None)
assert result["action"] == "skip-llm-error"
assert rec.closed == [] and rec.added == [] and rec.removed == []
def test_full_recovery_cycle(self, triage_module, monkeypatch):
"""pass -> regress -> recover, threading labels/comments like GitHub would."""
state = {"labels": [], "comments": []}
def fake_fetch(repo, n):
return _make_pr(labels=list(state["labels"]), created_at=JUST_NOW)
monkeypatch.setattr(triage_module, "fetch_pr", fake_fetch)
monkeypatch.setattr(
triage_module,
"post_comment",
lambda repo, n, body: state["comments"].append(
{"user": {"login": "agent-shin[bot]"}, "body": body}
),
)
monkeypatch.setattr(
triage_module,
"add_label",
lambda repo, n, label: state["labels"].append({"name": label}),
)
monkeypatch.setattr(
triage_module,
"remove_label",
lambda repo, n, label: state["labels"].clear(),
)
monkeypatch.setattr(
triage_module, "close_pr", lambda repo, n: pytest.fail("must not close")
)
# 1) passes -> tagged
r1 = _gate(
triage_module, judge=_pass, greptile_score=5, comments=state["comments"]
)
assert r1["action"] == "labeled-ready"
assert any(lbl["name"] == "ready for review" for lbl in state["labels"])
# 2) regresses -> tag removed, comment posted, PR still open
r2 = _gate(
triage_module, judge=_fail, greptile_score=2, comments=state["comments"]
)
assert r2["action"] == "label-removed-regressed"
assert state["labels"] == []
# 3) fixed again -> "all clear" + tag back
r3 = _gate(
triage_module, judge=_pass, greptile_score=5, comments=state["comments"]
)
assert r3["action"] == "labeled-ready"
assert any(lbl["name"] == "ready for review" for lbl in state["labels"])
assert "all clear" in state["comments"][-1]["body"].lower()

View file

@ -43,6 +43,7 @@ DESTRUCTIVE_GATE_ENV: dict[str, str] = {
"triage_pr_with_llm.yml": "DISPATCH_CLOSE",
"triage_issue_with_llm.yml": "DISPATCH_CLOSE",
"close_low_quality_prs.yml": "CLOSE_FLAG",
"review_gate.yml": "CLOSE_FLAG",
# The reconsider workflow has no per-run "really do it?" knob — its
# only kill switch is `AGENT_SHIN_ENABLED`, which already serves as
# both the destructive gate and the global enablement gate.