diff --git a/README.md b/README.md index 7b04ee0..ddc4353 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,19 @@ merge_request: **It is really useful with `extends` mechanism, f.e. define general template with `variables` which will be overriden in child `valhalla.yml`.** +### 🔁 auto-merge + +After a successful release valhalla can enable auto-merge on the created MR/PR, so it is merged +automatically as soon as it gets required approvals and the pipeline passes. + +```yml +merge_request: + auto_merge: True +``` + +- **GitLab:** configure approval rules, otherwise the MR is merged right after the pipeline succeeds. +- **GitHub:** enable "Allow auto-merge" in repository settings and branch protection with required reviews. + ### 🐛 environment variables Valhalla allows you to use any variable defined in your environment system, it is useful f.e when you diff --git a/test/ci_provider/github/merge_request_test.py b/test/ci_provider/github/merge_request_test.py index 2a683f9..5ad371f 100644 --- a/test/ci_provider/github/merge_request_test.py +++ b/test/ci_provider/github/merge_request_test.py @@ -216,3 +216,107 @@ def test_request_reviewers_failure(self, mock_warn, mock_info, mock_resolve, moc # then mock_warn.assert_any_call("Could not add reviewers: 404 Not found") + + @patch("valhalla.ci_provider.github.merge_request.GitHubClient") + @patch("valhalla.ci_provider.github.merge_request.resolve") + @patch("valhalla.ci_provider.github.merge_request.info") + @patch("valhalla.ci_provider.github.merge_request.warn") + def test_enable_auto_merge(self, mock_warn, mock_info, mock_resolve, mock_client_cls): + # given + with patch.dict('os.environ', {'GITHUB_REF_NAME': 'feature-branch', + 'GITHUB_GRAPHQL_URL': 'https://api.github.com/graphql'}): + mock_resolve.side_effect = lambda x: x + mock_client = MagicMock() + mock_client.api_url = "https://api.github.com" + mock_client.repo = "owner/repo" + + pr_response = MagicMock() + pr_response.status_code = 201 + pr_response.json.return_value = {"html_url": "url", "number": 7, "node_id": "PR_node7"} + + graphql_response = MagicMock() + graphql_response.status_code = 200 + graphql_response.json.return_value = {"data": {}} + + mock_client.post.side_effect = [pr_response, graphql_response] + mock_client_cls.return_value = mock_client + + pr = GitHubValhallaPullRequest() + config = MergeRequestConfig(enabled=True, target_branch="main", title="T", description="D", reviewers=[], + auto_merge=True) + + # when + hook = pr.create(config) + hook.enable_auto_merge() + + # then + url, kwargs = mock_client.post.call_args[0][0], mock_client.post.call_args[1] + self.assertEqual(url, "https://api.github.com/graphql") + self.assertIn("enablePullRequestAutoMerge", kwargs['json']['query']) + self.assertEqual(kwargs['json']['variables'], {"id": "PR_node7"}) + mock_warn.assert_not_called() + + @patch("valhalla.ci_provider.github.merge_request.GitHubClient") + @patch("valhalla.ci_provider.github.merge_request.resolve") + @patch("valhalla.ci_provider.github.merge_request.info") + @patch("valhalla.ci_provider.github.merge_request.warn") + def test_enable_auto_merge_graphql_error_warns(self, mock_warn, mock_info, mock_resolve, mock_client_cls): + # given + with patch.dict('os.environ', {'GITHUB_REF_NAME': 'feature-branch'}): + mock_resolve.side_effect = lambda x: x + mock_client = MagicMock() + mock_client.api_url = "https://api.github.com" + mock_client.repo = "owner/repo" + + pr_response = MagicMock() + pr_response.status_code = 201 + pr_response.json.return_value = {"html_url": "url", "number": 7, "node_id": "PR_node7"} + + graphql_response = MagicMock() + graphql_response.status_code = 200 + graphql_response.text = "auto-merge is not allowed" + graphql_response.json.return_value = {"errors": [{"message": "auto-merge is not allowed"}]} + + mock_client.post.side_effect = [pr_response, graphql_response] + mock_client_cls.return_value = mock_client + + pr = GitHubValhallaPullRequest() + config = MergeRequestConfig(enabled=True, target_branch="main", title="T", description="D", reviewers=[], + auto_merge=True) + + # when + hook = pr.create(config) + hook.enable_auto_merge() + + # then + mock_warn.assert_any_call("Could not enable auto-merge for pull request: 200 auto-merge is not allowed") + + @patch("valhalla.ci_provider.github.merge_request.GitHubClient") + @patch("valhalla.ci_provider.github.merge_request.resolve") + @patch("valhalla.ci_provider.github.merge_request.info") + @patch("valhalla.ci_provider.github.merge_request.warn") + def test_enable_auto_merge_exception(self, mock_warn, mock_info, mock_resolve, mock_client_cls): + # given + with patch.dict('os.environ', {'GITHUB_REF_NAME': 'feature-branch'}): + mock_resolve.side_effect = lambda x: x + mock_client = MagicMock() + mock_client.api_url = "https://api.github.com" + mock_client.repo = "owner/repo" + + pr_response = MagicMock() + pr_response.status_code = 201 + pr_response.json.return_value = {"html_url": "url", "number": 7, "node_id": "PR_node7"} + + mock_client.post.side_effect = [pr_response, Exception("Network error")] + mock_client_cls.return_value = mock_client + + pr = GitHubValhallaPullRequest() + config = MergeRequestConfig(enabled=True, target_branch="main", title="T", description="D", reviewers=[], + auto_merge=True) + + # when + hook = pr.create(config) + hook.enable_auto_merge() + + # then + mock_warn.assert_any_call("Could not enable auto-merge for pull request because: Network error") diff --git a/test/ci_provider/gitlab/merge_request_test.py b/test/ci_provider/gitlab/merge_request_test.py index 4928aa0..fb7d92b 100644 --- a/test/ci_provider/gitlab/merge_request_test.py +++ b/test/ci_provider/gitlab/merge_request_test.py @@ -176,3 +176,91 @@ def test_add_comment_exception(self, mock_warn, mock_info, mock_resolve, mock_ge # then: mock_warn.assert_any_call("Could not add comment to merge request because: GitLab error") + + @patch("valhalla.ci_provider.gitlab.merge_request.get_gitlab_client") + @patch("valhalla.ci_provider.gitlab.merge_request.get_project_id") + @patch("valhalla.ci_provider.gitlab.merge_request.resolve") + @patch("valhalla.ci_provider.gitlab.merge_request.info") + @patch("valhalla.ci_provider.gitlab.merge_request.warn") + def test_enable_auto_merge(self, mock_warn, mock_info, mock_resolve, mock_get_project_id, + mock_get_gitlab_client): + with patch.dict('os.environ', {'CI_COMMIT_BRANCH': 'feature-branch'}): + # given: + mock_get_project_id.return_value = "123" + mock_resolve.side_effect = lambda x: x + mock_gitlab_client = MagicMock() + mock_project = MagicMock() + mock_mr_instance = MagicMock() + mock_mr_instance.iid = 101 + mock_project.mergerequests.create.return_value = mock_mr_instance + mock_gitlab_client.projects.get.return_value = mock_project + mock_get_gitlab_client.return_value = mock_gitlab_client + + mock_mr_obj = MagicMock() + mock_project.mergerequests.get.return_value = mock_mr_obj + + merge_request = GitLabValhallaMergeRequest() + config = MergeRequestConfig(enabled=True, target_branch="main", title="T", description="D", reviewers=[], + auto_merge=True) + + # when: + hook = merge_request.create(config) + hook.enable_auto_merge() + + # then: + mock_mr_obj.merge.assert_called_once_with(merge_when_pipeline_succeeds=True) + mock_info.assert_any_call("Auto-merge enabled, merge request will be merged when approvals and pipeline succeed") + + @patch("valhalla.ci_provider.gitlab.merge_request.get_gitlab_client") + @patch("valhalla.ci_provider.gitlab.merge_request.get_project_id") + @patch("valhalla.ci_provider.gitlab.merge_request.resolve") + @patch("valhalla.ci_provider.gitlab.merge_request.info") + @patch("valhalla.ci_provider.gitlab.merge_request.warn") + def test_enable_auto_merge_exception(self, mock_warn, mock_info, mock_resolve, mock_get_project_id, + mock_get_gitlab_client): + with patch.dict('os.environ', {'CI_COMMIT_BRANCH': 'feature-branch'}): + # given: + mock_get_project_id.return_value = "123" + mock_resolve.side_effect = lambda x: x + mock_gitlab_client = MagicMock() + mock_project = MagicMock() + mock_gitlab_client.projects.get.return_value = mock_project + mock_get_gitlab_client.return_value = mock_gitlab_client + mock_project.mergerequests.get.return_value.merge.side_effect = Exception("405 Method Not Allowed") + + merge_request = GitLabValhallaMergeRequest() + config = MergeRequestConfig(enabled=True, target_branch="main", title="T", description="D", reviewers=[], + auto_merge=True) + + # when: + hook = merge_request.create(config) + hook.enable_auto_merge() + + # then: + mock_warn.assert_any_call("Could not enable auto-merge for merge request because: 405 Method Not Allowed") + + @patch("valhalla.ci_provider.gitlab.merge_request.get_gitlab_client") + @patch("valhalla.ci_provider.gitlab.merge_request.get_project_id") + @patch("valhalla.ci_provider.gitlab.merge_request.resolve") + @patch("valhalla.ci_provider.gitlab.merge_request.info") + @patch("valhalla.ci_provider.gitlab.merge_request.warn") + def test_auto_merge_disabled_by_default(self, mock_warn, mock_info, mock_resolve, mock_get_project_id, + mock_get_gitlab_client): + with patch.dict('os.environ', {'CI_COMMIT_BRANCH': 'feature-branch'}): + # given: + mock_get_project_id.return_value = "123" + mock_resolve.side_effect = lambda x: x + mock_gitlab_client = MagicMock() + mock_project = MagicMock() + mock_gitlab_client.projects.get.return_value = mock_project + mock_get_gitlab_client.return_value = mock_gitlab_client + + merge_request = GitLabValhallaMergeRequest() + config = MergeRequestConfig(enabled=True, target_branch="main", title="T", description="D", reviewers=[]) + + # when: + hook = merge_request.create(config) + hook.enable_auto_merge() + + # then: + mock_project.mergerequests.get.return_value.merge.assert_not_called() diff --git a/test/common/get_config_test.py b/test/common/get_config_test.py index 0dcc979..a69fca9 100644 --- a/test/common/get_config_test.py +++ b/test/common/get_config_test.py @@ -101,6 +101,7 @@ def test_get_config_no_mr_section(self, mock_open_file): self.assertEqual(config.merge_request.title, "Releasing version {VERSION} with valhalla!") self.assertEqual(config.merge_request.description, "Created by Valhalla! Visit https://github.com/logchange/valhalla and leave a star!") self.assertEqual(config.merge_request.reviewers, []) + self.assertEqual(config.merge_request.auto_merge, False) mock_open_file.assert_called_once_with(self.config_path) @@ -121,6 +122,7 @@ def test_get_config_no_mr_section(self, mock_open_file): merge_request: enabled: False title: test mr title + auto_merge: True """, ) def test_get_config_mr_disabled(self, mock_open_file): @@ -133,6 +135,7 @@ def test_get_config_mr_disabled(self, mock_open_file): self.assertEqual(config.merge_request.title, "test mr title") self.assertEqual(config.merge_request.description, "Created by Valhalla! Visit https://github.com/logchange/valhalla and leave a star!") self.assertEqual(config.merge_request.reviewers, None) + self.assertEqual(config.merge_request.auto_merge, True) mock_open_file.assert_called_once_with(self.config_path) diff --git a/test/main_start_test.py b/test/main_start_test.py index 1df1b0f..fa7877e 100644 --- a/test/main_start_test.py +++ b/test/main_start_test.py @@ -53,6 +53,7 @@ def __init__(self, text): self.assertEqual(args[2], "1.2.3") # merge request config is default and disabled in our test YAML self.assertEqual(mock_create_mr.call_count, 1) + mock_create_mr.return_value.enable_auto_merge.assert_called_once() def test_start_creates_mr_and_posts_error_when_version_empty(self): # given: version is empty and from_config does not resolve it (e.g. command failed) @@ -91,3 +92,4 @@ def __init__(self, text): call_args[0][0] for call_args in provider_hook.add_comment.call_args_list ) self.assertIn("Version to release is empty", posted) + provider_hook.enable_auto_merge.assert_not_called() diff --git a/valhalla/ci_provider/github/merge_request.py b/valhalla/ci_provider/github/merge_request.py index d70303b..d00e59d 100644 --- a/valhalla/ci_provider/github/merge_request.py +++ b/valhalla/ci_provider/github/merge_request.py @@ -59,7 +59,21 @@ def _add_comment(comment: str): except Exception as e: warn(f"Could not add comment to pull request because: {e}") - return MergeRequestHook(pr_number, _add_comment) + pr_node_id = pr.get('node_id') + + def _enable_auto_merge(): + try: + graphql_url = os.getenv("GITHUB_GRAPHQL_URL", f"{self.client.api_url}/graphql") + query = "mutation($id: ID!) { enablePullRequestAutoMerge(input: {pullRequestId: $id}) { clientMutationId } }" + resp = self.client.post(graphql_url, json={"query": query, "variables": {"id": pr_node_id}}) + if resp.status_code >= 300 or resp.json().get('errors'): + warn(f"Could not enable auto-merge for pull request: {resp.status_code} {resp.text}") + else: + info("Auto-merge enabled, pull request will be merged when approvals and checks succeed") + except Exception as e: + warn(f"Could not enable auto-merge for pull request because: {e}") + + return MergeRequestHook(pr_number, _add_comment, _enable_auto_merge if merge_request_config.auto_merge else None) def __request_reviewers(self, pr_number: int, reviewers): try: diff --git a/valhalla/ci_provider/gitlab/merge_request.py b/valhalla/ci_provider/gitlab/merge_request.py index 66bf4c7..2ffa605 100644 --- a/valhalla/ci_provider/gitlab/merge_request.py +++ b/valhalla/ci_provider/gitlab/merge_request.py @@ -51,7 +51,15 @@ def _add_comment(comment: str): except Exception as e: warn(f"Could not add comment to merge request because: {e}") - return MergeRequestHook(mr_iid, _add_comment) + def _enable_auto_merge(): + try: + mr_obj = self.project.mergerequests.get(mr_iid, iid=True) + mr_obj.merge(merge_when_pipeline_succeeds=True) + info("Auto-merge enabled, merge request will be merged when approvals and pipeline succeed") + except Exception as e: + warn(f"Could not enable auto-merge for merge request because: {e}") + + return MergeRequestHook(mr_iid, _add_comment, _enable_auto_merge if merge_request_config.auto_merge else None) def __get_reviewer_ids(self, reviewers: List[str]) -> List[int]: result = [] diff --git a/valhalla/ci_provider/merge_request_hook.py b/valhalla/ci_provider/merge_request_hook.py index 43d8dbe..74c4c6b 100644 --- a/valhalla/ci_provider/merge_request_hook.py +++ b/valhalla/ci_provider/merge_request_hook.py @@ -11,19 +11,27 @@ class MergeRequestHook: - id: identifier of the created MR/PR - add_comment: method to add a comment to the created MR/PR + - enable_auto_merge: method to let the git host merge the MR/PR once approvals and checks pass If creation is skipped, use MergeRequestHook.Skip which prints info that MR was not created. """ - def __init__(self, mr_id: Optional[int], add_comment_impl: Optional[Callable[[str], None]] = None): + def __init__(self, mr_id: Optional[int], add_comment_impl: Optional[Callable[[str], None]] = None, + enable_auto_merge_impl: Optional[Callable[[], None]] = None): self.id = mr_id self._add_comment_impl = add_comment_impl + self._enable_auto_merge_impl = enable_auto_merge_impl def add_comment(self, comment: str): if self._add_comment_impl is None: return self._add_comment_impl(resolve(comment)) + def enable_auto_merge(self): + if self._enable_auto_merge_impl is None: + return + self._enable_auto_merge_impl() + @classmethod def Skip(cls) -> "MergeRequestHook": # No MR created, provide a hook that only logs on add_comment diff --git a/valhalla/common/get_config.py b/valhalla/common/get_config.py index 852bb02..5621e5d 100644 --- a/valhalla/common/get_config.py +++ b/valhalla/common/get_config.py @@ -19,12 +19,14 @@ def __repr__(self): class MergeRequestConfig: - def __init__(self, enabled: bool, target_branch: str, title: str, description: str, reviewers: List[str]): + def __init__(self, enabled: bool, target_branch: str, title: str, description: str, reviewers: List[str], + auto_merge: bool = False): self.enabled = enabled self.target_branch = target_branch self.title = title self.description = description self.reviewers = reviewers + self.auto_merge = auto_merge def __repr__(self): return f"\n" \ @@ -34,6 +36,7 @@ def __repr__(self): f" title={self.title} \n" \ f" description={self.description} \n" \ f" reviewers={self.reviewers} \n" \ + f" auto_merge={self.auto_merge} \n" \ f" )" @@ -303,11 +306,13 @@ def get_merge_request_part(merge_request_dict: dict) -> MergeRequestConfig: description = get_from_dict(merge_request_dict, 'description', False) reviewers = get_from_dict(merge_request_dict, 'reviewers', False) + auto_merge = bool(get_from_dict(merge_request_dict, 'auto_merge', False)) return MergeRequestConfig(enabled, target_branch, get_default_merge_request_title(title), get_default_merge_request_description(description), - reviewers) + reviewers, + auto_merge) def get_default_merge_request_title(title: str): diff --git a/valhalla/main.py b/valhalla/main.py index f80d195..f172a2e 100644 --- a/valhalla/main.py +++ b/valhalla/main.py @@ -113,6 +113,7 @@ def start(): commit(config.commit_after_release, token) mr_hook.add_comment(f"✅ Release successful! Now wait for tagged version to be build. CC @{{AUTHOR}}") + mr_hook.enable_auto_merge() def __version_to_release(git_host: GitHost) -> VersionToRelease: