Skip to content

Commit 2a58049

Browse files
committed
Refactor release tool commands into classes.
Factored out cmd_* functions from release.py into separate classes with explicit dependency injection of Git and GitHub helpers. This improves testability and code organization. Simplified mocks in tests to use injection instead of module-level patching.
1 parent cc799f1 commit 2a58049

17 files changed

Lines changed: 1904 additions & 1363 deletions

tests/tools/private/release/release_test.py

Lines changed: 87 additions & 85 deletions
Large diffs are not rendered by default.

tools/private/release/BUILD.bazel

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,17 @@ py_library(
1010
py_binary(
1111
name = "release",
1212
srcs = [
13+
"__init__.py",
14+
"complete_prepare.py",
1315
"create_rc.py",
1416
"create_release_branch.py",
17+
"create_release_issue.py",
18+
"determine_next_version.py",
1519
"gh.py",
1620
"git.py",
1721
"prepare.py",
1822
"process_backports.py",
23+
"promote_rc.py",
1924
"release.py",
2025
"release_issue.py",
2126
"shell.py",

tools/private/release/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Release tools package."""
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Subcommand to mark preparation task as complete."""
2+
3+
import re
4+
5+
from tools.private.release.gh import GitHub
6+
from tools.private.release.release_issue import update_task_in_body
7+
8+
9+
class CompletePrepare:
10+
"""Class to mark preparation task as complete."""
11+
12+
def __init__(self, args, gh: GitHub):
13+
self.args = args
14+
self.gh = gh
15+
16+
def run(self) -> int:
17+
"""Executes the complete-prepare subcommand (Phase 2 PR merged)."""
18+
args = self.args
19+
print(f"Completing preparation for PR #{args.pr}...")
20+
21+
pr_info = self.gh.get_pr_info(args.pr)
22+
if not pr_info or pr_info.get("state") != "MERGED":
23+
state = pr_info.get("state", "UNKNOWN")
24+
print(f"Error: PR #{args.pr} is not merged yet (state: {state}).")
25+
return 1
26+
27+
# Resolve issue number from PR body
28+
pr_body = pr_info.get("body", "")
29+
match = re.search(r"Work towards #(\d+)", pr_body)
30+
if not match:
31+
match = re.search(r"#(\d+)", pr_body)
32+
if not match:
33+
print(
34+
f"Error: Could not determine tracking issue number from PR"
35+
f" #{args.pr} body: {pr_body}"
36+
)
37+
return 1
38+
39+
issue_num = int(match.group(1))
40+
print(f"Resolved tracking issue #{issue_num} from PR #{args.pr} body.")
41+
42+
commit_sha = pr_info["mergeCommit"]["oid"]
43+
short_commit = commit_sha[:8]
44+
print(
45+
f"PR #{args.pr} merged at commit {commit_sha}. Updating tracking issue..."
46+
)
47+
48+
# Update checklist: mark Prepare Release as done (checked) and set SUCCESS
49+
body = self.gh.get_issue_body(issue_num)
50+
metadata = {
51+
"status": "done",
52+
"pr": f"#{args.pr}",
53+
"commit": short_commit,
54+
}
55+
updated_body = update_task_in_body(
56+
body, "Prepare Release", checked=True, metadata=metadata
57+
)
58+
self.gh.update_issue_body(issue_num, updated_body)
59+
print("Prepare Release task marked complete successfully!")
60+
return 0
61+
62+
@classmethod
63+
def add_parser(cls, subparsers):
64+
"""Adds parser for complete-prepare subcommand."""
65+
parser = subparsers.add_parser(
66+
"complete-prepare",
67+
help="Mark the Prepare Release task as complete in the tracking issue.",
68+
)
69+
parser.add_argument(
70+
"--pr",
71+
type=int,
72+
required=True,
73+
help="The merged preparation PR number.",
74+
)
75+
parser.set_defaults(command=cls.run_from_args)
76+
77+
@classmethod
78+
def run_from_args(cls, args):
79+
"""Instantiates and runs the command from parsed args."""
80+
gh = GitHub()
81+
return cls(args, gh).run()

tools/private/release/create_rc.py

Lines changed: 129 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Subcommand to tag and push the next release candidate."""
22

3-
from tools.private.release import gh, git
3+
from tools.private.release.gh import GitHub
4+
from tools.private.release.git import Git
45
from tools.private.release.release_issue import (
56
RELEASE_TITLE_RE,
67
parse_backports,
@@ -13,98 +14,139 @@
1314
)
1415

1516

16-
def cmd_create_rc(args):
17-
"""Executes the create-rc subcommand."""
18-
body = gh.get_issue_body(args.issue)
19-
state = parse_checklist_state(body)
20-
21-
if (
22-
state["prepare_release"]["status"] != "done"
23-
or state["create_branch"]["status"] != "done"
24-
):
25-
print(
26-
"Error: Preconditions not met (release must be prepared and branch created)."
27-
)
28-
return 1
29-
30-
# Gating: RC tagging is blocked if any backport is unchecked OR does not have status=done
31-
backports = parse_backports(body)
32-
conflicting_or_pending = [
33-
b for b in backports if not b.checked or b.status != "done"
34-
]
35-
if conflicting_or_pending:
36-
print(
37-
f"Gating RC tagging: {len(conflicting_or_pending)} backports are still"
38-
" unfinished, failed, or in conflict."
39-
)
40-
return 1
41-
42-
# Resolve version and branch
43-
issue_title = gh.get_issue_title(args.issue)
44-
version_match = RELEASE_TITLE_RE.search(issue_title)
45-
if not version_match:
46-
print(f"Error: Could not parse version from issue title: {issue_title}")
47-
return 1
48-
49-
version = version_match.group(1)
50-
branch_version = ".".join(version.split(".")[:2])
51-
branch_name = f"release/{branch_version}"
52-
53-
# Determine next RC tag
54-
git.fetch(args.remote)
55-
git.fetch(args.remote, tags=True, force=True)
56-
latest_rc = get_latest_rc_tag(version, remote=args.remote)
57-
58-
if not latest_rc:
59-
next_rc_num = 0
60-
next_rc = f"{version}-rc0"
61-
else:
62-
rc_num = int(latest_rc.split("-rc")[-1])
63-
next_rc_num = rc_num + 1
64-
next_rc = f"{version}-rc{next_rc_num}"
65-
66-
# Precheck: next RC number must exist and be unchecked in the checklist
67-
rc_tags = state.get("rc_tags", {})
68-
if next_rc_num not in rc_tags:
69-
print(
70-
f"Error: Checklist is missing required task 'Tag RC{next_rc_num}'"
71-
f" to cut {version}-rc{next_rc_num}."
17+
class CreateRc:
18+
"""Class to tag and push the next release candidate."""
19+
20+
def __init__(self, args, git: Git, gh: GitHub):
21+
self.args = args
22+
self.git = git
23+
self.gh = gh
24+
25+
def run(self) -> int:
26+
"""Executes the create-rc subcommand."""
27+
args = self.args
28+
body = self.gh.get_issue_body(args.issue)
29+
state = parse_checklist_state(body)
30+
31+
if (
32+
state["prepare_release"].status != "done"
33+
or state["create_branch"].status != "done"
34+
):
35+
print(
36+
"Error: Preconditions not met (release must be prepared and"
37+
" branch created)."
38+
)
39+
return 1
40+
41+
# Gating: RC tagging is blocked if any backport is unchecked OR does not have status=done
42+
backports = parse_backports(body)
43+
conflicting_or_pending = [
44+
b for b in backports if not b.checked or b.status != "done"
45+
]
46+
if conflicting_or_pending:
47+
print(
48+
f"Gating RC tagging: {len(conflicting_or_pending)} backports"
49+
" are still unfinished, failed, or in conflict."
50+
)
51+
return 1
52+
53+
# Resolve version and branch
54+
issue_title = self.gh.get_issue_title(args.issue)
55+
version_match = RELEASE_TITLE_RE.search(issue_title)
56+
if not version_match:
57+
print(f"Error: Could not parse version from issue title: {issue_title}")
58+
return 1
59+
60+
version = version_match.group(1)
61+
branch_version = ".".join(version.split(".")[:2])
62+
branch_name = f"release/{branch_version}"
63+
64+
# Determine next RC tag
65+
self.git.fetch(args.remote)
66+
self.git.fetch(args.remote, tags=True, force=True)
67+
latest_rc = get_latest_rc_tag(version, remote=args.remote)
68+
69+
if not latest_rc:
70+
next_rc_num = 0
71+
next_rc = f"{version}-rc0"
72+
else:
73+
rc_num = int(latest_rc.split("-rc")[-1])
74+
next_rc_num = rc_num + 1
75+
next_rc = f"{version}-rc{next_rc_num}"
76+
77+
# Precheck: next RC number must exist and be unchecked in the checklist
78+
rc_tags = state.get("rc_tags", {})
79+
if next_rc_num not in rc_tags:
80+
print(
81+
f"Error: Checklist is missing required task 'Tag RC{next_rc_num}'"
82+
f" to cut {version}-rc{next_rc_num}."
83+
)
84+
return 1
85+
86+
target_rc_task = rc_tags[next_rc_num]
87+
if target_rc_task.checked or target_rc_task.status == "done":
88+
print(
89+
f"Error: Task 'Tag RC{next_rc_num}' is already marked done in"
90+
" the checklist."
91+
)
92+
return 1
93+
94+
target_ref = f"{args.remote}/{branch_name}"
95+
commit_sha = self.git.get_commit_sha(target_ref)
96+
97+
print(f"Tagging and pushing next RC: {next_rc}...")
98+
self.git.tag(next_rc, target_ref)
99+
self.git.push(args.remote, next_rc)
100+
101+
# Check off the appropriate "Tag RC{N}" task in the checklist
102+
print(f"Checking off Tag RC{next_rc_num} task...")
103+
metadata = {"status": "done", "tag": next_rc, "commit": commit_sha[:8]}
104+
task_name = f"Tag RC{next_rc_num}"
105+
updated_body = update_task_in_body(
106+
body, task_name, checked=True, metadata=metadata
72107
)
73-
return 1
74-
75-
target_rc_task = rc_tags[next_rc_num]
76-
if target_rc_task["checked"] or target_rc_task["status"] == "done":
77-
print(
78-
f"Error: Task 'Tag RC{next_rc_num}' is already marked done in the checklist."
79-
)
80-
return 1
81-
82-
target_ref = f"{args.remote}/{branch_name}"
83-
commit_sha = git.get_commit_sha(target_ref)
108+
self.gh.update_issue_body(args.issue, updated_body)
84109

85-
print(f"Tagging and pushing next RC: {next_rc}...")
86-
git.tag(next_rc, target_ref)
87-
git.push(args.remote, next_rc)
88-
89-
# Check off the appropriate "Tag RC{N}" task in the checklist
90-
print(f"Checking off Tag RC{next_rc_num} task...")
91-
metadata = {"status": "done", "tag": next_rc, "commit": commit_sha[:8]}
92-
task_name = f"Tag RC{next_rc_num}"
93-
updated_body = update_task_in_body(body, task_name, checked=True, metadata=metadata)
94-
gh.update_issue_body(args.issue, updated_body)
95-
96-
tag_url = f"{REPO_URL}/releases/tag/{next_rc}"
97-
bcr_entry_url = f"https://registry.bazel.build/modules/rules_python/{version}"
98-
bcr_search_url = f"https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+{version}"
99-
release_workflow_url = f"{REPO_URL}/actions/workflows/release.yml"
100-
comment_body = f"""**New Release Candidate Tagged!** 🐍🌿
110+
tag_url = f"{REPO_URL}/releases/tag/{next_rc}"
111+
bcr_entry_url = f"https://registry.bazel.build/modules/rules_python/{version}"
112+
bcr_search_url = f"https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+{version}"
113+
release_workflow_url = f"{REPO_URL}/actions/workflows/release.yml"
114+
comment_body = f"""**New Release Candidate Tagged!** 🐍🌿
101115
102116
Release Candidate **{next_rc}** has been successfully generated and tagged on branch `{branch_name}`.
103117
104118
- [Github Release {next_rc}]({tag_url})
105119
- BCR Entry: [rules_python@{version}]({bcr_entry_url})
106120
- [BCR PRs]({bcr_search_url})
107121
- [Release workflow status]({release_workflow_url})"""
108-
gh.post_issue_comment(args.issue, comment_body)
109-
print("RC creation completed successfully!")
110-
return 0
122+
self.gh.post_issue_comment(args.issue, comment_body)
123+
print("RC creation completed successfully!")
124+
return 0
125+
126+
@classmethod
127+
def add_parser(cls, subparsers):
128+
"""Adds parser for create-rc subcommand."""
129+
parser = subparsers.add_parser(
130+
"create-rc",
131+
help="Tags the next RC on the release branch if no backports remain.",
132+
)
133+
parser.add_argument(
134+
"--issue",
135+
type=int,
136+
required=True,
137+
help="The tracking issue number (required).",
138+
)
139+
parser.add_argument(
140+
"--remote",
141+
type=str,
142+
required=True,
143+
help="The git remote to push the RC tag to (required).",
144+
)
145+
parser.set_defaults(command=cls.run_from_args)
146+
147+
@classmethod
148+
def run_from_args(cls, args):
149+
"""Instantiates and runs the command from parsed args."""
150+
git = Git(".")
151+
gh = GitHub()
152+
return cls(args, git, gh).run()

0 commit comments

Comments
 (0)