From 765ac95ce62b0a8de9c0a8bd408cc752e00105b2 Mon Sep 17 00:00:00 2001 From: Valentin Rigal Date: Mon, 26 Jan 2026 10:40:11 +0100 Subject: [PATCH 1/4] Base Github reporter + script to comment on a real issue --- bot/code_review_bot/github.py | 0 bot/code_review_bot/report/__init__.py | 2 ++ bot/code_review_bot/report/github.py | 45 +++++++++++++++++++++++ bot/code_review_bot/sources/github.py | 50 ++++++++++++++++++++++++++ bot/github_comment.py | 47 ++++++++++++++++++++++++ bot/requirements.txt | 2 ++ 6 files changed, 146 insertions(+) delete mode 100644 bot/code_review_bot/github.py create mode 100644 bot/code_review_bot/report/github.py create mode 100644 bot/code_review_bot/sources/github.py create mode 100755 bot/github_comment.py diff --git a/bot/code_review_bot/github.py b/bot/code_review_bot/github.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/bot/code_review_bot/report/__init__.py b/bot/code_review_bot/report/__init__.py index f1d5294ee..ed79bb1ec 100644 --- a/bot/code_review_bot/report/__init__.py +++ b/bot/code_review_bot/report/__init__.py @@ -4,6 +4,7 @@ import structlog +from code_review_bot.report.github import GithubReporter from code_review_bot.report.lando import LandoReporter from code_review_bot.report.mail import MailReporter from code_review_bot.report.mail_builderrors import BuildErrorsReporter @@ -22,6 +23,7 @@ def get_reporters(configuration): "mail": MailReporter, "build_error": BuildErrorsReporter, "phabricator": PhabricatorReporter, + "github": GithubReporter, } out = {} diff --git a/bot/code_review_bot/report/github.py b/bot/code_review_bot/report/github.py new file mode 100644 index 000000000..1e55a605d --- /dev/null +++ b/bot/code_review_bot/report/github.py @@ -0,0 +1,45 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +from code_review_bot.report.base import Reporter +from code_review_bot.sources.github import GithubClient + + +class GithubReporter(Reporter): + # Auth to Github using a configuration (from Taskcluster secret) + + def __init__(self, configuration={}, *args, **kwargs): + if kwargs.get("api") is not None: + api_url = kwargs["api"] + else: + api_url = "https://api.github.com/" + + # Setup github App secret from the configuration + self.github_client = GithubClient( + api_url=api_url, + client_id=configuration.get("app_client_id"), + pem_file_path=configuration.get("app_pem_file"), + ) + + self.analyzers_skipped = configuration.get("analyzers_skipped", []) + assert isinstance( + self.analyzers_skipped, list + ), "analyzers_skipped must be a list" + + def publish(self, issues, revision, task_failures, notices, reviewers): + """ + Publish issues on a Github pull request. + """ + raise NotImplementedError + + @property + def github_jwt_token(self): + # Use the GitHub App's private key to create a JWT (JSON Web Token). + # Exchange the JWT for an installation access token via the GitHub API. + raise NotImplementedError + + def comment(self, *, owner, repo, issue_number, message): + self.github_client.make_request( + "post", f"repos/{owner}/{repo}/issues/{issue_number}/comments", json=message + ) diff --git a/bot/code_review_bot/sources/github.py b/bot/code_review_bot/sources/github.py new file mode 100644 index 000000000..7f44afd22 --- /dev/null +++ b/bot/code_review_bot/sources/github.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 + +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import time +from urllib.parse import urljoin + +import jwt +import requests + + +class GithubClient: + def __init__(self, api_url: str, client_id: str, pem_file_path: str): + self.api_url = api_url + self.client_id = client_id + self.pem_file_path = pem_file_path + + def generate_jwt(self): + with open(self.pem_file_path) as f: + signing_key = f.read() + + # https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app#example-using-python-to-generate-a-jwt + return jwt.encode( + { + # Issued at time + "iat": int(time.time()), + # JWT expiration time (10 minutes maximum) + "exp": int(time.time()) + 600, + # GitHub App's client ID + "iss": self.client_id, + }, + signing_key, + algorithm="RS256", + ) + + def make_request(self, method, path, *, headers={}, **kwargs): + jwt = self.generate_jwt() + headers["Authorization"] = f"Bearer {jwt}" + headers["Accept"] = "application/vnd.github+json" + + url = urljoin(self.api_url, path) + resp = getattr(requests, method)( + url, + headers=headers, + **kwargs, + ) + resp.raise_for_status() + return resp.json() diff --git a/bot/github_comment.py b/bot/github_comment.py new file mode 100755 index 000000000..869686127 --- /dev/null +++ b/bot/github_comment.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +import sys + +from code_review_bot.report.github import GithubReporter + + +def get_configuration(): + """ + Example of code review reporter configuration to publish on github API. + + bot: + REPORTERS: + - reporter: github + app_client_id: xxxxxxxxxxxxxxxxxxxx + app_pem_file: path/to.pem + """ + # Handle the GitHub app secret as the single script argument + _, *args = sys.argv + assert ( + len(args) == 2 + ), "Please run this script with a App client ID and the path to Github private key (`.pem` file)." + app_client_id, app_secret, *_ = args + assert len(app_client_id) == 20, "Github App client ID should be 20 characters." + return { + "reporter": "github", + "app_client_id": app_client_id, + "app_pem_file": app_secret, + } + + +def main(): + """ + Initialize a Github reporter and publish a simple comment on a defined issue + """ + print("Initializing Github reporter") + reporter = GithubReporter(get_configuration()) + print("Publishing a comment to https://github.com/vrigal/test-dev-mozilla/pull/1") + reporter.comment( + owner="vrigal", + repo="test-dev-mozilla", + issue_number=1, + message="test message", + ) + + +if __name__ == "__main__": + main() diff --git a/bot/requirements.txt b/bot/requirements.txt index 9d51590d1..6a102d6fc 100644 --- a/bot/requirements.txt +++ b/bot/requirements.txt @@ -1,6 +1,8 @@ aiohttp<4 influxdb==5.3.2 +jwt==1.4.0 libmozdata==0.2.12 +PyJWT==2.11.0 python-hglib==2.6.2 pyyaml==6.0.3 rs_parsepatch==0.4.4 From ff48776d8bb78946a4302422583c7b938e5fa8aa Mon Sep 17 00:00:00 2001 From: Valentin Rigal Date: Mon, 2 Feb 2026 17:11:02 +0100 Subject: [PATCH 2/4] Add app/installations example --- bot/github_comment.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/github_comment.py b/bot/github_comment.py index 869686127..57e296039 100755 --- a/bot/github_comment.py +++ b/bot/github_comment.py @@ -34,6 +34,9 @@ def main(): """ print("Initializing Github reporter") reporter = GithubReporter(get_configuration()) + print("Doing a GET on app/installations") + data = reporter.github_client.make_request("get", "app/installations") + print(f"Returned ID: {data[0]['id']}") print("Publishing a comment to https://github.com/vrigal/test-dev-mozilla/pull/1") reporter.comment( owner="vrigal", From 233f3f2f4bf4b045d684de23d767aa25e1640734 Mon Sep 17 00:00:00 2001 From: Valentin Rigal Date: Tue, 3 Feb 2026 16:54:48 +0100 Subject: [PATCH 3/4] Use pygithub library to handle API authentication --- bot/code_review_bot/report/github.py | 41 +++++++------- bot/code_review_bot/sources/github.py | 66 +++++++++++------------ bot/github_comment.py | 77 ++++++++++++++++++++------- bot/requirements.txt | 2 +- 4 files changed, 112 insertions(+), 74 deletions(-) diff --git a/bot/code_review_bot/report/github.py b/bot/code_review_bot/report/github.py index 1e55a605d..92d356501 100644 --- a/bot/code_review_bot/report/github.py +++ b/bot/code_review_bot/report/github.py @@ -2,24 +2,23 @@ # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. +import structlog + from code_review_bot.report.base import Reporter from code_review_bot.sources.github import GithubClient +logger = structlog.get_logger(__name__) + class GithubReporter(Reporter): # Auth to Github using a configuration (from Taskcluster secret) def __init__(self, configuration={}, *args, **kwargs): - if kwargs.get("api") is not None: - api_url = kwargs["api"] - else: - api_url = "https://api.github.com/" - # Setup github App secret from the configuration self.github_client = GithubClient( - api_url=api_url, client_id=configuration.get("app_client_id"), - pem_file_path=configuration.get("app_pem_file"), + pem_key_path=configuration.get("app_pem_file"), + installation_id=configuration.get("app_installation_id"), ) self.analyzers_skipped = configuration.get("analyzers_skipped", []) @@ -31,15 +30,19 @@ def publish(self, issues, revision, task_failures, notices, reviewers): """ Publish issues on a Github pull request. """ - raise NotImplementedError - - @property - def github_jwt_token(self): - # Use the GitHub App's private key to create a JWT (JSON Web Token). - # Exchange the JWT for an installation access token via the GitHub API. - raise NotImplementedError - - def comment(self, *, owner, repo, issue_number, message): - self.github_client.make_request( - "post", f"repos/{owner}/{repo}/issues/{issue_number}/comments", json=message - ) + if reviewers: + raise NotImplementedError + # Avoid publishing a patch from a de-activated analyzer + publishable_issues = [ + issue + for issue in issues + if issue.is_publishable() + and issue.analyzer.name not in self.analyzers_skipped + ] + if not publishable_issues: + logger.info("No publishable issue, nothing to do") + return + + # Publish a review comment summarizing detected, unresolved and closed issues + for issue in issues: + self.github_client.comment(revision=revision, issue=issue) diff --git a/bot/code_review_bot/sources/github.py b/bot/code_review_bot/sources/github.py index 7f44afd22..1ccdfa68c 100644 --- a/bot/code_review_bot/sources/github.py +++ b/bot/code_review_bot/sources/github.py @@ -4,47 +4,41 @@ # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. -import time -from urllib.parse import urljoin - -import jwt -import requests +from github import Auth, GithubIntegration class GithubClient: - def __init__(self, api_url: str, client_id: str, pem_file_path: str): - self.api_url = api_url + def __init__(self, client_id: str, pem_key_path: str, installation_id: str): self.client_id = client_id - self.pem_file_path = pem_file_path + with open(pem_key_path) as f: + private_key = f.read() - def generate_jwt(self): - with open(self.pem_file_path) as f: - signing_key = f.read() + # Setup auth + self.auth = Auth.AppAuth(self.client_id, private_key) + self.github_integration = GithubIntegration(auth=self.auth) - # https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app#example-using-python-to-generate-a-jwt - return jwt.encode( - { - # Issued at time - "iat": int(time.time()), - # JWT expiration time (10 minutes maximum) - "exp": int(time.time()) + 600, - # GitHub App's client ID - "iss": self.client_id, - }, - signing_key, - algorithm="RS256", + installations = self.github_integration.get_installations() + self.installation = next( + (i for i in installations if str(i.id) == installation_id), None ) - - def make_request(self, method, path, *, headers={}, **kwargs): - jwt = self.generate_jwt() - headers["Authorization"] = f"Bearer {jwt}" - headers["Accept"] = "application/vnd.github+json" - - url = urljoin(self.api_url, path) - resp = getattr(requests, method)( - url, - headers=headers, - **kwargs, + if not self.installation: + raise ValueError( + f"Installation ID is not available. Available installations are {list(installations)}" + ) + # setup API + self.api = self.installation.get_github_for_installation() + + def comment( + self, + *, + revision, # GithubRevision + issue, + ): + repo = self.api.get_repo(revision.repository) + pull_request = repo.get_pull(revision.pull_id) + pull_request.create_comment( + commit=revision.commit, + path=issue.path, + position=issue.line, + body=issue.message, ) - resp.raise_for_status() - return resp.json() diff --git a/bot/github_comment.py b/bot/github_comment.py index 57e296039..22146d469 100755 --- a/bot/github_comment.py +++ b/bot/github_comment.py @@ -1,48 +1,89 @@ -#!/usr/bin/env python3 +#!python import sys from code_review_bot.report.github import GithubReporter +from code_review_bot.revisions.base import Revision +from code_review_bot.tasks.clang_tidy import ClangTidyIssue, ClangTidyTask def get_configuration(): """ - Example of code review reporter configuration to publish on github API. + The configuration is directly set from command line arguments, in order to ease developments. + Example of code review reporter configuration to publish on github API: + ``` bot: REPORTERS: - reporter: github app_client_id: xxxxxxxxxxxxxxxxxxxx app_pem_file: path/to.pem + app_installation_id: 123456789 + ``` """ - # Handle the GitHub app secret as the single script argument _, *args = sys.argv - assert ( - len(args) == 2 - ), "Please run this script with a App client ID and the path to Github private key (`.pem` file)." - app_client_id, app_secret, *_ = args + if not len(args) == 3: + raise RuntimeError( + "Please run this script with 3 arguments:\n" + "* App client ID (xxxxxxxxxxxxxxxxxxxx)\n" + "* Path to Github private key (`.pem` file)\n" + "* App installation ID (123456789)" + ) + + app_client_id, app_secret, app_installation_id = args assert len(app_client_id) == 20, "Github App client ID should be 20 characters." + assert ( + app_installation_id.isdigit() + ), "Installation ID should be composed of digits." return { "reporter": "github", "app_client_id": app_client_id, "app_pem_file": app_secret, + "app_installation_id": app_installation_id, } +def mock_task(cls, name): + """Build configuration for any Analysis task""" + return cls(f"{name}-ID", {"task": {"metadata": {"name": name}}, "status": {}}) + + def main(): """ - Initialize a Github reporter and publish a simple comment on a defined issue + Initialize a Github reporter and publish issues """ - print("Initializing Github reporter") reporter = GithubReporter(get_configuration()) - print("Doing a GET on app/installations") - data = reporter.github_client.make_request("get", "app/installations") - print(f"Returned ID: {data[0]['id']}") - print("Publishing a comment to https://github.com/vrigal/test-dev-mozilla/pull/1") - reporter.comment( - owner="vrigal", - repo="test-dev-mozilla", - issue_number=1, - message="test message", + revision = Revision() + + # Add the attributes that will be supported by GithubRevision + revision.repository = "vrigal/test-dev-mozilla" + revision.commit = "da4ed2eccaff01034c1c2091d2797d55bc0c57cf" + revision.pull_id = 3 + + analyzer = mock_task(ClangTidyTask, "source-test-clang-tidy") + issue1 = ClangTidyIssue( + analyzer, + revision, + "a_first_test.cpp", + "1", + "1", + "parser-error", + "Reporter: this file is not C++ !", + ) + issue2 = ClangTidyIssue( + analyzer, + revision, + "another_test.cpp", + "11", + "11", + "no-line-after-return", + "Dummy message", + ) + # Mock all issues as publishable + issue1.is_publishable = lambda: True + issue2.is_publishable = lambda: True + + reporter.publish( + [issue1, issue2], revision, task_failures=[], notices=[], reviewers=[] ) diff --git a/bot/requirements.txt b/bot/requirements.txt index 6a102d6fc..c5db5db91 100644 --- a/bot/requirements.txt +++ b/bot/requirements.txt @@ -2,7 +2,7 @@ aiohttp<4 influxdb==5.3.2 jwt==1.4.0 libmozdata==0.2.12 -PyJWT==2.11.0 +PyGithub==2.8.1 python-hglib==2.6.2 pyyaml==6.0.3 rs_parsepatch==0.4.4 From 8c262523211373d20fdbfead97e571671fb45e60 Mon Sep 17 00:00:00 2001 From: Valentin Rigal Date: Thu, 5 Feb 2026 14:20:13 +0100 Subject: [PATCH 4/4] Remove conflicting jwt library --- bot/requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/requirements.txt b/bot/requirements.txt index c5db5db91..60aa1dd3f 100644 --- a/bot/requirements.txt +++ b/bot/requirements.txt @@ -1,6 +1,5 @@ aiohttp<4 influxdb==5.3.2 -jwt==1.4.0 libmozdata==0.2.12 PyGithub==2.8.1 python-hglib==2.6.2