Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- ### 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
Labels PRs for documentation as `docs`
- ### Copies `type-feature` label from issue to PR
Expand Down
3 changes: 2 additions & 1 deletion bedevere/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@
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,
gh_issue.router,
close_pr.router,
filepaths.router,
news.router,
policy.router,
stage.router,
)
cache = cachetools.LRUCache(maxsize=500)
Expand Down
117 changes: 117 additions & 0 deletions bedevere/policy.py
Original file line number Diff line number Diff line change
@@ -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 policy comment"
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.

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}
Comment thread
willingc marked this conversation as resolved.

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}
Comment thread
willingc marked this conversation as resolved.

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")
Comment thread
zanieb marked this conversation as resolved.
@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"<!-- bedevere: {label} -->"
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)
211 changes: 211 additions & 0 deletions tests/test_policy.py
Original file line number Diff line number Diff line change
@@ -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<!-- bedevere: {label} -->"
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"<!-- bedevere: {policy.COMMENT_LABEL} -->",
"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"
Loading