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.