diff --git a/dev/release/add_backports.py b/dev/release/add_backports.py index 78bcf7879b..d36086b95c 100644 --- a/dev/release/add_backports.py +++ b/dev/release/add_backports.py @@ -7,6 +7,7 @@ add_backports_to_body, add_rc_task_to_body, add_sync_changelog_task_to_body, + is_release_complete, load_release_tracking_template, parse_checklist_state, ) @@ -33,16 +34,31 @@ def run(self) -> int: ) try: open_issues = self.gh.get_open_tracking_issues() - if len(open_issues) > 1: + # An open issue whose release has already been tagged is + # finished; it just hasn't been closed. Don't add work to it. + active_issues = [] + for issue in open_issues: + body = self.gh.get_issue_body(issue["number"]) + if is_release_complete(body): + print( + "::warning::Ignoring open release tracking issue" + f" #{issue['number']} ({issue['title']}): its" + " 'Tag Final' task is done, so the release is" + " complete. Consider closing it." + ) + continue + active_issues.append(issue) + + if len(active_issues) > 1: print( "::error::Multiple open release tracking issues found." " Cannot determine active one:" ) - for issue in open_issues: + for issue in active_issues: print(f"- #{issue['number']}: {issue['title']}") return 1 - elif len(open_issues) == 1: - issue_num = open_issues[0]["number"] + elif len(active_issues) == 1: + issue_num = active_issues[0]["number"] print( f"Auto-discovered active release tracking issue: #{issue_num}" ) diff --git a/dev/release/release_issue.py b/dev/release/release_issue.py index 6bfa0f58e0..1b71e4a082 100644 --- a/dev/release/release_issue.py +++ b/dev/release/release_issue.py @@ -280,6 +280,25 @@ def parse_checklist_state(body): return state +def is_release_complete(body: str) -> bool: + """Returns whether the release tracked by an issue body has been completed. + + A release is complete once its "Tag Final" task is done: the checklist + item is checked, or it carries `status=done` metadata (as written by the + promote step). An open tracking issue in this state should not receive + further work, such as new backports. + + Args: + body: The release tracking issue body. + + Returns: + True if the "Tag Final" task is done, False otherwise (including when + the task is absent). + """ + tag_final = parse_checklist_state(body)["tag_final"] + return tag_final.checked or tag_final.status == "done" + + def parse_backports(body): """Parses the ## Backports checklist section.""" body = body.replace("\r\n", "\n") diff --git a/tests/tools/private/release/add_backports_test.py b/tests/tools/private/release/add_backports_test.py index 08fd9a888f..24636c6e3c 100644 --- a/tests/tools/private/release/add_backports_test.py +++ b/tests/tools/private/release/add_backports_test.py @@ -77,6 +77,84 @@ def test_add_backports_auto_discover_no_issues_creates_patch_release( assert "- [ ] Tag Final" in body +def test_add_backports_auto_discover_skips_complete_release_creates_patch_release( + mock_gh, mock_git, release_tool_env +): + # 1.2.1 has already been tagged (Tag Final done) but the issue was never + # closed. It must be left alone and a new 1.2.2 issue created instead. + mock_git.get_tags.return_value = ["1.2.0", "1.2.1"] + mock_git.get_current_branch.return_value = "main" + complete_body = """ +## Checklist +- [x] Sync Changelog #100 | status=done +- [x] Tag Final | status=done tag=1.2.1 commit= abcdef12 + +## Backports +- [x] #100 | status=done +""" + complete_issue_num = mock_gh.create_issue( + title="Release 1.2.1", + body=complete_body, + labels=["type: release"], + ) + + args = argparse.Namespace(issue=None, prs=["124"]) + + result = AddBackports(args, mock_gh, mock_git).run() + + assert result == 0 + # The completed issue is untouched. + assert mock_gh.get_issue_body(complete_issue_num) == complete_body + + open_issues = mock_gh.get_open_tracking_issues() + assert len(open_issues) == 2 + new_issue = next(i for i in open_issues if i["number"] != complete_issue_num) + assert new_issue["title"] == "Release 1.2.2" + body = new_issue["body"] + assert "- [ ] #124" in body + assert "- [ ] Sync Changelog #124" in body + assert "- [ ] Tag Final" in body + + +def test_add_backports_auto_discover_ignores_complete_release_among_multiple( + mock_gh, +): + # A stale-but-complete issue alongside an active one should not cause the + # "multiple open issues" error; the active one is used. + complete_issue_num = mock_gh.create_issue( + title="Release 1.2.1", + body=""" +## Checklist +- [x] Tag Final | status=done tag=1.2.1 commit= abcdef12 + +## Backports +""", + labels=["type: release"], + ) + active_issue_num = mock_gh.create_issue( + title="Release 1.3.0", + body=""" +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch +- [ ] Tag Final + +## Backports +""", + labels=["type: release"], + ) + + args = argparse.Namespace(issue=None, prs=["124"]) + + result = AddBackports(args, mock_gh).run() + + assert result == 0 + assert "#124" not in mock_gh.get_issue_body(complete_issue_num) + active_body = mock_gh.get_issue_body(active_issue_num) + assert "- [ ] #124" in active_body + assert "- [ ] Sync Changelog #124" in active_body + + def test_add_backports_patch_release_no_rc_added(mock_gh): args = argparse.Namespace(issue=123, prs=["124"]) mock_gh.issues[123] = { diff --git a/tests/tools/private/release/release_issue_test.py b/tests/tools/private/release/release_issue_test.py index 3530dfd38a..be1f2a8c40 100644 --- a/tests/tools/private/release/release_issue_test.py +++ b/tests/tools/private/release/release_issue_test.py @@ -2,6 +2,7 @@ add_backports_to_body, add_sync_changelog_task_to_body, format_metadata_line, + is_release_complete, load_release_tracking_template, parse_backports, parse_checklist_state, @@ -194,6 +195,22 @@ def test_parse_checklist_state_with_sync_changelogs(): assert task_126.pr is None +def test_is_release_complete(): + assert not is_release_complete("- [ ] Tag Final") + assert is_release_complete("- [x] Tag Final") + assert is_release_complete( + "- [x] Tag Final | status=done tag=2.0.0 commit= abcdef12" + ) + # status=done is authoritative even if the box is somehow unchecked. + assert is_release_complete("- [ ] Tag Final | status=done tag=2.0.0") + # A failed promote attempt leaves the release incomplete. + assert not is_release_complete( + "- [ ] Tag Final | status=error-rc-tag-not-branch-head rc=2.0.0rc1" + ) + # No Tag Final task at all (e.g. malformed body) is not complete. + assert not is_release_complete("## Checklist\n- [x] Prepare Release\n") + + def test_load_release_tracking_template(tmp_path): template_file = tmp_path / "template.md" template_file.write_text("""## Checklist