Skip to content

Commit 61d10ef

Browse files
[fix] Automate branch selection for porting changelog #646
Implemented automated branch detection to avoid choosing between master and main when only one exists. Fixes #646
1 parent fe1cc60 commit 61d10ef

4 files changed

Lines changed: 167 additions & 8 deletions

File tree

openwisp_utils/releaser/release.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from openwisp_utils.releaser.utils import (
2121
SkipSignal,
2222
adjust_markdown_headings,
23+
branch_exists,
2324
demote_markdown_headings,
2425
format_file_with_docstrfmt,
2526
get_current_branch,
@@ -176,10 +177,23 @@ def port_changelog_to_main(gh, config, version, changelog_body, original_branch)
176177
full_block_to_port = f"{version_header}\n{underline}\n\n{changelog_body}"
177178

178179
try:
179-
main_branch = questionary.select(
180-
"Which branch should the changelog be ported to?",
181-
choices=MAIN_BRANCHES,
182-
).ask()
180+
main_exists = branch_exists("main")
181+
master_exists = branch_exists("master")
182+
if main_exists and master_exists:
183+
main_branch = questionary.select(
184+
"Which branch should the changelog be ported to?",
185+
choices=MAIN_BRANCHES,
186+
).ask()
187+
elif master_exists:
188+
main_branch = "master"
189+
elif main_exists:
190+
main_branch = "main"
191+
else:
192+
print(
193+
"Neither 'master' nor 'main' branch were found locally. "
194+
"Skipping changelog porting."
195+
)
196+
return
183197

184198
if not main_branch:
185199
print("Porting cancelled.")

openwisp_utils/releaser/tests/test_release.py

Lines changed: 120 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -155,26 +155,136 @@ def test_main_flow_pr_merge_wait(mock_all):
155155
@patch("openwisp_utils.releaser.release.update_changelog_file")
156156
@patch("openwisp_utils.releaser.release.format_file_with_docstrfmt")
157157
@patch("openwisp_utils.releaser.release.subprocess.run")
158+
@patch("openwisp_utils.releaser.release.branch_exists")
158159
@patch("openwisp_utils.releaser.release.questionary")
159160
def test_port_changelog_to_main_flow(
160-
mock_questionary, mock_subprocess, mock_format_file, mock_update_changelog
161+
mock_questionary,
162+
mock_branch_exists,
163+
mock_subprocess,
164+
mock_format_file,
165+
mock_update_changelog,
161166
):
162167
"""Tests the changelog porting process for both RST and MD files, and the cancellation path."""
163168
mock_gh = MagicMock()
164169
mock_config_rst = {"changelog_path": "CHANGES.rst"}
170+
# Both branches exist: user is asked
171+
mock_branch_exists.return_value = True
165172
mock_questionary.select.return_value.ask.return_value = "main"
166173
port_changelog_to_main(mock_gh, mock_config_rst, "1.1.1", "- fix", "1.1.x")
167174
mock_gh.create_pr.assert_called_once()
168175
mock_format_file.assert_called_once_with("CHANGES.rst")
169176

170177
mock_gh.reset_mock()
171178

172-
# Test Cancellation path
179+
# Test Cancellation path (when both branches exist and user cancels)
173180
mock_questionary.select.return_value.ask.return_value = None
174181
port_changelog_to_main(mock_gh, mock_config_rst, "1.1.1", "- fix", "1.1.x")
175182
mock_gh.create_pr.assert_not_called()
176183

177184

185+
@patch("openwisp_utils.releaser.release.update_changelog_file")
186+
@patch("openwisp_utils.releaser.release.format_file_with_docstrfmt")
187+
@patch("openwisp_utils.releaser.release.subprocess.run")
188+
@patch("openwisp_utils.releaser.release.branch_exists")
189+
def test_port_changelog_only_master_exists(
190+
mock_branch_exists, mock_subprocess, mock_format_file, mock_update_changelog
191+
):
192+
"""`master` is auto-selected when `main` does not exist locally."""
193+
mock_gh = MagicMock()
194+
mock_config = {"changelog_path": "CHANGES.rst"}
195+
# Simulate: main=False, master=True
196+
mock_branch_exists.side_effect = lambda name: name == "master"
197+
port_changelog_to_main(mock_gh, mock_config, "1.1.1", "- fix", "1.1.x")
198+
mock_gh.create_pr.assert_called_once()
199+
# Verify PR was opened against master
200+
assert mock_gh.create_pr.call_args[0][1] == "master"
201+
202+
203+
@patch("openwisp_utils.releaser.release.update_changelog_file")
204+
@patch("openwisp_utils.releaser.release.format_file_with_docstrfmt")
205+
@patch("openwisp_utils.releaser.release.subprocess.run")
206+
@patch("openwisp_utils.releaser.release.branch_exists")
207+
def test_port_changelog_only_main_exists(
208+
mock_branch_exists, mock_subprocess, mock_format_file, mock_update_changelog
209+
):
210+
"""`main` is auto-selected when it exists and `master` does not."""
211+
mock_gh = MagicMock()
212+
mock_config = {"changelog_path": "CHANGES.rst"}
213+
# Simulate: main=True, master=False
214+
mock_branch_exists.side_effect = lambda name: name == "main"
215+
port_changelog_to_main(mock_gh, mock_config, "1.1.1", "- fix", "1.1.x")
216+
mock_gh.create_pr.assert_called_once()
217+
# Verify PR was opened against main
218+
assert mock_gh.create_pr.call_args[0][1] == "main"
219+
220+
221+
@patch("openwisp_utils.releaser.release.update_changelog_file")
222+
@patch("openwisp_utils.releaser.release.format_file_with_docstrfmt")
223+
@patch("openwisp_utils.releaser.release.subprocess.run")
224+
@patch("openwisp_utils.releaser.release.branch_exists")
225+
@patch("openwisp_utils.releaser.release.questionary")
226+
def test_port_changelog_both_branches_prompts_user(
227+
mock_questionary,
228+
mock_branch_exists,
229+
mock_subprocess,
230+
mock_format_file,
231+
mock_update_changelog,
232+
):
233+
"""User is prompted to choose when both `main` and `master` exist."""
234+
mock_gh = MagicMock()
235+
mock_config = {"changelog_path": "CHANGES.rst"}
236+
# Both branches exist
237+
mock_branch_exists.return_value = True
238+
mock_questionary.select.return_value.ask.return_value = "master"
239+
port_changelog_to_main(mock_gh, mock_config, "1.1.1", "- fix", "1.1.x")
240+
mock_questionary.select.assert_called_once()
241+
mock_gh.create_pr.assert_called_once()
242+
assert mock_gh.create_pr.call_args[0][1] == "master"
243+
244+
245+
@patch("openwisp_utils.releaser.release.update_changelog_file")
246+
@patch("openwisp_utils.releaser.release.format_file_with_docstrfmt")
247+
@patch("openwisp_utils.releaser.release.subprocess.run")
248+
@patch("openwisp_utils.releaser.release.branch_exists")
249+
def test_port_changelog_neither_branch_exists(
250+
mock_branch_exists, mock_subprocess, mock_format_file, mock_update_changelog
251+
):
252+
"""Porting is skipped with a message if neither branch exists."""
253+
mock_gh = MagicMock()
254+
mock_config = {"changelog_path": "CHANGES.rst"}
255+
# Neither exists
256+
mock_branch_exists.return_value = False
257+
port_changelog_to_main(mock_gh, mock_config, "1.1.1", "- fix", "1.1.x")
258+
# Verify no PR was created
259+
mock_gh.create_pr.assert_not_called()
260+
# Verify no file update was attempted
261+
mock_update_changelog.assert_not_called()
262+
263+
264+
@patch("openwisp_utils.releaser.release.update_changelog_file")
265+
@patch("openwisp_utils.releaser.release.format_file_with_docstrfmt")
266+
@patch("openwisp_utils.releaser.release.subprocess.run")
267+
@patch("openwisp_utils.releaser.release.branch_exists")
268+
@patch("openwisp_utils.releaser.release.questionary")
269+
def test_port_changelog_cancelled(
270+
mock_questionary,
271+
mock_branch_exists,
272+
mock_subprocess,
273+
mock_format_file,
274+
mock_update_changelog,
275+
):
276+
"""Porting is cancelled if user doesn't select a branch."""
277+
mock_gh = MagicMock()
278+
mock_config = {"changelog_path": "CHANGES.rst"}
279+
# Both exist to trigger prompt
280+
mock_branch_exists.return_value = True
281+
# User cancels (Ctrl+C or Esc)
282+
mock_questionary.select.return_value.ask.return_value = None
283+
port_changelog_to_main(mock_gh, mock_config, "1.1.1", "- fix", "1.1.x")
284+
# Verify no PR was created
285+
mock_gh.create_pr.assert_not_called()
286+
287+
178288
def test_main_bugfix_flow_with_porting(mock_all, mocker):
179289
"""Tests the main release flow for a bugfix, including accepting the changelog port."""
180290
mock_all["_git_command_map"][
@@ -239,11 +349,15 @@ def test_main_flow_skip_release_creation(mock_all):
239349
)
240350

241351

352+
@patch("openwisp_utils.releaser.release.branch_exists")
242353
@patch("openwisp_utils.releaser.release.subprocess.run")
243-
def test_port_changelog_to_main_flow_markdown(mock_subprocess, mock_all):
354+
def test_port_changelog_to_main_flow_markdown(
355+
mock_subprocess, mock_branch_exists, mock_all
356+
):
244357
"""Tests the changelog porting process for a Markdown file."""
245358
mock_gh = MagicMock()
246359
mock_config_md = {"changelog_path": "CHANGES.md"}
360+
mock_branch_exists.return_value = True
247361
mock_all["questionary_select"].return_value.ask.return_value = "main"
248362

249363
with patch("openwisp_utils.releaser.release.update_changelog_file") as mock_update:
@@ -253,12 +367,14 @@ def test_port_changelog_to_main_flow_markdown(mock_subprocess, mock_all):
253367
assert "## Version 1.1.1" in called_with_content
254368

255369

370+
@patch("openwisp_utils.releaser.release.branch_exists")
256371
@patch("openwisp_utils.releaser.release.subprocess.run")
257-
def test_port_changelog_skip_pr_creation(mock_subprocess, mock_all):
372+
def test_port_changelog_skip_pr_creation(mock_subprocess, mock_branch_exists, mock_all):
258373
"""Tests skipping PR creation during changelog porting."""
259374
mock_gh = MagicMock()
260375
mock_gh.create_pr.side_effect = SkipSignal
261376
mock_config = {"changelog_path": "CHANGES.rst"}
377+
mock_branch_exists.return_value = True
262378
mock_all["questionary_select"].return_value.ask.return_value = "main"
263379

264380
with patch("openwisp_utils.releaser.release.update_changelog_file"):

openwisp_utils/releaser/tests/test_utils.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
)
1111
from openwisp_utils.releaser.utils import (
1212
SkipSignal,
13+
branch_exists,
1314
format_file_with_docstrfmt,
1415
retryable_request,
1516
)
@@ -306,3 +307,20 @@ def test_retryable_request_retries_then_aborts(
306307
assert mock_request.call_count == 2
307308
assert mock_print.call_count == 3
308309
mock_sleep.assert_called_once_with(1)
310+
311+
312+
@patch("openwisp_utils.releaser.utils.subprocess.run")
313+
def test_branch_exists_success(mock_run):
314+
"""branch_exists returns True when git exit code is 0."""
315+
mock_run.return_value = MagicMock(returncode=0)
316+
assert branch_exists("master") is True
317+
# Ensure it's looking for refs/heads/master
318+
mock_run.assert_called_once()
319+
assert "refs/heads/master" in mock_run.call_args[0][0]
320+
321+
322+
@patch("openwisp_utils.releaser.utils.subprocess.run")
323+
def test_branch_exists_failure(mock_run):
324+
"""branch_exists returns False when git exit code is non-zero."""
325+
mock_run.return_value = MagicMock(returncode=1)
326+
assert branch_exists("non-existent") is False

openwisp_utils/releaser/utils.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,17 @@ def get_current_branch():
8585
return result.stdout.strip()
8686

8787

88+
def branch_exists(branch_name):
89+
"""Check if a Git branch exists locally."""
90+
result = subprocess.run(
91+
["git", "show-ref", "--verify", "--quiet", f"refs/heads/{branch_name}"],
92+
capture_output=True,
93+
text=True,
94+
encoding="utf-8",
95+
)
96+
return result.returncode == 0
97+
98+
8899
def rst_to_markdown(text):
89100
"""Convert reStructuredText to Markdown using pypandoc."""
90101
escaped_text = re.sub(r"(?<!`)_", r"\\_", text)

0 commit comments

Comments
 (0)