blob: 6bfa0f58e06b45625842b82fde992001dc6cc8e0 [file]
import pathlib
import re
from typing import Any
def load_release_tracking_template(
version: str | None = None,
template_path: pathlib.Path | None = None,
) -> str:
"""Loads the release tracking issue template, stripping non-patch tasks for patch releases.
Args:
version: Optional version string (e.g. '1.2.1'). If provided and represents a
patch release (i.e. does not end in '.0'), strips .0-only release tasks
('Prepare Release', 'Create Release branch', and 'Tag RC' tasks) from the template.
template_path: Optional path to the template file. Defaults to
.github/ISSUE_TEMPLATE/release_tracking_template.md.
Returns:
The template content string.
"""
if template_path is None:
template_path = pathlib.Path(
".github/ISSUE_TEMPLATE/release_tracking_template.md"
)
if not template_path.exists():
raise FileNotFoundError(f"Template file not found at {template_path}")
template_content = template_path.read_text(encoding="utf-8")
is_patch = version is not None and not version.endswith(".0")
if is_patch:
lines = template_content.splitlines()
filtered_lines = []
for line in lines:
parsed = parse_metadata_line(line)
if parsed:
name_lower = parsed["name"].lower()
if "prepare release" in name_lower:
continue
if "create release branch" in name_lower:
continue
if re.match(r"^tag rc\d+", name_lower):
continue
filtered_lines.append(line)
template_content = "\n".join(filtered_lines)
if not template_content.endswith("\n"):
template_content += "\n"
return template_content
class BackportTask:
"""Represents a backport task from the tracking issue checklist."""
def __init__(
self,
pr_ref: str,
checked: bool,
status: str,
rc: str | None = None,
commit: str | None = None,
metadata: dict[str, str] | None = None,
):
"""Initializes a BackportTask.
Args:
pr_ref: The PR reference (e.g. '#123').
checked: Whether the checklist item is checked.
status: The status of the backport (e.g. 'pending', 'done',
'error-merge-conflict').
rc: The release candidate version this PR was backported to.
commit: The cherry-pick commit SHA.
metadata: Raw metadata parsed from the checklist line.
"""
self.pr_ref = pr_ref
self.checked = checked
self.status = status
self.rc = rc
self.commit = commit
self.metadata = metadata or {}
def __repr__(self):
return (
f"BackportTask(pr_ref={self.pr_ref!r}, checked={self.checked!r}, "
f"status={self.status!r}, rc={self.rc!r}, commit={self.commit!r})"
)
class ReleaseTask:
"""Represents a release task from the tracking issue checklist."""
def __init__(
self,
name: str,
checked: bool,
status: str | None = None,
pr: str | None = None,
commit: str | None = None,
branch: str | None = None,
tag: str | None = None,
metadata: dict[str, str] | None = None,
):
"""Initializes a ReleaseTask.
Args:
name: The name of the task (e.g. 'Prepare Release').
checked: Whether the checklist item is checked.
status: The status of the task (e.g. 'pending', 'done').
pr: The associated PR reference (e.g. '#123').
commit: The associated commit SHA.
branch: The associated branch name.
tag: The associated tag name.
metadata: Raw metadata parsed from the checklist line.
"""
self.name = name
self.checked = checked
self.status = status
self.pr = pr
self.commit = commit
self.branch = branch
self.tag = tag
self.metadata = metadata or {}
def __repr__(self):
return (
f"ReleaseTask(name={self.name!r}, checked={self.checked!r}, "
f"status={self.status!r}, pr={self.pr!r}, commit={self.commit!r}, "
f"branch={self.branch!r}, tag={self.tag!r})"
)
RELEASE_TITLE_RE = re.compile(r"Release (\d+\.\d+\.\d+)", re.IGNORECASE)
def parse_metadata_line(line):
"""Parses a checklist line with optional | key=value metadata."""
match = re.match(r"^\s*-\s*\[([ xX])\]\s+([^|]+)(?:\s*\|\s*(.*))?$", line)
if not match:
return None
checked = match.group(1).lower() == "x"
name = match.group(2).strip()
metadata_str = match.group(3)
metadata = {}
if metadata_str:
for k, v in re.findall(r"(\w+)\s*=\s*(\S+)", metadata_str):
metadata[k] = v
return {
"checked": checked,
"name": name,
"metadata": metadata,
"original_line": line,
}
def format_metadata_line(checked, name, metadata):
"""Formats a checklist line with space-separated key=value metadata."""
check_str = "x" if checked else " "
if not metadata:
return f"- [{check_str}] {name}"
metadata_pairs = []
for k, v in metadata.items():
if k == "commit" or k.endswith("_commit"):
# The 'commit' key (and keys ending with '_commit') is special-cased with
# a space after '=' so that GitHub autolinks the commit SHA. Autolinking
# requires certain characters to precede the value.
metadata_pairs.append(f"{k}= {v}")
else:
metadata_pairs.append(f"{k}={v}")
metadata_str = " ".join(metadata_pairs)
return f"- [{check_str}] {name} | {metadata_str}"
def update_task_in_body(body, task_name, checked, metadata):
"""Updates a specific task's checked state and metadata in the issue body."""
lines = body.splitlines()
updated_lines = []
found = False
for line in lines:
parsed = parse_metadata_line(line)
if parsed and parsed["name"].lower() == task_name.lower():
updated_lines.append(format_metadata_line(checked, task_name, metadata))
found = True
else:
updated_lines.append(line)
if not found:
raise ValueError(
f"Task '{task_name}' not found in issue body. "
f"Expected format: '- [ ] {task_name}' or '- [x] {task_name}' (optionally followed by '| key=value')"
)
return "\n".join(updated_lines)
def parse_checklist_state(body):
"""Parses the main checklist tasks and their metadata.
Returns:
A dict containing ReleaseTask objects for 'prepare_release',
'create_branch', 'tag_final', and a dict of RC tags.
"""
state = {
"prepare_release": ReleaseTask("Prepare Release", False),
"create_branch": ReleaseTask("Create Release branch", False),
"tag_final": ReleaseTask("Tag Final", False),
"rc_tags": {}, # Dynamically mapped: int -> ReleaseTask
"sync_changelogs": {}, # Dynamically mapped: int -> ReleaseTask
}
lines = body.splitlines()
for line in lines:
parsed = parse_metadata_line(line)
if not parsed:
continue
name = parsed["name"].strip()
meta = parsed["metadata"]
checked = parsed["checked"]
name_lower = name.lower()
if "prepare release" in name_lower:
state["prepare_release"] = ReleaseTask(
name=name,
checked=checked,
status=meta.get("status"),
pr=meta.get("pr"),
commit=meta.get("commit"),
metadata=meta,
)
elif "create release branch" in name_lower:
state["create_branch"] = ReleaseTask(
name=name,
checked=checked,
status=meta.get("status"),
branch=meta.get("branch"),
commit=meta.get("commit"),
metadata=meta,
)
elif "tag final" in name_lower:
state["tag_final"] = ReleaseTask(
name=name,
checked=checked,
status=meta.get("status"),
tag=meta.get("tag"),
commit=meta.get("commit"),
metadata=meta,
)
else:
# Match Tag RC<num>
rc_match = re.match(r"Tag RC(\d+)", name, re.IGNORECASE)
if rc_match:
rc_num = int(rc_match.group(1))
state["rc_tags"][rc_num] = ReleaseTask(
name=name,
checked=checked,
status=meta.get("status"),
tag=meta.get("tag"),
commit=meta.get("commit"),
metadata=meta,
)
else:
# Match Sync Changelog #<num>
sync_match = re.match(r"Sync Changelog #(\d+)", name, re.IGNORECASE)
if sync_match:
pr_num = int(sync_match.group(1))
state["sync_changelogs"][pr_num] = ReleaseTask(
name=name,
checked=checked,
status=meta.get("status"),
pr=meta.get("pr"),
commit=meta.get("commit"),
metadata=meta,
)
return state
def parse_backports(body):
"""Parses the ## Backports checklist section."""
body = body.replace("\r\n", "\n")
match = re.search(
r"## Backports\n(.*?)(?=\n##|\n---|\Z)", body, re.DOTALL | re.IGNORECASE
)
if not match:
return []
section_content = match.group(1)
items = []
lines = section_content.splitlines()
for line in lines:
parsed = parse_metadata_line(line)
if parsed:
name = parsed["name"].strip()
# Ignore empty or placeholder checklist items (e.g. '#PR_NUMBER')
if not re.match(r"^#?\d+$", name):
continue
items.append(
BackportTask(
pr_ref=name,
checked=parsed["checked"],
status=parsed["metadata"].get("status", "pending"),
rc=parsed["metadata"].get("rc"),
commit=parsed["metadata"].get("commit"),
metadata=parsed["metadata"],
)
)
return items
def add_backports_to_body(body: str, items: list[dict[str, Any]]) -> str:
"""Adds new backport checklist items to the ## Backports section.
Args:
body: The issue body.
items: A list of dicts, where each dict has a 'ref' key (str) and
optional 'metadata' key (dict).
"""
body = body.replace("\r\n", "\n")
# Find the Backports section
pattern = r"(## Backports\n)(.*?)(?=\n##|\n---|\Z)"
match = re.search(pattern, body, re.DOTALL | re.IGNORECASE)
if not match:
raise ValueError("Could not find '## Backports' section in issue body.")
section_content = match.group(2)
# Filter out empty or placeholder checklist items (e.g. "- [ ] #PR_NUMBER")
cleaned_lines = []
for line in section_content.splitlines():
parsed = parse_metadata_line(line)
if parsed and not re.match(r"^#?\d+$", parsed["name"].strip()):
continue
cleaned_lines.append(line)
section_content = "\n".join(cleaned_lines)
# Parse existing backports to avoid duplicates
existing_items = parse_backports(body)
existing_refs = {item.pr_ref for item in existing_items}
new_lines = []
for item in items:
ref = item["ref"]
# Normalize numeric refs to #numeric
if not ref.startswith("#") and ref.isdigit():
ref = f"#{ref}"
if ref in existing_refs:
print(f"PR {ref} is already in the backports list. Skipping.")
continue
existing_refs.add(ref)
metadata = item.get("metadata", {})
new_lines.append(
format_metadata_line(checked=False, name=ref, metadata=metadata)
)
if not new_lines:
section_content_clean = section_content.rstrip("\n")
updated_section = section_content_clean + "\n\n"
start, end = match.span(2)
return body[:start] + updated_section + body[end:]
# Append new lines to the section content.
section_content_clean = section_content.rstrip("\n")
separator = "\n" if section_content_clean else ""
updated_section = section_content_clean + separator + "\n".join(new_lines) + "\n\n"
# 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<rc_num>' 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<M>" 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)
def add_sync_changelog_task_to_body(body: str, pr_num: int) -> str:
"""Adds a new 'Sync Changelog #<pr_num>' task to the checklist in the issue body."""
body = body.replace("\r\n", "\n")
# Check if already exists
task_name = f"Sync Changelog #{pr_num}"
lines = body.splitlines()
for line in lines:
parsed = parse_metadata_line(line)
if parsed and parsed["name"].lower() == task_name.lower():
print(f"Task '{task_name}' already exists. Skipping.")
return body
# Find the index of the last "Sync Changelog #<M>" line
last_sync_idx = -1
for i, line in enumerate(lines):
parsed = parse_metadata_line(line)
if parsed and re.match(r"Sync Changelog #\d+", parsed["name"], re.IGNORECASE):
last_sync_idx = i
if last_sync_idx == -1:
# If no Sync Changelog task found, insert before "Tag Final"
for i, line in enumerate(lines):
parsed = parse_metadata_line(line)
if parsed and parsed["name"].lower() == "tag final":
last_sync_idx = i - 1
break
if last_sync_idx == -1:
# If "Tag Final" not found, insert after "Create Release branch"
for i, line in enumerate(lines):
parsed = parse_metadata_line(line)
if parsed and parsed["name"].lower() == "create release branch":
last_sync_idx = i
break
if last_sync_idx == -1:
raise ValueError(
"Could not find a place to insert the new Sync Changelog task."
)
new_task_line = f"- [ ] Sync Changelog #{pr_num}"
lines.insert(last_sync_idx + 1, new_task_line)
return "\n".join(lines)