From 9010a0fafd88ad1f1275c7723609829ab932c081 Mon Sep 17 00:00:00 2001 From: Mariatta Date: Mon, 11 Sep 2023 05:09:49 -0700 Subject: [PATCH] Make bedevere works as a GitHub App (#569) Instead of using Oauth Token, use the GitHub App Installation Access Token. If the "installation" dict is passed in the webhook event, use it. Made some changes to accommodate testing in personal CPython fork. --- bedevere/__main__.py | 18 +++++++++++++++ bedevere/stage.py | 4 +++- bedevere/util.py | 20 ++++++++++------ runtime.txt | 2 +- tests/test___main__.py | 52 +++++++++++++++++++++++++++++++++++++++++- tests/test_stage.py | 22 +++++++++++++----- tests/test_util.py | 8 +++++++ 7 files changed, 110 insertions(+), 16 deletions(-) diff --git a/bedevere/__main__.py b/bedevere/__main__.py index 3a3c0740..a16111d1 100644 --- a/bedevere/__main__.py +++ b/bedevere/__main__.py @@ -10,6 +10,7 @@ from gidgethub import aiohttp as gh_aiohttp from gidgethub import routing from gidgethub import sansio +from gidgethub import apps from . import backport, gh_issue, close_pr, filepaths, news, stage @@ -35,6 +36,17 @@ async def main(request): gh = gh_aiohttp.GitHubAPI(session, "python/bedevere", oauth_token=oauth_token, cache=cache) + + if event.data.get("installation"): + # This path only works on GitHub App + installation_id = event.data["installation"]["id"] + installation_access_token = await apps.get_installation_access_token( + gh, + installation_id=installation_id, + app_id=os.environ.get("GH_APP_ID"), + private_key=os.environ.get("GH_PRIVATE_KEY") + ) + gh.oauth_token = installation_access_token["token"] # Give GitHub some time to reach internal consistency. await asyncio.sleep(1) await router.dispatch(event, gh, session=session) @@ -48,6 +60,12 @@ async def main(request): return web.Response(status=500) +@router.register("installation", action="created") +async def repo_installation_added(event, gh, *args, **kwargs): + # installation_id = event.data["installation"]["id"] + print(f"App installed by {event.data['installation']['account']['login']}, installation_id: {event.data['installation']['id']}") + + if __name__ == "__main__": # pragma: no cover app = web.Application() app.router.add_post("/", main) diff --git a/bedevere/stage.py b/bedevere/stage.py index f3914d9b..bf366ba7 100644 --- a/bedevere/stage.py +++ b/bedevere/stage.py @@ -146,7 +146,9 @@ async def new_commit_pushed(event, gh, *arg, **kwargs): if len(commits) > 0: # get the latest commit hash commit_hash = commits[-1]["id"] - pr = await util.get_pr_for_commit(gh, commit_hash) + repo_full_name = event.data["repository"]["full_name"] + pr = await util.get_pr_for_commit(gh, commit_hash, repo_full_name) + for label in util.labels(pr): if label == "awaiting merge": issue = await util.issue_for_PR(gh, pr) diff --git a/bedevere/util.py b/bedevere/util.py index 7cb6c7a9..97ed6c6a 100644 --- a/bedevere/util.py +++ b/bedevere/util.py @@ -187,11 +187,15 @@ async def is_core_dev(gh, username): """Check if the user is a CPython core developer.""" org_teams = "/orgs/python/teams" team_name = "python core" - async for team in gh.getiter(org_teams): - if team["name"].lower() == team_name: # pragma: no branch - break - else: - raise ValueError(f"{team_name!r} not found at {org_teams!r}") + try: + async for team in gh.getiter(org_teams): + if team["name"].lower() == team_name: # pragma: no branch + break + else: + raise ValueError(f"{team_name!r} not found at {org_teams!r}") + except gidgethub.BadRequest as exc: + # returns 403 error if the resource is not accessible by integration + return False # The 'teams' object only provides a URL to a deprecated endpoint, # so manually construct the URL to the non-deprecated team membership # endpoint. @@ -232,10 +236,12 @@ def no_labels(event_data): return False -async def get_pr_for_commit(gh, sha): +async def get_pr_for_commit(gh, sha, repo_full_name=None): """Find the PR containing the specific commit hash.""" + if not repo_full_name: + repo_full_name = "python/cpython" prs_for_commit = await gh.getitem( - f"/search/issues?q=type:pr+repo:python/cpython+sha:{sha}" + f"/search/issues?q=type:pr+repo:{repo_full_name}+sha:{sha}" ) if prs_for_commit["total_count"] > 0: # there should only be one return prs_for_commit["items"][0] diff --git a/runtime.txt b/runtime.txt index a5da7cc4..1e480cee 100644 --- a/runtime.txt +++ b/runtime.txt @@ -1 +1 @@ -python-3.10.5 +python-3.10.12 diff --git a/tests/test___main__.py b/tests/test___main__.py index e9b49172..9084683e 100644 --- a/tests/test___main__.py +++ b/tests/test___main__.py @@ -1,8 +1,21 @@ from aiohttp import web -import pytest + +from unittest import mock + from bedevere import __main__ as main +from gidgethub import sansio + + +app_installation_payload = { + "installation": + { + "id": 123, + "account": {"login": "mariatta"}, + } + } + async def test_ping(aiohttp_client): app = web.Application() @@ -36,3 +49,40 @@ async def test_failure(aiohttp_client): # Missing key headers. response = await client.post("/", headers={}) assert response.status == 500 + + +@mock.patch("gidgethub.apps.get_installation_access_token") +async def test_success_with_installation(get_access_token_mock, aiohttp_client): + + get_access_token_mock.return_value = {'token': 'ghs_blablabla', 'expires_at': '2023-06-14T19:02:50Z'} + app = web.Application() + app.router.add_post("/", main.main) + client = await aiohttp_client(app) + headers = {"x-github-event": "project", + "x-github-delivery": "1234"} + # Sending a payload that shouldn't trigger any networking, but no errors + # either. + data = {"action": "created"} + data.update(app_installation_payload) + response = await client.post("/", headers=headers, json=data) + assert response.status == 200 + + +class FakeGH: + + def __init__(self): + pass + + +async def test_repo_installation_added(capfd): + event_data = { + "action": "created", + } + event_data.update(app_installation_payload) + + event = sansio.Event(event_data, event='installation', + delivery_id='1') + gh = FakeGH() + await main.router.dispatch(event, gh) + out, err = capfd.readouterr() + assert f"App installed by {event.data['installation']['account']['login']}, installation_id: {event.data['installation']['id']}" in out diff --git a/tests/test_stage.py b/tests/test_stage.py index 39cc449d..9d2b4838 100644 --- a/tests/test_stage.py +++ b/tests/test_stage.py @@ -24,6 +24,8 @@ async def getiter(self, url, url_vars={}): self.getiter_url = sansio.format_url(url, url_vars) to_iterate = self._getiter_return[self.getiter_url] for item in to_iterate: + if isinstance(item, Exception): + raise item yield item async def getitem(self, url, url_vars={}): @@ -1096,17 +1098,21 @@ async def test_awaiting_label_not_removed_when_pr_not_merged(label): await awaiting.router.dispatch(event, gh) assert gh.delete_url is None + @pytest.mark.parametrize("issue_url_key", ["url", "issue_url"]) -async def test_new_commit_pushed_to_approved_pr(issue_url_key): +@pytest.mark.parametrize("repo_full_name", ["mariatta/cpython", "python/cpython"]) +async def test_new_commit_pushed_to_approved_pr(issue_url_key, repo_full_name): # There is new commit on approved PR username = "brettcannon" sha = "f2393593c99dd2d3ab8bfab6fcc5ddee540518a9" - data = {"commits": [{"id": sha}]} + data = {"commits": [{"id": sha}], + "repository": {"full_name": repo_full_name}, + } event = sansio.Event(data, event="push", delivery_id="12345") teams = [{"name": "python core", "id": 6}] items = { f"https://api.github.com/teams/6/memberships/{username}": "OK", - f"https://api.github.com/search/issues?q=type:pr+repo:python/cpython+sha:{sha}": { + f"https://api.github.com/search/issues?q=type:pr+repo:{repo_full_name}+sha:{sha}": { "total_count": 1, "items": [ { @@ -1169,14 +1175,18 @@ async def test_new_commit_pushed_to_approved_pr(issue_url_key): ) } + @pytest.mark.parametrize("issue_url_key", ["url", "issue_url"]) -async def test_new_commit_pushed_to_not_approved_pr(issue_url_key): +@pytest.mark.parametrize("repo_full_name", ["mariatta/cpython", "python/cpython"]) +async def test_new_commit_pushed_to_not_approved_pr(issue_url_key, repo_full_name): # There is new commit on approved PR sha = "f2393593c99dd2d3ab8bfab6fcc5ddee540518a9" - data = {"commits": [{"id": sha}]} + data = {"commits": [{"id": sha}], + "repository": {"full_name": repo_full_name}, + } event = sansio.Event(data, event="push", delivery_id="12345") items = { - f"https://api.github.com/search/issues?q=type:pr+repo:python/cpython+sha:{sha}": { + f"https://api.github.com/search/issues?q=type:pr+repo:{repo_full_name}+sha:{sha}": { "total_count": 1, "items": [ { diff --git a/tests/test_util.py b/tests/test_util.py index 3917dbe1..01dea940 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -98,6 +98,14 @@ async def test_is_core_dev(): await util.is_core_dev(gh, "andrea") +async def test_is_core_dev_resource_not_accessible(): + + gh = FakeGH(getiter={"https://api.github.com/orgs/python/teams": [gidgethub.BadRequest( + status_code=http.HTTPStatus(403) + )]}) + assert await util.is_core_dev(gh, "mariatta") is False + + def test_title_normalization(): title = "abcd" body = "1234"