diff --git a/README.md b/README.md index b0950d5b..9ff8ef5c 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,9 @@ 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. 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/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..a789cef6 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,65 @@ 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}) + 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"