Skip to content

Commit b0f79d3

Browse files
[change] Releaser: automated branch selection in changelog porting step #646
Closes #646 --------- Co-authored-by: Federico Capoano <f.capoano@openwisp.io>
1 parent 8b9fd39 commit b0f79d3

4 files changed

Lines changed: 162 additions & 11 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+
master_exists = branch_exists("master")
181+
main_exists = branch_exists("main")
182+
if master_exists and main_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' branches 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: 116 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -155,23 +155,126 @@ 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
):
162-
"""Tests the changelog porting process for both RST and MD files, and the cancellation path."""
167+
"""Tests the changelog porting process for RST files."""
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

170-
mock_gh.reset_mock()
171177

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

177280

@@ -239,11 +342,15 @@ def test_main_flow_skip_release_creation(mock_all):
239342
)
240343

241344

345+
@patch("openwisp_utils.releaser.release.branch_exists")
242346
@patch("openwisp_utils.releaser.release.subprocess.run")
243-
def test_port_changelog_to_main_flow_markdown(mock_subprocess, mock_all):
347+
def test_port_changelog_to_main_flow_markdown(
348+
mock_subprocess, mock_branch_exists, mock_all
349+
):
244350
"""Tests the changelog porting process for a Markdown file."""
245351
mock_gh = MagicMock()
246352
mock_config_md = {"changelog_path": "CHANGES.md"}
353+
mock_branch_exists.return_value = True
247354
mock_all["questionary_select"].return_value.ask.return_value = "main"
248355

249356
with patch("openwisp_utils.releaser.release.update_changelog_file") as mock_update:
@@ -253,12 +360,14 @@ def test_port_changelog_to_main_flow_markdown(mock_subprocess, mock_all):
253360
assert "## Version 1.1.1" in called_with_content
254361

255362

363+
@patch("openwisp_utils.releaser.release.branch_exists")
256364
@patch("openwisp_utils.releaser.release.subprocess.run")
257-
def test_port_changelog_skip_pr_creation(mock_subprocess, mock_all):
365+
def test_port_changelog_skip_pr_creation(mock_subprocess, mock_branch_exists, mock_all):
258366
"""Tests skipping PR creation during changelog porting."""
259367
mock_gh = MagicMock()
260368
mock_gh.create_pr.side_effect = SkipSignal
261369
mock_config = {"changelog_path": "CHANGES.rst"}
370+
mock_branch_exists.return_value = True
262371
mock_all["questionary_select"].return_value.ask.return_value = "main"
263372

264373
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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,16 @@ 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+
)
95+
return result.returncode == 0
96+
97+
8898
def rst_to_markdown(text):
8999
"""Convert reStructuredText to Markdown using pypandoc."""
90100
escaped_text = re.sub(r"(?<!`)_", r"\\_", text)

0 commit comments

Comments
 (0)