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>
This commit is contained in:
Cursor Agent 2026-05-17 16:25:32 +00:00
parent 7b4a09353e
commit 483042ef84
No known key found for this signature in database
3 changed files with 83 additions and 4 deletions

View file

@ -50,6 +50,10 @@ SCORE_PATTERN = re.compile(
re.IGNORECASE,
)
# `author_association` values for internal BerriAI contributors who should be
# exempt from auto-triage.
INTERNAL_AUTHOR_ASSOCIATIONS = frozenset({"OWNER", "MEMBER", "COLLABORATOR"})
def gh(*args: str) -> str:
"""Run a `gh` CLI command and return stdout. Raises on non-zero exit."""
@ -85,6 +89,38 @@ def fetch_open_prs(repo: str | None) -> list[dict]:
return json.loads(raw)
def fetch_pr_author_association(pr_number: int, repo: str | None) -> str:
"""Return the GitHub `author_association` for a PR, uppercase.
Values: OWNER, MEMBER, COLLABORATOR, CONTRIBUTOR, FIRST_TIME_CONTRIBUTOR,
FIRST_TIMER, MANNEQUIN, NONE. Returns "" on lookup failure.
"""
endpoint = (
f"repos/{repo}/pulls/{pr_number}"
if repo
else f"repos/{{owner}}/{{repo}}/pulls/{pr_number}"
)
try:
data = json.loads(gh("api", endpoint))
except subprocess.CalledProcessError:
return ""
return (data.get("author_association") or "").upper()
def is_external_pr_author(pr: dict, repo: str | None) -> bool:
"""Return True if the PR author is an external OSS contributor.
Internal = `OWNER` / `MEMBER` / `COLLABORATOR` association, or a bot login.
"""
login = ((pr.get("author") or {}).get("login") or "").lower()
if login.endswith("[bot]") or login in {"dependabot", "github-actions"}:
return False
association = fetch_pr_author_association(pr["number"], repo)
if association in INTERNAL_AUTHOR_ASSOCIATIONS:
return False
return True
def fetch_pr_comments(pr_number: int, repo: str | None) -> list[dict]:
"""Fetch issue-level comments on a PR (where Greptile posts its summary)."""
endpoint = (
@ -203,7 +239,7 @@ def evaluate_pr(
"""Decide whether to close `pr`.
Returns (action, score_or_none, age_days_or_none) where action is one of:
"skip-draft", "skip-too-young", "skip-optout-label",
"skip-draft", "skip-too-young", "skip-optout-label", "skip-internal",
"skip-no-greptile-score", "skip-score-ok", or "close".
"""
if pr.get("isDraft"):
@ -217,6 +253,11 @@ def evaluate_pr(
if age_days < min_age_days:
return ("skip-too-young", None, age_days)
# Only auto-close external OSS contributors. Internal contributors
# (BerriAI org members) handle their own backlog.
if not is_external_pr_author(pr, repo):
return ("skip-internal", None, age_days)
comments = fetch_pr_comments(pr["number"], repo)
extraction = extract_greptile_score(comments)
if extraction is None:
@ -298,6 +339,7 @@ def main() -> int:
"skip-draft": 0,
"skip-too-young": 0,
"skip-optout-label": 0,
"skip-internal": 0,
"skip-no-greptile-score": 0,
"skip-score-ok": 0,
}

View file

@ -20,7 +20,7 @@ on:
close:
description: "Actually close matching PRs (false = dry run)."
required: false
default: "true"
default: "false"
type: choice
options:
- "true"
@ -62,7 +62,11 @@ jobs:
- name: Run low-quality PR closer
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CLOSE_FLAG: ${{ github.event.inputs.close || 'true' }}
# Default to dry-run for scheduled triggers as well. The repo
# variable AGENT_SHIN_ENABLED must be "true" before scheduled runs
# actually close PRs, AND workflow_dispatch must opt-in via close=true.
CLOSE_FLAG: ${{ github.event.inputs.close || 'false' }}
AGENT_SHIN_ENABLED: ${{ vars.AGENT_SHIN_ENABLED }}
MIN_AGE_DAYS: ${{ github.event.inputs.min_age_days || '7' }}
MIN_SCORE: ${{ github.event.inputs.min_score || '4' }}
LIMIT: ${{ github.event.inputs.limit || '25' }}
@ -74,7 +78,12 @@ jobs:
--min-score "${MIN_SCORE}"
--limit "${LIMIT}"
)
if [ "${CLOSE_FLAG}" = "true" ]; then
if [ "${AGENT_SHIN_ENABLED:-false}" != "true" ]; then
echo "::notice::AGENT_SHIN_ENABLED is not 'true' -> forcing dry-run regardless of close input."
elif [ "${CLOSE_FLAG}" = "true" ]; then
ARGS+=(--close)
echo "::notice::Running in close-on-fail mode."
else
echo "::notice::AGENT_SHIN_ENABLED is true but close=false -> dry-run."
fi
python3 .github/scripts/close_low_quality_prs.py "${ARGS[@]}"

View file

@ -139,6 +139,13 @@ class TestEvaluatePr:
"url": f"https://example.com/pr/{number}",
}
@pytest.fixture(autouse=True)
def _external_author(self, closer_module, monkeypatch):
"""Treat every test PR as external unless overridden."""
monkeypatch.setattr(
closer_module, "is_external_pr_author", lambda pr, repo: True
)
def test_should_skip_drafts(self, closer_module, _now, monkeypatch):
monkeypatch.setattr(
closer_module,
@ -263,6 +270,27 @@ class TestEvaluatePr:
assert action == "close"
assert score == 1
def test_should_skip_internal_authors(self, closer_module, _now, monkeypatch):
# Override the fixture for this one test.
monkeypatch.setattr(
closer_module, "is_external_pr_author", lambda pr, repo: False
)
monkeypatch.setattr(
closer_module,
"fetch_pr_comments",
lambda *a, **kw: pytest.fail("should not fetch comments for internal"),
)
action, score, _ = closer_module.evaluate_pr(
self._make_pr(created_days_ago=14),
now=_now,
min_age_days=7,
min_score=4,
repo=None,
optout_labels=set(),
)
assert action == "skip-internal"
assert score is None
class TestHasOptoutLabel:
def test_should_match_label_case_insensitively(self, closer_module):