From 41f820e11beb6fd89d7823929d38bba4dacf329e Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Fri, 3 Jul 2026 08:08:47 +0000 Subject: [PATCH 1/2] feat(release): auto-add RC tasks to checklist in create-rc - Add `add_rc_task_to_body` to `release_issue.py` to dynamically insert new `Tag RC` tasks into the checklist before `Tag Final`. - Modify `create_rc.py` to detect if the next RC task is missing from the checklist, and if so, auto-add it and update the tracking issue body before proceeding. - Add `test_create_rc_auto_add_task` to `create_rc_test.py` to verify this behavior. --- tests/tools/private/release/create_rc_test.py | 41 +++++++++++++++++++ tools/private/release/create_rc.py | 25 ++++++----- tools/private/release/release_issue.py | 29 +++++++++++++ 3 files changed, 82 insertions(+), 13 deletions(-) diff --git a/tests/tools/private/release/create_rc_test.py b/tests/tools/private/release/create_rc_test.py index 193f11561d..0937dfbac6 100644 --- a/tests/tools/private/release/create_rc_test.py +++ b/tests/tools/private/release/create_rc_test.py @@ -1,3 +1,4 @@ +import argparse import os import pathlib import tempfile @@ -212,6 +213,46 @@ def test_create_rc_with_finished_backports(self): self.mock_git.tag.assert_called_once_with("2.0.0-rc0", "my-remote/release/2.0") self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc0") + def test_create_rc_auto_add_task(self): + # Arrange + args = argparse.Namespace(issue=123, remote="my-remote") + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 +- [x] Tag RC0 | status=done tag=2.0.0-rc0 commit=abcdef12 +- [ ] Tag Final +""" + self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0"] + self.mock_git.get_commit_sha.return_value = "1234567890" + + # Act + result = CreateRc(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 0) + self.mock_git.tag.assert_called_once_with("2.0.0-rc1", "my-remote/release/2.0") + self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc1") + + self.assertEqual(self.mock_gh.update_issue_body.call_count, 2) + call1_args = self.mock_gh.update_issue_body.call_args_list[0][0] + call2_args = self.mock_gh.update_issue_body.call_args_list[1][0] + + self.assertEqual(call1_args[0], 123) + self.assertIn("- [ ] Tag RC1", call1_args[1]) + self.assertIn( + "- [x] Tag RC0 | status=done tag=2.0.0-rc0 commit=abcdef12\n- [ ]" + " Tag RC1\n- [ ] Tag Final", + call1_args[1].strip(), + ) + + self.assertEqual(call2_args[0], 123) + self.assertIn( + "- [x] Tag RC1 | status=done tag=2.0.0-rc1 commit= 12345678", + call2_args[1], + ) + if __name__ == "__main__": unittest.main() diff --git a/tools/private/release/create_rc.py b/tools/private/release/create_rc.py index 260cbb8940..abc91a8273 100644 --- a/tools/private/release/create_rc.py +++ b/tools/private/release/create_rc.py @@ -4,6 +4,7 @@ from tools.private.release.git import Git from tools.private.release.release_issue import ( RELEASE_TITLE_RE, + add_rc_task_to_body, parse_backports, parse_checklist_state, update_task_in_body, @@ -77,19 +78,17 @@ def run(self) -> int: # Precheck: next RC number must exist and be unchecked in the checklist rc_tags = state.get("rc_tags", {}) if next_rc_num not in rc_tags: - print( - f"Error: Checklist is missing required task 'Tag RC{next_rc_num}'" - f" to cut {version}-rc{next_rc_num}." - ) - return 1 - - target_rc_task = rc_tags[next_rc_num] - if target_rc_task.checked or target_rc_task.status == "done": - print( - f"Error: Task 'Tag RC{next_rc_num}' is already marked done in" - " the checklist." - ) - return 1 + print(f"Task 'Tag RC{next_rc_num}' not found in checklist. Adding it...") + body = add_rc_task_to_body(body, next_rc_num) + self.gh.update_issue_body(args.issue, body) + else: + target_rc_task = rc_tags[next_rc_num] + if target_rc_task.checked or target_rc_task.status == "done": + print( + f"Error: Task 'Tag RC{next_rc_num}' is already marked done in" + " the checklist." + ) + return 1 target_ref = f"{args.remote}/{branch_name}" commit_sha = self.git.get_commit_sha(target_ref) diff --git a/tools/private/release/release_issue.py b/tools/private/release/release_issue.py index 75d00759ec..2650b6607c 100644 --- a/tools/private/release/release_issue.py +++ b/tools/private/release/release_issue.py @@ -284,3 +284,32 @@ def add_backports_to_body(body: str, prs: list[int]) -> str: # Replace the old section with the updated one start, end = match.span(2) return body[:start] + updated_section + body[end:] + + +def add_rc_task_to_body(body: str, rc_num: int) -> str: + """Adds a new 'Tag RC' task to the checklist in the issue body.""" + body = body.replace("\r\n", "\n") + lines = body.splitlines() + + # Find the index of the last "Tag RC" line + last_rc_idx = -1 + for i, line in enumerate(lines): + parsed = parse_metadata_line(line) + if parsed and re.match(r"Tag RC\d+", parsed["name"], re.IGNORECASE): + last_rc_idx = i + + if last_rc_idx == -1: + # If no RC task found (unexpected, but fallback to before "Tag Final") + for i, line in enumerate(lines): + parsed = parse_metadata_line(line) + if parsed and parsed["name"].lower() == "tag final": + last_rc_idx = i - 1 + break + + if last_rc_idx == -1: + raise ValueError("Could not find a place to insert the new RC task.") + + new_task_line = f"- [ ] Tag RC{rc_num}" + lines.insert(last_rc_idx + 1, new_task_line) + + return "\n".join(lines) From cc275de156fca6b342a86e6cb128850b277337c3 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Fri, 3 Jul 2026 08:12:04 +0000 Subject: [PATCH 2/2] feat(release): auto-add RC task in process-backports when adding PRs - Modify `process_backports.py` to check if a pending RC task exists when backports are added. - If no pending RC task exists, auto-add the next `Tag RC` task to the checklist. - Add `test_process_backports_add_backports_and_auto_add_rc_task` to `process_backports_test.py` to verify this behavior. --- .../private/release/process_backports_test.py | 58 +++++++++++++++++++ tools/private/release/process_backports.py | 17 ++++++ 2 files changed, 75 insertions(+) diff --git a/tests/tools/private/release/process_backports_test.py b/tests/tools/private/release/process_backports_test.py index a0de7c8566..639171f59f 100644 --- a/tests/tools/private/release/process_backports_test.py +++ b/tests/tools/private/release/process_backports_test.py @@ -247,6 +247,64 @@ def mock_resolve(items): self.mock_git.commit.assert_not_called() self.mock_git.push.assert_not_called() + @patch("tools.private.release.process_backports.datetime") + def test_process_backports_add_backports_and_auto_add_rc_task(self, mock_datetime): + mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) + args = argparse.Namespace( + issue=123, + remote="origin", + dry_run=False, + add=[124], + triggering_comment=None, + ) + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 +- [x] Tag RC0 | status=done tag=2.0.0-rc0 commit=abcdef12 +- [ ] Tag Final + +## Backports +""" + self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0"] + self.mock_git.get_commit_sha.return_value = "12345678" + self.mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' + + def mock_resolve(items): + for item in items: + if item.pr_ref == "#124": + item.commit = "abcdef12" + item.status = "done" + return items + + self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve + self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"] + + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() + + self.assertEqual(result, 0) + + # update_issue_body should be called twice: + # 1. When adding backports and auto-adding Tag RC1 task. + # 2. When updating the backport status to done. + self.assertEqual(self.mock_gh.update_issue_body.call_count, 2) + + call1_args = self.mock_gh.update_issue_body.call_args_list[0][0] + call2_args = self.mock_gh.update_issue_body.call_args_list[1][0] + + self.assertEqual(call1_args[0], 123) + self.assertIn("- [ ] #124", call1_args[1]) + self.assertIn("- [ ] Tag RC1", call1_args[1]) + self.assertIn( + "- [x] Tag RC0 | status=done tag=2.0.0-rc0 commit=abcdef12\n- [ ]" + " Tag RC1\n- [ ] Tag Final", + call1_args[1].strip(), + ) + + self.assertEqual(call2_args[0], 123) + self.assertIn("- [x] #124 | status=done rc=rc1 commit= 12345678", call2_args[1]) + if __name__ == "__main__": unittest.main() diff --git a/tools/private/release/process_backports.py b/tools/private/release/process_backports.py index f0308d7847..81dbe2bd5e 100644 --- a/tools/private/release/process_backports.py +++ b/tools/private/release/process_backports.py @@ -10,7 +10,9 @@ from tools.private.release.release_issue import ( RELEASE_TITLE_RE, add_backports_to_body, + add_rc_task_to_body, parse_backports, + parse_checklist_state, update_task_in_body, ) from tools.private.release.utils import ( @@ -207,6 +209,19 @@ def _run_internal(self) -> int: print(f"Adding backports {args.add} to tracking issue #{args.issue}...") try: body = add_backports_to_body(body, args.add) + state = parse_checklist_state(body) + rc_tags = state.get("rc_tags", {}) + has_pending_rc = any( + not task.checked and task.status != "done" + for task in rc_tags.values() + ) + next_rc_num = max(rc_tags.keys()) + 1 if rc_tags else 0 + if not has_pending_rc: + print( + f"No pending RC task found. Adding 'Tag" + f" RC{next_rc_num}' to checklist..." + ) + body = add_rc_task_to_body(body, next_rc_num) except ValueError as e: print(f"Error: {e}") return 1 @@ -219,6 +234,8 @@ def _run_internal(self) -> int: "[DRY RUN] Would update tracking issue checklist with new" " backports." ) + if not has_pending_rc: + print(f"[DRY RUN] Would add 'Tag RC{next_rc_num}' to checklist.") items = parse_backports(body)