litellm/tests/test_litellm/test_github_triage_with_llm.py
Mateo Wang 669ddc12c7
feat(agent-shin): automated PR/issue triage, low-quality auto-close, and review-gate label lifecycle (#30433)
* 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>
2026-06-17 20:42:27 -07:00

2073 lines
81 KiB
Python

"""Unit tests for `.github/scripts/triage_with_llm.py` (Agent Shin)."""
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
import pytest
SCRIPT_PATH = (
Path(__file__).resolve().parents[2] / ".github" / "scripts" / "triage_with_llm.py"
)
@pytest.fixture(scope="module")
def triage_module():
spec = importlib.util.spec_from_file_location("triage_with_llm", SCRIPT_PATH)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
sys.modules["triage_with_llm"] = module
spec.loader.exec_module(module)
return module
class TestIsInternalContributor:
@pytest.mark.parametrize("association", ["OWNER", "MEMBER", "COLLABORATOR"])
def test_should_mark_org_associations_as_internal(self, triage_module, association):
item = {
"author_association": association,
"user": {"login": "krrishdholakia"},
}
assert triage_module.is_internal_contributor(item) is True
@pytest.mark.parametrize(
"association",
["CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR", "FIRST_TIMER", "NONE"],
)
def test_should_mark_outside_associations_as_external(
self, triage_module, association
):
item = {
"author_association": association,
"user": {"login": "random-oss-dev"},
}
assert triage_module.is_internal_contributor(item) is False
@pytest.mark.parametrize(
"item",
[
{"author_association": "", "user": {"login": "random-oss-dev"}},
{"user": {"login": "random-oss-dev"}}, # association field absent
],
)
def test_should_fail_safe_when_author_association_is_missing(
self, triage_module, item
):
# Fail-safe: an empty/missing association must never make a PR
# eligible for the destructive close path. Treat as internal (skip).
assert triage_module.is_internal_contributor(item) is True
@pytest.mark.parametrize(
"login",
["dependabot[bot]", "greptile-apps[bot]", "dependabot", "github-actions"],
)
def test_should_skip_bot_accounts_regardless_of_association(
self, triage_module, login
):
item = {"author_association": "NONE", "user": {"login": login}}
assert triage_module.is_internal_contributor(item) is True
class TestHasLinkedIssue:
@pytest.mark.parametrize(
"body",
[
"Fixes #1234",
"closes #1",
"Resolves #99",
"fix #42 — this addresses the regression",
"Closes https://github.com/BerriAI/litellm/issues/27000",
"Resolved https://github.com/BerriAI/litellm/issues/27001",
],
)
def test_should_detect_common_link_phrases(self, triage_module, body):
assert triage_module.has_linked_issue(body) is True
@pytest.mark.parametrize(
"body",
[
"",
"Some change",
# Casual mentions must NOT auto-pass — they should fall through to
# the LLM judge so the stricter "not a passing mention" rule fires.
"See #1234",
"see #1234 for context",
"ref #1234",
"Refs https://github.com/BerriAI/litellm/issues/27000",
"this addresses #1234",
],
)
def test_should_not_auto_pass_casual_mentions(self, triage_module, body):
assert triage_module.has_linked_issue(body) is False
def test_should_not_detect_when_only_html_comment_template(self, triage_module):
body = "<!-- e.g. Fixes #1234 -->"
assert triage_module.has_linked_issue(body) is False
class TestStripHtmlComments:
def test_should_remove_single_line_comments(self, triage_module):
text = "before <!-- placeholder --> after"
assert "placeholder" not in triage_module.strip_html_comments(text)
def test_should_remove_multiline_comments(self, triage_module):
text = "kept\n<!--\nlots of placeholder text\nFixes #1\n-->\nkept2"
cleaned = triage_module.strip_html_comments(text)
assert "Fixes #1" not in cleaned
assert "kept" in cleaned and "kept2" in cleaned
def test_should_handle_none(self, triage_module):
assert triage_module.strip_html_comments(None) == ""
class TestCloseCommentText:
"""Pin the user-facing language in close comments so changes are intentional."""
def test_pr_close_comment_should_recommend_new_pr_primarily(self, triage_module):
body = triage_module.format_pr_close_comment(
{"verdict": "fail", "missing": ["QA proof"], "explanation": "thin"}
)
# Primary path: open a new PR (because OSS authors can't reopen a
# bot-closed PR). Secondary path: `@agent-shin reconsider`.
assert "Open a new PR" in body
assert "@agent-shin reconsider" in body
# Old advice that no longer works for OSS contributors must NOT
# appear (they can't reopen a PR closed by a bot/maintainer).
assert "Reopen the PR" not in body
def test_reopen_comment_should_carry_reconsider_marker(self, triage_module):
# The marker is what the rate-limit guard greps for to detect a
# prior reconsider verdict on the same PR. If the marker ever
# gets dropped from this comment, the cooldown silently breaks
# and a contributor can spam `@agent-shin reconsider` to burn
# LLM budget.
body = triage_module.format_reopen_comment("pr")
assert triage_module.RECONSIDER_COMMENT_MARKER in body
def test_still_failing_comment_should_carry_reconsider_marker(self, triage_module):
body = triage_module.format_reconsider_still_failing_comment(
"pr",
{"verdict": "fail", "missing": ["QA proof"], "explanation": "thin"},
)
assert triage_module.RECONSIDER_COMMENT_MARKER in body
def test_pr_close_comment_should_not_promise_automatic_reopen_on_open(
self, triage_module
):
# The previous comment said "I'll re-evaluate automatically" — that
# only worked because the author could reopen, which they often
# can't. The new wording must point them at the comment trigger or
# a new PR instead.
body = triage_module.format_pr_close_comment(
{"verdict": "fail", "missing": [], "explanation": ""}
)
assert "I'll re-evaluate automatically" not in body
def test_issue_close_comment_should_use_reconsider_trigger(self, triage_module):
# OSS authors have read access, which only lets them reopen issues
# they closed themselves; they CANNOT reopen an issue a maintainer or
# bot closed. So the recovery path is `@agent-shin reconsider` (the
# bot reopens), exactly like the PR path. If this regresses to "reopen
# it yourself", contributors hit a dead end on bot-closed issues.
body = triage_module.format_issue_close_comment(
{"verdict": "fail", "missing": ["repro"], "explanation": "thin"}
)
assert "@agent-shin reconsider" in body
def test_pr_close_comment_should_link_blog_explainer(self, triage_module):
# The blog post is the canonical public explanation of what the bot
# checks and why. Every action-required bot comment must link to it
# so contributors landing on a bot-closed PR can self-serve context
# without pinging a maintainer.
body = triage_module.format_pr_close_comment(
{"verdict": "fail", "missing": [], "explanation": ""}
)
assert "https://docs.litellm.ai/blog/agent-shin-triage" in body
def test_issue_close_comment_should_link_blog_explainer(self, triage_module):
body = triage_module.format_issue_close_comment(
{"verdict": "fail", "missing": [], "explanation": ""}
)
assert "https://docs.litellm.ai/blog/agent-shin-triage" in body
def test_pr_close_comment_should_flag_mocked_tests_as_insufficient_proof(
self, triage_module
):
# The PR rubric was tightened to require end-to-end QA proof and
# explicitly exclude mocked-dependency unit tests. The user-facing
# close comment must say so — otherwise contributors will keep
# re-submitting "pytest passed (mocks)" runs and getting closed
# again with no explanation of why.
body = triage_module.format_pr_close_comment(
{"verdict": "fail", "missing": [], "explanation": ""}
)
assert "end-to-end qa proof" in body.lower()
assert "mock" in body.lower()
def test_all_agent_shin_comments_should_use_bullet_train_emoji(self, triage_module):
# The bullet train (🚅) is Agent Shin's symbol, matching the LiteLLM
# logo; the previous wave (👋) was generic and didn't match the bot's
# identity. Every action-required comment the bot can post must use the
# bullet train so the contributor recognizes who's writing without
# reading the signoff.
verdict = {"verdict": "fail", "missing": [], "explanation": ""}
comments = {
"pr_close": triage_module.format_pr_close_comment(verdict),
"issue_close": triage_module.format_issue_close_comment(verdict),
"pr_grace": triage_module.format_grace_warning_pr_comment(verdict),
"issue_grace": triage_module.format_grace_warning_issue_comment(verdict),
"within_grace": triage_module.format_within_grace_comment(
[], "", grace_days=1
),
}
for name, body in comments.items():
assert "🚅" in body, f"{name} comment is missing the bullet train emoji"
assert "👋" not in body, f"{name} comment still uses the old wave emoji"
def test_pr_close_comment_should_show_what_pr_got_right(self, triage_module):
# The user explicitly asked for a "things you got right" section so
# the comment doesn't read as pure rejection. When the judge confirms
# a field is present (e.g. linked_issue), the bullet for it MUST
# appear in the close comment.
body = triage_module.format_pr_close_comment(
{
"verdict": "fail",
"linked_issue": True,
"has_problem_description": True,
"has_expected_vs_actual": False,
"has_qa_proof": False,
"missing": ["QA proof"],
"explanation": "no proof",
}
)
assert "What you got right" in body
# The two present fields surface as ✅ bullets; the two absent
# fields do not get a ✅ bullet (the QA-proof rubric block still
# mentions the concept, but only the affirmed fields get checkmarks).
assert "- ✅ Linked a related GitHub issue" in body
assert "- ✅ Clear problem description" in body
assert "- ✅ Expected vs. actual behavior" not in body
assert "- ✅ End-to-end QA proof" not in body
def test_pr_close_comment_should_omit_present_section_when_nothing_present(
self, triage_module
):
# If the judge says nothing is present (every flag False), the
# "what you got right" block is skipped entirely — better to omit
# than to render "What you got right: (nothing)".
body = triage_module.format_pr_close_comment(
{
"verdict": "fail",
"linked_issue": False,
"has_problem_description": False,
"has_expected_vs_actual": False,
"has_qa_proof": False,
"missing": [],
"explanation": "",
}
)
assert "What you got right" not in body
def test_issue_close_comment_should_show_what_issue_got_right(self, triage_module):
# `has_expected_vs_actual` is present, the end-to-end bug evidence is
# not: the "what you got right" block must surface the former and omit
# the latter (no "✅ (nothing)"-style noise for absent items).
body = triage_module.format_issue_close_comment(
{
"verdict": "fail",
"kind": "bug",
"has_repro": False,
"has_expected_vs_actual": True,
"missing": ["end-to-end evidence of the bug"],
"explanation": "no repro shown",
}
)
assert "What you got right" in body
assert "Expected vs. actual behavior" in body
assert "- ✅ End-to-end evidence of the bug" not in body
def test_close_comments_should_use_softer_park_for_later_framing(
self, triage_module
):
# User feedback: the messaging shouldn't feel like punishment. The
# comment must explicitly frame close as a "park this for later," not
# a rejection, and ground that in the queue-hygiene reason.
for body in (
triage_module.format_pr_close_comment(
{"verdict": "fail", "missing": [], "explanation": ""}
),
triage_module.format_issue_close_comment(
{"verdict": "fail", "missing": [], "explanation": ""}
),
):
assert "park this for later" in body
assert (
"not a rejection" in body
or "isn't a rejection" in body
or ("isn't us saying" in body)
)
def test_only_close_comments_carry_the_agent_shin_close_marker(self, triage_module):
# The reconsider reopen guard keys off AGENT_SHIN_CLOSE_MARKER to tell
# an Agent Shin close from a same-identity close by another workflow.
# That only works if the marker is stamped on the close comments and
# NOT on the grace warnings (which don't close anything).
verdict = {"verdict": "fail", "missing": [], "explanation": ""}
marker = triage_module.AGENT_SHIN_CLOSE_MARKER
assert marker in triage_module.format_pr_close_comment(verdict)
assert marker in triage_module.format_issue_close_comment(verdict)
assert marker not in triage_module.format_grace_warning_pr_comment(verdict)
assert marker not in triage_module.format_grace_warning_issue_comment(verdict)
class TestWasClosedByAgentShin:
"""Bot-closed guard: only Agent Shin's own closures are reopen candidates."""
@staticmethod
def _stub_close_event(
triage_module,
monkeypatch,
*,
actor: str | None,
closed_at: object = "now",
):
"""Stub the most recent `closed` event used by the guard.
`actor` is the login that closed the item. `closed_at` defaults
to "now" so the marker comment (stubbed at 42s ago) reads as
recent enough relative to the close; tests can pass a concrete
``datetime`` to simulate older closes (e.g. the stale-marker
regression scenario).
"""
import datetime as real_dt
if closed_at == "now":
closed_at = real_dt.datetime.now(real_dt.timezone.utc)
monkeypatch.setattr(
triage_module,
"fetch_last_close_event",
lambda repo, n: (actor, closed_at),
)
@staticmethod
def _stub_close_marker_present(
triage_module, monkeypatch, *, present: bool, age_seconds: float = 42.0
):
"""Stub the Agent Shin close-comment marker lookup.
`was_closed_by_agent_shin` requires the closing actor AND a
recent Agent Shin close comment; these tests pin the latter so
they exercise the actor half in isolation.
"""
monkeypatch.setattr(
triage_module,
"seconds_since_last_agent_shin_close",
lambda *a, **kw: age_seconds if present else None,
)
def test_should_return_true_when_bot_closed_and_close_comment_present(
self, triage_module, monkeypatch
):
self._stub_close_event(triage_module, monkeypatch, actor="github-actions[bot]")
self._stub_close_marker_present(triage_module, monkeypatch, present=True)
assert triage_module.was_closed_by_agent_shin("o/r", 1) is True
def test_should_return_false_when_bot_closed_but_no_agent_shin_comment(
self, triage_module, monkeypatch
):
# The `github-actions[bot]` identity is shared across workflows. A
# stale/duplicate sweep closing under that identity must NOT let
# @agent-shin reconsider reopen the item: without an Agent Shin close
# comment the guard fails closed.
self._stub_close_event(triage_module, monkeypatch, actor="github-actions[bot]")
self._stub_close_marker_present(triage_module, monkeypatch, present=False)
assert triage_module.was_closed_by_agent_shin("o/r", 1) is False
def test_should_return_false_when_last_close_actor_is_maintainer(
self, triage_module, monkeypatch
):
# A maintainer closed it (e.g. duplicate, security, design). The
# bot must refuse to reopen on @agent-shin reconsider even if an
# earlier Agent Shin close comment is still on the thread.
self._stub_close_event(triage_module, monkeypatch, actor="krrishdholakia")
self._stub_close_marker_present(triage_module, monkeypatch, present=True)
assert triage_module.was_closed_by_agent_shin("o/r", 1) is False
def test_should_fail_closed_when_no_close_event(self, triage_module, monkeypatch):
# If the events API returns nothing (network blip, repo permission
# quirk), the guard must fail-closed: refuse to reopen rather than
# assume the bot did it.
self._stub_close_event(triage_module, monkeypatch, actor=None, closed_at=None)
self._stub_close_marker_present(triage_module, monkeypatch, present=True)
assert triage_module.was_closed_by_agent_shin("o/r", 1) is False
def test_should_fail_closed_when_close_event_has_no_timestamp(
self, triage_module, monkeypatch
):
# Without a usable close timestamp the guard cannot prove the
# marker comment belongs to the latest close; fail-closed.
self._stub_close_event(
triage_module, monkeypatch, actor="github-actions[bot]", closed_at=None
)
self._stub_close_marker_present(triage_module, monkeypatch, present=True)
assert triage_module.was_closed_by_agent_shin("o/r", 1) is False
def test_should_return_false_when_marker_predates_latest_close(
self, triage_module, monkeypatch
):
# Regression for the stale-marker bug: Agent Shin closed once
# (marker stamped), reconsider reopened, and a different workflow
# later closed under the same bot identity without stamping the
# marker. The old marker is still on the thread but does NOT
# belong to the latest close, so reconsider must not reopen.
import datetime as real_dt
now = real_dt.datetime.now(real_dt.timezone.utc)
# Latest close happened a minute ago.
self._stub_close_event(
triage_module,
monkeypatch,
actor="github-actions[bot]",
closed_at=now - real_dt.timedelta(seconds=60),
)
# The most recent Agent Shin marker is from an hour ago (a prior
# closed/reopened cycle), which is well outside the skew window.
self._stub_close_marker_present(
triage_module, monkeypatch, present=True, age_seconds=3600.0
)
assert triage_module.was_closed_by_agent_shin("o/r", 1) is False
def test_should_respect_bot_login_override_via_env(
self, triage_module, monkeypatch
):
# Operators wiring Agent Shin to a PAT (instead of GITHUB_TOKEN)
# can override the expected bot login via env. The guard must
# respect the override so non-default deployments still work.
monkeypatch.setenv("AGENT_SHIN_BOT_LOGIN", "my-bot")
self._stub_close_marker_present(triage_module, monkeypatch, present=True)
self._stub_close_event(triage_module, monkeypatch, actor="my-bot")
assert triage_module.was_closed_by_agent_shin("o/r", 1) is True
# Default "github-actions[bot]" should NOT match when env is set.
self._stub_close_event(triage_module, monkeypatch, actor="github-actions[bot]")
assert triage_module.was_closed_by_agent_shin("o/r", 1) is False
class TestSecondsSinceLastAgentShinClose:
"""Close-provenance lookup: detects the bot's own auto-close marker."""
def _make_comment(self, *, login: str, body: str) -> dict:
return {
"user": {"login": login},
"body": body,
"created_at": "2026-05-18T05:00:00Z",
}
def test_should_return_none_when_bot_never_closed(self, triage_module, monkeypatch):
# Comments exist, but none is an Agent Shin close — e.g. only a grace
# warning, or a close by another workflow with no Agent Shin comment.
comments = [
self._make_comment(login="outside-dev", body="any update?"),
self._make_comment(
login="github-actions[bot]",
body=triage_module.format_grace_warning_pr_comment(
{"verdict": "fail", "missing": [], "explanation": ""}
),
),
]
monkeypatch.setattr(
triage_module, "_iter_paginated_json", lambda *a, **kw: iter(comments)
)
assert triage_module.seconds_since_last_agent_shin_close("o/r", 1) is None
def test_should_detect_bot_close_comment(self, triage_module, monkeypatch):
comments = [
self._make_comment(
login="github-actions[bot]",
body=triage_module.format_pr_close_comment(
{"verdict": "fail", "missing": [], "explanation": ""}
),
),
]
monkeypatch.setattr(
triage_module, "_iter_paginated_json", lambda *a, **kw: iter(comments)
)
assert triage_module.seconds_since_last_agent_shin_close("o/r", 1) is not None
def test_should_ignore_non_bot_comment_quoting_marker(
self, triage_module, monkeypatch
):
# A contributor quoting the hidden marker (GitHub "Quote reply"
# preserves HTML comments) must not be mistaken for a bot close.
comments = [
self._make_comment(
login="curious-user",
body=f"what is this? {triage_module.AGENT_SHIN_CLOSE_MARKER}",
),
]
monkeypatch.setattr(
triage_module, "_iter_paginated_json", lambda *a, **kw: iter(comments)
)
assert triage_module.seconds_since_last_agent_shin_close("o/r", 1) is None
class TestSecondsSinceLastReconsiderVerdict:
"""Rate-limit guard: detects the bot's own reconsider verdict marker."""
def _make_comment(
self, *, login: str, body: str, created_at: str | None = "2026-05-18T05:00:00Z"
) -> dict:
comment: dict = {"user": {"login": login}, "body": body}
if created_at is not None:
comment["created_at"] = created_at
return comment
def test_should_return_none_when_no_bot_reconsider_comments(
self, triage_module, monkeypatch
):
# An issue with chatter from other users but no bot reconsider
# verdict must not be rate-limited.
comments = [
self._make_comment(login="outside-dev", body="ping?"),
self._make_comment(
login="github-actions[bot]", body="some other bot message"
),
]
monkeypatch.setattr(
triage_module, "_iter_paginated_json", lambda *a, **kw: iter(comments)
)
assert triage_module.seconds_since_last_reconsider_verdict("o/r", 1) is None
def test_should_pick_latest_bot_reconsider_marker(self, triage_module, monkeypatch):
# When multiple reconsider verdicts exist, return the AGE of the
# most recent one. Using a frozen reference helps pin the math.
comments = [
self._make_comment(
login="github-actions[bot]",
body="old verdict " + triage_module.RECONSIDER_COMMENT_MARKER,
created_at="2026-05-18T04:00:00Z",
),
self._make_comment(
login="github-actions[bot]",
body="newer verdict " + triage_module.RECONSIDER_COMMENT_MARKER,
created_at="2026-05-18T04:55:00Z",
),
]
monkeypatch.setattr(
triage_module, "_iter_paginated_json", lambda *a, **kw: iter(comments)
)
# Freeze "now" via a tiny shim on the module's `dt` import.
import datetime as real_dt
class FrozenDateTime(real_dt.datetime):
@classmethod
def now(cls, tz=None):
return real_dt.datetime(2026, 5, 18, 5, 0, 0, tzinfo=tz)
frozen_module = type(triage_module.dt)("datetime")
frozen_module.datetime = FrozenDateTime
frozen_module.timezone = real_dt.timezone
monkeypatch.setattr(triage_module, "dt", frozen_module)
age = triage_module.seconds_since_last_reconsider_verdict("o/r", 1)
# newer verdict is 5 minutes (300 seconds) before "now"
assert age == 300.0
def test_should_ignore_non_bot_comments_with_marker(
self, triage_module, monkeypatch
):
# A user comment that happens to quote the marker (e.g. in
# a "what does this hidden marker do?" question) must NOT count.
# The rate-limit guard only trusts comments authored by the bot.
comments = [
self._make_comment(
login="curious-user",
body=f"Saw this marker: {triage_module.RECONSIDER_COMMENT_MARKER}",
),
]
monkeypatch.setattr(
triage_module, "_iter_paginated_json", lambda *a, **kw: iter(comments)
)
assert triage_module.seconds_since_last_reconsider_verdict("o/r", 1) is None
def test_should_ignore_bot_comments_without_marker(
self, triage_module, monkeypatch
):
# The bot posts other things too (Agent Shin close comments,
# CI status, etc.) — only the reconsider-verdict marker should
# arm the cooldown.
comments = [
self._make_comment(
login="github-actions[bot]",
body="Agent Shin closed this PR (no marker)",
),
]
monkeypatch.setattr(
triage_module, "_iter_paginated_json", lambda *a, **kw: iter(comments)
)
assert triage_module.seconds_since_last_reconsider_verdict("o/r", 1) is None
class TestParseVerdict:
def test_should_parse_plain_json(self, triage_module):
raw = '{"verdict": "pass", "missing": []}'
assert triage_module.parse_verdict(raw)["verdict"] == "pass"
def test_should_strip_markdown_fence(self, triage_module):
raw = '```json\n{"verdict": "fail", "missing": ["foo"]}\n```'
result = triage_module.parse_verdict(raw)
assert result["verdict"] == "fail"
assert result["missing"] == ["foo"]
def test_should_extract_embedded_json_from_prose(self, triage_module):
raw = 'Here you go: {"verdict": "pass", "missing": []}\nThanks.'
assert triage_module.parse_verdict(raw)["verdict"] == "pass"
def test_should_raise_for_unparseable_text(self, triage_module):
with pytest.raises(ValueError):
triage_module.parse_verdict("not even close to json")
def test_should_raise_for_empty(self, triage_module):
with pytest.raises(ValueError):
triage_module.parse_verdict("")
class TestBuildPrompts:
def test_should_include_pr_title_and_body(self, triage_module):
prompt = triage_module.build_pr_prompt(
title="Add foo", body="<!-- comment --> Real body"
)
assert "Add foo" in prompt
assert "Real body" in prompt
assert "comment" not in prompt # HTML comments are stripped
def test_should_show_empty_marker_for_empty_pr_body(self, triage_module):
prompt = triage_module.build_pr_prompt(title="t", body="<!-- nothing -->")
assert "(empty)" in prompt
def test_should_include_issue_title_and_body(self, triage_module):
prompt = triage_module.build_issue_prompt(title="Bug", body="repro here")
assert "Bug" in prompt
assert "repro here" in prompt
def test_issue_bug_rubric_requires_end_to_end_evidence_and_drops_pass_bias(
self, triage_module
):
# The bug bar was tightened: a report needs the "before" half shown
# end-to-end (video / screenshot / real command output), prose-only
# repro steps no longer pass, and the old "bias toward PASS" leniency
# is gone. If any of these regress, the judge silently goes soft on
# undemonstrated bug reports again.
prompt = triage_module.build_issue_prompt(title="t", body="x")
normalized = " ".join(prompt.split())
assert "Bias toward PASS when the issue has structure" not in normalized
assert "END-TO-END EVIDENCE OF THE BUG" in normalized
assert "Do not bias toward PASS" in normalized
# The three accepted forms of the "before" demonstration must be named.
assert "screen recording / video" in normalized
assert "screenshot of the bug" in normalized
assert "mocked or stubbed" in normalized
# Prose-only steps are explicitly insufficient now.
assert "steps to reproduce" in normalized
def test_should_not_crash_when_pr_body_contains_curly_braces(self, triage_module):
"""User-supplied content with `{` / `}` must NOT be re-parsed by
`str.format()`. `format` only scans the template literal for
replacement fields; values being substituted in are inserted as
plain strings, so a body like `{"foo": "bar"}` or `{unmatched`
cannot blow up the script. Pinning this here so a future
"improvement" to the templating doesn't reintroduce a crash on
every PR that quotes JSON.
"""
for body in (
'Here is some JSON: {"foo": "bar", "n": 1}',
"Half a brace { left dangling, and a stray }",
"Format-spec-looking thing: {0}, {name:>10}, {!r}",
"Nested {a: {b: c}} braces",
):
pr_prompt = triage_module.build_pr_prompt(title="t", body=body)
issue_prompt = triage_module.build_issue_prompt(title="t", body=body)
assert body in pr_prompt
assert body in issue_prompt
def test_should_not_crash_when_pr_title_contains_curly_braces(self, triage_module):
title = "Fix bug in {0:>10} format-spec handling"
pr_prompt = triage_module.build_pr_prompt(title=title, body="x")
issue_prompt = triage_module.build_issue_prompt(title=title, body="x")
assert title in pr_prompt
assert title in issue_prompt
def test_should_preserve_template_indentation_with_multiline_body(
self, triage_module
):
"""`textwrap.dedent` runs on the static template *before* user
content is interpolated, so a multi-line body (whose 2nd+ lines
start at column 0) cannot defeat the common-indent computation
and leave 8-space indentation on every template line. Pin the
dedented shape so the rendered prompt stays consistent for the
LLM judge.
"""
body = "first line\nsecond line at column 0\nthird line at column 0"
for builder in (
triage_module.build_pr_prompt,
triage_module.build_issue_prompt,
):
prompt = builder(title="t", body=body)
# Template lines should NOT carry the 8 leading spaces from
# the source-file indentation of the triple-quoted string.
assert " You are " not in prompt
assert 'You are "Agent Shin"' in prompt
assert body in prompt
class TestMainModelDefault:
"""`--model` falls back to DEFAULT_MODEL even when TRIAGE_MODEL is empty."""
def _stub_triage(self, triage_module, monkeypatch):
captured: dict = {}
def fake_triage(**kwargs):
captured.update(kwargs)
return {
"kind": kwargs["kind"],
"number": kwargs["number"],
"title": "",
"author": "x",
"author_association": "NONE",
"state": "open",
"action": "skip-no-llm-key",
}
monkeypatch.setattr(triage_module, "triage", fake_triage)
return captured
def test_should_fall_back_to_default_when_triage_model_env_empty(
self, triage_module, monkeypatch
):
captured = self._stub_triage(triage_module, monkeypatch)
monkeypatch.setenv("TRIAGE_MODEL", "")
monkeypatch.setattr(
sys,
"argv",
["triage_with_llm.py", "--repo", "o/r", "--pr", "1"],
)
rc = triage_module.main()
assert rc == 0
assert captured["model"] == triage_module.DEFAULT_MODEL
def test_should_respect_explicit_triage_model_env(self, triage_module, monkeypatch):
captured = self._stub_triage(triage_module, monkeypatch)
monkeypatch.setenv("TRIAGE_MODEL", "gpt-4o-mini")
monkeypatch.setattr(
sys,
"argv",
["triage_with_llm.py", "--repo", "o/r", "--pr", "1"],
)
rc = triage_module.main()
assert rc == 0
assert captured["model"] == "gpt-4o-mini"
class TestCallLlmJudge:
"""call_llm_judge sets gpt-5 specific kwargs correctly."""
def _stub_openai(self, monkeypatch, captured: dict):
"""Install a fake `openai.OpenAI` client into sys.modules.
The fake client records the kwargs passed to chat.completions.create
and returns a minimal response object whose .choices[0].message.content
is "ok".
"""
import types
class FakeMessage:
content = '{"verdict": "pass"}'
class FakeChoice:
message = FakeMessage()
class FakeResponse:
choices = [FakeChoice()]
class FakeCompletions:
def create(self, **kwargs):
captured.update(kwargs)
return FakeResponse()
class FakeChat:
completions = FakeCompletions()
class FakeClient:
def __init__(self, api_key, base_url=None):
captured["__client_kwargs__"] = {
"api_key": api_key,
"base_url": base_url,
}
self.chat = FakeChat()
fake_module = types.ModuleType("openai")
fake_module.OpenAI = FakeClient
monkeypatch.setitem(sys.modules, "openai", fake_module)
def test_should_set_reasoning_effort_none_for_gpt5_family(
self, triage_module, monkeypatch
):
captured: dict = {}
self._stub_openai(monkeypatch, captured)
triage_module.call_llm_judge(
"prompt", model="gpt-5.4-mini", api_key="sk-test", base_url=None
)
assert captured["model"] == "gpt-5.4-mini"
assert captured["temperature"] == 0
assert captured["extra_body"] == {"reasoning_effort": "none"}
def test_should_set_reasoning_effort_for_capitalized_or_dated_gpt5(
self, triage_module, monkeypatch
):
for model in ("GPT-5.4-mini", "gpt-5.4-mini-2026-03-17", "gpt-5"):
captured: dict = {}
self._stub_openai(monkeypatch, captured)
triage_module.call_llm_judge(
"prompt", model=model, api_key="sk-test", base_url=None
)
assert captured["extra_body"] == {"reasoning_effort": "none"}, model
def test_should_omit_reasoning_effort_for_non_gpt5(
self, triage_module, monkeypatch
):
captured: dict = {}
self._stub_openai(monkeypatch, captured)
triage_module.call_llm_judge(
"prompt", model="gpt-4o-mini", api_key="sk-test", base_url=None
)
assert "extra_body" not in captured
def test_should_pass_base_url_when_provided(self, triage_module, monkeypatch):
captured: dict = {}
self._stub_openai(monkeypatch, captured)
triage_module.call_llm_judge(
"p",
model="gpt-5.4-mini",
api_key="sk-test",
base_url="https://proxy.example.com/v1",
)
assert (
captured["__client_kwargs__"]["base_url"] == "https://proxy.example.com/v1"
)
class TestTriageOrchestration:
"""End-to-end-ish tests that mock both gh fetchers and the LLM."""
def _make_pr(self, **overrides):
base = {
"number": 1,
"title": "PR title",
"body": "PR body",
"state": "open",
"author_association": "NONE",
"user": {"login": "mateo-berri"},
}
base.update(overrides)
return base
def test_should_skip_internal_author(self, triage_module, monkeypatch):
pr = self._make_pr(
author_association="MEMBER", user={"login": "krrishdholakia"}
)
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
def boom(*a, **kw):
pytest.fail("LLM should not be called for internal authors")
result = triage_module.triage(
repo="o/r",
kind="pr",
number=1,
close=True,
model="m",
judge=boom,
allowlist=frozenset(),
)
assert result["action"] == "skip-internal-author"
def test_should_skip_closed_pr(self, triage_module, monkeypatch):
pr = self._make_pr(state="closed")
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
result = triage_module.triage(
repo="o/r",
kind="pr",
number=1,
close=True,
model="m",
judge=lambda p: pytest.fail("should not run on closed PRs"),
)
assert result["action"] == "skip-not-open"
def test_should_short_circuit_on_linked_issue(self, triage_module, monkeypatch):
pr = self._make_pr(body="Fixes #1234\n\nFoo bar")
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
result = triage_module.triage(
repo="o/r",
kind="pr",
number=1,
close=True,
model="m",
judge=lambda p: pytest.fail("LLM should not be called"),
)
assert result["action"] == "pass-linked-issue"
assert result["verdict"]["verdict"] == "pass"
def test_should_not_short_circuit_on_casual_mention(
self, triage_module, monkeypatch
):
# "See #1234" is a passing mention, not a closing keyword. The LLM
# must get a chance to apply the stricter rubric. With no prior
# grace warning, the first failing verdict triggers the warning
# path (`would-warn-grace` in dry-run).
pr = self._make_pr(body="See #1234 for context. No QA proof here.")
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
self._stub_grace_no_warning(triage_module, monkeypatch)
called = {"judge": False}
def judge(prompt):
called["judge"] = True
return json.dumps(
{"verdict": "fail", "missing": ["QA proof"], "explanation": "thin."}
)
result = triage_module.triage(
repo="o/r",
kind="pr",
number=1,
close=False,
model="m",
judge=judge,
)
assert called["judge"] is True
assert result["action"] == "would-warn-grace"
def test_should_return_pass_llm_when_judge_passes(self, triage_module, monkeypatch):
pr = self._make_pr(body="Long body, no linked issue.")
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
captured = {}
def judge(prompt):
captured["prompt"] = prompt
return json.dumps({"verdict": "pass", "missing": [], "explanation": "ok"})
result = triage_module.triage(
repo="o/r", kind="pr", number=1, close=True, model="m", judge=judge
)
assert result["action"] == "pass-llm"
assert "Long body" in captured["prompt"]
def test_should_return_would_close_in_dry_run_after_grace_aged_out(
self, triage_module, monkeypatch
):
# When the grace warning has already aged out (>= GRACE_PERIOD_SECONDS)
# AND the rubric still fails, the dry-run preview returns
# `would-close` so a step-summary writer can render the close
# comment without touching GitHub state.
pr = self._make_pr(body="just a sentence.")
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
self._stub_grace_aged_out(triage_module, monkeypatch)
def fake_post(*a, **kw):
pytest.fail("should not post comments in dry-run")
def fake_close(*a, **kw):
pytest.fail("should not close in dry-run")
monkeypatch.setattr(triage_module, "post_comment", fake_post)
monkeypatch.setattr(triage_module, "close_pr", fake_close)
verdict = {
"verdict": "fail",
"missing": ["problem description", "QA proof"],
"explanation": "Body is one sentence.",
}
result = triage_module.triage(
repo="o/r",
kind="pr",
number=1,
close=False,
model="m",
judge=lambda p: json.dumps(verdict),
)
assert result["action"] == "would-close"
assert result["verdict"]["missing"] == ["problem description", "QA proof"]
def test_should_post_comment_and_close_after_grace_window(
self, triage_module, monkeypatch
):
# The "real close" path: --close passed AND the grace warning has
# aged out AND the rubric still fails. The bot posts the close
# comment and closes the PR.
pr = self._make_pr(body="just a sentence.")
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
self._stub_grace_aged_out(triage_module, monkeypatch)
posted = {}
closed = {}
monkeypatch.setattr(
triage_module,
"post_comment",
lambda repo, n, body: posted.update({"repo": repo, "n": n, "body": body}),
)
monkeypatch.setattr(
triage_module,
"close_pr",
lambda repo, n: closed.update({"repo": repo, "n": n}),
)
verdict = {
"verdict": "fail",
"missing": ["QA proof"],
"explanation": "Body too thin.",
}
result = triage_module.triage(
repo="o/r",
kind="pr",
number=42,
close=True,
model="m",
judge=lambda p: json.dumps(verdict),
)
assert result["action"] == "closed"
assert posted["n"] == 42 and closed["n"] == 42
assert "Agent Shin" in posted["body"]
assert "QA proof" in posted["body"]
def test_should_skip_on_llm_error_in_close_mode(self, triage_module, monkeypatch):
pr = self._make_pr(body="something.")
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
monkeypatch.setattr(
triage_module,
"post_comment",
lambda *a, **kw: pytest.fail("must not comment on LLM error"),
)
monkeypatch.setattr(
triage_module,
"close_pr",
lambda *a, **kw: pytest.fail("must not close on LLM error"),
)
def broken_judge(prompt):
raise RuntimeError("upstream 500")
result = triage_module.triage(
repo="o/r",
kind="pr",
number=1,
close=True,
model="m",
judge=broken_judge,
)
assert result["action"] == "skip-llm-error"
assert "upstream 500" in result["error"]
def test_should_skip_open_pr_in_reconsider_mode(self, triage_module, monkeypatch):
# Reconsider only makes sense on a CLOSED PR — running it on an open
# one is a no-op (the regular triage flow already evaluated it).
pr = self._make_pr(state="open")
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
result = triage_module.triage(
repo="o/r",
kind="pr",
number=1,
close=False,
model="m",
judge=lambda p: pytest.fail("should not run on open PR in reconsider"),
reconsider=True,
)
assert result["action"] == "skip-not-closed"
@staticmethod
def _stub_reconsider_guards(triage_module, monkeypatch):
"""Default reconsider-guard stubs: pretend bot closed + no cooldown.
The new safety guards (`was_closed_by_agent_shin`,
`seconds_since_last_reconsider_verdict`) hit the GitHub API in
production. Tests that exercise the reconsider happy path stub
them to "yes the bot closed it, no recent reconsider comment"
so the test stays focused on its actual assertion.
"""
monkeypatch.setattr(
triage_module, "was_closed_by_agent_shin", lambda *a, **kw: True
)
monkeypatch.setattr(
triage_module,
"seconds_since_last_reconsider_verdict",
lambda *a, **kw: None,
)
@staticmethod
def _stub_grace_aged_out(triage_module, monkeypatch):
"""Pretend the grace warning has aged out.
For tests that exercise the post-grace close path. Set the age
to twice the grace window so a future tweak to
`GRACE_PERIOD_SECONDS` doesn't accidentally make the stub fall
back inside the window.
"""
monkeypatch.setattr(
triage_module,
"seconds_since_last_grace_warning",
lambda *a, **kw: triage_module.GRACE_PERIOD_SECONDS * 2,
)
@staticmethod
def _stub_grace_no_warning(triage_module, monkeypatch):
"""Pretend no grace warning has been posted yet (first detection)."""
monkeypatch.setattr(
triage_module,
"seconds_since_last_grace_warning",
lambda *a, **kw: None,
)
def test_should_reopen_on_reconsider_pass(self, triage_module, monkeypatch):
# Reconsider on a closed PR with a passing verdict -> reopen + post a
# friendly "re-evaluated" comment. close=True is the production path
# (the workflow only adds --close when AGENT_SHIN_ENABLED=true).
pr = self._make_pr(
state="closed", body="Updated body with QA proof + screenshots."
)
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
self._stub_reconsider_guards(triage_module, monkeypatch)
posted = {}
reopened = {}
monkeypatch.setattr(
triage_module,
"post_comment",
lambda repo, n, body: posted.update({"n": n, "body": body}),
)
monkeypatch.setattr(
triage_module,
"reopen_pr",
lambda repo, n: reopened.update({"n": n}),
)
# close_pr / close_issue MUST NOT fire in reconsider mode.
monkeypatch.setattr(
triage_module,
"close_pr",
lambda *a, **kw: pytest.fail("must not close on reconsider pass"),
)
result = triage_module.triage(
repo="o/r",
kind="pr",
number=42,
close=True,
model="m",
judge=lambda p: json.dumps(
{"verdict": "pass", "missing": [], "explanation": "ok now"}
),
reconsider=True,
)
assert result["action"] == "reopened"
assert reopened["n"] == 42
assert posted["n"] == 42
assert "reopened" in posted["body"].lower()
def test_should_dry_run_reconsider_pass_when_close_false(
self, triage_module, monkeypatch
):
# Reconsider must honor `close=False` (dry-run) just like the
# regular triage flow. A local invocation of
# `python triage_with_llm.py --reconsider --pr N` (no --close)
# must NOT post a comment or reopen the PR — it should return
# `would-reopen` so the operator can preview the outcome.
pr = self._make_pr(
state="closed", body="Updated body with QA proof + screenshots."
)
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
self._stub_reconsider_guards(triage_module, monkeypatch)
monkeypatch.setattr(
triage_module,
"post_comment",
lambda *a, **kw: pytest.fail("must not post comment in dry-run reconsider"),
)
monkeypatch.setattr(
triage_module,
"reopen_pr",
lambda *a, **kw: pytest.fail("must not reopen PR in dry-run reconsider"),
)
result = triage_module.triage(
repo="o/r",
kind="pr",
number=42,
close=False,
model="m",
judge=lambda p: json.dumps(
{"verdict": "pass", "missing": [], "explanation": "ok now"}
),
reconsider=True,
)
assert result["action"] == "would-reopen"
# The previewed comment body is still returned so a step-summary
# writer can render exactly what would have been posted.
assert "reopened" in result["comment"].lower()
def test_should_post_still_failing_on_reconsider_fail(
self, triage_module, monkeypatch
):
pr = self._make_pr(state="closed", body="still empty")
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
self._stub_reconsider_guards(triage_module, monkeypatch)
posted = {}
monkeypatch.setattr(
triage_module,
"post_comment",
lambda repo, n, body: posted.update({"n": n, "body": body}),
)
# Neither reopen nor close should fire when reconsider verdict is fail.
monkeypatch.setattr(
triage_module,
"reopen_pr",
lambda *a, **kw: pytest.fail("must not reopen on fail"),
)
monkeypatch.setattr(
triage_module,
"close_pr",
lambda *a, **kw: pytest.fail("must not close again on reconsider fail"),
)
verdict = {
"verdict": "fail",
"missing": ["QA proof"],
"explanation": "Still no QA proof.",
}
result = triage_module.triage(
repo="o/r",
kind="pr",
number=42,
close=True,
model="m",
judge=lambda p: json.dumps(verdict),
reconsider=True,
)
assert result["action"] == "reconsider-still-failing"
assert posted["n"] == 42
assert "QA proof" in posted["body"]
def test_should_not_reopen_on_reconsider_with_ambiguous_verdict(
self, triage_module, monkeypatch
):
# Regression: only an explicit `pass` verdict reopens. Missing,
# empty, or unexpected verdict strings ("failed", "", garbage)
# must fall through to the still-failing branch rather than
# reopen a PR the rubric did not actually clear.
pr = self._make_pr(state="closed", body="still empty")
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
self._stub_reconsider_guards(triage_module, monkeypatch)
posted = {}
monkeypatch.setattr(
triage_module,
"post_comment",
lambda repo, n, body: posted.update({"body": body}),
)
monkeypatch.setattr(
triage_module,
"reopen_pr",
lambda *a, **kw: pytest.fail("must not reopen on ambiguous verdict"),
)
for ambiguous in ("", "failed", "needs-info", "unknown"):
posted.clear()
result = triage_module.triage(
repo="o/r",
kind="pr",
number=42,
close=True,
model="m",
judge=lambda p, v=ambiguous: json.dumps(
{"verdict": v, "missing": [], "explanation": "weird"}
),
reconsider=True,
)
assert result["action"] == "reconsider-still-failing", ambiguous
assert "body" in posted, ambiguous
def test_should_dry_run_reconsider_fail_when_close_false(
self, triage_module, monkeypatch
):
# Mirror dry-run behavior for the FAIL branch — `close=False`
# must NOT post the "still failing" comment.
pr = self._make_pr(state="closed", body="still empty")
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
self._stub_reconsider_guards(triage_module, monkeypatch)
monkeypatch.setattr(
triage_module,
"post_comment",
lambda *a, **kw: pytest.fail(
"must not post still-failing comment in dry-run"
),
)
verdict = {
"verdict": "fail",
"missing": ["QA proof"],
"explanation": "Still no QA proof.",
}
result = triage_module.triage(
repo="o/r",
kind="pr",
number=42,
close=False,
model="m",
judge=lambda p: json.dumps(verdict),
reconsider=True,
)
assert result["action"] == "would-reconsider-still-failing"
assert "QA proof" in result["comment"]
def test_should_reopen_on_reconsider_with_linked_issue_short_circuit(
self, triage_module, monkeypatch
):
# The linked-issue short-circuit also has to honor reconsider mode:
# if the contributor edited the body to add `Fixes #1234`, the regex
# path should reopen the PR without calling the LLM.
pr = self._make_pr(state="closed", body="Fixes #1234\n\nAddresses the bug.")
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
self._stub_reconsider_guards(triage_module, monkeypatch)
posted = {}
reopened = {}
monkeypatch.setattr(
triage_module,
"post_comment",
lambda repo, n, body: posted.update({"body": body}),
)
monkeypatch.setattr(
triage_module,
"reopen_pr",
lambda repo, n: reopened.update({"n": n}),
)
result = triage_module.triage(
repo="o/r",
kind="pr",
number=55,
close=True,
model="m",
judge=lambda p: pytest.fail("LLM must not run when linked-issue matches"),
reconsider=True,
)
assert result["action"] == "reopened"
assert reopened["n"] == 55
assert "reopened" in posted["body"].lower()
def test_should_dry_run_reconsider_with_linked_issue_when_close_false(
self, triage_module, monkeypatch
):
# Linked-issue short-circuit must ALSO honor dry-run.
pr = self._make_pr(state="closed", body="Fixes #1234\n\nAddresses the bug.")
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
self._stub_reconsider_guards(triage_module, monkeypatch)
monkeypatch.setattr(
triage_module,
"post_comment",
lambda *a, **kw: pytest.fail("must not post in dry-run"),
)
monkeypatch.setattr(
triage_module,
"reopen_pr",
lambda *a, **kw: pytest.fail("must not reopen in dry-run"),
)
result = triage_module.triage(
repo="o/r",
kind="pr",
number=55,
close=False,
model="m",
judge=lambda p: pytest.fail("LLM must not run when linked-issue matches"),
reconsider=True,
)
assert result["action"] == "would-reopen"
def test_should_skip_internal_in_reconsider_mode(self, triage_module, monkeypatch):
# Internal authors are exempt from triage in both regular and
# reconsider mode — Agent Shin should never reopen one of their PRs
# automatically, in case a maintainer closed it intentionally.
pr = self._make_pr(
state="closed",
author_association="MEMBER",
user={"login": "krrishdholakia"},
)
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
monkeypatch.setattr(
triage_module,
"reopen_pr",
lambda *a, **kw: pytest.fail("must not reopen for internal author"),
)
result = triage_module.triage(
repo="o/r",
kind="pr",
number=1,
close=False,
model="m",
judge=lambda p: pytest.fail("LLM must not run for internal author"),
reconsider=True,
allowlist=frozenset(),
)
assert result["action"] == "skip-internal-author"
def test_should_skip_reconsider_when_not_bot_closed(
self, triage_module, monkeypatch
):
# SECURITY: `@agent-shin reconsider` must NOT reopen a PR/issue
# that a MAINTAINER closed for non-rubric reasons (e.g. duplicate,
# design rejection, security report). Only PRs closed by the bot
# itself should ever be candidates for the reconsider reopen path.
pr = self._make_pr(state="closed", body="something.")
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
monkeypatch.setattr(
triage_module, "was_closed_by_agent_shin", lambda *a, **kw: False
)
# Even though there's no rate-limit conflict, the bot-closed guard
# alone is sufficient to block. The LLM judge must never run on a
# maintainer-closed PR.
monkeypatch.setattr(
triage_module,
"seconds_since_last_reconsider_verdict",
lambda *a, **kw: None,
)
monkeypatch.setattr(
triage_module,
"post_comment",
lambda *a, **kw: pytest.fail("must not comment on maintainer-closed PR"),
)
monkeypatch.setattr(
triage_module,
"reopen_pr",
lambda *a, **kw: pytest.fail("must not reopen maintainer-closed PR"),
)
result = triage_module.triage(
repo="o/r",
kind="pr",
number=1,
close=True,
model="m",
judge=lambda p: pytest.fail("LLM must not run before bot-closed guard"),
reconsider=True,
)
assert result["action"] == "skip-not-bot-closed"
def test_should_rate_limit_repeated_reconsider_triggers(
self, triage_module, monkeypatch
):
# COST CONTROL: each `@agent-shin reconsider` event burns CI
# minutes + an OpenAI API call. If the bot already posted a
# reconsider verdict within the cooldown window
# (RECONSIDER_RATE_LIMIT_SECONDS), refuse to run again. This
# bounds the damage from a contributor spamming the trigger.
pr = self._make_pr(state="closed", body="something with new edits.")
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
monkeypatch.setattr(
triage_module, "was_closed_by_agent_shin", lambda *a, **kw: True
)
# Pretend the bot posted a reconsider verdict 1 second ago.
monkeypatch.setattr(
triage_module,
"seconds_since_last_reconsider_verdict",
lambda *a, **kw: 1.0,
)
monkeypatch.setattr(
triage_module,
"post_comment",
lambda *a, **kw: pytest.fail("must not comment during cooldown"),
)
monkeypatch.setattr(
triage_module,
"reopen_pr",
lambda *a, **kw: pytest.fail("must not reopen during cooldown"),
)
result = triage_module.triage(
repo="o/r",
kind="pr",
number=1,
close=True,
model="m",
judge=lambda p: pytest.fail("LLM must not run during cooldown"),
reconsider=True,
)
assert result["action"] == "skip-rate-limited"
assert result["rate_limit_age_seconds"] == 1.0
assert (
result["rate_limit_window_seconds"]
== triage_module.RECONSIDER_RATE_LIMIT_SECONDS
)
def test_should_allow_reconsider_after_cooldown_window(
self, triage_module, monkeypatch
):
# The cooldown is a window, not a one-shot lock — once
# RECONSIDER_RATE_LIMIT_SECONDS has elapsed since the last bot
# verdict, a fresh `@agent-shin reconsider` is allowed through.
pr = self._make_pr(state="closed", body="updated with screenshots now.")
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
monkeypatch.setattr(
triage_module, "was_closed_by_agent_shin", lambda *a, **kw: True
)
# Last reconsider was 1 hour ago — well outside the 10-min window.
monkeypatch.setattr(
triage_module,
"seconds_since_last_reconsider_verdict",
lambda *a, **kw: 3600.0,
)
posted = {}
reopened = {}
monkeypatch.setattr(
triage_module,
"post_comment",
lambda repo, n, body: posted.update({"n": n, "body": body}),
)
monkeypatch.setattr(
triage_module,
"reopen_pr",
lambda repo, n: reopened.update({"n": n}),
)
result = triage_module.triage(
repo="o/r",
kind="pr",
number=1,
close=True,
model="m",
judge=lambda p: json.dumps(
{"verdict": "pass", "missing": [], "explanation": "ok"}
),
reconsider=True,
)
assert result["action"] == "reopened"
assert reopened["n"] == 1
def test_should_reopen_issue_on_reconsider_pass(self, triage_module, monkeypatch):
issue = {
"number": 7,
"title": "Bug: now with repro",
"body": "## Repro\n```bash\ncurl ...\n```\n\nExpected X, got Y.",
"state": "closed",
"author_association": "NONE",
"user": {"login": "mateo-berri"},
}
monkeypatch.setattr(triage_module, "fetch_issue", lambda repo, n: issue)
self._stub_reconsider_guards(triage_module, monkeypatch)
posted = {}
reopened = {}
monkeypatch.setattr(
triage_module,
"post_comment",
lambda repo, n, body: posted.update({"body": body}),
)
monkeypatch.setattr(
triage_module,
"reopen_issue",
lambda repo, n: reopened.update({"n": n}),
)
result = triage_module.triage(
repo="o/r",
kind="issue",
number=7,
close=True,
model="m",
judge=lambda p: json.dumps(
{"verdict": "pass", "missing": [], "explanation": "now reproducible"}
),
reconsider=True,
)
assert result["action"] == "reopened"
assert reopened["n"] == 7
assert "reopened" in posted["body"].lower()
def test_should_triage_issues_kind(self, triage_module, monkeypatch):
issue = {
"number": 7,
"title": "Bug: X is broken",
"body": "no detail",
"state": "open",
"author_association": "NONE",
"user": {"login": "mateo-berri"},
}
monkeypatch.setattr(triage_module, "fetch_issue", lambda repo, n: issue)
# Grace already aged out -> close path. (Issues use the same
# GRACE_COMMENT_MARKER detection as PRs.)
self._stub_grace_aged_out(triage_module, monkeypatch)
closed = {}
posted = {}
monkeypatch.setattr(
triage_module,
"post_comment",
lambda repo, n, body: posted.update(body=body),
)
monkeypatch.setattr(
triage_module, "close_issue", lambda repo, n: closed.update(n=n)
)
verdict = {
"verdict": "fail",
"kind": "bug",
"has_repro": False,
"missing": ["reproduction", "expected vs. actual"],
"explanation": "No repro provided.",
}
result = triage_module.triage(
repo="o/r",
kind="issue",
number=7,
close=True,
model="m",
judge=lambda p: json.dumps(verdict),
)
assert result["action"] == "closed"
assert closed["n"] == 7
assert "reproduction" in posted["body"]
# ---- Grace-period flow ------------------------------------------------
def test_should_post_grace_warning_on_first_failing_run_in_close_mode(
self, triage_module, monkeypatch
):
# First low-quality detection -> bot posts a warning comment with
# the GRACE_COMMENT_MARKER. The PR must NOT be closed yet.
pr = self._make_pr(body="just a sentence.")
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
self._stub_grace_no_warning(triage_module, monkeypatch)
posted = {}
monkeypatch.setattr(
triage_module,
"post_comment",
lambda repo, n, body: posted.update({"n": n, "body": body}),
)
monkeypatch.setattr(
triage_module,
"close_pr",
lambda *a, **kw: pytest.fail("must not close on first detection"),
)
verdict = {
"verdict": "fail",
"missing": ["QA proof"],
"explanation": "Body too thin.",
}
result = triage_module.triage(
repo="o/r",
kind="pr",
number=42,
close=True,
model="m",
judge=lambda p: json.dumps(verdict),
)
assert result["action"] == "warned-grace"
assert posted["n"] == 42
# Pin the user-facing language pieces the user explicitly asked for.
assert "2 hours" in posted["body"]
assert "@agent-shin reconsider" in posted["body"]
assert "@greptileai" in posted["body"]
assert "even after the PR is closed" in posted["body"]
assert triage_module.GRACE_COMMENT_MARKER in posted["body"]
def test_should_skip_close_inside_grace_window(self, triage_module, monkeypatch):
# A warning was posted recently; do nothing on this run regardless
# of close=True. The next run after `GRACE_PERIOD_SECONDS` elapses
# is the one that flips to actual close.
pr = self._make_pr(body="just a sentence.")
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
monkeypatch.setattr(
triage_module,
"seconds_since_last_grace_warning",
lambda *a, **kw: 60.0,
)
monkeypatch.setattr(
triage_module,
"post_comment",
lambda *a, **kw: pytest.fail("must not comment during grace window"),
)
monkeypatch.setattr(
triage_module,
"close_pr",
lambda *a, **kw: pytest.fail("must not close during grace window"),
)
verdict = {
"verdict": "fail",
"missing": ["QA proof"],
"explanation": "Body too thin.",
}
result = triage_module.triage(
repo="o/r",
kind="pr",
number=42,
close=True,
model="m",
judge=lambda p: json.dumps(verdict),
)
assert result["action"] == "skip-in-grace-period"
assert result["grace_age_seconds"] == 60.0
assert result["grace_period_seconds"] == triage_module.GRACE_PERIOD_SECONDS
def test_should_dry_run_grace_warning_when_close_false(
self, triage_module, monkeypatch
):
# In dry-run mode the FIRST failing detection returns
# `would-warn-grace` (with the previewed comment body) and never
# touches GitHub state. Lets a local operator preview the
# warning before flipping --close on.
pr = self._make_pr(body="thin")
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
self._stub_grace_no_warning(triage_module, monkeypatch)
monkeypatch.setattr(
triage_module,
"post_comment",
lambda *a, **kw: pytest.fail("must not post in dry-run grace warn"),
)
verdict = {
"verdict": "fail",
"missing": ["QA proof"],
"explanation": "thin",
}
result = triage_module.triage(
repo="o/r",
kind="pr",
number=1,
close=False,
model="m",
judge=lambda p: json.dumps(verdict),
)
assert result["action"] == "would-warn-grace"
assert "2 hours" in result["comment"]
def test_should_warn_grace_for_swiftwinds_not_close_instantly(
self, triage_module, monkeypatch
):
# Regression: SwiftWinds (the dogfood account) used to be in a
# now-removed `IMMEDIATE_CLOSE_LOGINS` bypass that skipped the grace
# window and closed on first detection. It must follow the SAME
# grace path as every other author: warn first, close only after the
# window elapses. A re-added instant-close bypass would call
# close_pr here and fail the test.
pr = self._make_pr(body="just a sentence.", user={"login": "SwiftWinds"})
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
self._stub_grace_no_warning(triage_module, monkeypatch)
posted = {}
monkeypatch.setattr(
triage_module,
"post_comment",
lambda repo, n, body: posted.update({"n": n, "body": body}),
)
monkeypatch.setattr(
triage_module,
"close_pr",
lambda *a, **kw: pytest.fail(
"SwiftWinds must not close on first detection; it gets the grace window"
),
)
verdict = {
"verdict": "fail",
"missing": ["QA proof"],
"explanation": "Body too thin.",
}
result = triage_module.triage(
repo="o/r",
kind="pr",
number=99,
close=True,
model="m",
judge=lambda p: json.dumps(verdict),
)
assert result["action"] == "warned-grace"
assert "2 hours" in posted["body"]
class TestGraceWarningCommentText:
"""Pin the user-facing promises in the grace warning so a future
refactor can't silently drop them."""
def test_pr_grace_warning_should_state_grace_window(self, triage_module):
body = triage_module.format_grace_warning_pr_comment(
{"verdict": "fail", "missing": ["QA proof"], "explanation": "thin"}
)
# The user explicitly asked: "specify in the comment" the grace window.
assert "2 hours" in body
def test_pr_grace_warning_should_mention_reconsider_during_grace(
self, triage_module
):
body = triage_module.format_grace_warning_pr_comment(
{"verdict": "fail", "missing": [], "explanation": ""}
)
assert "@agent-shin reconsider" in body
def test_pr_grace_warning_should_promise_greptileai_works_post_close(
self, triage_module
):
body = triage_module.format_grace_warning_pr_comment(
{"verdict": "fail", "missing": [], "explanation": ""}
)
# Per user: comment should state @greptileai works even after close.
assert "@greptileai" in body
assert "even after the PR is closed" in body
def test_pr_grace_warning_should_carry_grace_marker(self, triage_module):
# The marker is what `seconds_since_last_grace_warning` greps for
# on subsequent runs to detect that a warning has been posted.
# Dropping it would silently break the close-after-grace path.
body = triage_module.format_grace_warning_pr_comment(
{"verdict": "fail", "missing": [], "explanation": ""}
)
assert triage_module.GRACE_COMMENT_MARKER in body
def test_issue_grace_warning_should_carry_grace_marker(self, triage_module):
body = triage_module.format_grace_warning_issue_comment(
{"verdict": "fail", "missing": [], "explanation": ""}
)
assert triage_module.GRACE_COMMENT_MARKER in body
assert "2 hours" in body
# OSS authors can't reopen a bot-closed issue, so recovery is
# `@agent-shin reconsider` (the bot reopens), like the PR path.
assert "@agent-shin reconsider" in body
def test_pr_close_comment_should_promise_greptileai_works_post_close(
self, triage_module
):
# The standard close comment must ALSO point at @greptileai so
# contributors see the same options whether they read the warning
# or only catch the close comment.
body = triage_module.format_pr_close_comment(
{"verdict": "fail", "missing": [], "explanation": ""}
)
assert "@greptileai" in body
assert "even after the PR is closed" in body
def test_pr_grace_warning_should_not_prompt_reconsider_during_grace_window(
self, triage_module
):
# Per user feedback: during the 24h grace window, the contributor
# should just update the PR description. Asking them to also comment
# "@agent-shin reconsider" right away adds a step they don't need —
# the bot re-checks automatically on the next sweep. The reconsider
# trigger is reserved for the post-close recovery path.
#
# We pin this by checking that the grace section explicitly tells
# the contributor they don't need to ping the bot during the grace
# window. The presence of "@agent-shin reconsider" elsewhere in the
# comment (as the post-close path) is fine and required by other
# tests.
body = triage_module.format_grace_warning_pr_comment(
{"verdict": "fail", "missing": [], "explanation": ""}
)
assert "No need to ping" in body or "no need to ping" in body
def test_grace_warnings_should_show_what_got_right(self, triage_module):
# The "What you got right" section must appear in the grace warning
# too, not only the close comment — the contributor sees the warning
# first and that's their best chance to know what to keep.
pr_body = triage_module.format_grace_warning_pr_comment(
{
"verdict": "fail",
"linked_issue": True,
"has_problem_description": True,
"has_expected_vs_actual": True,
"has_qa_proof": False,
"missing": ["QA proof"],
"explanation": "thin",
}
)
assert "What you got right" in pr_body
assert "Linked a related GitHub issue" in pr_body
issue_body = triage_module.format_grace_warning_issue_comment(
{
"verdict": "fail",
"kind": "feature",
"has_motivation_example": True,
"missing": ["concrete description"],
"explanation": "vague",
}
)
assert "What you got right" in issue_body
assert "Motivation and concrete example" in issue_body
def test_grace_warnings_should_use_softer_park_for_later_framing(
self, triage_module
):
# Same softer-framing pin as the close comment, but for the warning
# — the contributor's first contact with the bot must not read as a
# hard deadline / ultimatum.
for body in (
triage_module.format_grace_warning_pr_comment(
{"verdict": "fail", "missing": [], "explanation": ""}
),
triage_module.format_grace_warning_issue_comment(
{"verdict": "fail", "missing": [], "explanation": ""}
),
):
assert "park this for later" in body
assert (
"not a rejection" in body
or "isn't a rejection" in body
or ("isn't us saying" in body)
)
class TestSecondsSinceLastGraceWarning:
"""Mirror of TestSecondsSinceLastReconsiderVerdict for the new helper.
Both helpers share `_seconds_since_latest_marker_comment` underneath
so the parsing logic is exercised either way; these tests pin the
grace-marker-specific behavior."""
def _make_comment(
self,
*,
login: str,
body: str,
created_at: str | None = "2026-05-18T05:00:00Z",
) -> dict:
comment: dict = {"user": {"login": login}, "body": body}
if created_at is not None:
comment["created_at"] = created_at
return comment
def test_should_return_none_when_no_grace_marker(self, triage_module, monkeypatch):
comments = [
self._make_comment(
login="github-actions[bot]",
body="Some other bot message",
),
self._make_comment(login="random-user", body="ping?"),
]
monkeypatch.setattr(
triage_module, "_iter_paginated_json", lambda *a, **kw: iter(comments)
)
assert triage_module.seconds_since_last_grace_warning("o/r", 1) is None
def test_should_ignore_non_bot_comments_with_marker(
self, triage_module, monkeypatch
):
# A user who quotes the marker in a question must NOT be treated
# as the bot warning; otherwise the close-after-grace path would
# never fire because the timer keeps resetting.
comments = [
self._make_comment(
login="random-user",
body=f"What is {triage_module.GRACE_COMMENT_MARKER}?",
)
]
monkeypatch.setattr(
triage_module, "_iter_paginated_json", lambda *a, **kw: iter(comments)
)
assert triage_module.seconds_since_last_grace_warning("o/r", 1) is None
def test_should_pick_latest_grace_marker(self, triage_module, monkeypatch):
comments = [
self._make_comment(
login="github-actions[bot]",
body="old warning " + triage_module.GRACE_COMMENT_MARKER,
created_at="2026-05-18T03:00:00Z",
),
self._make_comment(
login="github-actions[bot]",
body="newer warning " + triage_module.GRACE_COMMENT_MARKER,
created_at="2026-05-18T04:55:00Z",
),
]
monkeypatch.setattr(
triage_module, "_iter_paginated_json", lambda *a, **kw: iter(comments)
)
import datetime as real_dt
class FrozenDateTime(real_dt.datetime):
@classmethod
def now(cls, tz=None):
return real_dt.datetime(2026, 5, 18, 5, 0, 0, tzinfo=tz)
frozen_module = type(triage_module.dt)("datetime")
frozen_module.datetime = FrozenDateTime
frozen_module.timezone = real_dt.timezone
monkeypatch.setattr(triage_module, "dt", frozen_module)
age = triage_module.seconds_since_last_grace_warning("o/r", 1)
# Newer warning is 5 minutes (300s) before "now".
assert age == 300.0
class TestTriageAllowlist:
"""The dogfood allowlist gates `triage`: while non-empty it is the sole
author filter (only the named accounts are acted on) and it bypasses the
internal-author exemption for them, so a maintainer can dogfood on their
own org account. Emptying it restores the internal-author skip."""
def _make_pr(self, **overrides):
base = {
"number": 1,
"title": "PR title",
"body": "Body with no linked issue and no QA proof.",
"state": "open",
"author_association": "NONE",
"user": {"login": "mateo-berri"},
}
base.update(overrides)
return base
def test_should_skip_author_not_on_allowlist(self, triage_module, monkeypatch):
pr = self._make_pr(user={"login": "random-oss-dev"})
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
result = triage_module.triage(
repo="o/r",
kind="pr",
number=1,
close=True,
model="m",
judge=lambda p: pytest.fail("LLM must not run for non-allowlisted author"),
)
assert result["action"] == "skip-not-allowlisted"
def test_should_act_on_allowlisted_internal_author(
self, triage_module, monkeypatch
):
pr = self._make_pr(author_association="MEMBER", user={"login": "mateo-berri"})
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
result = triage_module.triage(
repo="o/r",
kind="pr",
number=1,
close=True,
model="m",
judge=lambda p: json.dumps(
{"verdict": "pass", "missing": [], "explanation": "ok"}
),
)
assert result["action"] == "pass-llm"
def test_empty_allowlist_restores_internal_skip(self, triage_module, monkeypatch):
pr = self._make_pr(
author_association="MEMBER", user={"login": "krrishdholakia"}
)
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
result = triage_module.triage(
repo="o/r",
kind="pr",
number=1,
close=True,
model="m",
judge=lambda p: pytest.fail("LLM must not run for internal author"),
allowlist=frozenset(),
)
assert result["action"] == "skip-internal-author"
def test_allowlist_constant_is_the_two_dogfood_accounts(self, triage_module):
assert triage_module.ALLOWLIST_LOGINS == frozenset(
{"mateo-berri", "swiftwinds"}
)
for login in triage_module.ALLOWLIST_LOGINS:
assert login == login.lower(), login