diff --git a/bert_e/exceptions.py b/bert_e/exceptions.py index fb93bdcf..82e5aef3 100644 --- a/bert_e/exceptions.py +++ b/bert_e/exceptions.py @@ -273,6 +273,12 @@ class QueueBuildFailedMessage(TemplateException): template = "queue_build_failed.md" +class GitHubChecksFailed(TemplateException): + code = 137 + template = "github_checks_failed.md" + status = "failure" + + # internal exceptions class UnableToSendEmail(InternalException): code = 201 @@ -585,3 +591,8 @@ class JobFailure(SilentException): class QueueBuildFailed(SilentException): code = 309 + + +class GitHubChecksInProgress(SilentException): + code = 310 + status = "in_progress" diff --git a/bert_e/git_host/base.py b/bert_e/git_host/base.py index a38ab926..1e089cbd 100644 --- a/bert_e/git_host/base.py +++ b/bert_e/git_host/base.py @@ -474,6 +474,9 @@ def get_pull_request(self, pull_request_id: int) -> AbstractPullRequest: - pr_id: id of the pull request to get. """ + def get_check_runs(self, ref): + return [] + def create_pull_request(self, title: str, src_branch: str, dst_branch: str, description: str, **kwargs) -> AbstractPullRequest: """Create a new pull request diff --git a/bert_e/git_host/github/__init__.py b/bert_e/git_host/github/__init__.py index c6daf27b..e5118340 100644 --- a/bert_e/git_host/github/__init__.py +++ b/bert_e/git_host/github/__init__.py @@ -451,6 +451,16 @@ def get_build_url(self, revision: str, key: str) -> str: if status: return status.url + def get_check_runs(self, ref): + try: + result = AggregatedCheckRuns.get( + self.client, owner=self.owner, repo=self.slug, ref=ref) + return result.data.get('check_runs', []) + except HTTPError as err: + if err.response.status_code == 404: + return [] + raise + def get_build_description(self, revision: str, key: str) -> str: status = cache.BUILD_STATUS_CACHE[key].get(revision, None) if status: @@ -737,6 +747,11 @@ def __str__(self) -> str: return self.state +class AggregatedCheckRuns(base.AbstractGitHostObject): + GET_URL = "/repos/{owner}/{repo}/commits/{ref}/check-runs" + SCHEMA = schema.AggregateCheckRuns + + class PullRequest(base.AbstractGitHostObject, base.AbstractPullRequest): LIST_URL = '/repos/{owner}/{repo}/pulls' GET_URL = '/repos/{owner}/{repo}/pulls/{number}' diff --git a/bert_e/git_host/mock.py b/bert_e/git_host/mock.py index 02138b94..3c4e4965 100644 --- a/bert_e/git_host/mock.py +++ b/bert_e/git_host/mock.py @@ -254,6 +254,12 @@ def set_build_status(self, revision, key, state, **kwargs): self.get_git_url() self.revisions[(revision, key)] = state + def get_check_runs(self, ref): + return self.revisions.get((ref, '_check_runs'), []) + + def set_check_runs(self, ref, check_runs): + self.revisions[(ref, '_check_runs')] = check_runs + @property def owner(self): return self.repo_owner diff --git a/bert_e/settings.py b/bert_e/settings.py index d00cf318..12efd4b0 100644 --- a/bert_e/settings.py +++ b/bert_e/settings.py @@ -92,6 +92,7 @@ class PrAuthorsOptions(fields.Dict): 'bypass_incompatible_branch', 'bypass_peer_approval', 'bypass_leader_approval', + 'bypass_github_checks', ] def serialize(self, value, attr=None, obj=None, **kwargs): diff --git a/bert_e/tests/test_bert_e.py b/bert_e/tests/test_bert_e.py index 90009e56..06b59a9b 100644 --- a/bert_e/tests/test_bert_e.py +++ b/bert_e/tests/test_bert_e.py @@ -787,7 +787,8 @@ class RepositoryTests(unittest.TestCase): 'bypass_incompatible_branch', 'bypass_jira_check', 'bypass_peer_approval', - 'bypass_leader_approval' + 'bypass_leader_approval', + 'bypass_github_checks', ] def get_last_pr_comment(self, pr): @@ -4779,6 +4780,150 @@ def test_admin_self_bypass(self): with self.assertRaises(exns.SuccessMessage): self.handle(pr.id, backtrace=True) + def test_github_checks_pass(self): + pr = self.create_pr('bugfix/TEST-00090', 'development/4.3') + self.set_build_status_on_pr_id(pr.id, 'SUCCESSFUL') + self.gitrepo.cmd('git fetch --all') + wbranches = [ + 'w/5.1/bugfix/TEST-00090', + 'w/10.0/bugfix/TEST-00090', + ] + for branch in wbranches: + sha = self.gitrepo.cmd( + f'git rev-parse origin/{branch}').rstrip() + self.set_build_status(sha, 'SUCCESSFUL') + self.robot_bb.set_check_runs(sha, [ + {'name': 'policy-check', 'status': 'completed', + 'conclusion': 'success', 'html_url': ''}, + ]) + with self.assertRaises(exns.SuccessMessage): + self.handle(pr.id, options=self.bypass_all_but( + ['bypass_build_status', 'bypass_github_checks']), + backtrace=True) + + def test_github_checks_failed(self): + pr = self.create_pr('bugfix/TEST-00091', 'development/4.3') + self.set_build_status_on_pr_id(pr.id, 'SUCCESSFUL') + self.gitrepo.cmd('git fetch --all') + wbranches = [ + 'w/5.1/bugfix/TEST-00091', + 'w/10.0/bugfix/TEST-00091', + ] + for branch in wbranches: + sha = self.gitrepo.cmd( + f'git rev-parse origin/{branch}').rstrip() + self.set_build_status(sha, 'SUCCESSFUL') + sha_last = self.gitrepo.cmd( + f'git rev-parse origin/{wbranches[-1]}').rstrip() + self.robot_bb.set_check_runs(sha_last, [ + {'name': 'commit-msg-check', 'status': 'completed', + 'conclusion': 'failure', 'html_url': 'https://example.com/check'}, + ]) + with self.assertRaises(exns.GitHubChecksFailed): + self.handle(pr.id, options=self.bypass_all_but( + ['bypass_build_status', 'bypass_github_checks']), + backtrace=True) + + def test_github_checks_in_progress(self): + pr = self.create_pr('bugfix/TEST-00092', 'development/4.3') + self.set_build_status_on_pr_id(pr.id, 'SUCCESSFUL') + self.gitrepo.cmd('git fetch --all') + wbranches = [ + 'w/5.1/bugfix/TEST-00092', + 'w/10.0/bugfix/TEST-00092', + ] + for branch in wbranches: + sha = self.gitrepo.cmd( + f'git rev-parse origin/{branch}').rstrip() + self.set_build_status(sha, 'SUCCESSFUL') + sha_last = self.gitrepo.cmd( + f'git rev-parse origin/{wbranches[-1]}').rstrip() + self.robot_bb.set_check_runs(sha_last, [ + {'name': 'policy-check', 'status': 'in_progress', + 'conclusion': None, 'html_url': ''}, + ]) + with self.assertRaises(exns.GitHubChecksInProgress): + self.handle(pr.id, options=self.bypass_all_but( + ['bypass_build_status', 'bypass_github_checks']), + backtrace=True) + + def test_github_checks_bert_e_excluded(self): + pr = self.create_pr('bugfix/TEST-00093', 'development/4.3') + self.set_build_status_on_pr_id(pr.id, 'SUCCESSFUL') + self.gitrepo.cmd('git fetch --all') + wbranches = [ + 'w/5.1/bugfix/TEST-00093', + 'w/10.0/bugfix/TEST-00093', + ] + for branch in wbranches: + sha = self.gitrepo.cmd( + f'git rev-parse origin/{branch}').rstrip() + self.set_build_status(sha, 'SUCCESSFUL') + self.robot_bb.set_check_runs(sha, [ + {'name': 'bert-e', 'status': 'completed', + 'conclusion': 'failure', 'html_url': ''}, + ]) + with self.assertRaises(exns.SuccessMessage): + self.handle(pr.id, options=self.bypass_all_but( + ['bypass_build_status', 'bypass_github_checks']), + backtrace=True) + + def test_github_checks_bypass(self): + pr = self.create_pr('bugfix/TEST-00094', 'development/4.3') + self.set_build_status_on_pr_id(pr.id, 'SUCCESSFUL') + self.gitrepo.cmd('git fetch --all') + wbranches = [ + 'w/5.1/bugfix/TEST-00094', + 'w/10.0/bugfix/TEST-00094', + ] + for branch in wbranches: + sha = self.gitrepo.cmd( + f'git rev-parse origin/{branch}').rstrip() + self.set_build_status(sha, 'SUCCESSFUL') + self.robot_bb.set_check_runs(sha, [ + {'name': 'policy-check', 'status': 'completed', + 'conclusion': 'failure', 'html_url': ''}, + ]) + with self.assertRaises(exns.SuccessMessage): + self.handle(pr.id, options=self.bypass_all_but( + ['bypass_build_status']), + backtrace=True) + + def test_github_checks_neutral_skipped(self): + pr = self.create_pr('bugfix/TEST-00095', 'development/4.3') + self.set_build_status_on_pr_id(pr.id, 'SUCCESSFUL') + self.gitrepo.cmd('git fetch --all') + wbranches = [ + 'w/5.1/bugfix/TEST-00095', + 'w/10.0/bugfix/TEST-00095', + ] + for branch in wbranches: + sha = self.gitrepo.cmd( + f'git rev-parse origin/{branch}').rstrip() + self.set_build_status(sha, 'SUCCESSFUL') + sha_first = self.gitrepo.cmd( + f'git rev-parse origin/{wbranches[0]}').rstrip() + sha_last = self.gitrepo.cmd( + f'git rev-parse origin/{wbranches[-1]}').rstrip() + self.robot_bb.set_check_runs(sha_first, [ + {'name': 'check-neutral', 'status': 'completed', + 'conclusion': 'neutral', 'html_url': ''}, + ]) + self.robot_bb.set_check_runs(sha_last, [ + {'name': 'check-skipped', 'status': 'completed', + 'conclusion': 'skipped', 'html_url': ''}, + ]) + with self.assertRaises(exns.SuccessMessage): + self.handle(pr.id, options=self.bypass_all_but( + ['bypass_build_status', 'bypass_github_checks']), + backtrace=True) + + def test_github_checks_no_check_runs(self): + pr = self.create_pr('bugfix/TEST-00096', 'development/4.3') + with self.assertRaises(exns.SuccessMessage): + self.handle(pr.id, options=self.bypass_all, + backtrace=True) + class TestQueueing(RepositoryTests): """Tests which validate all things related to the merge queue. diff --git a/bert_e/workflow/gitwaterflow/__init__.py b/bert_e/workflow/gitwaterflow/__init__.py index 7bda9d6c..e3dcfcdb 100644 --- a/bert_e/workflow/gitwaterflow/__init__.py +++ b/bert_e/workflow/gitwaterflow/__init__.py @@ -31,7 +31,8 @@ ) from .utils import ( bypass_incompatible_branch, bypass_peer_approval, - bypass_author_approval, bypass_leader_approval, bypass_build_status + bypass_author_approval, bypass_leader_approval, bypass_build_status, + bypass_github_checks ) from .commands import setup # noqa from .integration import (check_integration_branches, @@ -197,6 +198,7 @@ def _handle_pull_request(job: PullRequestJob): check_approvals(job) check_build_status(job, wbranches) + check_github_checks(job, wbranches) interactive = job.settings.interactive if interactive and not confirm('Do you want to merge/queue?'): @@ -667,3 +669,47 @@ def status(branch): elif worst_status == 'INPROGRESS': raise messages.BuildInProgress() assert worst_status == 'SUCCESSFUL' + + +def check_github_checks(job, wbranches): + if bypass_github_checks(job): + return + + failed_checks = [] + in_progress = False + + for wbranch in wbranches: + sha = wbranch.get_latest_commit() + check_runs = job.project_repo.get_check_runs(sha) + + for cr in check_runs: + if cr.get('name') == 'bert-e': + continue + + if cr.get('status') != 'completed': + in_progress = True + continue + + conclusion = cr.get('conclusion') + if conclusion not in ('success', 'neutral', 'skipped'): + failed_checks.append({ + 'name': cr.get('name', 'unknown'), + 'conclusion': conclusion, + 'html_url': cr.get('html_url', ''), + 'branch': wbranch.name, + }) + + if failed_checks: + first = failed_checks[0] + raise messages.GitHubChecksFailed( + branch=first['branch'], + dst_branch=wbranches[0].dst_branch.name, + failed_checks=failed_checks, + githost=job.settings.repository_host, + owner=job.settings.repository_owner, + slug=job.settings.repository_slug, + active_options=job.active_options, + ) + + if in_progress: + raise messages.GitHubChecksInProgress() diff --git a/bert_e/workflow/gitwaterflow/commands.py b/bert_e/workflow/gitwaterflow/commands.py index eb5b5bc2..c8641da3 100644 --- a/bert_e/workflow/gitwaterflow/commands.py +++ b/bert_e/workflow/gitwaterflow/commands.py @@ -196,6 +196,11 @@ def setup(defaults={}): "Bypass the pull request leaders' approval", privileged=True, default=defaults.get("bypass_leader_approval", False)) + Reactor.add_option( + "bypass_github_checks", + "Bypass the GitHub check runs validation", + privileged=True, + default=defaults.get("bypass_github_checks", False)) # Other options Reactor.add_option( diff --git a/bert_e/workflow/gitwaterflow/utils.py b/bert_e/workflow/gitwaterflow/utils.py index bc710066..8ed800fe 100644 --- a/bert_e/workflow/gitwaterflow/utils.py +++ b/bert_e/workflow/gitwaterflow/utils.py @@ -27,3 +27,8 @@ def bypass_build_status(job): def bypass_jira_check(job): return (job.settings.bypass_jira_check or job.author_bypass.get('bypass_jira_check', False)) + + +def bypass_github_checks(job): + return (job.settings.bypass_github_checks or + job.author_bypass.get('bypass_github_checks', False))