litellm/tests/test_litellm/test_github_triage_with_llm.py
mateo-berri dc80aa8290
fix(triage): anchor reconsider provenance marker to the current open→closed cycle
The previous provenance check returned a false positive when a different
workflow that shares the github-actions[bot] identity (e.g. the repo's
actions/stale workflow, which uses secrets.GITHUB_TOKEN) re-closes a PR
after Agent Shin previously auto-closed it. The check only required ANY
historical marker comment by the closer login, so the old Agent Shin
marker satisfied it even though the most recent close was stale's.

Require the marker comment to be timestamped after the most recent
reopened event (if any) and no later than the most recent closed event,
so it must belong to the same open→closed cycle that's being reconsidered.

Stale's close cycle never posts that marker, so the cycle-anchored check
refuses to override it. The legitimate Agent Shin reconsider path still
passes because Agent Shin posts the marker comment immediately before
calling close_pr / close_issue in the same job.
2026-05-19 08:42:48 +00:00

1379 lines
51 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_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):
body = triage_module.format_issue_close_comment(
{"verdict": "fail", "missing": ["repro"], "explanation": "thin"}
)
assert "@agent-shin reconsider" in body
assert "Reopen the issue" not in body
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_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 TestWasAutoClosedByAgentShin:
"""Provenance check that gates reconsider's reopen path."""
@staticmethod
def _install(monkeypatch, triage_module, events, comments):
monkeypatch.setattr(triage_module, "fetch_issue_events", lambda repo, n: events)
monkeypatch.setattr(
triage_module, "fetch_issue_comments", lambda repo, n: comments
)
def test_should_return_true_when_latest_close_was_bot_with_marker(
self, triage_module, monkeypatch
):
events = [
{
"event": "closed",
"actor": {"login": "github-actions[bot]"},
"created_at": "2025-01-01T00:00:10Z",
}
]
comments = [
{
"user": {"login": "github-actions[bot]"},
"created_at": "2025-01-01T00:00:05Z",
"body": (
"👋 Hi, thanks for the PR! I'm **Agent Shin**, the automated "
"triage bot for this repository.\n\nThis PR is being **auto-closed**..."
),
}
]
self._install(monkeypatch, triage_module, events, comments)
assert triage_module.was_auto_closed_by_agent_shin("o/r", 1) is True
def test_should_return_false_when_no_close_event(self, triage_module, monkeypatch):
self._install(monkeypatch, triage_module, [], [])
assert triage_module.was_auto_closed_by_agent_shin("o/r", 1) is False
def test_should_ignore_non_bot_author_with_marker(self, triage_module, monkeypatch):
# A contributor pasting the marker into a manual comment must NOT
# be treated as proof Agent Shin closed the PR. Even if a bot did
# the most recent close, the marker comment must be authored by
# that same bot login — not by the human.
events = [
{
"event": "closed",
"actor": {"login": "github-actions[bot]"},
"created_at": "2025-01-01T00:00:10Z",
}
]
comments = [
{
"user": {"login": "outside-dev"},
"created_at": "2025-01-01T00:00:05Z",
"body": "I'm **Agent Shin**, just kidding — please reconsider this.",
}
]
self._install(monkeypatch, triage_module, events, comments)
assert triage_module.was_auto_closed_by_agent_shin("o/r", 1) is False
def test_should_ignore_bot_comment_without_marker(self, triage_module, monkeypatch):
# Other bots (codecov, cla-assistant, etc.) post on every PR; their
# presence must not satisfy the provenance check.
events = [
{
"event": "closed",
"actor": {"login": "github-actions[bot]"},
"created_at": "2025-01-01T00:00:10Z",
}
]
comments = [
{
"user": {"login": "codecov[bot]"},
"created_at": "2025-01-01T00:00:01Z",
"body": "## Codecov Report ...",
},
{
"user": {"login": "greptile-apps[bot]"},
"created_at": "2025-01-01T00:00:02Z",
"body": "Confidence Score: 2/5",
},
]
self._install(monkeypatch, triage_module, events, comments)
assert triage_module.was_auto_closed_by_agent_shin("o/r", 1) is False
def test_should_anchor_on_most_recent_close_event(self, triage_module, monkeypatch):
# Agent Shin auto-closed first, contributor commented after; the
# bot-authored marker comment is anywhere in the timeline.
events = [
{
"event": "labeled",
"actor": {"login": "krrishdholakia"},
"created_at": "2025-01-01T00:00:01Z",
},
{
"event": "closed",
"actor": {"login": "github-actions[bot]"},
"created_at": "2025-01-01T00:00:10Z",
},
]
comments = [
{
"user": {"login": "codecov[bot]"},
"created_at": "2025-01-01T00:00:02Z",
"body": "## Codecov Report",
},
{
"user": {"login": "github-actions[bot]"},
"created_at": "2025-01-01T00:00:05Z",
"body": "I'm **Agent Shin**, the automated triage bot ...",
},
{
"user": {"login": "outside-dev"},
"created_at": "2025-01-01T00:00:15Z",
"body": "Replying after auto-close ...",
},
]
self._install(monkeypatch, triage_module, events, comments)
assert triage_module.was_auto_closed_by_agent_shin("o/r", 1) is True
def test_should_refuse_when_maintainer_re_closed_after_agent_shin(
self, triage_module, monkeypatch
):
# Agent Shin auto-closed, the PR was reopened, then a maintainer
# closed it again (e.g. as a duplicate). `@agent-shin reconsider`
# must NOT override the maintainer's later closure even though the
# historical Agent Shin marker comment still exists.
events = [
{
"event": "closed",
"actor": {"login": "github-actions[bot]"},
"created_at": "2025-01-01T00:00:10Z",
},
{
"event": "reopened",
"actor": {"login": "github-actions[bot]"},
"created_at": "2025-01-01T00:00:20Z",
},
{
"event": "closed",
"actor": {"login": "krrishdholakia"},
"created_at": "2025-01-01T00:00:30Z",
},
]
comments = [
{
"user": {"login": "github-actions[bot]"},
"created_at": "2025-01-01T00:00:05Z",
"body": "I'm **Agent Shin**, the automated triage bot ...",
}
]
self._install(monkeypatch, triage_module, events, comments)
assert triage_module.was_auto_closed_by_agent_shin("o/r", 1) is False
def test_should_refuse_when_unrelated_bot_re_closed_after_agent_shin(
self, triage_module, monkeypatch
):
# Agent Shin closed, the PR was reopened, then a different bot
# (stale, etc.) closed it. The marker comment is from
# `github-actions[bot]` but the most recent closer is
# `stale[bot]`, so the logins don't match -> refuse to reopen.
events = [
{
"event": "closed",
"actor": {"login": "github-actions[bot]"},
"created_at": "2025-01-01T00:00:10Z",
},
{
"event": "reopened",
"actor": {"login": "github-actions[bot]"},
"created_at": "2025-01-01T00:00:20Z",
},
{
"event": "closed",
"actor": {"login": "stale[bot]"},
"created_at": "2025-01-01T00:00:30Z",
},
]
comments = [
{
"user": {"login": "github-actions[bot]"},
"created_at": "2025-01-01T00:00:05Z",
"body": "I'm **Agent Shin**, the automated triage bot ...",
}
]
self._install(monkeypatch, triage_module, events, comments)
assert triage_module.was_auto_closed_by_agent_shin("o/r", 1) is False
def test_should_refuse_when_stale_workflow_re_closed_after_agent_shin(
self, triage_module, monkeypatch
):
# The repo's `actions/stale` workflow uses `secrets.GITHUB_TOKEN`,
# so its closes are attributed to `github-actions[bot]` — the same
# login as Agent Shin. A historical Agent Shin marker comment
# from an earlier close cycle must NOT satisfy the provenance check
# for a later stale-initiated close (which never posts that marker).
events = [
{
"event": "closed",
"actor": {"login": "github-actions[bot]"},
"created_at": "2025-01-01T00:00:10Z",
},
{
"event": "reopened",
"actor": {"login": "github-actions[bot]"},
"created_at": "2025-01-01T00:00:20Z",
},
{
"event": "closed",
"actor": {"login": "github-actions[bot]"},
"created_at": "2025-04-01T00:00:00Z",
},
]
comments = [
{
"user": {"login": "github-actions[bot]"},
"created_at": "2025-01-01T00:00:05Z",
"body": "I'm **Agent Shin**, the automated triage bot ...",
},
{
"user": {"login": "github-actions[bot]"},
"created_at": "2025-03-25T00:00:00Z",
"body": "This pull request has been automatically marked as stale...",
},
]
self._install(monkeypatch, triage_module, events, comments)
assert triage_module.was_auto_closed_by_agent_shin("o/r", 1) is False
def test_should_accept_marker_from_current_cycle_after_reopen(
self, triage_module, monkeypatch
):
# Two clean Agent Shin cycles: closed, reconsider→reopened, closed
# again with a new marker comment posted in the current cycle.
# The second-cycle marker is what proves provenance.
events = [
{
"event": "closed",
"actor": {"login": "github-actions[bot]"},
"created_at": "2025-01-01T00:00:10Z",
},
{
"event": "reopened",
"actor": {"login": "github-actions[bot]"},
"created_at": "2025-01-01T00:00:20Z",
},
{
"event": "closed",
"actor": {"login": "github-actions[bot]"},
"created_at": "2025-01-01T00:00:40Z",
},
]
comments = [
{
"user": {"login": "github-actions[bot]"},
"created_at": "2025-01-01T00:00:05Z",
"body": "I'm **Agent Shin**, the automated triage bot ...",
},
{
"user": {"login": "github-actions[bot]"},
"created_at": "2025-01-01T00:00:35Z",
"body": "I'm **Agent Shin** — still missing X ...",
},
]
self._install(monkeypatch, triage_module, events, comments)
assert triage_module.was_auto_closed_by_agent_shin("o/r", 1) is True
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": "outside-dev"},
}
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,
)
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.
pr = self._make_pr(body="See #1234 for context. No QA proof here.")
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
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-close"
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(self, triage_module, monkeypatch):
pr = self._make_pr(body="just a sentence.")
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
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_when_close_enabled(
self, triage_module, monkeypatch
):
pr = self._make_pr(body="just a sentence.")
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
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"
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.
pr = self._make_pr(
state="closed", body="Updated body with QA proof + screenshots."
)
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
# Provenance: the PR was auto-closed by Agent Shin (a bot-authored
# auto-close comment exists), so reconsider is allowed to reopen.
monkeypatch.setattr(
triage_module, "was_auto_closed_by_agent_shin", lambda repo, n: True
)
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_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)
monkeypatch.setattr(
triage_module, "was_auto_closed_by_agent_shin", lambda repo, n: True
)
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_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)
monkeypatch.setattr(
triage_module, "was_auto_closed_by_agent_shin", lambda repo, n: True
)
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_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"),
)
# Internal check must fire *before* provenance, so the provenance
# helper should never be invoked for an internal author.
monkeypatch.setattr(
triage_module,
"was_auto_closed_by_agent_shin",
lambda *a, **kw: pytest.fail("must not check provenance for internal"),
)
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"),
reconsider=True,
)
assert result["action"] == "skip-internal-author"
def test_should_skip_reconsider_when_not_bot_closed(
self, triage_module, monkeypatch
):
# A maintainer-closed PR (no Agent Shin auto-close comment) must
# never be reopened by `@agent-shin reconsider`, regardless of how
# good the LLM verdict would be. Otherwise the original author
# could polish the description and silently override a maintainer's
# "closed as duplicate / out of scope" decision.
pr = self._make_pr(state="closed", body="Fixes #1234")
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
monkeypatch.setattr(
triage_module, "was_auto_closed_by_agent_shin", lambda repo, n: False
)
monkeypatch.setattr(
triage_module,
"post_comment",
lambda *a, **kw: pytest.fail("must not comment when not bot-closed"),
)
monkeypatch.setattr(
triage_module,
"reopen_pr",
lambda *a, **kw: pytest.fail("must not reopen when not bot-closed"),
)
result = triage_module.triage(
repo="o/r",
kind="pr",
number=1,
close=True,
model="m",
judge=lambda p: pytest.fail("LLM must not run when not bot-closed"),
reconsider=True,
)
assert result["action"] == "skip-not-bot-closed"
def test_should_skip_reconsider_issue_when_not_bot_closed(
self, triage_module, monkeypatch
):
# Same provenance gate for issues: only Agent Shin auto-closed
# issues are eligible for reopen-on-reconsider.
issue = {
"number": 7,
"title": "Bug",
"body": "Repro: curl ...",
"state": "closed",
"author_association": "NONE",
"user": {"login": "outside"},
}
monkeypatch.setattr(triage_module, "fetch_issue", lambda repo, n: issue)
monkeypatch.setattr(
triage_module, "was_auto_closed_by_agent_shin", lambda repo, n: False
)
monkeypatch.setattr(
triage_module,
"reopen_issue",
lambda *a, **kw: pytest.fail("must not reopen maintainer-closed issue"),
)
result = triage_module.triage(
repo="o/r",
kind="issue",
number=7,
close=True,
model="m",
judge=lambda p: pytest.fail("LLM must not run for non-bot-closed issue"),
reconsider=True,
)
assert result["action"] == "skip-not-bot-closed"
def test_should_preview_reopen_in_reconsider_dry_run(
self, triage_module, monkeypatch
):
# When `close=False` and `reconsider=True`, a passing verdict must
# produce a `would-reopen` preview WITHOUT posting a comment or
# reopening — same dry-run pattern as `would-close` in regular mode.
pr = self._make_pr(state="closed", body="Now with screenshots + repro.")
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
monkeypatch.setattr(
triage_module, "was_auto_closed_by_agent_shin", lambda repo, n: True
)
monkeypatch.setattr(
triage_module,
"post_comment",
lambda *a, **kw: pytest.fail("dry-run must not post"),
)
monkeypatch.setattr(
triage_module,
"reopen_pr",
lambda *a, **kw: pytest.fail("dry-run must not reopen"),
)
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 preview should include the comment body the bot WOULD post
# (useful for $GITHUB_STEP_SUMMARY).
assert "reopened" in result["comment"].lower()
def test_should_preview_reopen_in_reconsider_dry_run_linked_issue(
self, triage_module, monkeypatch
):
# The linked-issue short-circuit also has to honor dry-run in
# reconsider mode — no LLM call AND no destructive side effects.
pr = self._make_pr(state="closed", body="Fixes #1234\n\nDetails.")
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
monkeypatch.setattr(
triage_module, "was_auto_closed_by_agent_shin", lambda repo, n: True
)
monkeypatch.setattr(
triage_module,
"post_comment",
lambda *a, **kw: pytest.fail("dry-run must not post"),
)
monkeypatch.setattr(
triage_module,
"reopen_pr",
lambda *a, **kw: pytest.fail("dry-run must not reopen"),
)
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"
assert "reopened" in result["comment"].lower()
def test_should_preview_still_failing_in_reconsider_dry_run(
self, triage_module, monkeypatch
):
# When `close=False` and the verdict is fail, the dry-run preview
# must say `would-leave-closed-still-failing` and not post the
# "still failing" comment.
pr = self._make_pr(state="closed", body="still thin")
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
monkeypatch.setattr(
triage_module, "was_auto_closed_by_agent_shin", lambda repo, n: True
)
monkeypatch.setattr(
triage_module,
"post_comment",
lambda *a, **kw: pytest.fail("dry-run must not post"),
)
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-leave-closed-still-failing"
assert "QA proof" in result["comment"]
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": "outside"},
}
monkeypatch.setattr(triage_module, "fetch_issue", lambda repo, n: issue)
monkeypatch.setattr(
triage_module, "was_auto_closed_by_agent_shin", lambda repo, n: True
)
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_reopen_before_posting_on_reconsider_pass(
self, triage_module, monkeypatch
):
# A failed reopen call must not leave a misleading "we reopened it"
# comment on a still-closed PR. Pin the order: reopen happens first;
# if reopen raises, post_comment must not run.
pr = self._make_pr(state="closed", body="Updated body with QA proof.")
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
monkeypatch.setattr(
triage_module, "was_auto_closed_by_agent_shin", lambda repo, n: True
)
monkeypatch.setattr(
triage_module,
"post_comment",
lambda *a, **kw: pytest.fail("must not post comment if reopen fails"),
)
def boom(*a, **kw):
raise RuntimeError("reopen 422")
monkeypatch.setattr(triage_module, "reopen_pr", boom)
with pytest.raises(RuntimeError):
triage_module.triage(
repo="o/r",
kind="pr",
number=42,
close=True,
model="m",
judge=lambda p: json.dumps(
{"verdict": "pass", "missing": [], "explanation": "ok"}
),
reconsider=True,
)
def test_should_reopen_before_posting_on_reconsider_linked_issue(
self, triage_module, monkeypatch
):
# Same ordering invariant for the linked-issue short-circuit.
pr = self._make_pr(state="closed", body="Fixes #1234")
monkeypatch.setattr(triage_module, "fetch_pr", lambda repo, n: pr)
monkeypatch.setattr(
triage_module, "was_auto_closed_by_agent_shin", lambda repo, n: True
)
monkeypatch.setattr(
triage_module,
"post_comment",
lambda *a, **kw: pytest.fail("must not post comment if reopen fails"),
)
def boom(*a, **kw):
raise RuntimeError("reopen 422")
monkeypatch.setattr(triage_module, "reopen_pr", boom)
with pytest.raises(RuntimeError):
triage_module.triage(
repo="o/r",
kind="pr",
number=55,
close=True,
model="m",
judge=lambda p: pytest.fail("LLM must not run for linked-issue path"),
reconsider=True,
)
def test_should_reopen_before_posting_on_reconsider_issue_pass(
self, triage_module, monkeypatch
):
# Same ordering invariant for issues (reopen_issue, not reopen_pr).
issue = {
"number": 7,
"title": "Bug",
"body": "Repro: curl ...",
"state": "closed",
"author_association": "NONE",
"user": {"login": "outside"},
}
monkeypatch.setattr(triage_module, "fetch_issue", lambda repo, n: issue)
monkeypatch.setattr(
triage_module, "was_auto_closed_by_agent_shin", lambda repo, n: True
)
monkeypatch.setattr(
triage_module,
"post_comment",
lambda *a, **kw: pytest.fail("must not post comment if reopen fails"),
)
def boom(*a, **kw):
raise RuntimeError("reopen 422")
monkeypatch.setattr(triage_module, "reopen_issue", boom)
with pytest.raises(RuntimeError):
triage_module.triage(
repo="o/r",
kind="issue",
number=7,
close=True,
model="m",
judge=lambda p: json.dumps(
{"verdict": "pass", "missing": [], "explanation": "ok"}
),
reconsider=True,
)
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": "outside"},
}
monkeypatch.setattr(triage_module, "fetch_issue", lambda repo, n: issue)
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"]