diff --git a/.env b/.env index 6fd5bba9d..a01e92e0c 100644 --- a/.env +++ b/.env @@ -63,3 +63,9 @@ PUBLIC_API_GEO_COUNTRY_QUERY_ENDPOINT="/api/resources/3580bf65-1d11-4574-a2ca-90 #### TCHAP canal variable PUBLIC_CONTACT_URL="https://www.tchap.gouv.fr/#/room/!pwyfzLTDXyMeinVsgL:agent.dinum.tchap.gouv.fr" PUBLIC_CONTACT_EMAIL="equipe-ami@numerique.gouv.fr" + +# Github App credentials to list open PRs +# Generate with: base64 -i ami-reviewapps.private-key.pem +GITHUB_APP_ID="" +GITHUB_APP_INSTALLATION_ID="" +GITHUB_APP_PRIVATE_KEY_B64="" diff --git a/.gitignore b/.gitignore index fa69c25c9..275afb6bd 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ static/ # The private key file for Firebase Cloud Messaging *-adminsdk-*.json + +# The private key for the "ami-reviewapps" Github App +*.private-key.pem diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8526df75d..2555faddb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -301,3 +301,23 @@ For vite, we used to take advantage of `basicSsl` provided by a vitejs `plugin-b ```sh mkcert -install ``` + + +# Making authenticated requests to the Github API + +As explained in +https://github.com/numerique-gouv/ami-notifications-api/issues/417, we need to +make authenticated requests to the Github API, in the staging mobile apps. + +To do so, we use a Github App named `ami-reviewapps`, and store its ID in the following env variable: `GITHUB_APP_ID`. + +This app is then used to generate a private key stored in the `GITHUB_APP_PRIVATE_KEY` env variable, +as a base64 value, generated using: + +```sh +base64 -i ami-reviewapps.private-key.pem +``` + +The call chain with PyGithub is: + +`Auth.AppAuth → GithubIntegration → get_installation → get_pulls` diff --git a/ami/settings.py b/ami/settings.py index 4e5cec892..fcc490aa8 100644 --- a/ami/settings.py +++ b/ami/settings.py @@ -1,3 +1,4 @@ +import base64 import os import sys from pathlib import Path @@ -297,6 +298,14 @@ def before_send(event, hint): PARTNERS_PSL_OTV_JWT_CERT_PUBLIC_KEY = CONFIG.get("PARTNERS_PSL_OTV_JWT_CERT_PUBLIC_KEY", "") PARTNERS_PSL_OTV_JWE_PUBLIC_KEY = CONFIG.get("PARTNERS_PSL_OTV_JWE_PUBLIC_KEY", "") +# Github App credentials to list open PRs +GITHUB_APP_ID = CONFIG.get("GITHUB_APP_ID", "") +GITHUB_APP_PRIVATE_KEY = ( + base64.b64decode(CONFIG.get("GITHUB_APP_PRIVATE_KEY_B64", "")).decode() + if CONFIG.get("GITHUB_APP_PRIVATE_KEY_B64") + else "" +) + # Channels CHANNEL_UNAUTHORIZED_CODE = 4001 diff --git a/ami/utils/api_views.py b/ami/utils/api_views.py index b842782e8..c2b707530 100644 --- a/ami/utils/api_views.py +++ b/ami/utils/api_views.py @@ -4,11 +4,11 @@ from django.db import connection from django.http import HttpResponse from drf_spectacular.utils import extend_schema +from github import Auth, GithubIntegration from rest_framework.decorators import api_view from rest_framework.response import Response from ami.user.utils import build_fc_hash -from ami.utils.httpx import httpxClient from ami.utils.serializers import RecipientFcHashSerializer @@ -55,34 +55,33 @@ def _dev_utils_recipient_fc_hash(request) -> HttpResponse: @api_view(["GET"]) def _dev_utils_review_apps(request) -> Response[list[dict[str, str | int]]]: """Returns a list of tuples: (review app url, pull request title).""" - with httpxClient() as httpx_client: - response = httpx_client.get( - "https://api.github.com/repos/numerique-gouv/ami-notifications-api/pulls", - params={"state": "open", "sort": "created", "per_page": 100}, - headers={ - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - }, - ) staging_app = { "url": "https://ami-back-staging.osc-fr1.scalingo.io/", "title": "Staging", "number": 0, "description": "Staging", } - if response.status_code >= 400: - # Possibly rate limited + try: + auth = Auth.AppAuth( + app_id=settings.GITHUB_APP_ID, + private_key=settings.GITHUB_APP_PRIVATE_KEY, + ) + gi = GithubIntegration(auth=auth) + installation = gi.get_repo_installation("numerique-gouv", "ami-notifications-api") + gh = installation.get_github_for_installation() + repo = gh.get_repo("numerique-gouv/ami-notifications-api") + open_pulls = repo.get_pulls(state="open", sort="created") + except Exception: return Response([staging_app]) - json_data = response.json() review_apps = [ { - "url": f"https://ami-back-staging-pr{review_app['number']}.osc-fr1.scalingo.io/", - "title": f"PR{review_app['number']}: {review_app['title']}", - "number": review_app["number"], - "description": review_app["body"], + "url": f"https://ami-back-staging-pr{pr.number}.osc-fr1.scalingo.io/", + "title": f"PR{pr.number}: {pr.title}", + "number": pr.number, + "description": pr.body, } - for review_app in json_data + for pr in open_pulls ] return Response([staging_app] + review_apps) diff --git a/ami/utils/tests/test_all.py b/ami/utils/tests/test_all.py index 8acae810f..77978154f 100644 --- a/ami/utils/tests/test_all.py +++ b/ami/utils/tests/test_all.py @@ -1,6 +1,5 @@ from typing import Any - -from pytest_httpx import HTTPXMock +from unittest.mock import Mock def test_ping(app) -> None: @@ -35,24 +34,54 @@ def test_recipient_fc_hash(app) -> None: assert response.text == "7e74df2cbebae761eccedbc24b7fe589bb83137f7808a2930031f52c73d75efe" -def test_review_apps(app, httpx_mock: HTTPXMock) -> None: - httpx_mock.add_response( - method="GET", - url="https://api.github.com/repos/numerique-gouv/ami-notifications-api/pulls?state=open&sort=created&per_page=100", - json=TRUNCATED_GITHUB_JSON_RESPONSE, +def _make_github_mock(pulls: list) -> Mock: + """Build a GithubIntegration mock that returns the given list of pull requests.""" + pr_mocks = [] + for pr in pulls: + mock_pr = Mock() + mock_pr.number = pr["number"] + mock_pr.title = pr["title"] + mock_pr.body = pr["body"] + pr_mocks.append(mock_pr) + + mock_repo = Mock() + mock_repo.get_pulls.return_value = pr_mocks + + mock_gh = Mock() + mock_gh.get_repo.return_value = mock_repo + + mock_installation = Mock() + mock_installation.get_github_for_installation.return_value = mock_gh + + mock_gi = Mock() + mock_gi.get_repo_installation.return_value = mock_installation + + return mock_gi + + +def test_review_apps(app, monkeypatch, settings) -> None: + settings.GITHUB_APP_ID = "123456" + settings.GITHUB_APP_PRIVATE_KEY = "fake-key" + monkeypatch.setattr("ami.utils.api_views.Auth", Mock()) + monkeypatch.setattr( + "ami.utils.api_views.GithubIntegration", + lambda **kwargs: _make_github_mock(TRUNCATED_GITHUB_JSON_RESPONSE), ) response = app.get("/dev-utils/review-apps") json_data = response.json - assert len(json_data) == 2 # Staging + the PR returned in GITHUB_JSON_RESPONSE + assert len(json_data) == 2 # Staging + the PR returned in TRUNCATED_GITHUB_JSON_RESPONSE assert json_data[0]["title"] == "Staging" -def test_review_apps_github_failure(app, httpx_mock: HTTPXMock) -> None: - httpx_mock.add_response( - method="GET", - url="https://api.github.com/repos/numerique-gouv/ami-notifications-api/pulls?state=open&sort=created&per_page=100", - status_code=400, - ) +def test_review_apps_github_failure(app, monkeypatch, settings) -> None: + settings.GITHUB_APP_ID = "123456" + settings.GITHUB_APP_PRIVATE_KEY = "fake-key" + monkeypatch.setattr("ami.utils.api_views.Auth", Mock()) + + def raise_error(**kwargs): + raise Exception("GitHub API error") + + monkeypatch.setattr("ami.utils.api_views.GithubIntegration", raise_error) response = app.get("/dev-utils/review-apps") json_data = response.json assert len(json_data) == 1 # Only the hardcoded Staging diff --git a/pyproject.toml b/pyproject.toml index a5abb8971..4ff2de468 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "libsass>=0.23.0", "mozilla-django-oidc>=5.0.2", "psycopg[binary]>=3.3.3", + "pygithub>=2.9.1", "python-dotenv>=1.2.2", "sentry-sdk>=2.29.1", "types-requests>=2.32.0.20250328", diff --git a/uv.lock b/uv.lock index 13437946a..49b8b4714 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.13" resolution-markers = [ "python_full_version >= '3.14'", @@ -29,6 +29,7 @@ dependencies = [ { name = "libsass" }, { name = "mozilla-django-oidc" }, { name = "psycopg", extra = ["binary"] }, + { name = "pygithub" }, { name = "python-dotenv" }, { name = "sentry-sdk" }, { name = "types-requests" }, @@ -79,6 +80,7 @@ requires-dist = [ { name = "libsass", specifier = ">=0.23.0" }, { name = "mozilla-django-oidc", specifier = ">=5.0.2" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.3.3" }, + { name = "pygithub", specifier = ">=2.9.1" }, { name = "python-dotenv", specifier = ">=1.2.2" }, { name = "sentry-sdk", specifier = ">=2.29.1" }, { name = "types-requests", specifier = ">=2.32.0.20250328" }, @@ -1784,6 +1786,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/ae/fe31e7f4a62431222d8f65a3bd02e3fa7e6026d154a00818e6d30520ea77/pydantic_core-2.33.1-cp313-cp313t-win_amd64.whl", hash = "sha256:338ea9b73e6e109f15ab439e62cb3b78aa752c7fd9536794112e14bee02c8d18", size = 1931810, upload-time = "2025-04-02T09:48:17.97Z" }, ] +[[package]] +name = "pygithub" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyjwt", extra = ["crypto"] }, + { name = "pynacl" }, + { name = "requests" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/c3/8465a311197e16cf5ab68789fe689535e90f6b61ab524cc32a39e67237ae/pygithub-2.9.1.tar.gz", hash = "sha256:59771d7ff63d54d427be2e7d0dad2208dfffc2b0a045fec959263787739b611c", size = 2594989, upload-time = "2026-04-14T07:26:13.622Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/aa/81a5506f089a26338bff17535e4339b3b22049ebd1bcdeff756c4d7a7559/pygithub-2.9.1-py3-none-any.whl", hash = "sha256:2ec78fca30092d51a42d76f4ddb02131b6f0c666a35dfdf364cf302cdda115b9", size = 449710, upload-time = "2026-04-14T07:26:12.382Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -1822,6 +1840,41 @@ version = "0.5.12" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/81/76/599896b37e60f43078afd8354b3802eb7ca257a7e7f6253cc21c4c672877/PyMeeus-0.5.12.tar.gz", hash = "sha256:548f7186bd8b96cbc069cf649a8e8e377dce49ac74486709849fe63a99cad684", size = 5752712, upload-time = "2022-12-11T11:37:48.504Z" } +[[package]] +name = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + [[package]] name = "pyopenssl" version = "26.0.0"