From ca4bb0a2589ea4dd2c8f1fa33d4b1dd3ef46a468 Mon Sep 17 00:00:00 2001 From: mariatta Date: Wed, 28 Oct 2026 08:55:17 -0700 Subject: [PATCH 1/3] Only one of the AI policy labels can be applied at a time Adding AI-guidelines-not-met removes AI-needs-explanation, so triagers no longer have to remove the first label by hand. When both labels are added together, the close label wins and the explanation handler steps aside, so only one comment is posted and only one label is removed. --- README.md | 3 ++- bedevere/policy.py | 9 +++++++ tests/test_policy.py | 60 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b0950d5b..f01ba5cb 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,8 @@ Closes PRs that try to merge a maintenance branch into the main branch, adds - ### 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. +label also closes the contribution and removes `AI-needs-explanation` if it +is present, so only one of the two labels applies at a time. - ### 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 f5a47956..80a4b352 100644 --- a/bedevere/policy.py +++ b/bedevere/policy.py @@ -99,6 +99,15 @@ async def label_added(event, gh, *args, **kwargs): if issue["state"] != "open" or label not in util.labels(issue): return + # Only one AI policy label applies at a time and the close label wins, + # so its handler drops the other label and saves the triager a click. + # When both labels arrive together, the handler for the other label + # steps aside so only one of them comments and only one removes a label. + if label == COMMENT_LABEL and CLOSE_LABEL in util.labels(issue): + return + if label == CLOSE_LABEL and COMMENT_LABEL in util.labels(issue): + await gh.delete(issue["labels_url"], {"name": COMMENT_LABEL}) + marker = f"" app_id = os.environ.get("GH_APP_ID") async for comment in gh.getiter(issue["comments_url"]): diff --git a/tests/test_policy.py b/tests/test_policy.py index 70290d2f..3d34867a 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -10,6 +10,7 @@ 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" +LABELS_URL = f"{ISSUE_URL}/labels{{/name}}" def make_event(kind, label, *, action="labeled", state="open", sender_type="User"): @@ -37,6 +38,7 @@ def __init__(self, label): "state": "open", "labels": [{"name": label}], "comments_url": COMMENTS_URL, + "labels_url": LABELS_URL, } self.comments = [] self.calls = [] @@ -48,6 +50,12 @@ async def getitem(self, url): self.calls.append(("get", url)) return deepcopy(self.issue) + async def delete(self, url, url_vars): + self.calls.append(("delete", sansio.format_url(url, url_vars))) + self.issue["labels"] = [ + label for label in self.issue["labels"] if label["name"] != url_vars["name"] + ] + async def getiter(self, url): self.calls.append(("getiter", url)) for comment in self.comments: @@ -203,9 +211,61 @@ async def test_other_authors_cannot_suppress_message(app): async def test_comment_then_close(): gh = FakeGH(policy.COMMENT_LABEL) await policy.router.dispatch(make_event("issues", policy.COMMENT_LABEL), gh) + assert not any(call[0] == "delete" for call in gh.calls) 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" + assert gh.issue["labels"] == [{"name": policy.CLOSE_LABEL}] + + +@pytest.mark.parametrize("kind", ["issues", "pull_request"]) +async def test_close_label_replaces_comment_label(kind): + gh = FakeGH(policy.COMMENT_LABEL) + gh.issue["labels"].append({"name": policy.CLOSE_LABEL}) + await policy.router.dispatch(make_event(kind, policy.CLOSE_LABEL), gh) + + removed_url = f"{ISSUE_URL}/labels/{policy.COMMENT_LABEL}" + assert gh.calls[:2] == [("get", ISSUE_URL), ("delete", removed_url)] + assert gh.issue["labels"] == [{"name": policy.CLOSE_LABEL}] + assert gh.issue["state"] == "closed" + assert len(gh.comments) == 1 + assert gh.comments[0]["body"].endswith(f"") + + +@pytest.mark.parametrize("kind", ["issues", "pull_request"]) +async def test_comment_label_defers_to_close_label(kind): + gh = FakeGH(policy.CLOSE_LABEL) + gh.issue["labels"].append({"name": policy.COMMENT_LABEL}) + await policy.router.dispatch(make_event(kind, policy.COMMENT_LABEL), gh) + + assert gh.calls == [("get", ISSUE_URL)] + assert not gh.comments + assert gh.issue["state"] == "open" + assert len(gh.issue["labels"]) == 2 + + +@pytest.mark.parametrize("kind", ["issues", "pull_request"]) +@pytest.mark.parametrize("first", [policy.COMMENT_LABEL, policy.CLOSE_LABEL]) +async def test_both_labels_added_together(kind, first): + gh = FakeGH(policy.COMMENT_LABEL) + gh.issue["labels"].append({"name": policy.CLOSE_LABEL}) + second = policy.CLOSE_LABEL if first == policy.COMMENT_LABEL else policy.COMMENT_LABEL + await policy.router.dispatch(make_event(kind, first), gh) + await policy.router.dispatch(make_event(kind, second), gh) + + assert gh.issue["labels"] == [{"name": policy.CLOSE_LABEL}] + assert gh.issue["state"] == "closed" + assert len(gh.comments) == 1 + assert gh.comments[0]["body"].endswith(f"") + assert sum(call[0] == "delete" for call in gh.calls) == 1 + + +async def test_only_the_other_policy_label_is_removed(): + gh = FakeGH("type-bug") + gh.issue["labels"].append({"name": policy.COMMENT_LABEL}) + await policy.router.dispatch(make_event("issues", policy.COMMENT_LABEL), gh) + assert not any(call[0] == "delete" for call in gh.calls) + assert gh.issue["labels"] == [{"name": "type-bug"}, {"name": policy.COMMENT_LABEL}] From e7a040214c4a68d68d711301b9907979dce4d93b Mon Sep 17 00:00:00 2001 From: mariatta Date: Wed, 28 Oct 2026 08:57:40 -0700 Subject: [PATCH 2/3] Format the policy tests with black --- tests/test_policy.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_policy.py b/tests/test_policy.py index 3d34867a..c11d4110 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -252,7 +252,9 @@ async def test_comment_label_defers_to_close_label(kind): async def test_both_labels_added_together(kind, first): gh = FakeGH(policy.COMMENT_LABEL) gh.issue["labels"].append({"name": policy.CLOSE_LABEL}) - second = policy.CLOSE_LABEL if first == policy.COMMENT_LABEL else policy.COMMENT_LABEL + second = ( + policy.CLOSE_LABEL if first == policy.COMMENT_LABEL else policy.COMMENT_LABEL + ) await policy.router.dispatch(make_event(kind, first), gh) await policy.router.dispatch(make_event(kind, second), gh) From dcb6d6955be352ac9108c101357732d44bc2cc87 Mon Sep 17 00:00:00 2001 From: mariatta Date: Wed, 28 Oct 2026 09:01:58 -0700 Subject: [PATCH 3/3] Test that unrelated labels survive the AI label swap Also state in the README that adding AI-needs-explanation to an item that already has AI-guidelines-not-met does nothing. --- README.md | 3 ++- tests/test_policy.py | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f01ba5cb..9ff8ef5c 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ Closes PRs that try to merge a maintenance branch into the main branch, adds 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 and removes `AI-needs-explanation` if it -is present, so only one of the two labels applies at a time. +is present. Adding `AI-needs-explanation` to an item that already has +`AI-guidelines-not-met` does nothing. - ### Labels PRs for docs Labels PRs for documentation as `docs` - ### Copies `type-feature` label from issue to PR diff --git a/tests/test_policy.py b/tests/test_policy.py index c11d4110..a789cef6 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -268,6 +268,8 @@ async def test_both_labels_added_together(kind, first): async def test_only_the_other_policy_label_is_removed(): gh = FakeGH("type-bug") gh.issue["labels"].append({"name": policy.COMMENT_LABEL}) - await policy.router.dispatch(make_event("issues", policy.COMMENT_LABEL), gh) - assert not any(call[0] == "delete" for call in gh.calls) - assert gh.issue["labels"] == [{"name": "type-bug"}, {"name": policy.COMMENT_LABEL}] + gh.issue["labels"].append({"name": policy.CLOSE_LABEL}) + await policy.router.dispatch(make_event("issues", policy.CLOSE_LABEL), gh) + assert sum(call[0] == "delete" for call in gh.calls) == 1 + assert gh.issue["labels"] == [{"name": "type-bug"}, {"name": policy.CLOSE_LABEL}] + assert gh.issue["state"] == "closed"