Repository: python/bedevere
Pull request #569 (2023-06-14 19:12:13)

Make bedevere work as a GitHub App
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.

Checklist for deploying this app:
- [x] Choose a name for the GitHub App. (Can't use `bedevere-bot` since it's already taken). Suggestions: `bedevere-py`, `bedevere-app`
- [x] Create a GitHub App under the Python org ~~/ transfer mariatta's app to the Python org~~
- [x] Set environment variables in bedevere's Heroku app: `GH_APP_ID`, `GH_PRIVATE_KEY` (from the newly created GitHub App in the previous step)
- [ ] Deploy and test

Mariatta: I tested the app by deploying it to a personal heroku instance, and created a GitHub App on my account.
Sample how it works: https://github.com/Mariatta/cpython/pull/77

It shows the bot applying the "skip news" and "docs" labels to the PR.
Since it is a personal App, it does not have access to the CPython core devs list, and therefore it applies the "awaiting review" label. (It doesn't know that I'm a core developer).


hugovk: > * [ ]  Choose a name for the GitHub App. (Can't use `bedevere-bot` since it's already taken). Suggestions: `bedevere-py`, `bedevere-app`

`bedevere-app` sounds good: it's a GitHub app, so it's descriptive. `bedevere-py` could be almost anything.

Mariatta: @ewdurbin checking if you've had chance to review this. Thanks.
ewdurbin: Hi @Mariatta this looks ready to go. I think I'd prefer us to create a new GitHub application under the python org.

What [permission requests](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/choosing-permissions-for-a-github-app) need to be configured for the application to work?
Mariatta: These are the settings I have on my test app:

General:
- Webhook: active
- Webhook URL: the url to bedevere's Heroku app
- Webhook Secret: the `GH_SECRET` env var in Heroku

Repository permissions:
- Commit statuses: Read and write
- Issues: Read and write
- Metadata: Readonly
- Pull requests: read and write
- the rest are no access (default)

Organization permissions:
- no access 

Account permissions:
- no access 

Subscribe to events
- Issues
- Pull request
- Repository
- Pull Request Review

Let me know if there's anything else needed, and also give me heads up when this is set up. I can help debug issues in case anything arises.
ewdurbin: OK @Mariatta everything is set. The app has been created. I'm going to see if we can make it work :)

Patch:
diff --git a/bedevere/__main__.py b/bedevere/__main__.py
index 3a3c074..a16111d 100644
--- a/bedevere/__main__.py
+++ b/bedevere/__main__.py
@@ -10,6 +10,7 @@ import cachetools
 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 f3914d9..bf366ba 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 7cb6c7a..97ed6c6 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 a5da7cc..1e480ce 100644
--- a/runtime.txt
+++ b/runtime.txt
@@ -1,1 +1,1 @@
-python-3.10.5
+python-3.10.12


Test patch:
diff --git a/tests/test___main__.py b/tests/test___main__.py
index e9b4917..9084683 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 39cc449..9d2b483 100644
--- a/tests/test_stage.py
+++ b/tests/test_stage.py
@@ -24,6 +24,8 @@ class FakeGH:
         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 3917dbe..01dea94 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"