diff --git a/doozerlib/cli/__main__.py b/doozerlib/cli/__main__.py index a7117db6..5d3e1759 100644 --- a/doozerlib/cli/__main__.py +++ b/doozerlib/cli/__main__.py @@ -786,8 +786,9 @@ def print_build_metrics(runtime): help="Specify an exact arch to push (golang name e.g. 'amd64').") @click.option('--dry-run', default=False, is_flag=True, help='Do not build anything, but only print build operations.') @click.option('--build-retries', type=int, default=1, help='Number of build attempts for an osbs build') +@click.option('--comment-on-pr', default=False, is_flag=True, help='Comment on PR after a build, if flag is enabled') @pass_runtime -def images_build_image(runtime, repo_type, repo, push_to_defaults, push_to, scratch, threads, filter_by_os, dry_run, build_retries): +def images_build_image(runtime, repo_type, repo, push_to_defaults, push_to, scratch, threads, filter_by_os, dry_run, build_retries, comment_on_pr): """ Attempts to build container images for all of the distgit repositories in a group. If an image has already been built, it will be treated as @@ -882,7 +883,8 @@ def images_build_image(runtime, repo_type, repo, push_to_defaults, push_to, scra lambda dgr, terminate_event: dgr.build_container( active_profile, push_to_defaults, additional_registries=push_to, retries=build_retries, terminate_event=terminate_event, scratch=scratch, realtime=(threads == 1), - dry_run=dry_run, registry_config_dir=runtime.registry_config_dir, filter_by_os=filter_by_os), + dry_run=dry_run, registry_config_dir=runtime.registry_config_dir, + filter_by_os=filter_by_os, comment_on_pr=comment_on_pr), items, n_threads=threads) results = results.get() diff --git a/doozerlib/comment_on_pr.py b/doozerlib/comment_on_pr.py new file mode 100644 index 00000000..e6eaac3f --- /dev/null +++ b/doozerlib/comment_on_pr.py @@ -0,0 +1,79 @@ +from ghapi.all import GhApi +from doozerlib.pushd import Dir +from dockerfile_parse import DockerfileParser + + +class CommentOnPr: + def __init__(self, distgit_dir: str, token: str): + self.distgit_dir = distgit_dir + self.token = token + self.owner = None + self.repo = None + self.commit = None + self.gh_client = None # GhApi client + self.pr_url = None + self.pr_no = None + + def list_comments(self): + """ + List the comments in a PR + """ + # https://docs.github.com/rest/reference/issues#list-issue-comments + return self.gh_client.issues.list_comments(issue_number=self.pr_no, per_page=100) + + def check_if_comment_exist(self, comment): + """ + Check if the same comment already exists in the PR + """ + issue_comments = self.list_comments() + for issue_comment in issue_comments: + if issue_comment["body"] == comment: + return True + return False + + def post_comment(self, comment): + """ + Post the comment in the PR if the comment doesn't exist already + """ + # https://docs.github.com/rest/reference/issues#create-an-issue-comment + if not self.check_if_comment_exist(comment): + self.gh_client.issues.create_comment(issue_number=self.pr_no, body=comment) + return True + return False + + def set_pr_from_commit(self): + """ + Get the PR from the merge commit + """ + # https://docs.github.com/rest/commits/commits#list-pull-requests-associated-with-a-commit + prs = self.gh_client.repos.list_pull_requests_associated_with_commit(self.commit) + if len(prs) == 1: + # self._logger.info(f"PR from merge commit {sha}: {pull_url}") + self.pr_url = prs[0]["html_url"] + self.pr_no = prs[0]["number"] + return + raise Exception(f"Multiple PRs found for merge commit {self.commit}") + + def set_repo_details(self): + """ + Get the owner, commit and repo from the dfp label + """ + with Dir(self.distgit_dir): + dfp = DockerfileParser(str(Dir.getpath().joinpath('Dockerfile'))) + + # eg: "https://github.com/openshift/origin/commit/660e0c785a2c9b1fd5fad33cbcffd77a6d84ccb5" + source_commit_url = dfp.labels["io.openshift.build.commit.url"] + url_split = source_commit_url.split("/") + commit = url_split[-1] # eg: 660e0c785a2c9b1fd5fad33cbcffd77a6d84ccb5 + repo = url_split[-3] # eg: origin + owner = url_split[-4] # eg: openshift + + self.owner = owner + self.commit = commit + self.repo = repo + + def set_github_client(self): + """ + Set the gh client after the get_source_details function is run + """ + self.gh_client = GhApi(owner=self.owner, repo=self.repo, token=self.token) diff --git a/doozerlib/distgit.py b/doozerlib/distgit.py index 15eca41a..4f06d3a3 100644 --- a/doozerlib/distgit.py +++ b/doozerlib/distgit.py @@ -38,6 +38,8 @@ from doozerlib.rpm_utils import parse_nvr from doozerlib.source_modifications import SourceModifierFactory from doozerlib.util import convert_remote_git_to_https, yellow_print +from doozerlib.comment_on_pr import CommentOnPr +from string import Template # doozer used to be part of OIT OIT_COMMENT_PREFIX = '#oit##' @@ -937,7 +939,7 @@ def wait_for_rebase(self, image_name, terminate_event): def build_container( self, profile, push_to_defaults, additional_registries, terminate_event, - scratch=False, retries=3, realtime=False, dry_run=False, registry_config_dir=None, filter_by_os=None): + scratch=False, retries=3, realtime=False, dry_run=False, registry_config_dir=None, filter_by_os=None, comment_on_pr=False): """ This method is designed to be thread-safe. Multiple builds should take place in brew at the same time. After a build, images are pushed serially to all mirrors. @@ -1046,6 +1048,26 @@ def wait(n): record["nvrs"] = build_info["nvr"] if not dry_run: self.update_build_db(True, task_id=task_id, scratch=scratch) + if comment_on_pr: + try: + comment_on_pr_obj = CommentOnPr(distgit_dir=self.distgit_dir, + token=os.getenv(constants.GITHUB_TOKEN)) + comment_on_pr_obj.set_repo_details() + comment_on_pr_obj.set_github_client() + comment_on_pr_obj.set_pr_from_commit() + # Message to be posted to the comment + message = Template("**[ART PR BUILD NOTIFIER]** _(beta)_\n\n" + "This PR has been included in build " + "[$nvr](https://brewweb.engineering.redhat.com/brew/buildinfo" + "?buildID=$build_id)" + "for distgit *$distgit_name*. \n All builds following this will " + "include this PR.") + comment_on_pr_obj.post_comment(message.substitute(nvr=build_info["nvr"], + build_id=build_info["id"], + distgit_name=self.metadata.name)) + except Exception as e: + self.logger.error(f"Error commenting on PR for build task id {task_id} for distgit" + f"{self.metadata.name}: {e}") if not scratch: push_version = build_info["version"] push_release = build_info["release"] diff --git a/requirements.txt b/requirements.txt index 32cdc291..ca700684 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,3 +19,4 @@ setuptools-scm setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability aiohttp jira==3.4.1 +ghapi \ No newline at end of file diff --git a/tests/test_comment_on_pr.py b/tests/test_comment_on_pr.py new file mode 100644 index 00000000..2a36fc9a --- /dev/null +++ b/tests/test_comment_on_pr.py @@ -0,0 +1,107 @@ +import unittest +from unittest.mock import MagicMock, patch +from doozerlib.comment_on_pr import CommentOnPr + + +class TestCommentOnPr(unittest.TestCase): + def setUp(self): + self.distgit_dir = "distgit_dir" + self.token = "token" + self.commit = "commit_sha" + self.comment = "comment" + + def test_list_comments(self): + pr_no = 1 + api_mock = MagicMock() + api_mock.issues.list_comments.return_value = [{"body": "test comment"}] + comment_on_pr = CommentOnPr(self.distgit_dir, self.token) + comment_on_pr.pr_no = pr_no + comment_on_pr.gh_client = api_mock + result = comment_on_pr.list_comments() + api_mock.issues.list_comments.assert_called_once_with(issue_number=pr_no, per_page=100) + self.assertEqual(result, [{"body": "test comment"}]) + + @patch.object(CommentOnPr, "list_comments") + def test_check_if_comment_exist(self, mock_list_comments): + api_mock = MagicMock() + api_mock.issues.list_comments.return_value = [{"body": self.comment}] + mock_list_comments.return_value = api_mock.issues.list_comments() + comment_on_pr = CommentOnPr(self.distgit_dir, self.token) + result = comment_on_pr.check_if_comment_exist(self.comment) + self.assertTrue(result) + + @patch.object(CommentOnPr, "list_comments") + def test_check_if_comment_exist_when_comment_does_not_exist(self, mock_list_comments): + api_mock = MagicMock() + api_mock.issues.list_comments.return_value = [{"body": "test comment"}] + mock_list_comments.return_value = api_mock.issues.list_comments() + comment_on_pr = CommentOnPr(self.distgit_dir, self.token) + result = comment_on_pr.check_if_comment_exist(self.comment) + self.assertFalse(result) + + @patch.object(CommentOnPr, "check_if_comment_exist") + def test_post_comment(self, mock_check_if_comment_exist): + pr_no = 1 + api_mock = MagicMock() + mock_check_if_comment_exist.return_value = False + comment_on_pr = CommentOnPr(self.distgit_dir, self.token) + comment_on_pr.pr_no = pr_no + comment_on_pr.gh_client = api_mock + result = comment_on_pr.post_comment(self.comment) + api_mock.issues.create_comment.assert_called_once_with(issue_number=pr_no, body=self.comment) + self.assertTrue(result) + + @patch.object(CommentOnPr, "check_if_comment_exist") + def test_post_comment_when_comment_already_exists(self, mock_check_if_comment_exist): + pr_no = 1 + api_mock = MagicMock() + mock_check_if_comment_exist.return_value = True + comment_on_pr = CommentOnPr(self.distgit_dir, self.token) + comment_on_pr.pr_no = pr_no + comment_on_pr.gh_client = api_mock + result = comment_on_pr.post_comment(self.comment) + api_mock.issues.create_comment.assert_not_called() + self.assertFalse(result) + + def test_get_pr_from_commit(self): + api_mock = MagicMock() + comment_on_pr = CommentOnPr(self.distgit_dir, self.token) + comment_on_pr.gh_client = api_mock + api_mock.repos.list_pull_requests_associated_with_commit.return_value = [{"html_url": "test_url", "number": 1}] + comment_on_pr.set_pr_from_commit() + self.assertEqual(comment_on_pr.pr_url, 'test_url') + self.assertEqual(comment_on_pr.pr_no, 1) + + def test_multiple_prs_for_merge_commit(self): + api_mock = MagicMock() + comment_on_pr = CommentOnPr(self.distgit_dir, self.token) + comment_on_pr.gh_client = api_mock + api_mock.repos.list_pull_requests_associated_with_commit.return_value = [{"html_url": "test_url", "number": 1}, + {"html_url": "test_url_2", + "number": 2}] + with self.assertRaises(Exception): + comment_on_pr.set_pr_from_commit() + + def test_set_github_client(self): + comment_on_pr = CommentOnPr(self.distgit_dir, self.token) + comment_on_pr.owner = "owner" + comment_on_pr.repo = "repo" + comment_on_pr.token = "token" + comment_on_pr.set_github_client() + self.assertIsNotNone(comment_on_pr.gh_client) + + @patch('doozerlib.comment_on_pr.DockerfileParser') + def test_get_source_details(self, mock_parser): + comment_on_pr = CommentOnPr(self.distgit_dir, self.token) + # Mocking the labels dictionary of the DockerfileParser object + mock_parser.return_value.labels = { + "io.openshift.build.commit.url": "https://github.com/openshift/origin/commit/660e0c785a2c9b1fd5fad33cbcffd77a6d84ccb5" + } + + # Calling the get_source_details method + comment_on_pr.set_repo_details() + + # Asserting that the owner, commit, and repo attributes are set correctly + self.assertEqual(comment_on_pr.owner, 'openshift') + self.assertEqual(comment_on_pr.commit, '660e0c785a2c9b1fd5fad33cbcffd77a6d84ccb5') + self.assertEqual(comment_on_pr.repo, 'origin')