mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
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:
parent
7b4a09353e
commit
483042ef84
3 changed files with 83 additions and 4 deletions
44
.github/scripts/close_low_quality_prs.py
vendored
44
.github/scripts/close_low_quality_prs.py
vendored
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
15
.github/workflows/close_low_quality_prs.yml
vendored
15
.github/workflows/close_low_quality_prs.yml
vendored
|
|
@ -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[@]}"
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue