Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ repos:
rev: v2.2.4
hooks:
- id: codespell
exclude_types: [json]
exclude_types: [json, pem]
- repo: https://github.com/marco-c/taskcluster_yml_validator
rev: v0.0.11
hooks:
Expand Down
2 changes: 2 additions & 0 deletions bot/code_review_bot/report/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,6 +23,7 @@ def get_reporters(configuration):
"mail": MailReporter,
"build_error": BuildErrorsReporter,
"phabricator": PhabricatorReporter,
"github": GithubReporter,
}

out = {}
Expand Down
62 changes: 62 additions & 0 deletions bot/code_review_bot/report/github.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# 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 structlog

from code_review_bot.report.base import Reporter
from code_review_bot.sources.github import GithubClient, ReviewEvent

logger = structlog.get_logger(__name__)


class GithubReporter(Reporter):
# Auth to Github using a configuration (from Taskcluster secret)

def __init__(self, configuration={}, *args, **kwargs):
for key in ("client_id", "private_key_pem", "installation_id"):
if not configuration.get(key):
raise Exception(f"Missing github reporter configuration key {key}")

# Setup github App secret from the configuration
self.github_client = GithubClient(
client_id=configuration["client_id"],
private_key=configuration["private_key_pem"],
installation_id=configuration["installation_id"],
)

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.
"""
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 publishable_issues:
# Publish a review summarizing detected, unresolved and closed issues
message = f"{len(issues)} issues have been found in this revision"
event = ReviewEvent.RequestChanges
else:
# Simply approve the pull request
logger.info("No publishable issue, approving the pull request")
message = None
event = ReviewEvent.Approved

self.github_client.publish_review(
issues=publishable_issues,
revision=revision,
message=message,
event=event,
)
26 changes: 24 additions & 2 deletions bot/code_review_bot/revisions/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
# 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 functools import cached_property
from urllib.parse import urlparse

import requests
import structlog

from code_review_bot import taskcluster
from code_review_bot.git import build_repo_slug
from code_review_bot.revisions import Revision

Expand Down Expand Up @@ -77,15 +79,35 @@ def as_dict(self):
"pull_number": self.pull_number,
}

@cached_property
def pull_request(self):
from code_review_bot.sources.github import GithubClient

reporter_conf = next(
(
reporter
for reporter in taskcluster.secrets["REPORTERS"]
if reporter["reporter"] == "github"
),
None,
)
# A github reporter configuration is required to perform a github Pull Request analysis
assert reporter_conf, "Github reporter secrets must be set to access information about the pull request"
client = GithubClient(
client_id=reporter_conf["client_id"],
private_key=reporter_conf["private_key_pem"],
installation_id=reporter_conf["installation_id"],
)
return client.get_pull_request(self)

def serialize(self):
"""
Outputs a tuple of dicts for revision and diff (empty for Github) sent to backend
"""
revision = {
"provider": "github",
"provider_id": self.pull_number,
# TODO: Use the pull request information from the API
"title": f"Issue {self.pull_number}",
"title": self.pull_request.title,
"bugzilla_id": None,
"base_repository": self.base_repository,
"base_changeset": self.base_changeset,
Expand Down
97 changes: 97 additions & 0 deletions bot/code_review_bot/sources/github.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/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 enum

import structlog
from github import Auth, GithubIntegration
from github.PullRequest import ReviewComment

from code_review_bot import Issue
from code_review_bot.revisions import GithubRevision

logger = structlog.get_logger(__name__)


class ReviewEvent(enum.Enum):
"""
Review action you want to perform.
https://docs.github.com/en/rest/pulls/reviews?apiVersion=2022-11-28#create-a-review-for-a-pull-request--parameters
"""

Pending = "PENDING"
Approved = "APPROVE"
RequestChanges = "REQUEST_CHANGES"
Comment = "COMMENT"


class GithubClient:
def __init__(self, client_id: str, private_key: str, installation_id: str):
self.client_id = client_id

# Setup auth
self.auth = Auth.AppAuth(self.client_id, private_key)
self.github_integration = GithubIntegration(auth=self.auth)

installations = self.github_integration.get_installations()
self.installation = next(
(i for i in installations if i.id == installation_id), None
)
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()

self.review_comments = []

def get_pull_request(self, revision: GithubRevision):
repo = self.api.get_repo(revision.repo_name)
return repo.get_pull(revision.pull_number)

def _build_review_comment(self, issue):
return ReviewComment(
path=issue.path,
line=issue.line,
body=issue.message,
)

def publish_review(
self,
issues: list[Issue],
revision: GithubRevision,
event: ReviewEvent,
message: str | None = None,
):
"""
Publish a review from a list of publishable issues, requesting changes to the author.
"""

if not isinstance(revision, GithubRevision):
logger.warning(
f"Revision must originate from Github in order to publish a review, skipping {revision}."
)
return

repo = self.api.get_repo(revision.repo_name)
pull_request = repo.get_pull(revision.pull_number)

attrs = {}
if message is None:
assert (
event == ReviewEvent.Approved
), "Body can be left null only when approving a pull request"
else:
attrs["body"] = message

pull_request.create_review(
commit=repo.get_commit(revision.head_changeset),
comments=[self._build_review_comment(issue) for issue in issues],
# https://docs.github.com/en/rest/pulls/reviews?apiVersion=2022-11-28#create-a-review-for-a-pull-request
event=event.value,
**attrs,
)
1 change: 1 addition & 0 deletions bot/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ aiohttp<4
gitpython==3.1.47
influxdb==5.3.2
libmozdata==0.2.12
PyGithub==2.8.1
python-hglib==2.6.2
pyyaml==6.0.3
rs_parsepatch==0.4.4
Expand Down
79 changes: 79 additions & 0 deletions bot/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
from collections import defaultdict, namedtuple
from configparser import ConfigParser
from contextlib import contextmanager
from datetime import UTC, datetime, timedelta
from textwrap import dedent
from unittest.mock import MagicMock

import hglib
Expand Down Expand Up @@ -282,6 +284,64 @@ def diff_search(request):
yield PhabricatorAPI(url="http://phabricator.test/api/", api_key="deadbeef")


@pytest.fixture
def mock_github(mock_config):
"""
Mock default github API calls made by the client
"""
diff = dedent(
"""diff --git a/path/to/test.cpp b/path/to/test.cpp
index c57eff55..980a0d5f 100644
--- a/path/to/test.cpp
+++ b/path/to/test.cpp
@@ -1 +1 @@
-#include <random>
+Hello World!
"""
)

responses.add(
responses.GET,
"https://github.tests.com/owner/repo-name/pull/1.diff",
json=diff,
)
responses.add(
responses.GET,
"https://api.github.com:443/app/installations",
json=[
{
"id": 123456789,
"access_tokens_url": "https://github.tests.com/app/installations/123456789/access_tokens",
}
],
)
responses.add(
responses.POST,
"https://api.github.com:443/app/installations/123456789/access_tokens",
json={
"token": "auth_token",
"expires_at": (datetime.now(UTC) + timedelta(1)).strftime(
"%Y-%m-%dT%H:%M:%S.%fZ"
),
},
)
responses.add(
responses.GET,
"https://api.github.com:443/repos/owner/repo-name",
json={"url": "https://api.github.com/repos/owner/repo-name"},
)
responses.add(
responses.GET,
"https://api.github.com:443/repos/owner/repo-name/pulls/1",
json={"url": "https://api.github.com/repos/owner/repo-name/pulls/1"},
)
responses.add(
responses.GET,
"https://api.github.com:443/repos/owner/repo-name/commits/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
json={"sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
)


@pytest.fixture
def mock_try_task():
"""
Expand All @@ -290,6 +350,25 @@ def mock_try_task():
return {"extra": {"code-review": {"phabricator-diff": "PHID-HMBT-test"}}}


@pytest.fixture
def mock_github_decision_task():
"""
Mock a decision task definition from a github revision
"""
return {
"payload": {
"env": {
"GECKO_REPOSITORY_TYPE": "git",
"GECKO_HEAD_REPOSITORY": "https://github.tests.com/owner/repo-name",
"GECKO_HEAD_REV": "a" * 40,
"GECKO_BASE_REPOSITORY": "https://github.tests.com/owner/repo-name",
"GECKO_BASE_REV": "b" * 40,
"GECKO_PULL_REQUEST_NUMBER": 1,
}
}
}


@pytest.fixture
def mock_decision_task():
"""
Expand Down
28 changes: 28 additions & 0 deletions bot/tests/fixtures/private_key.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# THIS IS A FAKE PRIVATE KEY USED FOR TESTS
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAjIf0Q38ga5TF9CbNXewI/duyPJgz/TJAvdHvexwtp3qIIWfH
1CNK0NvcmaLWcvgyVn4nj8aLexQiJZQVQYII/YMwXAg2tK75dWkP56cWL0odb4Zs
o2GU14xRGdonixVb8COC8CxiLzFGBXpY2gMfru/9di6/0hRY1o5Qd2aYrXZVHDpe
0ATuIF0gR8MbMz8y8Azqvs+89epjAmKo+6+sU/7yITm9aJ685CEiug3127Kowk07
TYeebXlzw2eqxcIw35loAs/oqyOIEX607KtVjCBl73FoEfuLHhsi1hFxBOg+HvXn
TzdFiLN/99ZGRRMlJhZCw/DWpZ9uip5MrohneQIDAQABAoIBAD9TVELGGnngBIPM
qGZWYobiZSLhAyxpZLskyuGTBQ+fK5DCD04MyT3slS+2LSSJq0VGe9VSBrBjli+Q
1zM5wYtbfoM6QEyTPF4oBb7BkEGnCDSlQnctFcE7vaAEqiUGbvN7TRmlJmlVrtPx
GfDDz5cpFfIXhuDHwnCMmL31QX+ITV8pmrYYwhpOp+js+25wA7PFzXhgqQtqgGbM
L0O1Tn/POqTbEUBi/S95KwMnNYVEAx6lwmcpLb0KD3x2g0bkHFyeIc+pMeJa+3Kn
OpY1DTlJP3nkpiLmpWIpKk4HMo6oZhcRo9DDLQtJzE+rllEwjZBrSKW3l4d3Eh6t
itsGj7ECgYEA+9OcFcUuMVwFlknTVSoykGv4DQ2gcN/W+H70bYHgAAvjolYxnsQX
hYucxJqTBaG8l5LXjlSynoqMnbm5FY1FLOu8lzt8YhPfc0f/N1jTVL5v52mxyUNr
HjPcbbv9+vihcrhEFkhgRN/lzimv7Eweo5x4wlm8aLOIb/Eg8Y8Wth8CgYEAjtwq
hsPdf0NP/tkJFSrB7Y2I1zBruNvTuJarcv1/w61XLEBR42Odq+Y11/crY8qkwK7z
A5SOxsI6o/si2RDlSZJ5w8t+7Kz/yr5PSFcQHGsokIadQgXCP/hetS2eiNNH94vM
YvwHvSl0ey47qK0h3ugqIZJ1pBSRc0NlfJz8v2cCgYEA0hXdd0QCn2cXuiNozPnh
KR8J10nw+XmkC7dODzV0PFWu2DV0O/F3dg/c/x+9W8tsXD9C2RjL0vvfB45zXAl5
FlqsALa9s8zEc5Yy0memFmKxVKuWiENYT+AQGvPklMVrWxtiofxLY+ot+2pHu6hd
Pz1AeVMHnYl5X3oYc61d0x0CgYBBYT9RJ8hx2rN8lYVTm5rfBdwvZ2iVVH2jx8i1
OpDDU8xGYzVW1JsvNY9ExEimRfJ6gFaVN+LT0cYWj/OV1eapchCp67KtzErQVaJh
H/8uklghNIo50frhXeCyGCuqwM752o/yaRd9mcBGM5V4D6wloKjPboDKU+NxFdIX
Yp1FVwKBgGgG4RA3UqAY51E7zA7k3WR3sj49c6oktXVi2n7FuO3PPVTg5LAZ/c81
vVrip+dOQD53APtrwnFpDeM+AJ03RsIfjVVfB822xRpcy7jDA04bOmJ1Skouoptx
CyIV/PUVbtmNdxJ6T1dCzAvhmK6895FK+xCwBnpaN213Nx/eG49+
-----END RSA PRIVATE KEY-----
Loading