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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
104 changes: 104 additions & 0 deletions test/ci_provider/github/merge_request_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
88 changes: 88 additions & 0 deletions test/ci_provider/gitlab/merge_request_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
3 changes: 3 additions & 0 deletions test/common/get_config_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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):
Expand All @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions test/main_start_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
16 changes: 15 additions & 1 deletion valhalla/ci_provider/github/merge_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 9 additions & 1 deletion valhalla/ci_provider/gitlab/merge_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
10 changes: 9 additions & 1 deletion valhalla/ci_provider/merge_request_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions valhalla/common/get_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand All @@ -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" )"


Expand Down Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions valhalla/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading