diff --git a/README.md b/README.md index a1797843..1d7bc9df 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ description label is added to it. - ### Closes invalid PRs Closes PRs that try to merge a maintenance branch into the main branch, adds `invalid` label, and posts a relevant message. +- ### Responds to AI policy labels +Posts a reminder of the AI guidelines on issues and PRs labeled +`ai policy comment`. The `ai policy close` label also closes the contribution. - ### Labels PRs for docs Labels PRs for documentation as `docs` - ### Copies `type-feature` label from issue to PR diff --git a/bedevere/__main__.py b/bedevere/__main__.py index 8f9b98c5..ecf65129 100644 --- a/bedevere/__main__.py +++ b/bedevere/__main__.py @@ -11,7 +11,7 @@ from gidgethub import aiohttp as gh_aiohttp from gidgethub import apps, routing, sansio -from . import backport, close_pr, filepaths, gh_issue, news, stage +from . import backport, close_pr, filepaths, gh_issue, news, policy, stage router = routing.Router( backport.router, @@ -19,6 +19,7 @@ close_pr.router, filepaths.router, news.router, + policy.router, stage.router, ) cache = cachetools.LRUCache(maxsize=500) diff --git a/bedevere/policy.py b/bedevere/policy.py new file mode 100644 index 00000000..40cc6852 --- /dev/null +++ b/bedevere/policy.py @@ -0,0 +1,84 @@ +"""Respond to AI policy labels applied by triagers and core developers.""" + +import os + +import gidgethub.routing + +from . import util + +router = gidgethub.routing.Router() + +COMMENT_LABEL = "ai policy comment" +CLOSE_LABEL = "ai policy close" + +_MESSAGE_TEMPLATE = """\ +Thank you for your interest in contributing to Python. This is a community \ +driven project, and we want it to be a welcoming place to learn and collaborate. + +Contributors are responsible for reviewing and \ +understanding what they submit, checking its claims, and being able to explain \ +changes in their own words. This helps reviewers focus on the contribution and \ +leaves more time to support other contributors. + +{action} + +Please read our [guidelines for using AI tools]\ +(https://devguide.python.org/getting-started/ai-tools/index.html).""" + +POLICY_MESSAGE = _MESSAGE_TEMPLATE.format( + action=( + "This contribution appears to have been authored with AI assistance. " + "Please explain your contribution in your own words and describe how you " + "checked its correctness." + ) +) + +CLOSE_MESSAGE = _MESSAGE_TEMPLATE.format( + action=( + "We're closing this contribution because we believe it does not meet our " + "guidelines for using AI tools. " + "If you think we've misunderstood your contribution, please let us know. " + "You're welcome to explain your approach or ask questions about the guidelines." + ) +) + + +@router.register("issues", action="labeled") +@router.register("pull_request", action="labeled") +async def label_added(event, gh, *args, **kwargs): + """Post a policy reminder and close the contribution when requested.""" + label = util.label_name(event.data) + if label not in {COMMENT_LABEL, CLOSE_LABEL}: + return + if event.data["sender"]["type"] == "Bot": + return + + is_pr = "pull_request" in event.data + target = event.data["pull_request" if is_pr else "issue"] + if target["state"] != "open": + return + + issue_url = target["issue_url"] if is_pr else target["url"] + issue = await gh.getitem(issue_url) + if issue["state"] != "open" or label not in util.labels(issue): + return + + marker = f"" + app_id = os.environ["GH_APP_ID"] + async for comment in gh.getiter(issue["comments_url"]): + app = comment.get("performed_via_github_app") or {} + if str(app.get("id")) == app_id and marker in comment["body"]: + break + else: + message = CLOSE_MESSAGE if label == CLOSE_LABEL else POLICY_MESSAGE + await gh.post(issue["comments_url"], data={"body": f"{message}\n\n{marker}"}) + + if label == CLOSE_LABEL: + # A maintainer may remove the label while the comment is being posted. + issue = await gh.getitem(issue_url) + if issue["state"] != "open" or label not in util.labels(issue): + return + data = {"state": "closed"} + if not is_pr: + data["state_reason"] = "not_planned" + await gh.patch(target["url"], data=data) diff --git a/tests/test_policy.py b/tests/test_policy.py new file mode 100644 index 00000000..8bb6f72a --- /dev/null +++ b/tests/test_policy.py @@ -0,0 +1,206 @@ +from copy import deepcopy + +import pytest +from gidgethub import sansio + +from bedevere import __main__ as main +from bedevere import policy + +APP_ID = 123 +ISSUE_URL = "https://api.github.com/repos/python/cpython/issues/42" +PR_URL = "https://api.github.com/repos/python/cpython/pulls/42" +COMMENTS_URL = f"{ISSUE_URL}/comments" + + +def make_event(kind, label, *, action="labeled", state="open", sender_type="User"): + target = { + "number": 42, + "state": state, + "labels": [{"name": label}], + "url": PR_URL if kind == "pull_request" else ISSUE_URL, + "issue_url": ISSUE_URL, + "comments_url": COMMENTS_URL, + } + data = { + "action": action, + "label": {"name": label}, + "sender": {"login": "triager", "type": sender_type}, + "pull_request" if kind == "pull_request" else "issue": target, + } + return sansio.Event(data, event=kind, delivery_id="policy-label-delivery") + + +class FakeGH: + def __init__(self, label): + self.issue = { + "state": "open", + "labels": [{"name": label}], + "comments_url": COMMENTS_URL, + } + self.comments = [] + self.calls = [] + self.post_error = None + self.patch_error = None + self.remove_label_after_post = False + + async def getitem(self, url): + self.calls.append(("get", url)) + return deepcopy(self.issue) + + async def getiter(self, url): + self.calls.append(("getiter", url)) + for comment in self.comments: + yield comment + + async def post(self, url, *, data): + self.calls.append(("post", url, data)) + if self.post_error: + raise self.post_error + self.comments.append( + {"body": data["body"], "performed_via_github_app": {"id": APP_ID}} + ) + if self.remove_label_after_post: + self.issue["labels"] = [] + + async def patch(self, url, *, data): + self.calls.append(("patch", url, data)) + if self.patch_error: + raise self.patch_error + self.issue.update(data) + + +@pytest.fixture(autouse=True) +def app_id(monkeypatch): + monkeypatch.setenv("GH_APP_ID", str(APP_ID)) + + +@pytest.mark.parametrize("kind", ["issues", "pull_request"]) +@pytest.mark.parametrize("label", [policy.COMMENT_LABEL, policy.CLOSE_LABEL]) +async def test_policy_action_through_main_router(kind, label): + event = make_event(kind, label) + gh = FakeGH(label) + + await main.router.dispatch(event, gh) + + assert gh.calls[0] == ("get", ISSUE_URL) + assert gh.calls[1] == ("getiter", COMMENTS_URL) + message = ( + policy.CLOSE_MESSAGE if label == policy.CLOSE_LABEL else policy.POLICY_MESSAGE + ) + assert gh.comments[0]["body"] == f"{message}\n\n" + assert gh.calls[2] == ("post", COMMENTS_URL, {"body": gh.comments[0]["body"]}) + if label == policy.CLOSE_LABEL: + close_url = PR_URL if kind == "pull_request" else ISSUE_URL + data = {"state": "closed"} + if kind == "issues": + data["state_reason"] = "not_planned" + assert gh.calls[3:] == [("get", ISSUE_URL), ("patch", close_url, data)] + else: + assert len(gh.calls) == 3 + assert gh.issue["state"] == "open" + + +async def test_unrelated_label(): + gh = FakeGH("type-bug") + await policy.router.dispatch(make_event("issues", "type-bug"), gh) + assert not gh.calls + + +@pytest.mark.parametrize("action", ["opened", "reopened", "synchronize", "unlabeled"]) +async def test_other_actions_do_not_trigger_closure(action): + gh = FakeGH(policy.CLOSE_LABEL) + event = make_event("pull_request", policy.CLOSE_LABEL, action=action) + await policy.router.dispatch(event, gh) + assert not gh.calls + + +async def test_bot_applied_label(): + gh = FakeGH(policy.CLOSE_LABEL) + event = make_event("issues", policy.CLOSE_LABEL, sender_type="Bot") + await policy.router.dispatch(event, gh) + assert not gh.calls + + +@pytest.mark.parametrize("kind", ["issues", "pull_request"]) +async def test_already_closed_at_labeling(kind): + gh = FakeGH(policy.CLOSE_LABEL) + event = make_event(kind, policy.CLOSE_LABEL, state="closed") + await policy.router.dispatch(event, gh) + assert not gh.calls + + +@pytest.mark.parametrize("state", ["open", "closed"]) +async def test_removed_label_or_closed_target(state): + gh = FakeGH(policy.CLOSE_LABEL) + gh.issue["state"] = state + if state == "open": + gh.issue["labels"] = [] + await policy.router.dispatch(make_event("issues", policy.CLOSE_LABEL), gh) + assert gh.calls == [("get", ISSUE_URL)] + + +async def test_removed_label_during_comment(): + gh = FakeGH(policy.CLOSE_LABEL) + gh.remove_label_after_post = True + await policy.router.dispatch(make_event("issues", policy.CLOSE_LABEL), gh) + assert len(gh.comments) == 1 + assert gh.issue["state"] == "open" + assert not any(call[0] == "patch" for call in gh.calls) + + +@pytest.mark.parametrize("label", [policy.COMMENT_LABEL, policy.CLOSE_LABEL]) +async def test_repeated_delivery(label): + event = make_event("issues", label) + gh = FakeGH(label) + await policy.router.dispatch(event, gh) + await policy.router.dispatch(event, gh) + assert len(gh.comments) == 1 + assert sum(call[0] == "patch" for call in gh.calls) == (label == policy.CLOSE_LABEL) + + +@pytest.mark.parametrize("kind", ["issues", "pull_request"]) +async def test_retry_after_close_failed(kind): + event = make_event(kind, policy.CLOSE_LABEL) + gh = FakeGH(policy.CLOSE_LABEL) + gh.patch_error = RuntimeError("closure failed") + with pytest.raises(RuntimeError, match="closure failed"): + await policy.router.dispatch(event, gh) + + gh.patch_error = None + await policy.router.dispatch(event, gh) + assert len(gh.comments) == 1 + assert gh.issue["state"] == "closed" + + +async def test_comment_failure_does_not_close(): + gh = FakeGH(policy.CLOSE_LABEL) + gh.post_error = RuntimeError("comment failed") + with pytest.raises(RuntimeError, match="comment failed"): + await policy.router.dispatch(make_event("issues", policy.CLOSE_LABEL), gh) + assert gh.issue["state"] == "open" + assert not any(call[0] == "patch" for call in gh.calls) + + +@pytest.mark.parametrize("app", [None, {"id": APP_ID + 1}]) +async def test_other_authors_cannot_suppress_message(app): + gh = FakeGH(policy.COMMENT_LABEL) + gh.comments.append( + { + "body": f"", + "performed_via_github_app": app, + } + ) + await policy.router.dispatch(make_event("issues", policy.COMMENT_LABEL), gh) + assert len(gh.comments) == 2 + assert policy.POLICY_MESSAGE in gh.comments[-1]["body"] + + +async def test_comment_then_close(): + gh = FakeGH(policy.COMMENT_LABEL) + await policy.router.dispatch(make_event("issues", policy.COMMENT_LABEL), gh) + gh.issue["labels"].append({"name": policy.CLOSE_LABEL}) + await policy.router.dispatch(make_event("issues", policy.CLOSE_LABEL), gh) + assert len(gh.comments) == 2 + assert policy.POLICY_MESSAGE in gh.comments[0]["body"] + assert policy.CLOSE_MESSAGE in gh.comments[1]["body"] + assert gh.issue["state"] == "closed"