From e79154de95d77867724f0a0a766bbd4a348512ed Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Fri, 23 Oct 2026 00:34:50 +0000 Subject: [PATCH 01/11] Add AI policy label actions Give triagers and core developers a consistent way to direct contributors to the AI guidelines and close contributions that fall short. Handle `ai policy comment` and `ai policy close` on issues and pull requests, keeping conversations open for clarification and questions. --- README.md | 3 + bedevere/__main__.py | 3 +- bedevere/policy.py | 84 ++++++++++++++++++ tests/test_policy.py | 206 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 bedevere/policy.py create mode 100644 tests/test_policy.py 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" From 4f4c497a89810c267d64ba7af256823afdcbcc9b Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Fri, 23 Oct 2026 18:19:02 +0000 Subject: [PATCH 02/11] Tailor AI policy messages for issues --- bedevere/policy.py | 43 +++++++++++++++++++++++++++++++++++++++---- tests/test_policy.py | 22 +++++++++++++--------- 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/bedevere/policy.py b/bedevere/policy.py index 40cc6852..55e6e536 100644 --- a/bedevere/policy.py +++ b/bedevere/policy.py @@ -11,7 +11,7 @@ COMMENT_LABEL = "ai policy comment" CLOSE_LABEL = "ai policy close" -_MESSAGE_TEMPLATE = """\ +_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. @@ -25,7 +25,7 @@ Please read our [guidelines for using AI tools]\ (https://devguide.python.org/getting-started/ai-tools/index.html).""" -POLICY_MESSAGE = _MESSAGE_TEMPLATE.format( +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 " @@ -33,7 +33,7 @@ ) ) -CLOSE_MESSAGE = _MESSAGE_TEMPLATE.format( +PR_CLOSE_MESSAGE = _PR_MESSAGE_TEMPLATE.format( action=( "We're closing this contribution because we believe it does not meet our " "guidelines for using AI tools. " @@ -42,6 +42,36 @@ ) ) +_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. + +Please review and verify the information in your issue and make sure you can \ +explain it in your own words. This helps us understand your issue and leaves \ +more time to address other issues. + +{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." + ) +) + +ISSUE_CLOSE_MESSAGE = _ISSUE_MESSAGE_TEMPLATE.format( + action=( + "We're closing this issue because we believe it does not meet our " + "guidelines for using AI tools. " + "If you think we've misunderstood your issue, please let us know. " + "You're welcome to clarify it or ask questions about the guidelines." + ) +) + @router.register("issues", action="labeled") @router.register("pull_request", action="labeled") @@ -70,7 +100,12 @@ async def label_added(event, gh, *args, **kwargs): if str(app.get("id")) == app_id and marker in comment["body"]: break else: - message = CLOSE_MESSAGE if label == CLOSE_LABEL else POLICY_MESSAGE + if is_pr: + message = PR_CLOSE_MESSAGE if label == CLOSE_LABEL else PR_COMMENT_MESSAGE + else: + message = ( + ISSUE_CLOSE_MESSAGE if label == CLOSE_LABEL else ISSUE_COMMENT_MESSAGE + ) await gh.post(issue["comments_url"], data={"body": f"{message}\n\n{marker}"}) if label == CLOSE_LABEL: diff --git a/tests/test_policy.py b/tests/test_policy.py index 8bb6f72a..03aeef57 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -74,9 +74,16 @@ 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): +@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) @@ -84,9 +91,6 @@ async def test_policy_action_through_main_router(kind, label): 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: @@ -192,7 +196,7 @@ async def test_other_authors_cannot_suppress_message(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"] + assert policy.ISSUE_COMMENT_MESSAGE in gh.comments[-1]["body"] async def test_comment_then_close(): @@ -201,6 +205,6 @@ async def test_comment_then_close(): 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 policy.ISSUE_COMMENT_MESSAGE in gh.comments[0]["body"] + assert policy.ISSUE_CLOSE_MESSAGE in gh.comments[1]["body"] assert gh.issue["state"] == "closed" From f379c2bb41bde41cc1b61b56ae7b64da3e6e5745 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Fri, 23 Oct 2026 14:16:08 -0700 Subject: [PATCH 03/11] Apply Hugo's feedback on prose Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- bedevere/policy.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bedevere/policy.py b/bedevere/policy.py index 55e6e536..968d0ea4 100644 --- a/bedevere/policy.py +++ b/bedevere/policy.py @@ -1,4 +1,4 @@ -"""Respond to AI policy labels applied by triagers and core developers.""" +"""Respond to AI policy labels applied by triage and core teams.""" import os @@ -12,8 +12,8 @@ CLOSE_LABEL = "ai policy close" _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. +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 \ @@ -43,7 +43,7 @@ ) _ISSUE_MESSAGE_TEMPLATE = """\ -Thank you for taking the time to open this issue. Python is a community driven \ +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. Please review and verify the information in your issue and make sure you can \ From 50699512230b74c9de95a4aeb906caeefc42daa8 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Fri, 23 Oct 2026 23:39:13 +0000 Subject: [PATCH 04/11] Remove repetition from AI policy messages for issues --- bedevere/policy.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/bedevere/policy.py b/bedevere/policy.py index 968d0ea4..a6cd6c15 100644 --- a/bedevere/policy.py +++ b/bedevere/policy.py @@ -46,10 +46,6 @@ 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. -Please review and verify the information in your issue and make sure you can \ -explain it in your own words. This helps us understand your issue and leaves \ -more time to address other issues. - {action} Please read our [guidelines for using AI tools]\ @@ -59,16 +55,18 @@ 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." + "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're closing this issue because we believe it does not meet our " "guidelines for using AI tools. " - "If you think we've misunderstood your issue, please let us know. " - "You're welcome to clarify it or ask questions about the guidelines." + "If you think we've misunderstood, you're welcome to clarify the issue " + "or ask questions about the guidelines." ) ) From 5772837b68370e45eeb27dbb7ebcbb6f10a74978 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Sat, 24 Oct 2026 00:15:15 +0000 Subject: [PATCH 05/11] Give the AI policy reason before closing issues and PRs --- bedevere/policy.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bedevere/policy.py b/bedevere/policy.py index a6cd6c15..81234fb3 100644 --- a/bedevere/policy.py +++ b/bedevere/policy.py @@ -35,8 +35,8 @@ PR_CLOSE_MESSAGE = _PR_MESSAGE_TEMPLATE.format( action=( - "We're closing this contribution because we believe it does not meet our " - "guidelines for using AI tools. " + "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." ) @@ -63,8 +63,8 @@ ISSUE_CLOSE_MESSAGE = _ISSUE_MESSAGE_TEMPLATE.format( action=( "We expect issues to be explained in your own words. " - "We're closing this issue because we believe it does not meet our " - "guidelines for using AI tools. " + "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." ) From ab2f5788c4ff98bb230a3c51e1511af02cfd685d Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Sat, 24 Oct 2026 10:05:18 -0700 Subject: [PATCH 06/11] Capitalize "AI" Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- README.md | 2 +- bedevere/policy.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1d7bc9df..3e0b70a2 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ 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. +`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/policy.py b/bedevere/policy.py index 81234fb3..ae270502 100644 --- a/bedevere/policy.py +++ b/bedevere/policy.py @@ -8,8 +8,8 @@ router = gidgethub.routing.Router() -COMMENT_LABEL = "ai policy comment" -CLOSE_LABEL = "ai policy close" +COMMENT_LABEL = "AI policy comment" +CLOSE_LABEL = "AI policy close" _PR_MESSAGE_TEMPLATE = """\ Thank you for your interest in contributing to Python. This is a community-driven \ From 7c9885c6eea78b69daf15dbb798a50a2e045f453 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Sat, 24 Oct 2026 19:14:37 -0700 Subject: [PATCH 07/11] Adjust the section header Co-authored-by: Mariatta --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3e0b70a2..e65f2546 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ 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 +- ### Remind Contributors about AI policy 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 From f1af168aec5e7d0d3b59bf981370883a8b28fa2c Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Sun, 25 Oct 2026 02:16:53 +0000 Subject: [PATCH 08/11] Select AI policy actions by webhook event type --- bedevere/policy.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/bedevere/policy.py b/bedevere/policy.py index ae270502..6349604c 100644 --- a/bedevere/policy.py +++ b/bedevere/policy.py @@ -81,29 +81,32 @@ async def label_added(event, gh, *args, **kwargs): 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 "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_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"] + 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: - if is_pr: - message = PR_CLOSE_MESSAGE if label == CLOSE_LABEL else PR_COMMENT_MESSAGE - else: - message = ( - ISSUE_CLOSE_MESSAGE if label == CLOSE_LABEL else ISSUE_COMMENT_MESSAGE - ) + 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: @@ -111,7 +114,4 @@ async def label_added(event, gh, *args, **kwargs): 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) + await gh.patch(target["url"], data=close_data) From b1b40f6027dca75bb5c72af37ce9a02d24dc84a6 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Sun, 25 Oct 2026 02:50:19 +0000 Subject: [PATCH 09/11] Match issue and pull request webhook fields in policy tests --- tests/test_policy.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_policy.py b/tests/test_policy.py index 03aeef57..70290d2f 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -17,16 +17,17 @@ def make_event(kind, label, *, action="labeled", state="open", sender_type="User "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, } + 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") From 4c0814954c456fa7d91bcf0db59e7e8e6f0b9fca Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Sun, 25 Oct 2026 02:50:31 +0000 Subject: [PATCH 10/11] Normalize line endings on the AI policy labels --- bedevere/policy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bedevere/policy.py b/bedevere/policy.py index 6349604c..05c9bd5d 100644 --- a/bedevere/policy.py +++ b/bedevere/policy.py @@ -8,8 +8,8 @@ router = gidgethub.routing.Router() -COMMENT_LABEL = "AI policy comment" -CLOSE_LABEL = "AI policy close" +COMMENT_LABEL = "AI policy comment" +CLOSE_LABEL = "AI policy close" _PR_MESSAGE_TEMPLATE = """\ Thank you for your interest in contributing to Python. This is a community-driven \ From b52c2eea124b210899c925f1c05421871b914650 Mon Sep 17 00:00:00 2001 From: mariatta Date: Tue, 27 Oct 2026 13:57:16 -0700 Subject: [PATCH 11/11] Rename AI policy labels to AI-needs-explanation and AI-guidelines-not-met --- README.md | 3 ++- bedevere/policy.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e65f2546..b0950d5b 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ 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 policy comment`. The `AI policy close` label also closes the contribution. +`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/policy.py b/bedevere/policy.py index 05c9bd5d..f5a47956 100644 --- a/bedevere/policy.py +++ b/bedevere/policy.py @@ -8,8 +8,8 @@ router = gidgethub.routing.Router() -COMMENT_LABEL = "AI policy comment" -CLOSE_LABEL = "AI policy close" +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 \