diff --git a/.github/scripts/close_low_quality_prs.py b/.github/scripts/close_low_quality_prs.py
index 476e9dde646..a71ad6fd950 100644
--- a/.github/scripts/close_low_quality_prs.py
+++ b/.github/scripts/close_low_quality_prs.py
@@ -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:
-#
Confidence Score: 3/5
-# **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})
diff --git a/.github/scripts/triage_with_llm.py b/.github/scripts/triage_with_llm.py
index 6989f210569..433ef0c0dc5 100644
--- a/.github/scripts/triage_with_llm.py
+++ b/.github/scripts/triage_with_llm.py
@@ -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 = ""
+REGRESSED_MARKER = ""
+WITHIN_GRACE_MARKER = ""
+
+# 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:
+# Confidence Score: 3/5
+# **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"])
diff --git a/.github/workflows/review_gate.yml b/.github/workflows/review_gate.yml
new file mode 100644
index 00000000000..73215f359e4
--- /dev/null
+++ b/.github/workflows/review_gate.yml
@@ -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
diff --git a/tests/test_litellm/test_github_review_gate.py b/tests/test_litellm/test_github_review_gate.py
new file mode 100644
index 00000000000..635aec17c97
--- /dev/null
+++ b/tests/test_litellm/test_github_review_gate.py
@@ -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()
diff --git a/tests/test_litellm/test_github_triage_workflows.py b/tests/test_litellm/test_github_triage_workflows.py
index da2743da585..0b01a9e3a92 100644
--- a/tests/test_litellm/test_github_triage_workflows.py
+++ b/tests/test_litellm/test_github_triage_workflows.py
@@ -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.