From 483042ef84a15a5f46779ba6111bc7d39f768d84 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 17 May 2026 16:25:32 +0000 Subject: [PATCH] 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 --- .github/scripts/close_low_quality_prs.py | 44 ++++++++++++++++++- .github/workflows/close_low_quality_prs.yml | 15 +++++-- .../test_github_close_low_quality_prs.py | 28 ++++++++++++ 3 files changed, 83 insertions(+), 4 deletions(-) diff --git a/.github/scripts/close_low_quality_prs.py b/.github/scripts/close_low_quality_prs.py index 0a0ff980201..6338ad380ce 100644 --- a/.github/scripts/close_low_quality_prs.py +++ b/.github/scripts/close_low_quality_prs.py @@ -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, } diff --git a/.github/workflows/close_low_quality_prs.yml b/.github/workflows/close_low_quality_prs.yml index 7768e89c0f8..4271adca184 100644 --- a/.github/workflows/close_low_quality_prs.yml +++ b/.github/workflows/close_low_quality_prs.yml @@ -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[@]}" diff --git a/tests/test_litellm/test_github_close_low_quality_prs.py b/tests/test_litellm/test_github_close_low_quality_prs.py index 2b3119fc920..0d63562ec0f 100644 --- a/tests/test_litellm/test_github_close_low_quality_prs.py +++ b/tests/test_litellm/test_github_close_low_quality_prs.py @@ -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):