mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
* feat(triage): auto-close stale PRs with Greptile score <4/5
Adds .github/scripts/close_low_quality_prs.py and a daily workflow that
closes PRs which:
- are open for at least 7 days, and
- carry a most-recent greptile-apps review with Confidence Score <4/5,
- and are not drafts or opt-out-labeled ('do not close', 'wip', etc.).
Each closure posts an explanatory comment telling the contributor how to
bring the PR back (rebase, re-request greptile, reopen at 4+/5). The
4/5 bar is already documented in the PR template
(.github/pull_request_template.md), so this just enforces it.
Tested with a dry run against the live BerriAI/litellm backlog of 1000
open PRs: 100 candidates identified, 598 PRs pass the bar (4+/5), 186
are too young, 97 are drafts, 19 lack any Greptile review and are left
alone.
Workflow defaults to closing 25 PRs/run as a safety net and supports
workflow_dispatch with overrides (close=false for a dry run, custom
min_age_days/min_score/limit).
18 unit tests cover score extraction (HTML/markdown/plain text, login
variants, multi-review picks latest) and per-PR evaluation (drafts,
opt-out labels, age, missing/passing/failing scores).
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
* docs(templates): require expected/actual + QA proof for external contributions
PR template:
- Make the rubric explicit at the top: link an issue, OR provide a clear
problem description + expected vs. actual + visual QA proof.
- Add dedicated sections for each piece so the bot has a deterministic
shape to read.
- Keep the existing 'Linear ticket' section for internal contributors
(they're exempt from the auto-triage rubric).
Bug report template:
- Split 'What happened?' into 'Actual behavior' + 'Expected behavior'.
- Make logs/screenshot a required textarea.
- Warning banner at the top tells external contributors that incomplete
reports will be auto-closed (with re-evaluation on reopen).
Feature request template:
- Require a concrete use case + example in the motivation field, not just
a one-liner pitch.
- Same auto-triage warning banner.
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
* feat(triage): Agent Shin LLM-as-judge for external PRs and issues
Adds a new triage flow that evaluates external pull requests and issues
against the project's contribution rubric and, when configured to do so,
auto-closes non-conforming ones with an explanatory comment. Contributors
can update + reopen to be re-evaluated.
Scope:
- Internal BerriAI contributors (author_association OWNER/MEMBER/COLLABORATOR)
and bot accounts are skipped entirely.
- 'Fixes #1234' / 'Resolves https://github.com/.../issues/N' in the PR body
short-circuits to PASS without burning LLM tokens.
- LLM judge returns structured JSON (verdict, missing[], explanation);
parser tolerates markdown fences and embedded JSON.
- LLM errors NEVER close PRs/issues — failure surfaces as 'skip-llm-error'.
Safety:
- pull_request_target / issues triggers are FORCED dry-run in the workflow;
only manual workflow_dispatch with close=true (and AGENT_SHIN_ENABLED=true)
takes destructive action.
- Default mode writes verdicts to GITHUB_STEP_SUMMARY only — no public
comments until the team flips the AGENT_SHIN_ENABLED repo variable.
- LLM uses an OpenAI-compatible endpoint (model and base URL configurable
via repo variables; key via OPENAI_API_KEY secret).
Files:
- .github/scripts/triage_with_llm.py - judge orchestrator + CLI
- .github/workflows/triage_pr_with_llm.yml
- .github/workflows/triage_issue_with_llm.yml
- tests/test_litellm/test_github_triage_with_llm.py - 33 unit tests
End-to-end validated against four real PRs (#28117 internal collaborator,
#28108 bot, #28129 'Fixes #28128', #28116 no linked issue) and issue
#28132 with a stubbed LLM judge: each path produces the expected action.
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
* feat(triage): scope Greptile auto-closer to external contributors + dry-run by default
- close_low_quality_prs.py now filters by GitHub author_association via
the REST API: PRs from OWNER / MEMBER / COLLABORATOR (and bot accounts)
are skipped with a new 'skip-internal' summary bucket.
- close_low_quality_prs.yml now defaults workflow_dispatch close=false,
and ignores 'close=true' unless the new repo variable
AGENT_SHIN_ENABLED is set to 'true'. Scheduled runs are dry-run only
until the team flips that switch.
- Updated unit tests: one new test asserting internal authors are
skipped, and an autouse fixture treats unspecified test PRs as
external so the rest of the suite still exercises the close path.
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
* fix(workflows): scheduled cron closes PRs; safe --close strip in triage
Co-authored-by: Yassin Kortam <yassin@berri.ai>
* fix(triage): scheduled cron stays dry-run; dedent prompts before interpolation
- close_low_quality_prs.yml: only workflow_dispatch with close=true (and
AGENT_SHIN_ENABLED=true) actually closes PRs. Scheduled runs are always
dry-run, matching the safety invariant documented for triage_pr/issue.
- triage_with_llm.py: textwrap.dedent on an f-string with multi-line
interpolated bodies fails because the body's 2nd+ lines start at column 0,
making the common-indent zero. Dedent the static template first, then
.format() the title/body in.
Co-authored-by: Yassin Kortam <yassin@berri.ai>
* Fix bugs in auto-close PR triage scripts
- close_low_quality_prs.py: Treat author_association API lookup failures
as internal (fail-safe) so transient errors don't cause internal
contributors' PRs to be auto-closed.
- triage_with_llm.py: Update summary heading from 'Would post comment:'
to 'Posted comment:' since this branch only runs after the comment
has already been posted.
Co-authored-by: Yassin Kortam <yassin@berri.ai>
* feat(triage): default Agent Shin to gpt-5.4-mini with reasoning_effort=none
- Bump DEFAULT_MODEL from gpt-4o-mini to gpt-5.4-mini (more modern;
4M total context window per OpenAI catalog, JSON-schema response
format, function calling all supported).
- For gpt-5.x family models, pass reasoning_effort="none" via
extra_body. gpt-5.x rejects temperature != 1 unless reasoning_effort
is explicitly "none"; setting it lets us keep temperature=0 for
deterministic JSON rubric judgments. extra_body works across openai
SDK versions regardless of whether they natively type the kwarg.
- For non-gpt5 overrides (TRIAGE_MODEL=gpt-4o-mini etc.), reasoning_effort
is not sent.
- 4 new unit tests cover: gpt-5.4-mini -> reasoning_effort=none,
capitalized/dated gpt-5 variants -> reasoning_effort=none,
gpt-4o-mini -> no extra_body, base_url passthrough.
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
* fix(triage): bugbot — drop dead gh_json and fix --optout-label append-with-default
- Removed the unused gh_json helper (bugbot low-severity dead code).
- Replaced argparse `action="append", default=[...]` with default=None
+ DEFAULT_OPTOUT_LABELS fallback. The mutable-default + append combo
silently APPENDS to the canonical defaults instead of replacing them,
so --optout-label could not actually scope the opt-out list.
- Added tests covering both the canonical default and the
flag-replaces-defaults behavior.
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
* fix(triage): bugbot — tighten linked-issue regex, fail-safe author_association, fix empty TRIAGE_MODEL
Three independent bugbot findings against triage_with_llm.py:
1. LINKED_ISSUE_PATTERN included weak keywords (`see`, `ref`,
`addresses`) so casual mentions like "See #1234 for context" were
short-circuited to pass-linked-issue without ever calling the LLM —
contradicting the prompt's own "a bare issue number without a closing
keyword counts only if it's clearly the related issue (not a passing
mention)" rubric. Limit the regex to GitHub's documented PR-closing
keywords (fixes/fix/fixed/closes/close/closed/resolves/resolve/resolved).
2. is_internal_contributor() treated an empty/missing author_association
as external (eligible for the destructive close path), while the sibling
is_external_pr_author() in close_low_quality_prs.py fail-safes the same
case as internal. Align the two so a partial/unknown GitHub response can
never make a PR eligible for auto-close.
3. argparse `default=os.environ.get("TRIAGE_MODEL", DEFAULT_MODEL)` returns
the empty string when GitHub Actions exposes an unset repo variable as
an empty-string env var (the optional vars.TRIAGE_MODEL case in the
workflow). Use `os.environ.get(...) or DEFAULT_MODEL` so empty -> default,
matching the existing OPENAI_BASE_URL pattern.
Tests:
- Casual mentions now must fall through to the LLM (parametrized);
added an orchestration test ensuring "See #1234" reaches the judge.
- Empty/missing author_association now fails safe (parametrized).
- Empty TRIAGE_MODEL env var falls back to DEFAULT_MODEL; explicit
TRIAGE_MODEL is still honored.
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
* fix(workflows): bugbot — gate Agent Shin --close on '= true' not '!= false'
The PR and issue Agent Shin workflows gated the destructive --close
flag with [ "${DISPATCH_CLOSE:-false}" != "false" ]. That pattern
treats anything other than the literal string "false" as enabling
closure — "True", "yes", "1", typos, accidental whitespace, etc.
The workflow_dispatch input UI is a 'true'/'false' choice dropdown so
the form is constrained, but the API (`gh workflow run -f close=...`)
accepts any string, and a CI cron / external invoker passing a
non-canonical truthy value would have silently enabled real
contributor PR closures.
Mirror the sibling Greptile closer's [ "${CLOSE_FLAG}" = "true" ]
pattern: only the EXACT string "true" enables --close; every other
value (including the unset/empty default) resolves to dry-run. This is
the fail-safe philosophy applied everywhere else in this PR.
Added tests/test_litellm/test_github_triage_workflows.py with two
parametrized invariants:
1. The destructive gate uses '= "true"' for its env-var
comparison (either bare '${ENV}' or '${ENV:-false}' form
accepted), and never the fail-open '!= "false"' pattern.
2. Every destructive gate is also gated on AGENT_SHIN_ENABLED being
"true" — either by entering the close branch on '=' or by
bailing out early on '!=' — so flipping the repo variable off is
a true kill switch regardless of per-run inputs.
Manually verified the test fails on the buggy '!= "false"' pattern and
passes on the fix, so it would have caught the regression at PR time.
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
* feat(triage): close any PR (incl. drafts, any age); add @agent-shin reconsider flow
Follow-up to PR #28117. Three behavior changes + one new workflow,
addressing the team's concerns on the original review:
1) Apply auto-close to ALL open PRs, not just those over a week old.
- close_low_quality_prs.py: --min-age-days default flipped from 7 to
0. The flag is preserved as an opt-in safety net for one-off
backfill runs that want to spare very-young PRs, but the daily
scheduled sweep now closes external-author PRs as soon as Greptile
scores them <4/5.
- close_low_quality_prs.yml: workflow_dispatch input default also
flipped to 0; doc comments updated.
2) Apply auto-close to draft PRs too.
- close_low_quality_prs.py: removed the skip-draft branch in
evaluate_pr. Drafts are NOT a free pass — the team's intent is
'open PR count == PRs internal collaborators need to action on',
so a draft Greptile scored 2/5 still belongs in the closed bucket.
Authors who genuinely need a long-lived draft can attach the 'wip'
opt-out label, which is unchanged.
- The 'skip-draft' action is gone; the 'wip' label still skips.
3) Address the 'OSS contributors cannot reopen a bot-closed PR' wrinkle.
GitHub does NOT let an external (non-write-access) contributor
reopen a PR that was closed by a bot or maintainer (long-standing
limitation). The original PR's close-comments told contributors to
'Reopen the PR — I'll re-evaluate automatically', which is broken
for the very audience this triage targets. Two changes:
a) Reword every close-comment (Greptile sweep + Agent Shin PR
close + Agent Shin issue close + PR template) to recommend:
- Open a new PR with the updated branch (primary path).
- Or comment '@agent-shin reconsider' on the closed PR for a
re-evaluation that, on pass, reopens the PR via the bot's
GH_TOKEN write access.
b) Add the @agent-shin reconsider workflow:
- .github/workflows/triage_reconsider.yml: new
'issue_comment'-triggered workflow. Authorizes only the
PR/issue author or an internal collaborator
(OWNER/MEMBER/COLLABORATOR), gated via a step output so
unauthorized commenters never reach the destructive steps.
Globally gated on AGENT_SHIN_ENABLED='true' (positive form,
matching the test_github_triage_workflows guardrail
patterns).
- triage_with_llm.py: --reconsider mode. On a closed PR/issue,
re-runs the LLM judge (or linked-issue regex short-circuit)
and:
- on pass: reopens via reopen_pr/reopen_issue + posts a
'Re-evaluated and reopened' comment.
- on fail: leaves closed and posts a 'still missing X'
comment so the contributor can iterate again.
Reconsider-on-open is a no-op ('skip-not-closed').
Internal-author + bot-account skips still take priority over
reconsider.
4) Greptile-on-closed-PRs question: the team asked whether Greptile can
re-review a closed PR. Greptile's docs don't address this and we
shouldn't promise behavior we can't verify, so the new close-comment
wording does NOT instruct contributors to 're-request greptile on
the closed PR'. Instead it points them at the new-PR path (which
Greptile definitely reviews) or the @agent-shin reconsider trigger
(which re-runs the LiteLLM-side rubric judge, not Greptile).
Tests: 93 passing (was 59).
- test_github_close_low_quality_prs.py: replaced 'skip drafts' test
with 'closes drafts when score is low' + 'closes brand-new PR when
min_age=0' + 'no skip when min_age=0'. The 'skip too young'
assertion is preserved as opt-in.
- test_github_triage_with_llm.py: 6 new TestTriageOrchestration cases
for reconsider mode (skip-not-closed on open, reopen on pass,
still-failing comment on fail, linked-issue short-circuit reopen,
skip internal author in reconsider, reopen-issue on pass) + a new
TestCloseCommentText class that pins the user-facing 'open a new
PR' + '@agent-shin reconsider' wording.
- test_github_triage_workflows.py: added triage_reconsider.yml to
the destructive-gate guardrail table; AGENT_SHIN_ENABLED is its
own destructive gate (no separate per-run flag needed).
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
* test(triage): pin safe behavior for curly braces in PR/issue title+body
Adds regression tests covering the bugbot high-severity finding that
str.format() would crash on user-supplied content containing { or }.
Empirically str.format() does NOT re-parse interpolated values — only
the template literal is scanned for replacement fields — so the bug
does not exist in the current code, but pinning the safe behavior
prevents a future templating change from silently reintroducing it.
Also pins the dedented prompt shape (no leading 8-space indentation on
template lines) so a future change to the build_*_prompt functions can't
silently regress the LLM judge prompt format on multi-line bodies.
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
* fix(triage): bugbot — reconsider dry-run + bot-closed guard + rate limit
Address three Greptile/veria-ai concerns on the @agent-shin reconsider
flow:
1. **Reconsider had no dry-run path.** The previous reconsider mode
ignored `--close` and always posted comments + reopened on a pass.
A local operator running
`python triage_with_llm.py --reconsider --pr N` would silently
take destructive GitHub actions with no way to preview. Reconsider
now honors `close=False` the same way regular triage does and
returns `would-reopen` / `would-reconsider-still-failing` for
step-summary rendering.
2. **Reconsider could reopen maintainer-closed PRs/issues** (Medium
security finding from veria-ai). The workflow only checked that the
commenter was authorized — it did NOT check that the most recent
close was performed by Agent Shin. A contributor could comment
`@agent-shin reconsider` on a PR a maintainer closed for non-rubric
reasons (duplicate, security report, design rejection) and have the
bot reopen it. Add `was_closed_by_agent_shin()` which inspects the
issue events API for the most recent `closed` actor and only
permits reopen when that actor matches the configured bot login
(default `github-actions[bot]`, overridable via env). Fail-closed
on missing events.
3. **No rate-limiting on the reconsider trigger.** Every
`@agent-shin reconsider` comment burns CI minutes + an OpenAI API
call. Add a 10-minute cooldown via
`seconds_since_last_reconsider_verdict()` which greps the issue's
comment list for the bot's own verdict marker
(`<!-- agent-shin:reconsider-verdict -->`). Inside the window the
triage returns `skip-rate-limited` and the LLM never runs.
Workflow update:
- `triage_reconsider.yml` now passes `--close` only when
`AGENT_SHIN_ENABLED=true`, matching the pattern of
`triage_pr_with_llm.yml`. The script runs in both states so the
verdict still appears in the step summary for QA.
Tests:
- Add 5 reconsider safety tests: dry-run for pass / fail / linked-issue
short-circuit, bot-closed-guard refusal on maintainer close,
rate-limit refusal inside the cooldown window, and cooldown-elapsed
acceptance.
- Add unit tests for `was_closed_by_agent_shin` (bot / maintainer /
missing actor / env-override) and
`seconds_since_last_reconsider_verdict` (no marker / multiple
markers / non-bot comment with marker / bot comment without marker).
- Pin the `<!-- agent-shin:reconsider-verdict -->` marker in both
reopen and still-failing comments — dropping it would silently
break the cooldown.
Existing reconsider tests updated to pass `close=True` (the
production path now) + stub the new guards via
`_stub_reconsider_guards`. 112 tests pass (was 93).
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
* feat(triage): 1-day grace period before close + SwiftWinds immediate-close bypass
- Add a 24-hour grace window between the first low-quality detection
and the actual auto-close. The first detection posts a warning
comment that explicitly says "You have 1 day to address this before
this PR is auto-closed" and points the contributor at:
* `@agent-shin reconsider` to request another look (and re-open)
* `@greptileai` to request a fresh Greptile review — works
even after the PR is closed
- Both `triage_with_llm.py` (LLM judge) and `close_low_quality_prs.py`
(Greptile-score closer) share the same `<!-- agent-shin:grace-warning -->`
HTML marker so a warning posted by either path is recognized by both.
- Add IMMEDIATE_CLOSE_LOGINS = {swiftwinds} to bypass BOTH the grace
period AND the dry-run / AGENT_SHIN_ENABLED gating. SwiftWinds is the
user's personal account (no push permissions to litellm) used to
dogfood the bot; user explicitly asked: "For SwiftWinds, just close
immediately. Faster iteration that way."
- Update the standard close comments to mention that `@greptileai`
works even after the PR is closed.
- Add 23 new tests covering: warn-grace on first detection, skip during
grace window, close after grace expires, SwiftWinds bypass (case
insensitive, with close=False, no random-login false positives), the
grace-warning text invariants, and the SwiftWinds entry in the
IMMEDIATE_CLOSE_LOGINS constant.
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
* fix: skip grace-period text in close comment for IMMEDIATE_CLOSE_LOGINS
For PRs from IMMEDIATE_CLOSE_LOGINS (e.g. swiftwinds), evaluate_pr
returns 'close' immediately without ever posting a grace warning, so
the close comment should not reference a 1-day grace period.
Make close_pr take a grace_period_elapsed flag, default True, and
pass False from the main loop when the close path was the
immediate-close branch.
Co-authored-by: Yassin Kortam <yassin@berri.ai>
* fix(close-low-quality-prs): report actual closes in dry-run summary
IMMEDIATE_CLOSE_LOGINS PRs are closed even when the global --close flag is
not set, but the summary used the global dry-run flag to choose between
'would close' and 'closed'. Split the count so operators can see both
actual closures and dry-run would-be closures.
Co-authored-by: Yassin Kortam <yassin@berri.ai>
* chore(triage): vendor Agent Shin (#28117) onto demo branch
Brings the Agent Shin OSS-triage scripts, workflows, issue/PR templates, and
tests from PR #28117 onto this branch so the new review-gate feature and its
end-to-end demo are self-contained and runnable in CI.
https://claude.ai/code/session_01XyyWa8t2VYmoGd6mKMEqkZ
* 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
* Port review-gate feature from #28758 onto #28147 triage scripts
Adds the "ready for review" label lifecycle (originally PR #28758) on top
of #28147's refactored triage_with_llm.py. The original commit was
authored against an older snapshot of #28117 and could not be applied
cleanly, so the additions were re-applied surgically:
- New constants: READY_FOR_REVIEW_LABEL, DEFAULT_GRACE_DAYS,
DEFAULT_MIN_GREPTILE_SCORE, READY/REGRESSED/WITHIN_GRACE markers,
GREPTILE_BOT_LOGINS, SCORE_PATTERN, AGENT_SHIN_AUTO_CLOSE_MARKER.
- New helpers: add_label, remove_label, extract_greptile_score,
parse_iso8601 (the latter two mirrored from close_low_quality_prs.py
so the daily sweep and the review gate read the score through the
same logic).
- New comment formatters: format_ready_for_review_comment,
format_all_clear_comment, format_regression_comment,
format_within_grace_comment.
- New entry point: review_gate() implementing the pass/regress/recover
state machine, with the label itself acting as persisted state so
transition comments fire only on actual transitions.
- main() learns --review-gate, --grace-days, --min-greptile-score and
dispatches to review_gate() when the flag is set.
Verified via tests/test_litellm/test_github_review_gate.py (18 tests)
and the existing triage suites (144 more) — all 162 pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* agent_shin: extract shared constants/helpers; cover review_gate.yml in guardrail tests
Bug 1: `triage_with_llm.py` and `close_low_quality_prs.py` each defined
their own copies of `extract_greptile_score`, `parse_iso8601`,
`GREPTILE_BOT_LOGINS`, `SCORE_PATTERN`, `GRACE_COMMENT_MARKER`,
`GRACE_PERIOD_SECONDS`, `IMMEDIATE_CLOSE_LOGINS`, and
`AGENT_SHIN_DEFAULT_BOT_LOGIN`. The comments explicitly said the two
copies had to stay in sync, but nothing enforced it. A future change to
one (e.g. extending `SCORE_PATTERN` for a new Greptile output format)
would silently diverge from the other and the daily sweep and the LLM
judge would disagree on which PRs have low scores.
Extract these to `.github/scripts/agent_shin_shared.py` and re-export
them from each script so the existing test attribute access
(`triage_module.GRACE_COMMENT_MARKER`, etc.) keeps working without
any test changes.
Bug 2: `review_gate.yml` is a destructive workflow (close PRs, add/remove
labels, post comments) with the same gating philosophy as the others
(`AGENT_SHIN_ENABLED = "true"` + a per-run `CLOSE_FLAG = "true"`),
but it was missing from `DESTRUCTIVE_GATE_ENV` in the guardrail tests.
Add it so a future regression (e.g. flipping to `!= "false"`) is
caught by the same parameterized invariants as every other workflow.
Co-authored-by: Yassin Kortam <yassin@berri.ai>
* agent_shin: fix bug bundle (gated LLM key, author-filtered marker dedup, dedup gh/grace helpers)
Co-authored-by: Yassin Kortam <yassin@berri.ai>
* agent_shin: fix review_gate close-after-regression and case-insensitive label match
Co-authored-by: Yassin Kortam <yassin@berri.ai>
* feat(triage): add one-shot 7-day heads-up sweep for Agent Shin rollout
Adds a rollout-day workflow that comments on every open external PR/issue
that the new triage bot WOULD auto-close, giving contributors 7 days to
fix their description before any destructive action runs.
Why now: merging this PR enables Agent Shin in dry-run. The follow-up
"enact" PR (next Monday) flips the destructive paths on. Without this
heads-up, contributors would get a close-comment on day 8 with no prior
warning. The heads-up names the cutoff date, lists the rubric, calls out
each PR/issue's specific missing pieces, and explains the recovery paths
(@agent-shin reconsider for PRs, edit + reopen for issues).
Files
- .github/scripts/_agent_shin_actions.py — thin maybe_post_comment /
maybe_close_* / maybe_add_label / etc. wrappers. Each is a single
`if dry_run: log; return; else: call_through()` so a dry-run preview
differs from the real run in exactly one call site per mutation. The
call-through goes via `triage_with_llm.<name>` (module-qualified) so
monkeypatching the underlying function in tests is reflected here.
- .github/scripts/triage_rollout_heads_up.py — the sweep. Iterates every
open PR + issue via `gh pr list` / `gh issue list`, runs the future
rubric (review_gate for PRs, triage(kind="issue") for issues), and
posts the heads-up on any item that would be auto-closed. Idempotent
via a `<!-- agent-shin:rollout-heads-up -->` marker. Defaults to dry-
run; --close opts in to real posts. --close-on overrides the cutoff
date (defaults to today + 7 days).
- .github/workflows/triage_rollout_heads_up.yml — one-shot workflow.
Triggers on push to litellm_internal_staging filtered to the script
path (fires on rollout merge) plus workflow_dispatch with a dry_run
input that defaults to "true" for safe manual re-runs.
- tests/test_litellm/test_triage_rollout_heads_up.py — 28 unit tests
covering: the dry-run wrappers (each maybe_* gates correctly), the
_would_be_closed predicate for PR vs. issue results, the comment
formatter (cutoff/rubric/marker/recovery wording), per-item dispatch
(skip-not-open, skip-internal-author, skip-already-notified,
skip-passing, would-post/posted), and the sweep loop end-to-end.
Local preview (no GitHub mutations):
python3 .github/scripts/triage_rollout_heads_up.py --repo BerriAI/litellm
Real run (what the workflow does):
python3 .github/scripts/triage_rollout_heads_up.py --repo BerriAI/litellm --close
TODO: replace the placeholder ROLLOUT_BLOG_URL with the canonical
docs URL once the litellm-docs PR ships.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: gate reconsider workflow OPENAI_API_KEY + remove dead actions wrappers
- Mirror sibling Agent Shin workflows by only exposing OPENAI_API_KEY in
triage_reconsider.yml when vars.AGENT_SHIN_ENABLED == 'true'. Previously
the secret was unconditionally exposed, so any PR/issue author could
trigger paid LLM calls by commenting '@agent-shin reconsider' even while
the bot was supposed to be in dry-run.
- Remove the six unused dry-run wrappers (maybe_close_pr, maybe_close_issue,
maybe_reopen_pr, maybe_reopen_issue, maybe_add_label, maybe_remove_label)
from _agent_shin_actions.py — only maybe_post_comment is used by rollout
scripts. Drop the associated tests that exercised the now-removed
functions.
Co-authored-by: Yassin Kortam <yassin@berri.ai>
* fix: address triage script edge cases
- triage_rollout_heads_up.py: replace %-d strftime specifier (GNU-only)
with portable day formatting so the script doesn't crash on Windows.
- close_low_quality_prs.py: skip malformed JSON lines in fetch_pr_comments
instead of letting one bad line abort the daily sweep, matching the
pattern in triage_with_llm._iter_paginated_json.
- triage_with_llm.py: move has_linked_issue short-circuit before
build_pr_prompt to avoid unnecessary prompt construction on PRs that
link an issue.
Co-authored-by: Yassin Kortam <yassin@berri.ai>
* fix(scripts): per-PR error isolation and limit grace warnings in close_low_quality_prs
- Wrap per-PR processing in try/except so a transient GitHub API failure
on one PR no longer aborts the entire daily sweep (mirrors the pattern
already used in triage_rollout_heads_up.py).
- Have --limit bound *all* destructive write actions (closures and grace
warnings combined), not just closures. Prevents a backlog of newly
failing PRs from flooding contributors with comments in a single run.
Co-authored-by: Yassin Kortam <yassin@berri.ai>
* fix(agent-shin): remove 1000-PR cap on bulk sweeps; sweep entire backlog
Both bulk-sweep scripts hardcoded `gh {pr,issue} list --limit 1000`, and gh
lists newest-first — so the OLDEST ~900 PRs and ~380 issues were silently
dropped. That's exactly the stale backlog the daily closer and one-shot
rollout heads-up exist to catch.
Extract a single `list_open_items(kind, *, repo, fields)` helper into
`agent_shin_shared.py` with `GH_LIST_ALL_LIMIT = 100_000` — a ceiling far
above any realistic open backlog so gh paginates until the queue is
exhausted. `fetch_open_prs` and `_list_open_numbers` both delegate to it,
so the limit lives in exactly one place going forward.
Verified live against BerriAI/litellm:
- `fetch_open_prs` -> 1981 PRs (was 1000)
- `_list_open_numbers(issue)` -> 1382 issues (was 1000)
- `_list_open_numbers(pr)` -> 1981 PRs (was 1000)
Adds 7 regression tests asserting the new limit is passed, the dedicated
`gh {pr,issue} list` command + fields are used per kind, bad kind raises
ValueError, and both callers delegate to the shared helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(agent-shin): require non-mocked end-to-end QA proof for PR pass
The PR rubric previously passed any PR with a linked issue, regardless
of whether it showed the fix actually working. Sample spot-check found
21/25 recent external PRs passing, including ones that linked an issue
but provided zero QA evidence.
Tighten the rubric so a pass now requires BOTH:
(1) CONTEXT — a linked issue OR a clear problem description with
expected-vs-actual behavior.
(2) END-TO-END QA PROOF — at least one of:
(a) screenshot(s) of the fix working,
(b) screen recording / video,
(c) specific commands actually run, paired with their real
output, against the real system.
Mocked unit tests, generic 'I tested it' claims, 'all tests pass'
without output, and the linked issue itself are explicitly excluded
from QA proof.
Also add 'qa_proof_type' to the JSON schema so the per-PR report
surfaces which kind of proof (or 'none') the judge saw.
Re-sample on the same 25 recent external PRs shifts the verdict
distribution from 21 pass / 4 fail to 4 pass / 21 fail, with zero
prior-fails now passing — the stricter rule catches PRs that ship
only with unit-test claims and no real integration evidence.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(agent-shin): link blog explainer from every action-required bot comment
Adds "What's this and why am I getting it?" links to docs.litellm.ai/blog/
agent-shin-triage from the four comments contributors actually read when
something went wrong: PR close, PR grace warning, issue close, issue grace
warning. PR comments also link the rubric section directly from the
QA-proof bullet so contributors can self-serve "what counts as proof"
without pinging a maintainer.
Pins the new guarantees in tests: blog link must appear in all four
comments, and the PR close comment must continue to flag mocked-dependency
unit tests as insufficient proof.
The linked blog post is in BerriAI/litellm-docs PR #240; the URL will 404
until that lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(review_gate): raise sweep limit from 1000 to 100000 to match GH_LIST_ALL_LIMIT
gh lists newest-first, so capping at 1000 silently drops the oldest open
PRs — exactly the stale ones the daily sweep is meant to reconcile. Use
the same ceiling as agent_shin_shared.GH_LIST_ALL_LIMIT so the workflow
sees the entire backlog.
Co-authored-by: Yassin Kortam <yassin@berri.ai>
* Fix three Agent Shin triage edge cases
- review_gate: expire the regression-marker short-circuit after grace_days
so PRs that were regressed and then abandoned can eventually be closed.
- review_gate: when the rubric short-circuits to pass via the linked-issue
regex but Greptile drags the PR below the bar, replace the synthetic
'LLM was not called' explanation with the real Greptile shortfall so
regression / close comments are not misleading.
- triage_rollout_heads_up._comments_have_marker: drop the unused 'kind'
parameter and filter by bot author so a contributor quoting the
heads-up via 'Quote reply' cannot trick the idempotency check, matching
the pattern in triage_with_llm._has_marker.
Co-authored-by: Yassin Kortam <yassin@berri.ai>
* fix: pass min_greptile_score through to ready-for-review comment text
Co-authored-by: Yassin Kortam <yassin@berri.ai>
* feat(agent-shin): warmer triage comments — bullet-train emoji, 'what you got right' section, softer 'park this for later' framing
User feedback on the auto-triage comments contributors will see:
1. Tone — the previous 'You have 1 day to address this before this PR is
auto-closed' framing reads as an ultimatum. Replace with: 'If the
description isn't updated in the next 1 day, I'll auto-close this PR.
That's not us saying we don't care about the change — we want the
open-PR list to mirror what a maintainer can act on right now, so
contributors don't get lost in a backlog. A closed PR is a soft "park
this for later," not a rejection. Take your time.'
2. Positive feedback — the previous comments only listed what was missing.
Now every close + grace-warning comment opens with a 'What you got
right:' section rendered from the judge's per-field flags. Contributors
see a checkmark for everything they got right (linked issue, problem
description, expected/actual, QA proof for PRs; runnable repro,
screenshot/log, expected/actual, motivation+example for issues) before
the gaps. The block is omitted entirely when nothing is present so
we never render 'What you got right: (nothing).'
3. Reconsider trigger — the previous grace warning told contributors to
comment '@agent-shin reconsider' during the grace window. They don't
need to — the bot re-checks on every sweep. The new copy says 'just
update the description, no need to ping me' for the grace path, and
reserves '@agent-shin reconsider' for the post-close recovery path.
4. Bullet-train emoji — replace 👋 with 🚄 (Shinkansen, the symbol of
Agent Shin) across every action-required comment: PR close, PR grace
warning, issue close, issue grace warning, within-grace, Greptile-
closer grace warning, rollout heads-up. Pinned in tests so a future
refactor can't silently revert.
5. Greptile-post-close — the @greptileai bullet now explicitly says 'a
low Greptile score isn't a blocker either,' since the previous copy
buried the fact that @greptileai works after auto-close.
Comment templates updated: format_pr_close_comment,
format_issue_close_comment, format_grace_warning_pr_comment,
format_grace_warning_issue_comment, format_within_grace_comment
(triage_with_llm.py); format_grace_warning_comment
(close_low_quality_prs.py); format_heads_up_comment header
(triage_rollout_heads_up.py).
New helpers: _format_present_for_pr / _format_present_for_issue /
_format_present_block, driven off the existing per-field flags the
LLM judge already emits — no prompt change needed.
New tests pin: bullet-train emoji in every action-required comment;
'What you got right' appears with ✅ bullets when fields are present;
the block is omitted when no fields are present; 'park this for
later' / 'not a rejection' softer framing; grace warnings tell the
contributor 'no need to ping' during the grace window (reconsider is
the post-close path only).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(agent-shin): gate triage on a dogfood allowlist
Add ALLOWLIST_LOGINS to agent_shin_shared so Agent Shin only acts on the
named accounts while the set is non-empty. mateo-berri and SwiftWinds are
allowlisted for the dogfood rollout; everyone else is skipped with
skip-not-allowlisted across all four entrypoints (triage, review gate, the
daily low-quality sweep, and the rollout heads-up).
For an allowlisted author the usual internal/external classification is
bypassed, so a maintainer's own org account still gets triaged during
testing. Emptying the set lifts the restriction and restores full triage
for the public rollout. The gate is dependency-injected via an `allowlist`
parameter defaulting to the constant, so the internal/external-skip paths
stay testable.
* feat(agent-shin): tighten QA-proof and issue rubrics, ack reconsider with reactions
Reorder the end-to-end QA proof options to video, then screenshots, then
exact commands with their real output across the PR template, the LLM judge
prompts, and every contributor-facing comment, and spell out that mocked or
stubbed runs (including pytest on the repo's own unit tests, which mock the
provider, DB, and network) never count as proof. QA proof is now required of
all contributors, not just external ones.
Tighten the issue bug-report rubric to require end-to-end evidence of the bug
(the "before" half: a video, screenshot, or command paired with real output)
plus expected vs. actual behavior, drop the bias toward PASS, and collapse the
separate has_repro/has_proof flags into a single has_repro signal.
Standardize the bullet-train emoji and strip em dashes from the bot's
public-facing messages, and route issue recovery through @agent-shin
reconsider since GitHub doesn't let OSS authors reopen an issue a bot closed.
Acknowledge an @agent-shin reconsider the moment it's accepted with an eyes
reaction and a thumbs-up once the run finishes, both gated on
AGENT_SHIN_ENABLED so dry-run leaves no trace.
* fix(agent-shin): shorten auto-close grace to 2 hours and drop the instant-close bypass
Two dogfooding changes to the Agent Shin grace window. First, the warn-then-close
grace (GRACE_PERIOD_SECONDS) drops from a day to 2 hours so the "fix it before it
closes" loop can be exercised in one sitting; the constant carries a note to bump
it back up for the public rollout.
Second, remove IMMEDIATE_CLOSE_LOGINS entirely. SwiftWinds (the external dogfood
account) used to skip the grace window and close on first detection, which also
meant closing real PRs even during a scheduled dry run because the per-PR
override flipped dry_run off. It now follows the same warn-then-close path as
every other author, so a low-quality PR is warned first and only closed once the
2-hour window elapses. This also closes the Greptile finding that the sweep could
mutate real PRs while AGENT_SHIN_ENABLED was still off.
The review gate's separate age-based grace (DEFAULT_GRACE_DAYS) is left unchanged.
Regression tests pin that SwiftWinds now warns-grace instead of closing instantly,
and that a dry-run sweep over a closeable PR reports "would close" without making
any GitHub mutation.
* fix(agent-shin): gate reconsider reopen on an Agent Shin close marker
was_closed_by_agent_shin only checked that the most recent close actor was
the bot identity. That identity defaults to github-actions[bot], which is
shared by every workflow in the repo (stale/duplicate sweeps included), so a
contributor could @agent-shin reconsider an item another workflow closed and,
if the description passed the rubric, get it reopened even though Agent Shin
was never the closer.
Require a second, Agent-Shin-specific signal alongside the actor check: an
auto-close comment stamped with a hidden AGENT_SHIN_CLOSE_MARKER. Both close
paths (the grace-period close and the review-gate close) flow through
format_pr_close_comment / format_issue_close_comment, so stamping the marker
there covers every real close while leaving the grace warnings unmarked. The
guard stays fail-closed: no marker, no reopen.
This also replaces the unused AGENT_SHIN_AUTO_CLOSE_MARKER constant (a visible
phrase the guard never consulted) with the hidden marker the guard now relies
on.
* fix(agent-shin): stamp close marker on sweep closes and disclose regression deadline
The daily Greptile sweep's close comment advertised `@agent-shin reconsider`
but never stamped AGENT_SHIN_CLOSE_MARKER, so the reconsider reopen guard
(was_closed_by_agent_shin), which now also requires that marker, silently
rejected every sweep-closed PR with `skip-not-bot-closed`. Move the marker into
agent_shin_shared so both close paths share one source of truth, extract
format_close_comment so the sweep close comment is unit-testable, and stamp the
marker there.
Also disclose the grace_days deadline in the review-gate regression comment; it
promised "the PR stays open" without mentioning that a still-failing PR is
auto-closed grace_days after the notice, which would surprise contributors with
a close they were never warned about.
* fix(triage): tighten Agent Shin reconsider reopen guards
The bot-closed guard accepted any historical Agent Shin marker comment
on the thread as proof that Agent Shin owned the latest close, so a
post-reopen close by another workflow under the shared
`github-actions[bot]` identity could still satisfy the gate and let
`@agent-shin reconsider` reopen a PR that Agent Shin did not close
this cycle. `fetch_last_close_event` now also returns the latest
`closed` event timestamp, and `was_closed_by_agent_shin` requires
the most recent Agent Shin marker comment to sit at (or just before)
that timestamp, with a small skew window for clock drift between the
events and comments APIs.
In the same path the LLM verdict check used `decision != "fail"` to
choose the reopen branch, which treated a missing, empty, or typo
verdict as a pass. Reopen is destructive, so the check now requires an
explicit `decision == "pass"` and ambiguous verdicts fall through
to the "still failing" branch instead.
* style(agent-shin): black-format reconsider guard hardening
* docs(agent-shin): scope dry-run wrapper docstring to the single existing helper
The module docstring claimed it wrapped every Agent Shin mutation and
referenced post_comment/close_pr/etc., but only maybe_post_comment exists.
Describe the single helper accurately while keeping the dry-run pattern
guidance for any future wrapper.
* chore(agent-shin): defer issue/PR template changes to the rollout PR
The triage and review-gate automation is gated to the allowlisted authors
(mateo-berri, SwiftWinds) and AGENT_SHIN_ENABLED, so during this rollout it
only acts on internal PRs/issues. The issue and PR templates have no such
gate; they change for every contributor on merge and advertise that an LLM
bot auto-closes external submissions, which won't happen while the allowlist
is the sole author gate. Revert bug_report.yml, feature_request.yml, and
pull_request_template.md to base so the public-facing messaging lands with
the rollout flip instead of ahead of it. The scripts embed their own rubric
and never read these files, so triage behavior is unchanged.
* ci(agent-shin): hash-pin the openai install in privileged triage workflows
The triage workflows install the OpenAI client with `pip install
"openai>=1.40.0"`, a floating lower bound that resolves openai and its
whole transitive tree to whatever PyPI serves at run time. These jobs run
under pull_request_target with a write-scoped GITHUB_TOKEN, and the
install plus the triage run happen on every PR open regardless of the
AGENT_SHIN_ENABLED dry-run gate (that gate only withholds the LLM key and
the destructive --close path), so a compromised release would execute
during install or import while the token is in scope.
Install instead from a new .github/scripts/triage-requirements.txt that
pins openai==2.33.0 and every transitive dependency to an exact version
with sha256 hashes, via pip --require-hashes. The workflows already
sparse-checkout .github/scripts from the base repo (never fork code), so
the pinned file is trusted. Add static guardrails to
test_github_triage_workflows.py that fail if any installer workflow
reverts to a floating openai install or if the requirements file loses
its exact pins or hashes.
* ci(agent-shin): gate rollout heads-up real run behind manual dispatch
The rollout heads-up workflow fired its real `--close` sweep on every push
to litellm_internal_staging that touched the script, and exposed
OPENAI_API_KEY unconditionally, unlike every sibling triage workflow which
only exposes the key on an enabled or dispatched run. That made merging the
script post real heads-up comments (bounded only by the dogfood allowlist),
which contradicts the inert-by-default safety invariant; once the allowlist
is cleared for the public rollout, any later edit to the file would sweep
the whole open backlog with real writes.
The heads-up cannot be gated on AGENT_SHIN_ENABLED: its whole job is to warn
contributors before that flag flips on, so it has to run while the flag is
still off. Instead the automatic push trigger now stays dry-run, and the
real one-shot sweep is a deliberate manual workflow_dispatch with
dry_run=false, the sole path that adds `--close`. OPENAI_API_KEY is exposed
only on that dispatch, matching the sibling workflows.
Add static guardrails that fail if the push path regains a `--close`, if the
dispatch gate stops fail-closing on the exact string "false", or if the key
is exposed unconditionally again.
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
Co-authored-by: Yassin Kortam <yassin@berri.ai>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Mateo <mateo@Mateos-MacBook-Pro.local>
1778 lines
72 KiB
Python
1778 lines
72 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Agent Shin — LLM-as-judge triage for external OSS pull requests and issues.
|
|
|
|
Evaluates a single PR or issue against the contribution rubric and, when the
|
|
LLM judge marks it as failing, posts an explanatory comment + closes the
|
|
PR/issue. Re-triggers on `reopened` so contributors can iterate back in by
|
|
filling in the missing pieces and reopening.
|
|
|
|
Internal BerriAI contributors (`author_association` in {OWNER, MEMBER,
|
|
COLLABORATOR}) and bot accounts are skipped entirely.
|
|
|
|
Usage:
|
|
triage_with_llm.py --repo owner/repo --pr 1234
|
|
triage_with_llm.py --repo owner/repo --issue 5678
|
|
triage_with_llm.py --repo owner/repo --pr 1234 --close # actually close
|
|
triage_with_llm.py --repo owner/repo --pr 1234 --print-prompt # show prompt
|
|
|
|
Defaults are SAFE: without `--close` the script writes a verdict to stdout (and,
|
|
when running in GitHub Actions, to $GITHUB_STEP_SUMMARY) but takes no GitHub
|
|
write actions.
|
|
|
|
Environment:
|
|
GH_TOKEN / GITHUB_TOKEN - for `gh` CLI auth (auto-set in Actions)
|
|
OPENAI_API_KEY - required when --close is passed
|
|
OPENAI_BASE_URL - optional (route to any OpenAI-compatible API)
|
|
TRIAGE_MODEL - optional model override (default: gpt-5.4-mini)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import datetime as dt
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import textwrap
|
|
import urllib.parse
|
|
from typing import Any, Iterable
|
|
|
|
# Add this script's directory to `sys.path` so the sibling
|
|
# `agent_shin_shared` module is importable when the script is invoked
|
|
# directly (e.g. `python3 .github/scripts/triage_with_llm.py ...`) and
|
|
# also when the tests load this script via
|
|
# `importlib.util.spec_from_file_location`.
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
from agent_shin_shared import ( # noqa: E402 -- sys.path adjusted above
|
|
AGENT_SHIN_CLOSE_MARKER,
|
|
AGENT_SHIN_DEFAULT_BOT_LOGIN,
|
|
ALLOWLIST_LOGINS,
|
|
GRACE_COMMENT_MARKER,
|
|
GRACE_PERIOD_SECONDS,
|
|
GREPTILE_BOT_LOGINS,
|
|
SCORE_PATTERN,
|
|
extract_greptile_score,
|
|
gh,
|
|
parse_iso8601,
|
|
seconds_since_latest_marker_comment,
|
|
)
|
|
|
|
DEFAULT_MODEL = "gpt-5.4-mini"
|
|
|
|
INTERNAL_ASSOCIATIONS = frozenset({"OWNER", "MEMBER", "COLLABORATOR"})
|
|
|
|
# `AGENT_SHIN_DEFAULT_BOT_LOGIN` is imported from `agent_shin_shared`.
|
|
# When the workflow uses the default `secrets.GITHUB_TOKEN`, the
|
|
# closure / reopen event's `actor.login` is `github-actions[bot]`. The
|
|
# env override `AGENT_SHIN_BOT_LOGIN` exists for local debugging and for
|
|
# repos that wire Agent Shin to a PAT.
|
|
|
|
# HTML marker appended to every reconsider verdict comment. We grep for this
|
|
# on subsequent reconsider triggers to enforce a short cooldown so that
|
|
# repeated `@agent-shin reconsider` comments don't burn CI/LLM budget.
|
|
# Using a unique HTML comment keeps the marker invisible to humans while
|
|
# being trivially greppable from a comments-list API response.
|
|
RECONSIDER_COMMENT_MARKER = "<!-- agent-shin:reconsider-verdict -->"
|
|
|
|
# Minimum gap between two reconsider verdicts on the same PR/issue. Set to
|
|
# 10 minutes — long enough that a contributor can't trivially spam the
|
|
# trigger, short enough that a genuine "I just pushed a fix and reupdated
|
|
# the body" iteration loop isn't punished.
|
|
RECONSIDER_RATE_LIMIT_SECONDS = 600
|
|
|
|
# `GRACE_COMMENT_MARKER` (HTML marker on the grace-period warning comment
|
|
# posted on the first low-quality detection — used on subsequent triage
|
|
# runs to detect that a warning was already posted and measure how long
|
|
# ago it was posted) and `GRACE_PERIOD_SECONDS` (length of the grace
|
|
# period between the warning and the actual auto-close, 2 hours) are
|
|
# imported from `agent_shin_shared` so the daily Greptile sweep and the
|
|
# LLM judge agree on the same marker and duration.
|
|
|
|
# --- 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").
|
|
READY_MARKER = "<!-- agent-shin:ready -->"
|
|
REGRESSED_MARKER = "<!-- agent-shin:regressed -->"
|
|
WITHIN_GRACE_MARKER = "<!-- agent-shin:within-grace -->"
|
|
|
|
# `GREPTILE_BOT_LOGINS` (Greptile's GitHub App login variants —
|
|
# `greptile-apps[bot]` in REST API comments, `greptile-apps` in
|
|
# `gh pr view --json` output) and `SCORE_PATTERN` (regex matching lines
|
|
# like `Confidence Score: 3/5`) are imported from `agent_shin_shared`
|
|
# so the daily sweep and the review gate read the score through the
|
|
# same set of logins / patterns.
|
|
|
|
# `AGENT_SHIN_CLOSE_MARKER` is imported from `agent_shin_shared` so this LLM
|
|
# judge and the daily Greptile sweep stamp the same marker on their close
|
|
# comments — `was_closed_by_agent_shin` keys the reconsider reopen path off it.
|
|
|
|
# Model families that require `reasoning_effort` to be set, and that reject
|
|
# `temperature != 1` unless `reasoning_effort` is "none". For these models we
|
|
# pass `reasoning_effort="none"` so a `temperature=0` deterministic judgment
|
|
# is still accepted. See litellm/llms/openai/chat/gpt_5_transformation.py for
|
|
# the full set of constraints LiteLLM applies to these models.
|
|
GPT5_FAMILY_PREFIX = "gpt-5"
|
|
|
|
# Regexes for picking off "obvious passes" without burning LLM tokens.
|
|
#
|
|
# Keep this list to GitHub's documented PR-closing keywords only
|
|
# (https://docs.github.com/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
|
|
# Casual mentions like "see #1234" or "ref #1234" are intentionally NOT
|
|
# auto-passed — they should fall through to the LLM judge, which has the
|
|
# stricter rubric "a bare issue number without a closing keyword counts only
|
|
# if it's clearly the related issue (not a passing mention)".
|
|
LINKED_ISSUE_PATTERN = re.compile(
|
|
r"\b(?:fixes|fix|fixed|closes|close|closed|resolves|resolve|resolved)\s+"
|
|
r"(?:#\d+|https?://github\.com/[\w.-]+/[\w.-]+/issues/\d+)",
|
|
re.IGNORECASE,
|
|
)
|
|
HTML_COMMENT_PATTERN = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# gh helpers
|
|
#
|
|
# `gh` is imported from `agent_shin_shared` so a future change (timeout,
|
|
# logging, retry) only needs to be made once.
|
|
|
|
|
|
def fetch_pr(repo: str, number: int) -> dict:
|
|
"""Return the full GitHub REST representation of a PR."""
|
|
return json.loads(gh("api", f"repos/{repo}/pulls/{number}"))
|
|
|
|
|
|
def fetch_issue(repo: str, number: int) -> dict:
|
|
"""Return the full GitHub REST representation of an issue."""
|
|
return json.loads(gh("api", f"repos/{repo}/issues/{number}"))
|
|
|
|
|
|
def post_comment(repo: str, number: int, body: str) -> None:
|
|
"""Post an issue-style comment (works for both issues and PRs)."""
|
|
gh(
|
|
"api",
|
|
f"repos/{repo}/issues/{number}/comments",
|
|
"-X",
|
|
"POST",
|
|
"-f",
|
|
f"body={body}",
|
|
)
|
|
|
|
|
|
def close_pr(repo: str, number: int) -> None:
|
|
"""Close a pull request (state=closed)."""
|
|
gh(
|
|
"api",
|
|
f"repos/{repo}/pulls/{number}",
|
|
"-X",
|
|
"PATCH",
|
|
"-f",
|
|
"state=closed",
|
|
)
|
|
|
|
|
|
def reopen_pr(repo: str, number: int) -> None:
|
|
"""Reopen a previously-closed pull request (state=open).
|
|
|
|
Used by the `@agent-shin reconsider` comment-trigger flow: the bot has
|
|
write access via GH_TOKEN, so it can reopen on the contributor's behalf
|
|
even though GitHub doesn't let the OSS author do it themselves.
|
|
"""
|
|
gh(
|
|
"api",
|
|
f"repos/{repo}/pulls/{number}",
|
|
"-X",
|
|
"PATCH",
|
|
"-f",
|
|
"state=open",
|
|
)
|
|
|
|
|
|
def close_issue(repo: str, number: int, *, not_planned: bool = True) -> None:
|
|
"""Close an issue, marking state_reason=not_planned by default."""
|
|
args = [
|
|
"api",
|
|
f"repos/{repo}/issues/{number}",
|
|
"-X",
|
|
"PATCH",
|
|
"-f",
|
|
"state=closed",
|
|
]
|
|
if not_planned:
|
|
args.extend(["-f", "state_reason=not_planned"])
|
|
gh(*args)
|
|
|
|
|
|
def reopen_issue(repo: str, number: int) -> None:
|
|
"""Reopen a previously-closed issue (state=open, state_reason=reopened)."""
|
|
gh(
|
|
"api",
|
|
f"repos/{repo}/issues/{number}",
|
|
"-X",
|
|
"PATCH",
|
|
"-f",
|
|
"state=open",
|
|
"-f",
|
|
"state_reason=reopened",
|
|
)
|
|
|
|
|
|
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 _iter_paginated_json(*api_args: str) -> Any:
|
|
"""Yield JSON objects from `gh api --paginate ... -q '.[]'`.
|
|
|
|
`gh api --paginate` on a JSON-array endpoint concatenates pages into
|
|
one stream; `-q '.[]'` flattens that stream into newline-delimited
|
|
objects (jq-style). This keeps memory bounded for chatty endpoints
|
|
like issue events/comments on long-lived PRs.
|
|
"""
|
|
raw = gh("api", "--paginate", *api_args, "-q", ".[]")
|
|
for line in raw.splitlines():
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
yield json.loads(line)
|
|
except json.JSONDecodeError:
|
|
# A malformed line should not blow up the whole guard. Skip and
|
|
# carry on — at worst the guard fail-closes (returns False /
|
|
# None) and the caller treats it as "unknown".
|
|
continue
|
|
|
|
|
|
def fetch_last_close_event(
|
|
repo: str, number: int
|
|
) -> tuple[str | None, dt.datetime | None]:
|
|
"""Return the actor login and timestamp of the most recent `closed` event.
|
|
|
|
Either field may be None: actor when the events API returns nothing
|
|
(unusual for a closed item, but possible on transient errors), and
|
|
timestamp when the event lacks `created_at` or the value can't be
|
|
parsed. `was_closed_by_agent_shin` fail-closes on either.
|
|
"""
|
|
actor: str | None = None
|
|
closed_at: dt.datetime | None = None
|
|
for event in _iter_paginated_json(f"repos/{repo}/issues/{number}/events"):
|
|
if event.get("event") != "closed":
|
|
continue
|
|
actor = (event.get("actor") or {}).get("login")
|
|
created = event.get("created_at")
|
|
if not created:
|
|
closed_at = None
|
|
continue
|
|
try:
|
|
closed_at = parse_iso8601(created)
|
|
except ValueError:
|
|
closed_at = None
|
|
return actor, closed_at
|
|
|
|
|
|
# How much older than the latest `closed` event the Agent Shin marker
|
|
# comment is allowed to be while still counting as "this close was Agent
|
|
# Shin's". Agent Shin posts the close comment immediately before closing,
|
|
# so the marker timestamp is normally at most a few seconds before the
|
|
# close event; the buffer just absorbs clock skew between the comments
|
|
# API and the events API.
|
|
AGENT_SHIN_CLOSE_MARKER_SKEW_SECONDS = 300
|
|
|
|
|
|
def was_closed_by_agent_shin(
|
|
repo: str, number: int, *, bot_login: str | None = None
|
|
) -> bool:
|
|
"""Return True iff Agent Shin itself most-recently closed this PR/issue.
|
|
|
|
This is the guard that stops `@agent-shin reconsider` from reopening an
|
|
item Agent Shin did not close — a maintainer closing for non-rubric
|
|
reasons (security, duplicate, design rejection), or a different workflow
|
|
(stale/duplicate sweeps) closing under the shared `github-actions[bot]`
|
|
identity. Three independent signals must all hold, because that identity
|
|
is not unique to Agent Shin and a marker comment from a prior
|
|
closed/reopened cycle would otherwise vouch for an unrelated close:
|
|
|
|
1. The most recent `closed` event's actor is the bot identity.
|
|
2. Agent Shin left one of its auto-close comments, detected via
|
|
`AGENT_SHIN_CLOSE_MARKER`. The actor check alone can't tell an
|
|
Agent Shin close from any other `github-actions[bot]` close.
|
|
3. That marker comment was posted at (or just before) the latest
|
|
close event, not on a previous close in an
|
|
Agent-Shin-close -> reconsider-reopen -> other-bot-reclose cycle.
|
|
|
|
The check is intentionally fail-closed: any uncertainty about who closed
|
|
the item is treated as "not Agent Shin" so the destructive reopen path
|
|
stays gated.
|
|
"""
|
|
expected = (
|
|
bot_login
|
|
or os.environ.get("AGENT_SHIN_BOT_LOGIN")
|
|
or AGENT_SHIN_DEFAULT_BOT_LOGIN
|
|
).lower()
|
|
actor, closed_at = fetch_last_close_event(repo, number)
|
|
if not actor or actor.lower() != expected or closed_at is None:
|
|
return False
|
|
marker_seconds = seconds_since_last_agent_shin_close(
|
|
repo, number, bot_login=bot_login
|
|
)
|
|
if marker_seconds is None:
|
|
return False
|
|
close_age_seconds = (dt.datetime.now(dt.timezone.utc) - closed_at).total_seconds()
|
|
return marker_seconds <= close_age_seconds + AGENT_SHIN_CLOSE_MARKER_SKEW_SECONDS
|
|
|
|
|
|
def _seconds_since_latest_marker_comment(
|
|
repo: str,
|
|
number: int,
|
|
*,
|
|
marker: str,
|
|
bot_login: str | None = None,
|
|
) -> float | None:
|
|
"""Return seconds since the bot's most recent comment with ``marker``.
|
|
|
|
Fetches comments via `_iter_paginated_json` and delegates the
|
|
iteration / author-filter / timestamp logic to
|
|
`agent_shin_shared.seconds_since_latest_marker_comment` so the daily
|
|
Greptile sweep and the LLM judge use one source of truth for the
|
|
"bot already posted X" detection. The wall-clock `now` is resolved
|
|
against this module's `dt` so tests that freeze time via
|
|
`monkeypatch.setattr(triage_module, "dt", ...)` still apply.
|
|
"""
|
|
return seconds_since_latest_marker_comment(
|
|
_iter_paginated_json(f"repos/{repo}/issues/{number}/comments"),
|
|
marker=marker,
|
|
bot_login=bot_login,
|
|
now=dt.datetime.now(dt.timezone.utc),
|
|
)
|
|
|
|
|
|
def seconds_since_last_reconsider_verdict(
|
|
repo: str, number: int, *, bot_login: str | None = None
|
|
) -> float | None:
|
|
"""Return seconds since the bot's most recent reconsider verdict comment.
|
|
|
|
Detects comments by matching the HTML marker `RECONSIDER_COMMENT_MARKER`
|
|
appended by `format_reopen_comment` and
|
|
`format_reconsider_still_failing_comment`. Returns None when the bot
|
|
has never posted a reconsider verdict on this PR/issue (or when the
|
|
only matching comments are missing a `created_at` timestamp, which
|
|
shouldn't happen on a real GitHub response).
|
|
"""
|
|
return _seconds_since_latest_marker_comment(
|
|
repo, number, marker=RECONSIDER_COMMENT_MARKER, bot_login=bot_login
|
|
)
|
|
|
|
|
|
def seconds_since_last_grace_warning(
|
|
repo: str, number: int, *, bot_login: str | None = None
|
|
) -> float | None:
|
|
"""Return seconds since the bot's most recent grace-period warning.
|
|
|
|
Detects warning comments by matching the HTML marker
|
|
`GRACE_COMMENT_MARKER` appended by `format_grace_warning_pr_comment`
|
|
and `format_grace_warning_issue_comment`. Returns None when no
|
|
grace warning has ever been posted on this PR/issue — that's the
|
|
"first low-quality detection" signal that drives the warning path.
|
|
"""
|
|
return _seconds_since_latest_marker_comment(
|
|
repo, number, marker=GRACE_COMMENT_MARKER, bot_login=bot_login
|
|
)
|
|
|
|
|
|
def seconds_since_last_agent_shin_close(
|
|
repo: str, number: int, *, bot_login: str | None = None
|
|
) -> float | None:
|
|
"""Return seconds since Agent Shin's most recent auto-close comment.
|
|
|
|
Detects close comments by matching `AGENT_SHIN_CLOSE_MARKER` (stamped by
|
|
`format_pr_close_comment` / `format_issue_close_comment`). Returns None
|
|
when Agent Shin has never closed this PR/issue — the signal
|
|
`was_closed_by_agent_shin` uses to keep the reconsider reopen path gated
|
|
against closures performed by other workflows sharing the bot identity.
|
|
"""
|
|
return _seconds_since_latest_marker_comment(
|
|
repo, number, marker=AGENT_SHIN_CLOSE_MARKER, bot_login=bot_login
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Author classification
|
|
|
|
|
|
def is_internal_contributor(item: dict) -> bool:
|
|
"""Return True if the PR/issue author should be exempted from triage.
|
|
|
|
Fail-safe: if `author_association` is missing or empty (which should never
|
|
happen on a successful GitHub REST response but is possible on schema
|
|
changes or partial responses), treat the author as INTERNAL so the
|
|
destructive close path never fires on an unknown contributor. This matches
|
|
the sibling `is_external_pr_author` in `close_low_quality_prs.py`.
|
|
"""
|
|
login = ((item.get("user") or {}).get("login") or "").lower()
|
|
if login.endswith("[bot]") or login in {"dependabot", "github-actions"}:
|
|
return True
|
|
association = (item.get("author_association") or "").upper()
|
|
if not association or association in INTERNAL_ASSOCIATIONS:
|
|
return True
|
|
return False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Greptile score + age helpers (`extract_greptile_score`, `parse_iso8601`)
|
|
# live in `agent_shin_shared` — they're imported at the top of this module
|
|
# so both `triage_with_llm.py` and `close_low_quality_prs.py` share a
|
|
# single source of truth for the Confidence-Score regex and ISO-8601
|
|
# parsing.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Prompt construction
|
|
|
|
|
|
def strip_html_comments(text: str) -> str:
|
|
"""Remove HTML comments — template placeholder text shouldn't fool the judge."""
|
|
return HTML_COMMENT_PATTERN.sub("", text or "")
|
|
|
|
|
|
def has_linked_issue(text: str) -> bool:
|
|
"""Heuristic: does this body link to an open issue (Fixes #123 etc.)?"""
|
|
return bool(LINKED_ISSUE_PATTERN.search(strip_html_comments(text or "")))
|
|
|
|
|
|
def build_pr_prompt(*, title: str, body: str) -> str:
|
|
cleaned_body = strip_html_comments(body or "").strip() or "(empty)"
|
|
# 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("""
|
|
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.
|
|
|
|
A PR PASSES triage only if BOTH (1) AND (2) are satisfied. A linked
|
|
issue alone is NOT enough — it covers context, not proof.
|
|
|
|
(1) CONTEXT — the PR provides AT LEAST ONE of:
|
|
(a) A link to a related GitHub issue. Acceptable forms:
|
|
"Fixes #1234", "Closes #1234", "Resolves #1234",
|
|
"Refs https://github.com/BerriAI/litellm/issues/1234". A
|
|
bare "#1234" without a closing keyword counts only if it
|
|
is clearly the related issue (not a passing mention).
|
|
(b) A clear problem description in the body (what bug or
|
|
missing feature this addresses, beyond the title) AND
|
|
expected vs. actual behavior (or, for features, "what's
|
|
possible now vs. with this PR").
|
|
|
|
(2) END-TO-END QA PROOF: the PR body contains AT LEAST ONE of:
|
|
(a) A screen recording / video showing the behavior before
|
|
and after the change (the bug reproducing, then the fix
|
|
working). For a brand-new feature with no meaningful
|
|
"before", a recording of it working end-to-end is fine.
|
|
(b) A screenshot (or before/after screenshots) showing the
|
|
fix or feature working.
|
|
(c) Specific commands that were actually run (curl, python,
|
|
a CLI invocation, etc.) PAIRED WITH their real
|
|
output, demonstrating the change works end-to-end against
|
|
the real system. Commands whose external dependencies
|
|
(LLM provider, DB, network) are mocked or stubbed do NOT
|
|
satisfy (2c); they are not end-to-end.
|
|
|
|
`has_qa_proof` must be set to `true` only when (2a), (2b),
|
|
or a non-mocked (2c) is actually present in the body. If the
|
|
only "proof" is mocked tests, `has_qa_proof` is `false` and
|
|
the verdict is "fail".
|
|
|
|
The following do NOT count as QA proof:
|
|
- Generic claims like "I tested it", "works locally", "all
|
|
tests pass", or a checked "I added tests" checkbox with no
|
|
output shown.
|
|
- A description of what tests exist or were added, without
|
|
their actual output in the PR body.
|
|
- `pytest` (or any test runner) executed against the
|
|
repository's own unit tests. Those mock the LLM provider,
|
|
DB, and network, so they are NOT end-to-end and never
|
|
satisfy (2), no matter how much passing output is pasted.
|
|
- A linked issue. The linked issue is context (1a), never
|
|
proof (2).
|
|
|
|
FAIL the PR if EITHER (1) or (2) is missing. Do not bias toward PASS:
|
|
if QA proof is absent, the verdict is "fail" even when the rest of
|
|
the PR is well-written.
|
|
|
|
Respond with a single JSON object, no prose:
|
|
|
|
{{
|
|
"verdict": "pass" | "fail",
|
|
"linked_issue": boolean,
|
|
"has_problem_description": boolean,
|
|
"has_expected_vs_actual": boolean,
|
|
"has_qa_proof": boolean,
|
|
"qa_proof_type": "video" | "screenshot" | "commands_with_output" | "none",
|
|
"missing": ["plain-english strings naming what is missing"],
|
|
"explanation": "1-2 sentence reasoning for the team to skim"
|
|
}}
|
|
|
|
---
|
|
PR title: {title}
|
|
|
|
PR body:
|
|
---
|
|
{cleaned_body}
|
|
---
|
|
""").strip()
|
|
return template.format(title=title, cleaned_body=cleaned_body)
|
|
|
|
|
|
def build_issue_prompt(*, title: str, body: str) -> str:
|
|
cleaned_body = strip_html_comments(body or "").strip() or "(empty)"
|
|
# 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("""
|
|
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.
|
|
|
|
For a BUG REPORT the issue PASSES triage only when it contains BOTH:
|
|
(1) END-TO-END EVIDENCE OF THE BUG (the "before"; set
|
|
`has_repro=true` only when this is present): AT LEAST ONE of:
|
|
(a) A screen recording / video of the bug happening.
|
|
(b) A screenshot of the bug.
|
|
(c) The exact command(s) actually run (curl, python, a CLI
|
|
invocation, etc.) PAIRED WITH their real output, traceback,
|
|
or logs showing the failure against the real system.
|
|
Commands whose external dependencies (LLM provider, DB,
|
|
network) are mocked or stubbed do NOT count.
|
|
Prose-only "steps to reproduce" with no run output, video, or
|
|
screenshot do NOT satisfy (1).
|
|
(2) Expected vs. actual behavior (`has_expected_vs_actual`).
|
|
|
|
FAIL the bug report if either (1) or (2) is missing. Do not bias
|
|
toward PASS: if the bug isn't demonstrated end-to-end, the verdict is
|
|
"fail" even when the report is well-written.
|
|
|
|
For a FEATURE REQUEST the issue PASSES triage only when it contains
|
|
ALL of:
|
|
- A clear description of the proposed feature (what should LiteLLM do
|
|
that it does not today).
|
|
- Motivation / use case with a concrete example (config, API call,
|
|
UI flow, or scenario showing what's blocked today).
|
|
|
|
For an issue that is neither a bug report nor a feature request (a
|
|
question, support request, or discussion), PASS as long as it has a
|
|
clear, specific ask and is not empty or template placeholder text.
|
|
|
|
Respond with a single JSON object, no prose:
|
|
|
|
{{
|
|
"verdict": "pass" | "fail",
|
|
"kind": "bug" | "feature" | "other",
|
|
"has_repro": boolean,
|
|
"has_expected_vs_actual": boolean,
|
|
"has_motivation_example": boolean,
|
|
"missing": ["plain-english strings naming what is missing"],
|
|
"explanation": "1-2 sentence reasoning for the team to skim"
|
|
}}
|
|
|
|
---
|
|
Issue title: {title}
|
|
|
|
Issue body:
|
|
---
|
|
{cleaned_body}
|
|
---
|
|
""").strip()
|
|
return template.format(title=title, cleaned_body=cleaned_body)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LLM call + verdict parsing
|
|
|
|
|
|
def call_llm_judge(
|
|
prompt: str, *, model: str, api_key: str, base_url: str | None
|
|
) -> str:
|
|
"""Call an OpenAI-compatible chat completions endpoint. Returns raw text."""
|
|
# Import inside the function so unit tests that monkey-patch this never
|
|
# need the openai package installed.
|
|
from openai import OpenAI
|
|
|
|
client = (
|
|
OpenAI(api_key=api_key, base_url=base_url)
|
|
if base_url
|
|
else OpenAI(api_key=api_key)
|
|
)
|
|
kwargs: dict[str, Any] = {
|
|
"model": model,
|
|
"messages": [{"role": "user", "content": prompt}],
|
|
"temperature": 0,
|
|
"response_format": {"type": "json_object"},
|
|
}
|
|
# gpt-5.x reasoning models reject `temperature != 1` unless
|
|
# `reasoning_effort` is explicitly "none". Set it via `extra_body` so this
|
|
# works across openai SDK versions regardless of whether the SDK natively
|
|
# types `reasoning_effort` as a top-level chat-completions param yet.
|
|
if model.lower().startswith(GPT5_FAMILY_PREFIX):
|
|
kwargs["extra_body"] = {"reasoning_effort": "none"}
|
|
response = client.chat.completions.create(**kwargs)
|
|
return response.choices[0].message.content or ""
|
|
|
|
|
|
def parse_verdict(raw: str) -> dict:
|
|
"""Parse the LLM's JSON response. Tolerates ```json fences and stray text."""
|
|
if not raw:
|
|
raise ValueError("empty LLM response")
|
|
text = raw.strip()
|
|
if text.startswith("```"):
|
|
text = re.sub(r"^```(?:json)?\s*", "", text)
|
|
text = re.sub(r"\s*```$", "", text)
|
|
try:
|
|
return json.loads(text)
|
|
except json.JSONDecodeError:
|
|
match = re.search(r"\{.*\}", text, re.DOTALL)
|
|
if not match:
|
|
raise ValueError(f"could not extract JSON from LLM response: {raw[:200]}")
|
|
return json.loads(match.group(0))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Comment composition
|
|
|
|
|
|
def _format_missing(missing: list[str]) -> str:
|
|
if not missing:
|
|
return "- (see explanation below)"
|
|
return "\n".join(f"- {m}" for m in missing)
|
|
|
|
|
|
# Rubric items the judge can mark present. The first element of each tuple is
|
|
# the verdict-JSON boolean field, the second is the human-readable label we
|
|
# render in the "what you got right" section of close / grace-warning comments.
|
|
_PR_PRESENT_LABELS: tuple[tuple[str, str], ...] = (
|
|
("linked_issue", "Linked a related GitHub issue"),
|
|
("has_problem_description", "Clear problem description"),
|
|
("has_expected_vs_actual", "Expected vs. actual behavior"),
|
|
("has_qa_proof", "End-to-end QA proof"),
|
|
)
|
|
|
|
# Issue rubric labels grouped by `kind`. The judge sets `kind` to one of
|
|
# {"bug", "feature", "other"}; when "other" we render both groups so we don't
|
|
# silently drop a present-flag the judge actually set to True.
|
|
_ISSUE_BUG_LABELS: tuple[tuple[str, str], ...] = (
|
|
(
|
|
"has_repro",
|
|
"End-to-end evidence of the bug (video, screenshot, or command + real output)",
|
|
),
|
|
("has_expected_vs_actual", "Expected vs. actual behavior"),
|
|
)
|
|
_ISSUE_FEATURE_LABELS: tuple[tuple[str, str], ...] = (
|
|
("has_motivation_example", "Motivation and concrete example"),
|
|
)
|
|
|
|
|
|
def _format_present_for_pr(verdict: dict) -> list[str]:
|
|
"""Human-readable rubric items the judge confirmed are present on a PR.
|
|
|
|
Drives the "what you got right" section in close / grace-warning comments.
|
|
The user gave explicit feedback: contributors should see what they nailed
|
|
*before* the list of gaps, so the comment doesn't read as pure rejection.
|
|
"""
|
|
return [label for field, label in _PR_PRESENT_LABELS if verdict.get(field)]
|
|
|
|
|
|
def _format_present_for_issue(verdict: dict) -> list[str]:
|
|
"""Human-readable rubric items the judge confirmed are present on an issue.
|
|
|
|
Branches on the judge's `kind` field. For `"other"` (or missing kind) we
|
|
render the union so a present-flag isn't dropped just because the judge
|
|
couldn't classify the issue cleanly.
|
|
"""
|
|
kind = (verdict.get("kind") or "").lower()
|
|
groups: list[tuple[tuple[str, str], ...]] = []
|
|
if kind in ("bug", "other", ""):
|
|
groups.append(_ISSUE_BUG_LABELS)
|
|
if kind in ("feature", "other", ""):
|
|
groups.append(_ISSUE_FEATURE_LABELS)
|
|
out: list[str] = []
|
|
for group in groups:
|
|
for field, label in group:
|
|
if verdict.get(field) and label not in out:
|
|
out.append(label)
|
|
return out
|
|
|
|
|
|
def _format_present_block(items: list[str]) -> str:
|
|
"""Render the optional "what you got right" block. Empty string when the
|
|
judge didn't confirm anything as present — better to omit the section
|
|
entirely than to show "What you got right: (nothing)".
|
|
"""
|
|
if not items:
|
|
return ""
|
|
bullets = "\n".join(f"- ✅ {item}" for item in items)
|
|
return f"**What you got right:**\n\n{bullets}\n\n"
|
|
|
|
|
|
def format_pr_close_comment(verdict: dict) -> str:
|
|
missing_lines = _format_missing(verdict.get("missing") or [])
|
|
present_block = _format_present_block(_format_present_for_pr(verdict))
|
|
explanation = verdict.get("explanation") or ""
|
|
return (
|
|
"🚅 Hi, thanks for the PR! I'm **Agent Shin**, the automated triage bot for this "
|
|
"repository. "
|
|
"[What's this and why am I getting it?](https://docs.litellm.ai/blog/agent-shin-triage)\n"
|
|
"\n"
|
|
"I read the description against our "
|
|
"[contribution rubric](https://github.com/BerriAI/litellm/blob/main/.github/pull_request_template.md). "
|
|
"Here's how it lined up:\n"
|
|
"\n"
|
|
f"{present_block}"
|
|
"**What's still missing:**\n"
|
|
"\n"
|
|
f"{missing_lines}\n"
|
|
"\n"
|
|
f"> {explanation}\n"
|
|
"\n"
|
|
"**Closing this PR isn't a rejection of the change.** We want the open-PR list to "
|
|
"mirror what a maintainer can act on *right now*, so contributors don't get lost in a "
|
|
'backlog. A closed PR is a soft "park this for later"; your work is still here, '
|
|
"the diff is still here, and getting it reopened is one comment away. Take your time.\n"
|
|
"\n"
|
|
"**To bring this PR back:**\n"
|
|
"\n"
|
|
"- Update the description with the missing pieces, then comment `@agent-shin reconsider` "
|
|
"on this PR. I'll re-evaluate and reopen if it now passes.\n"
|
|
"- Or **Open a new PR** with the same fix and the updated description. GitHub doesn't "
|
|
"always let external contributors reopen a bot-closed PR, so a fresh PR is the most "
|
|
"reliable path back into the review queue.\n"
|
|
"- If Greptile's most recent score on this PR was below 4/5, comment `@greptileai` to "
|
|
"request a fresh review; that **still works even after the PR is closed**, and a "
|
|
"stronger score is one of the signals that lifts the PR back into the queue. A low "
|
|
"Greptile score isn't a blocker.\n"
|
|
"\n"
|
|
'**What "end-to-end QA proof" means**, since it\'s the most common gap: at least one '
|
|
"of a short before/after screen recording / video (the bug reproducing, then the fix "
|
|
"working; for a brand-new feature, a recording of it working end-to-end), a screenshot "
|
|
"(or before/after screenshots) of it working, or the exact commands you ran paired "
|
|
"with their **real output** against the real system. Running `pytest` on the repo's "
|
|
"unit tests doesn't count; those mock the LLM provider, DB, and network, so they "
|
|
"aren't end-to-end. Output from a real, no-mocks integration run is what we look "
|
|
"for. A linked issue alone isn't enough either: it covers context, not proof. See "
|
|
"[the full rubric](https://docs.litellm.ai/blog/agent-shin-triage#the-rubric-for-pull-requests).\n"
|
|
"\n"
|
|
"Internal BerriAI contributors: this rubric doesn't apply to you; ping a maintainer.\n"
|
|
"\n"
|
|
"_(I'm an LLM, so I'm not infallible. If you think I got this wrong, comment "
|
|
"`@agent-shin reconsider` or ping a maintainer; they'll override me.)_"
|
|
f"\n\n{AGENT_SHIN_CLOSE_MARKER}"
|
|
)
|
|
|
|
|
|
def format_issue_close_comment(verdict: dict) -> str:
|
|
missing_lines = _format_missing(verdict.get("missing") or [])
|
|
present_block = _format_present_block(_format_present_for_issue(verdict))
|
|
explanation = verdict.get("explanation") or ""
|
|
return (
|
|
"🚅 Hi, thanks for filing this! I'm **Agent Shin**, the automated triage bot for this "
|
|
"repository. "
|
|
"[What's this and why am I getting it?](https://docs.litellm.ai/blog/agent-shin-triage)\n"
|
|
"\n"
|
|
"I read the issue against our reporting checklist. Here's how it lined up:\n"
|
|
"\n"
|
|
f"{present_block}"
|
|
"**What's still missing:**\n"
|
|
"\n"
|
|
f"{missing_lines}\n"
|
|
"\n"
|
|
f"> {explanation}\n"
|
|
"\n"
|
|
"**Closing this isn't us saying the bug isn't real or the request isn't useful.** We "
|
|
"want the open-issue list to mirror what a maintainer can act on *right now*, so "
|
|
"reports like yours don't get buried in a backlog. A closed issue is a soft \"park "
|
|
'this for later"; your report is still here, and getting it reopened is one comment '
|
|
"away. Take your time.\n"
|
|
"\n"
|
|
"**To bring this issue back:**\n"
|
|
"\n"
|
|
"1. Edit the issue description to add the missing pieces:\n"
|
|
" - For **bug reports**: end-to-end evidence of the bug (a screen recording / "
|
|
"video, a screenshot, or the exact commands you ran with their real output / "
|
|
"traceback) plus expected vs. actual behavior. Written steps with no run output, "
|
|
"video, or screenshot don't count, and mocked or stubbed runs don't count.\n"
|
|
" - For **feature requests**: a concrete description of what should change, plus a "
|
|
"use case and example (config / API call / UI flow).\n"
|
|
"2. Comment `@agent-shin reconsider`. I'll re-run triage and reopen the issue if it "
|
|
"now meets the bar. (GitHub doesn't let external authors reopen an issue a maintainer "
|
|
"or bot closed, so the comment-based reconsider is the reliable path.)\n"
|
|
"\n"
|
|
"Internal BerriAI contributors: this rubric doesn't apply to you; ping a maintainer.\n"
|
|
"\n"
|
|
"_(I'm an LLM, so I'm not infallible. If you think I got this wrong, comment "
|
|
"`@agent-shin reconsider` or ping a maintainer; they'll override me.)_"
|
|
f"\n\n{AGENT_SHIN_CLOSE_MARKER}"
|
|
)
|
|
|
|
|
|
def format_grace_warning_pr_comment(verdict: dict) -> str:
|
|
"""Comment posted on the FIRST low-quality detection — gives the
|
|
contributor a 2-hour grace window to fix the PR before the next
|
|
triage run actually closes it.
|
|
|
|
This is the "before-close" warning. On the second triage run, if the
|
|
grace marker is older than `GRACE_PERIOD_SECONDS` AND the PR still
|
|
fails the rubric, the close path runs (which posts
|
|
`format_pr_close_comment` and closes the PR).
|
|
"""
|
|
missing_lines = _format_missing(verdict.get("missing") or [])
|
|
present_block = _format_present_block(_format_present_for_pr(verdict))
|
|
explanation = verdict.get("explanation") or ""
|
|
return (
|
|
"🚅 Hi, thanks for the PR! I'm **Agent Shin**, the automated triage bot for this "
|
|
"repository. "
|
|
"[What's this and why am I getting it?](https://docs.litellm.ai/blog/agent-shin-triage)\n"
|
|
"\n"
|
|
"I read the description against our "
|
|
"[contribution rubric](https://github.com/BerriAI/litellm/blob/main/.github/pull_request_template.md). "
|
|
"Here's how it lined up:\n"
|
|
"\n"
|
|
f"{present_block}"
|
|
"**What's still missing:**\n"
|
|
"\n"
|
|
f"{missing_lines}\n"
|
|
"\n"
|
|
f"> {explanation}\n"
|
|
"\n"
|
|
"If the description isn't updated in the next **2 hours**, I'll auto-close this PR. "
|
|
"That's **not** us saying we don't care about the change; we want the open-PR list to "
|
|
"mirror what a maintainer can act on *right now*, so contributors don't get lost in a "
|
|
'backlog. A closed PR is a soft "park this for later," not a rejection. Take your '
|
|
"time; everything below still works after the close.\n"
|
|
"\n"
|
|
"**During the grace period:** just update the PR description with the missing pieces. "
|
|
"No need to ping me; I'll re-check on the next sweep and skip the auto-close if it "
|
|
"now passes. See "
|
|
"[what counts as QA proof](https://docs.litellm.ai/blog/agent-shin-triage#the-rubric-for-pull-requests) "
|
|
"for the full rubric (a linked issue alone isn't enough; it covers context, not proof).\n"
|
|
"\n"
|
|
"**If the PR does get auto-closed in 2 hours, you still have easy recovery paths:**\n"
|
|
"\n"
|
|
"- Comment `@agent-shin reconsider` after updating the description. I'll re-evaluate "
|
|
"and reopen the PR if it now passes.\n"
|
|
"- Comment `@greptileai` to request a fresh Greptile review; that **still works even "
|
|
"after the PR is closed**, and a stronger score is one of the signals that lifts the "
|
|
"PR back into the queue. So a low Greptile score isn't a blocker either.\n"
|
|
"\n"
|
|
"Internal BerriAI contributors: this rubric doesn't apply to you; ping a maintainer.\n"
|
|
"\n"
|
|
"_(I'm an LLM, so I'm not infallible. If you think I got this wrong, ping a "
|
|
"maintainer; they'll override me.)_\n"
|
|
"\n"
|
|
f"{GRACE_COMMENT_MARKER}"
|
|
)
|
|
|
|
|
|
def format_grace_warning_issue_comment(verdict: dict) -> str:
|
|
"""Issue analogue of `format_grace_warning_pr_comment`."""
|
|
missing_lines = _format_missing(verdict.get("missing") or [])
|
|
present_block = _format_present_block(_format_present_for_issue(verdict))
|
|
explanation = verdict.get("explanation") or ""
|
|
return (
|
|
"🚅 Hi, thanks for filing this! I'm **Agent Shin**, the automated triage bot for this "
|
|
"repository. "
|
|
"[What's this and why am I getting it?](https://docs.litellm.ai/blog/agent-shin-triage)\n"
|
|
"\n"
|
|
"I read the issue against our reporting checklist. Here's how it lined up:\n"
|
|
"\n"
|
|
f"{present_block}"
|
|
"**What's still missing:**\n"
|
|
"\n"
|
|
f"{missing_lines}\n"
|
|
"\n"
|
|
f"> {explanation}\n"
|
|
"\n"
|
|
"If the issue isn't updated in the next **2 hours**, I'll auto-close it. That's **not** us "
|
|
"saying the bug isn't real or the request isn't useful; we want the open-issue list "
|
|
"to mirror what a maintainer can act on *right now*, so reports like yours don't get "
|
|
'buried in a backlog. A closed issue is a soft "park this for later," not a '
|
|
"rejection. Take your time; reopening is one comment away.\n"
|
|
"\n"
|
|
"**During the grace period:** just edit the issue description with the missing "
|
|
"pieces. No need to ping me; I'll re-check on the next sweep and skip the auto-close "
|
|
"if it now passes.\n"
|
|
"\n"
|
|
"Missing pieces, depending on what this is:\n"
|
|
"\n"
|
|
"- For **bug reports**: end-to-end evidence of the bug (a screen recording / video, a "
|
|
"screenshot, or the exact commands you ran with their real output / traceback) plus "
|
|
"expected vs. actual behavior. Written steps with no run output don't count, and "
|
|
"mocked or stubbed runs don't count.\n"
|
|
"- For **feature requests**: a concrete description of what should change, plus a use "
|
|
"case and example (config / API call / UI flow).\n"
|
|
"\n"
|
|
"**If the issue does get auto-closed in 2 hours**, comment `@agent-shin reconsider` "
|
|
"and I'll re-evaluate. If it now meets the bar, I'll reopen the issue.\n"
|
|
"\n"
|
|
"Internal BerriAI contributors: this rubric doesn't apply to you; ping a maintainer.\n"
|
|
"\n"
|
|
"_(I'm an LLM, so I'm not infallible. If you think I got this wrong, ping a "
|
|
"maintainer; they'll override me.)_\n"
|
|
"\n"
|
|
f"{GRACE_COMMENT_MARKER}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Step-summary helpers
|
|
|
|
|
|
def write_step_summary(content: str) -> None:
|
|
"""When running inside GitHub Actions, append to the step summary file."""
|
|
path = os.environ.get("GITHUB_STEP_SUMMARY")
|
|
if not path:
|
|
return
|
|
try:
|
|
with open(path, "a", encoding="utf-8") as handle:
|
|
handle.write(content)
|
|
if not content.endswith("\n"):
|
|
handle.write("\n")
|
|
except OSError as exc:
|
|
print(f"warn: failed to write step summary: {exc}", file=sys.stderr)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Core orchestration
|
|
|
|
|
|
def format_reopen_comment(kind: str) -> str:
|
|
"""Comment posted when Agent Shin reopens after a successful reconsider."""
|
|
noun = "PR" if kind == "pr" else "issue"
|
|
# The trailing HTML marker is used by `seconds_since_last_reconsider_verdict`
|
|
# to enforce a cooldown between repeated `@agent-shin reconsider` triggers.
|
|
# Keep the marker on its own line so it doesn't disturb the rendered text.
|
|
return (
|
|
f"♻️ **Re-evaluated and reopened.** Thanks for updating the {noun}!\n"
|
|
"\n"
|
|
"Agent Shin re-ran triage on the latest description and it now meets "
|
|
"the bar. A maintainer will take another look soon; please don't "
|
|
f"close this {noun} again unless asked to.\n"
|
|
"\n"
|
|
"_(If a maintainer ends up closing this for non-rubric reasons, that "
|
|
"decision stands; comment `@agent-shin reconsider` again only if you "
|
|
"have substantively new information.)_\n"
|
|
"\n"
|
|
f"{RECONSIDER_COMMENT_MARKER}"
|
|
)
|
|
|
|
|
|
def format_reconsider_still_failing_comment(kind: str, verdict: dict) -> str:
|
|
"""Comment posted when reconsider re-runs triage but the verdict is still fail."""
|
|
missing_lines = _format_missing(verdict.get("missing") or [])
|
|
explanation = verdict.get("explanation") or ""
|
|
noun = "PR" if kind == "pr" else "issue"
|
|
# The trailing HTML marker is used by `seconds_since_last_reconsider_verdict`
|
|
# to enforce a cooldown between repeated `@agent-shin reconsider` triggers.
|
|
return (
|
|
f"⏸️ **Re-evaluated; this {noun} still doesn't meet the rubric.**\n"
|
|
"\n"
|
|
"Agent Shin re-ran triage on the current description but is still "
|
|
"missing:\n"
|
|
"\n"
|
|
f"{missing_lines}\n"
|
|
"\n"
|
|
f"> {explanation}\n"
|
|
"\n"
|
|
"Update the description with the missing pieces and comment "
|
|
"`@agent-shin reconsider` again, or ping a maintainer if you think "
|
|
"I got this wrong.\n"
|
|
"\n"
|
|
"_(I'm an LLM and I'm not infallible.)_\n"
|
|
"\n"
|
|
f"{RECONSIDER_COMMENT_MARKER}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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, *, bot_login: str | None = None
|
|
) -> bool:
|
|
"""Return True iff the bot itself posted a comment containing ``marker``.
|
|
|
|
Filters by author so a contributor who quotes the marker (e.g. via
|
|
GitHub's "Quote reply" feature, which preserves HTML comments in
|
|
raw markdown) is not mistaken for a bot action — that would
|
|
silently suppress notifications or change which "recovered" wording
|
|
is selected. Matches the author-filter pattern used by the sibling
|
|
`_seconds_since_latest_marker_comment` helper.
|
|
"""
|
|
expected_login = (
|
|
bot_login
|
|
or os.environ.get("AGENT_SHIN_BOT_LOGIN")
|
|
or AGENT_SHIN_DEFAULT_BOT_LOGIN
|
|
).lower()
|
|
for comment in comments:
|
|
author = ((comment.get("user") or {}).get("login") or "").lower()
|
|
if author != expected_login:
|
|
continue
|
|
if marker in (comment.get("body") or ""):
|
|
return True
|
|
return False
|
|
|
|
|
|
def format_ready_for_review_comment(
|
|
verdict: dict,
|
|
greptile_score: int | None,
|
|
min_greptile_score: int = DEFAULT_MIN_GREPTILE_SCORE,
|
|
) -> 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 {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, grace_days: int
|
|
) -> str:
|
|
"""Posted when a previously-tagged PR regresses (label removed, PR stays open).
|
|
|
|
Discloses the same ``grace_days`` deadline the state machine enforces:
|
|
once that window elapses with the PR still failing, the close path fires.
|
|
Hiding the deadline behind a bare "stays open" would surprise contributors
|
|
with an auto-close they were never warned about.
|
|
"""
|
|
window = "24 hours" if grace_days == 1 else f"{grace_days} days"
|
|
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"
|
|
f"The PR stays open for ~{window}; address the points above and Agent "
|
|
'Shin will post an "all clear" comment and re-add the tag '
|
|
"automatically. If the points still aren't addressed after that "
|
|
"window, the PR is auto-closed; that's not a rejection, and you can "
|
|
"comment `@agent-shin reconsider` to have it re-evaluated and reopened "
|
|
"once it passes.\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 quite 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; just update the description and I'll re-check on the next "
|
|
"sweep. Once it passes I'll tag it `ready for review`. If it does get "
|
|
"auto-closed, that's not a rejection; comment `@agent-shin reconsider` "
|
|
"and I'll re-evaluate and reopen if it now passes.\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,
|
|
allowlist: frozenset[str] = ALLOWLIST_LOGINS,
|
|
) -> 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 ""
|
|
# GitHub label names are case-insensitive; compare lowercased so a repo
|
|
# that already has e.g. "Ready for Review" is recognized as the same
|
|
# label as our READY_FOR_REVIEW_LABEL constant ("ready for review").
|
|
labels_now = {(lbl.get("name") or "").lower() for lbl in (item.get("labels") or [])}
|
|
label_key = label.lower()
|
|
created_raw = item.get("created_at") or ""
|
|
|
|
base_result = {
|
|
"kind": "pr",
|
|
"number": number,
|
|
"title": title,
|
|
"author": login,
|
|
"author_association": association,
|
|
"state": state,
|
|
"labeled": label_key in labels_now,
|
|
"review_gate": True,
|
|
}
|
|
|
|
if state != "open":
|
|
return {**base_result, "action": "skip-not-open"}
|
|
|
|
if allowlist:
|
|
if login.lower() not in allowlist:
|
|
return {**base_result, "action": "skip-not-allowlisted"}
|
|
elif 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 = list(_iter_paginated_json(f"repos/{repo}/issues/{number}/comments"))
|
|
|
|
# --- 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_key in labels_now
|
|
explanation = verdict.get("explanation") or ""
|
|
# When the rubric short-circuited to pass (linked-issue regex) but
|
|
# Greptile dragged the PR below the bar, the synthetic verdict's
|
|
# explanation ("LLM was not called") would mislead a contributor reading
|
|
# the regression / close comment. Surface the real reason instead.
|
|
if rubric_pass and not greptile_ok:
|
|
explanation = (
|
|
f"Greptile's most recent review scored this PR "
|
|
f"{greptile_score}/5 (below the {min_greptile_score}/5 bar)."
|
|
)
|
|
verdict = {**verdict, "explanation": explanation}
|
|
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, min_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, grace_days)
|
|
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. If the PR was previously tagged and then
|
|
# regressed (we removed the label and posted REGRESSED_MARKER), honor the
|
|
# "PR stays open — fix it and the tag comes back" promise from
|
|
# `format_regression_comment` and skip the close path. Without this guard,
|
|
# any PR older than `grace_days` would be closed on the next evaluation,
|
|
# giving the contributor no realistic window to address the regression.
|
|
#
|
|
# The promise has a deliberate expiration: once `grace_days` have elapsed
|
|
# since the regression notice, fall through to the close path so a PR that
|
|
# was abandoned post-regression doesn't sit open forever.
|
|
if _has_marker(comments, REGRESSED_MARKER):
|
|
reference = now or dt.datetime.now(dt.timezone.utc)
|
|
seconds_since_regression = seconds_since_latest_marker_comment(
|
|
comments, marker=REGRESSED_MARKER, now=reference
|
|
)
|
|
grace_seconds = grace_days * 86400
|
|
if seconds_since_regression is None or seconds_since_regression < grace_seconds:
|
|
return {**base_result, "action": "regressed-already-notified"}
|
|
|
|
# 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,
|
|
kind: str,
|
|
number: int,
|
|
close: bool,
|
|
model: str,
|
|
judge: Any = None,
|
|
print_prompt: bool = False,
|
|
reconsider: bool = False,
|
|
allowlist: frozenset[str] = ALLOWLIST_LOGINS,
|
|
) -> dict:
|
|
"""Triage a single PR or issue. Returns a result dict for logging/tests.
|
|
|
|
`judge` is an optional callable `(prompt) -> str` for tests / dry-run with
|
|
a stub. In production, leave it None and the script uses `call_llm_judge`.
|
|
|
|
When `reconsider=True`, the closed-state guard is skipped and a
|
|
fail-but-no-comment is replaced with a "still failing" comment + leave
|
|
closed; a pass triggers `reopen_pr`/`reopen_issue` plus a reopen comment.
|
|
Reconsider mode is intended for the `@agent-shin reconsider` comment
|
|
trigger. Like regular triage, `close=False` keeps reconsider in dry-run
|
|
(returns `would-reopen` / `would-reconsider-still-failing` so a local
|
|
operator can preview without write side effects); the workflow only
|
|
passes `--close` when `AGENT_SHIN_ENABLED=true`.
|
|
|
|
Reconsider mode adds two extra safety guards on top of the regular
|
|
triage skip-internal-author check:
|
|
|
|
1. **Bot-closed guard.** Only reopens if the most recent close was
|
|
performed by the bot identity (default `github-actions[bot]`).
|
|
This stops a contributor from using `@agent-shin reconsider` to
|
|
override a maintainer's close for non-rubric reasons.
|
|
2. **Rate-limit guard.** If the bot has already posted a reconsider
|
|
verdict on this PR/issue within `RECONSIDER_RATE_LIMIT_SECONDS`,
|
|
skip — repeated triggers from the same contributor shouldn't burn
|
|
CI minutes or LLM budget.
|
|
"""
|
|
fetcher = {"pr": fetch_pr, "issue": fetch_issue}[kind]
|
|
item = fetcher(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 ""
|
|
|
|
base_result = {
|
|
"kind": kind,
|
|
"number": number,
|
|
"title": title,
|
|
"author": login,
|
|
"author_association": association,
|
|
"state": state,
|
|
"reconsider": reconsider,
|
|
}
|
|
|
|
# Reconsider only makes sense on a closed PR/issue. A "reconsider on an
|
|
# open PR" is a no-op (the regular triage flow already evaluates open
|
|
# PRs); return a clear skip so the workflow can short-circuit.
|
|
if reconsider:
|
|
if state != "closed":
|
|
return {**base_result, "action": "skip-not-closed"}
|
|
else:
|
|
if state != "open":
|
|
return {**base_result, "action": "skip-not-open"}
|
|
|
|
if allowlist:
|
|
if login.lower() not in allowlist:
|
|
return {**base_result, "action": "skip-not-allowlisted"}
|
|
elif is_internal_contributor(item):
|
|
return {**base_result, "action": "skip-internal-author"}
|
|
|
|
# Reconsider-only guards — these run BEFORE the LLM call so a
|
|
# maintainer-closed PR / rate-limited trigger never spends LLM budget.
|
|
if reconsider:
|
|
if not was_closed_by_agent_shin(repo, number):
|
|
return {**base_result, "action": "skip-not-bot-closed"}
|
|
age = seconds_since_last_reconsider_verdict(repo, number)
|
|
if age is not None and age < RECONSIDER_RATE_LIMIT_SECONDS:
|
|
return {
|
|
**base_result,
|
|
"action": "skip-rate-limited",
|
|
"rate_limit_age_seconds": age,
|
|
"rate_limit_window_seconds": RECONSIDER_RATE_LIMIT_SECONDS,
|
|
}
|
|
|
|
if kind == "pr":
|
|
# Short-circuit: if body very clearly links a related issue, just pass.
|
|
if has_linked_issue(body):
|
|
base = {
|
|
**base_result,
|
|
"action": "pass-linked-issue",
|
|
"verdict": {
|
|
"verdict": "pass",
|
|
"linked_issue": True,
|
|
"explanation": "Linked-issue regex matched; LLM was not called.",
|
|
},
|
|
}
|
|
if reconsider:
|
|
# Pass-on-reconsider -> reopen the PR with a friendly comment.
|
|
reopen_body = format_reopen_comment(kind)
|
|
if not close:
|
|
return {
|
|
**base,
|
|
"action": "would-reopen",
|
|
"comment": reopen_body,
|
|
}
|
|
post_comment(repo, number, reopen_body)
|
|
reopen_pr(repo, number)
|
|
return {
|
|
**base,
|
|
"action": "reopened",
|
|
"comment": reopen_body,
|
|
}
|
|
return base
|
|
prompt = build_pr_prompt(title=title, body=body)
|
|
else:
|
|
prompt = build_issue_prompt(title=title, body=body)
|
|
|
|
if print_prompt:
|
|
return {**base_result, "action": "print-prompt", "prompt": prompt}
|
|
|
|
if judge is None:
|
|
api_key = os.environ.get("OPENAI_API_KEY")
|
|
if not api_key:
|
|
# No key configured — never take a destructive action. Report skip.
|
|
return {
|
|
**base_result,
|
|
"action": "skip-no-llm-key",
|
|
"prompt_preview": prompt[:200],
|
|
}
|
|
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:
|
|
raw = judge(prompt)
|
|
verdict = parse_verdict(raw)
|
|
except Exception as exc: # noqa: BLE001 - judge errors must never close PRs
|
|
return {**base_result, "action": "skip-llm-error", "error": str(exc)}
|
|
|
|
decision = (verdict.get("verdict") or "").lower()
|
|
|
|
if reconsider:
|
|
# Reconsider: an explicit `pass` -> reopen + post reopen comment;
|
|
# anything else (fail, missing/malformed verdict, typo) -> leave
|
|
# closed + post a "still failing" comment so the contributor can
|
|
# iterate again. Reopen is destructive, so a flaky/empty verdict
|
|
# must not satisfy the gate.
|
|
# In dry-run (`close=False`) we return `would-*` actions instead
|
|
# of touching GitHub state, mirroring the regular triage flow's
|
|
# `would-close`. This lets a local operator preview the outcome
|
|
# of `python triage_with_llm.py --reconsider --pr N` without
|
|
# risking accidental comments or reopens.
|
|
if decision == "pass":
|
|
reopen_body = format_reopen_comment(kind)
|
|
if not close:
|
|
return {
|
|
**base_result,
|
|
"action": "would-reopen",
|
|
"verdict": verdict,
|
|
"comment": reopen_body,
|
|
}
|
|
post_comment(repo, number, reopen_body)
|
|
if kind == "pr":
|
|
reopen_pr(repo, number)
|
|
else:
|
|
reopen_issue(repo, number)
|
|
return {
|
|
**base_result,
|
|
"action": "reopened",
|
|
"verdict": verdict,
|
|
"comment": reopen_body,
|
|
}
|
|
still_failing = format_reconsider_still_failing_comment(kind, verdict)
|
|
if not close:
|
|
return {
|
|
**base_result,
|
|
"action": "would-reconsider-still-failing",
|
|
"verdict": verdict,
|
|
"comment": still_failing,
|
|
}
|
|
post_comment(repo, number, still_failing)
|
|
return {
|
|
**base_result,
|
|
"action": "reconsider-still-failing",
|
|
"verdict": verdict,
|
|
"comment": still_failing,
|
|
}
|
|
|
|
if decision != "fail":
|
|
return {**base_result, "action": "pass-llm", "verdict": verdict}
|
|
|
|
# Grace-period flow: on the first low-quality detection, post a warning
|
|
# comment instead of closing immediately. On a subsequent triage run
|
|
# (manual re-trigger, or the daily `close_low_quality_prs.py` cron
|
|
# finding the same PR in its own pass), if `GRACE_PERIOD_SECONDS` has
|
|
# elapsed since the warning AND the PR still fails the rubric, close.
|
|
grace_age = seconds_since_last_grace_warning(repo, number)
|
|
if grace_age is None:
|
|
warning_body = (
|
|
format_grace_warning_pr_comment(verdict)
|
|
if kind == "pr"
|
|
else format_grace_warning_issue_comment(verdict)
|
|
)
|
|
if not close:
|
|
return {
|
|
**base_result,
|
|
"action": "would-warn-grace",
|
|
"verdict": verdict,
|
|
"comment": warning_body,
|
|
}
|
|
post_comment(repo, number, warning_body)
|
|
return {
|
|
**base_result,
|
|
"action": "warned-grace",
|
|
"verdict": verdict,
|
|
"comment": warning_body,
|
|
}
|
|
if grace_age < GRACE_PERIOD_SECONDS:
|
|
return {
|
|
**base_result,
|
|
"action": "skip-in-grace-period",
|
|
"verdict": verdict,
|
|
"grace_age_seconds": grace_age,
|
|
"grace_period_seconds": GRACE_PERIOD_SECONDS,
|
|
}
|
|
|
|
# The grace window has elapsed. `--close` still gates the destructive
|
|
# write so a dry-run preview never posts or closes — the workflow only
|
|
# passes `--close` when `AGENT_SHIN_ENABLED=true`, which keeps the bot
|
|
# inert by default.
|
|
if not close:
|
|
return {**base_result, "action": "would-close", "verdict": verdict}
|
|
|
|
comment_body = (
|
|
format_pr_close_comment(verdict)
|
|
if kind == "pr"
|
|
else format_issue_close_comment(verdict)
|
|
)
|
|
post_comment(repo, number, comment_body)
|
|
if kind == "pr":
|
|
close_pr(repo, number)
|
|
else:
|
|
close_issue(repo, number)
|
|
|
|
return {
|
|
**base_result,
|
|
"action": "closed",
|
|
"verdict": verdict,
|
|
"comment": comment_body,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI
|
|
|
|
|
|
def render_summary(result: dict) -> str:
|
|
"""Render a human-readable summary block (used for stdout + step summary)."""
|
|
lines = ["## Agent Shin verdict", ""]
|
|
lines.append(
|
|
f"- **{result['kind'].upper()} #{result['number']}**: {result.get('title', '')}"
|
|
)
|
|
lines.append(
|
|
f"- **Author**: `{result.get('author', '')}` ({result.get('author_association', '')})"
|
|
)
|
|
lines.append(f"- **State**: {result.get('state', '')}")
|
|
lines.append(f"- **Action**: `{result['action']}`")
|
|
verdict = result.get("verdict")
|
|
if verdict:
|
|
lines.append("")
|
|
lines.append("```json")
|
|
lines.append(json.dumps(verdict, indent=2))
|
|
lines.append("```")
|
|
error = result.get("error")
|
|
if error:
|
|
lines.append("")
|
|
lines.append(f"_LLM error: {error}_")
|
|
comment = result.get("comment")
|
|
if comment:
|
|
lines.append("")
|
|
lines.append("### Posted comment:")
|
|
lines.append("")
|
|
lines.append("> " + comment.replace("\n", "\n> "))
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--repo", required=True, help="Repository (owner/repo).")
|
|
target = parser.add_mutually_exclusive_group(required=True)
|
|
target.add_argument("--pr", type=int, help="Pull request number to triage.")
|
|
target.add_argument("--issue", type=int, help="Issue number to triage.")
|
|
parser.add_argument(
|
|
"--close",
|
|
action="store_true",
|
|
help="Actually post comment + close on fail (default: dry run).",
|
|
)
|
|
parser.add_argument(
|
|
"--model",
|
|
# `os.environ.get("TRIAGE_MODEL", DEFAULT_MODEL)` would return "" when
|
|
# GitHub Actions exposes an unset repo variable as an empty-string env
|
|
# var, silently bypassing DEFAULT_MODEL and causing every call to fail
|
|
# as `skip-llm-error`. The `or` guard collapses empty -> default.
|
|
default=os.environ.get("TRIAGE_MODEL") or DEFAULT_MODEL,
|
|
help=f"OpenAI-compatible model name (default: {DEFAULT_MODEL}).",
|
|
)
|
|
parser.add_argument(
|
|
"--print-prompt",
|
|
action="store_true",
|
|
help="Print the prompt that would be sent to the judge and exit.",
|
|
)
|
|
parser.add_argument(
|
|
"--reconsider",
|
|
action="store_true",
|
|
help=(
|
|
"Re-run triage on a CLOSED PR/issue and reopen it on pass. "
|
|
"Used by the `@agent-shin reconsider` comment-trigger workflow. "
|
|
"Only invoke this from a workflow that has already gated on "
|
|
"AGENT_SHIN_ENABLED=true and verified the commenter is the "
|
|
"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
|
|
|
|
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"])
|
|
return 0
|
|
|
|
summary = render_summary(result)
|
|
print(summary)
|
|
write_step_summary(summary + "\n")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|