diff --git a/README.md b/README.md index a1797843..b0950d5b 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,10 @@ 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. +- ### Remind Contributors about AI policy +Posts a reminder of the AI guidelines on issues and PRs labeled +`AI-needs-explanation` or `AI-guidelines-not-met`. The `AI-guidelines-not-met` +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..f5a47956 --- /dev/null +++ b/bedevere/policy.py @@ -0,0 +1,117 @@ +"""Respond to AI policy labels applied by triage and core teams.""" + +import os + +import gidgethub.routing + +from . import util + +router = gidgethub.routing.Router() + +COMMENT_LABEL = "AI-needs-explanation" +CLOSE_LABEL = "AI-guidelines-not-met" + +_PR_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).""" + +PR_COMMENT_MESSAGE = _PR_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." + ) +) + +PR_CLOSE_MESSAGE = _PR_MESSAGE_TEMPLATE.format( + action=( + "We believe this contribution does not meet our guidelines for using AI " + "tools, so we're closing it. " + "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." + ) +) + +_ISSUE_MESSAGE_TEMPLATE = """\ +Thank you for taking the time to open this issue. Python is a community-driven \ +project, and we want it to be a welcoming place to learn and collaborate. + +{action} + +Please read our [guidelines for using AI tools]\ +(https://devguide.python.org/getting-started/ai-tools/index.html).""" + +ISSUE_COMMENT_MESSAGE = _ISSUE_MESSAGE_TEMPLATE.format( + action=( + "This issue appears to have been written with AI assistance. " + "Please explain your issue in your own words and describe how you checked " + "the information you provided. This helps us understand your issue and " + "leaves more time to address other issues." + ) +) + +ISSUE_CLOSE_MESSAGE = _ISSUE_MESSAGE_TEMPLATE.format( + action=( + "We expect issues to be explained in your own words. " + "We believe this issue does not meet our guidelines for using AI tools, " + "so we're closing it. " + "If you think we've misunderstood, you're welcome to clarify the issue " + "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 + + if "pull_request" in event.data: + target = event.data["pull_request"] + issue_url = target["issue_url"] + comment_message, close_message = PR_COMMENT_MESSAGE, PR_CLOSE_MESSAGE + close_data = {"state": "closed"} + else: + target = event.data["issue"] + issue_url = target["url"] + comment_message, close_message = ISSUE_COMMENT_MESSAGE, ISSUE_CLOSE_MESSAGE + close_data = {"state": "closed", "state_reason": "not_planned"} + + if target["state"] != "open": + return + + issue = await gh.getitem(issue_url) + if issue["state"] != "open" or label not in util.labels(issue): + return + + marker = f"" + app_id = os.environ.get("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 comment_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 + await gh.patch(target["url"], data=close_data) diff --git a/tests/test_policy.py b/tests/test_policy.py new file mode 100644 index 00000000..70290d2f --- /dev/null +++ b/tests/test_policy.py @@ -0,0 +1,211 @@ +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}], + "comments_url": COMMENTS_URL, + } + data = { + "action": action, + "label": {"name": label}, + "sender": {"login": "triager", "type": sender_type}, + } + if kind == "pull_request": + data["pull_request"] = {**target, "url": PR_URL, "issue_url": ISSUE_URL} + else: + data["issue"] = {**target, "url": ISSUE_URL} + 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, label, message", + [ + ("issues", policy.COMMENT_LABEL, policy.ISSUE_COMMENT_MESSAGE), + ("issues", policy.CLOSE_LABEL, policy.ISSUE_CLOSE_MESSAGE), + ("pull_request", policy.COMMENT_LABEL, policy.PR_COMMENT_MESSAGE), + ("pull_request", policy.CLOSE_LABEL, policy.PR_CLOSE_MESSAGE), + ], +) +async def test_policy_action_through_main_router(kind, label, message): + 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) + 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.ISSUE_COMMENT_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.ISSUE_COMMENT_MESSAGE in gh.comments[0]["body"] + assert policy.ISSUE_CLOSE_MESSAGE in gh.comments[1]["body"] + assert gh.issue["state"] == "closed"