diff --git a/release_automation/docs/repository-setup.md b/release_automation/docs/repository-setup.md index abde6dd..2237feb 100644 --- a/release_automation/docs/repository-setup.md +++ b/release_automation/docs/repository-setup.md @@ -256,7 +256,7 @@ The README must contain release information section delimiters: ``` -These delimiters are used by the release automation to update the Release Information section during snapshot creation and post-release sync. If missing, the README update step will fail. +These delimiters are used by the release automation to update the Release Information section during snapshot creation and post-release sync. If they are missing, the README update is skipped with a warning in the workflow log and the snapshot and the sync PR are created without it; to get the section into the release, add the delimiters on `main`, then `/discard-snapshot` and `/create-snapshot` again. The delimiters are distributed by the `campaign-release-info` campaign in `project-administration`. diff --git a/release_automation/scripts/bot_responder.py b/release_automation/scripts/bot_responder.py index 63fafd9..0a121d5 100644 --- a/release_automation/scripts/bot_responder.py +++ b/release_automation/scripts/bot_responder.py @@ -186,3 +186,23 @@ def merge_comment_context(base_context: Any, delta: Dict[str, Any]) -> Dict[str, apis = [] raw["apis"] = apis if isinstance(apis, list) else [] return build_context(**raw) + + +def post_comment( + gh, + template_name: str, + issue_number: Any, + base_context: Any, + delta: Dict[str, Any], + release_tag: str, + comment_id: str = "", +) -> int: + """Render a bot comment and post it, or replace comment_id's body; returns the comment id.""" + body = BotResponder().render_comment(template_name, base_context, delta, release_tag) + if comment_id: + gh.update_comment(int(comment_id), body) + print(f"Updated comment {comment_id} ({template_name})") + return int(comment_id) + new_id = gh.create_comment(int(issue_number), body) + print(f"Created comment {new_id} on #{issue_number} ({template_name})") + return new_id diff --git a/release_automation/scripts/draft_release.py b/release_automation/scripts/draft_release.py new file mode 100644 index 0000000..9090ba5 --- /dev/null +++ b/release_automation/scripts/draft_release.py @@ -0,0 +1,279 @@ +""" +Release PR merge and draft cleanup for CAMARA release automation. + +On a Release PR merge the snapshot branch's CHANGELOG loses its candidate +changes block (a commit on the snapshot branch) and its "## Release Notes" +section becomes the release body. What follows depends on the plan's +publish_mode: + +- draft: create_draft() — a draft release; /publish-release publishes it +- on_merge: publish_on_merge() — release_date commit, published release and + tag, then the same post-publication steps as /publish-release + +discard_snapshot() and delete_draft() undo a snapshot or a draft; both keep the +release-review branch as -preserved so the next /create-snapshot can +reuse the name. + +The plan decides which of these runs; this module only executes. +""" + +import re +import time +from dataclasses import dataclass +from typing import List, Optional, Tuple + +from .github_client import GitHubClientError +from .release_publisher import PublishOutcome, ReleasePublisher, complete_publication + +CANDIDATE_START = "" +CANDIDATE_END = "" +CHANGELOG_CLEANUP_MESSAGE = "chore: auto-remove candidate changes block from CHANGELOG" + +# The snapshot-branch commit triggers ruleset evaluation, which GitHub can +# abort with this transient timeout; only this error is retried. +RULESET_TIMEOUT = re.compile(r"rule was unable to be completed", re.IGNORECASE) +RETRY_DELAYS = (5, 10) + +_sleep = time.sleep + + +@dataclass(frozen=True) +class ChangelogResult: + notes: str = "" + stripped: bool = False + + +@dataclass(frozen=True) +class DraftOutcome: + success: bool + draft_release_url: str = "" + candidate_block_stripped: bool = False + error_message: str = "" + + +@dataclass(frozen=True) +class CleanupOutcome: + """Result of /discard-snapshot or /delete-draft.""" + success: bool + error_message: str = "" + renamed_review_branch: str = "" + + +def is_prerelease(release_type: str) -> bool: + return release_type.startswith("pre-release") + + +# ── CHANGELOG ──────────────────────────────────────────────────────────────── + + +def changelog_candidates(release_tag: str) -> List[str]: + """CHANGELOG/CHANGELOG-rX.md of the meta-release cycle first, then CHANGELOG.md.""" + match = re.match(r"(r\d+)", release_tag) + return ([f"CHANGELOG/CHANGELOG-{match.group(1)}.md"] if match else []) + ["CHANGELOG.md"] + + +def strip_candidate_block(text: str) -> Tuple[str, bool]: + """Remove the candidate changes block, or an orphan marker line of it.""" + start = text.find(CANDIDATE_START) + end = text.find(CANDIDATE_END) + if start != -1 and end != -1 and end > start: + stripped = text[:start] + text[end + len(CANDIDATE_END):] + print("Stripped full candidate changes block") + return re.sub(r"\n{3,}", "\n\n", stripped), True + if start != -1 or end != -1: + # A codeowner deleted part of the block; the marker line alone is safe to drop. + orphan = CANDIDATE_START if start != -1 else CANDIDATE_END + print(f"Stripped orphan marker: {orphan}") + return "\n".join(line for line in text.split("\n") if line.strip() != orphan), True + return text, False + + +def extract_release_notes(text: str) -> str: + """From "## Release Notes" to the next "# rX.Y" heading or EOF, without trailing blank lines.""" + lines = text.split("\n") + start = next((i for i, line in enumerate(lines) if re.match(r"## Release Notes", line, re.IGNORECASE)), -1) + if start == -1: + return "" + end = next((i for i in range(start + 1, len(lines)) if re.match(r"# r\d+", lines[i])), len(lines)) + while end > start and lines[end - 1].strip() == "": + end -= 1 + return "\n".join(lines[start:end]) + + +def _commit_with_retry(gh, path: str, content: str, message: str, branch: str) -> None: + for delay in RETRY_DELAYS + (None,): + try: + gh.update_file(path=path, content=content, message=message, branch=branch) + return + except GitHubClientError as e: + if delay is None or not RULESET_TIMEOUT.search(str(e)): + raise + print(f"::warning::Ruleset validation timed out on {branch}; retrying in {delay}s") + _sleep(delay) + + +def prepare_changelog(gh, release_tag: str, snapshot_branch: str) -> ChangelogResult: + """ + Strip the candidate block on the snapshot branch and extract the release notes. + + Raises: + GitHubClientError: If the cleanup commit fails + """ + content: Optional[str] = None + path = "" + for candidate in changelog_candidates(release_tag): + content = gh.get_file_content(candidate, ref=snapshot_branch) + if content is not None: + path = candidate + print(f"Fetched {path} ({len(content)} chars)") + break + print(f"{candidate} not found, trying next...") + if content is None: + print("::warning::No CHANGELOG file found on snapshot branch") + return ChangelogResult() + + content, stripped = strip_candidate_block(content) + if stripped: + _commit_with_retry(gh, path, content, CHANGELOG_CLEANUP_MESSAGE, snapshot_branch) + print(f"Committed cleaned CHANGELOG to {snapshot_branch}") + + notes = extract_release_notes(content) + if notes: + print(f"Extracted release notes: {len(notes)} chars") + else: + print(f'::warning::No "## Release Notes" section found in {path}') + return ChangelogResult(notes=notes, stripped=stripped) + + +def release_body(release_tag: str, release_type: str, notes: str) -> str: + if notes: + return notes + return ( + f"## Release {release_tag}\n\n" + f"**Release type:** {release_type}\n\n" + "---\n\n" + "_CHANGELOG.md content was not available — please add release notes manually._\n" + ) + + +# ── Release PR merge ───────────────────────────────────────────────────────── + + +def create_draft(gh, release_tag: str, snapshot_branch: str, release_type: str) -> DraftOutcome: + """publish_mode draft: CHANGELOG cleanup, then a draft release on the snapshot branch.""" + print(f"Creating draft release for {release_tag}") + print(f" Snapshot branch: {snapshot_branch}") + print(f" Release type: {release_type}") + try: + changelog = prepare_changelog(gh, release_tag, snapshot_branch) + except GitHubClientError as e: + print(f"::error::CHANGELOG cleanup failed: {e}") + return DraftOutcome(success=False, error_message=f"CHANGELOG cleanup failed: {e}") + try: + release = gh.create_release( + release_tag, snapshot_branch, release_tag, + release_body(release_tag, release_type, changelog.notes), + draft=True, prerelease=is_prerelease(release_type), + ) + except GitHubClientError as e: + print(f"::error::Draft release creation failed: {e}") + return DraftOutcome(success=False, candidate_block_stripped=changelog.stripped, error_message=str(e)) + print(f"Draft release created: {release['html_url']}") + return DraftOutcome(success=True, draft_release_url=release["html_url"], candidate_block_stripped=changelog.stripped) + + +def publish_on_merge( + gh, release_tag: str, snapshot_branch: str, review_branch: str, release_type: str, src_commit_sha: str +) -> PublishOutcome: + """publish_mode on_merge: CHANGELOG cleanup, release_date commit, published release and tag.""" + print(f"Publishing {release_tag} on Release PR merge") + try: + changelog = prepare_changelog(gh, release_tag, snapshot_branch) + except GitHubClientError as e: + print(f"::error::CHANGELOG cleanup failed: {e}") + return PublishOutcome(success=False, error_message=f"CHANGELOG cleanup failed: {e}") + publisher = ReleasePublisher(gh) + result = publisher.publish_new_release( + release_tag, snapshot_branch, + release_body(release_tag, release_type, changelog.notes), + is_prerelease(release_type), + ) + return complete_publication(publisher, result, release_tag, src_commit_sha, snapshot_branch, review_branch) + + +# ── discard and delete ─────────────────────────────────────────────────────── + + +def _delete_snapshot_branch(gh, snapshot_branch: str) -> None: + print(f"Deleting snapshot branch: {snapshot_branch}...") + if not gh.delete_branch(snapshot_branch): + raise GitHubClientError(f"Snapshot branch {snapshot_branch} not found") + print("Snapshot branch deleted") + + +def preserve_review_branch(gh, review_branch: str) -> str: + """Rename the review branch to -preserved; returns the new name, "" if not renamed.""" + if not review_branch: + return "" + preserved = f"{review_branch}-preserved" + try: + sha = gh.get_branch_sha(review_branch) + if not sha: + raise GitHubClientError(f"{review_branch} not found") + # Repeated cycles: replace the previous preserved copy + if gh.delete_branch(preserved): + print(f"Deleted previous {preserved}") + gh.create_branch_at_sha(preserved, sha) + gh.delete_branch(review_branch) + except GitHubClientError as e: + print(f"Could not rename review branch: {e}") + return "" + print(f"Renamed: {review_branch} → {preserved}") + return preserved + + +def discard_snapshot(gh, release_pr_number: str, snapshot_branch: str, review_branch: str) -> CleanupOutcome: + """/discard-snapshot: close the Release PR unmerged, delete the snapshot branch.""" + print(f" Snapshot branch: {snapshot_branch}") + print(f" Release PR: #{release_pr_number}") + try: + if release_pr_number: + print(f"Closing Release PR #{release_pr_number}...") + gh.close_pr(int(release_pr_number)) + print("Release PR closed") + if snapshot_branch: + _delete_snapshot_branch(gh, snapshot_branch) + except GitHubClientError as e: + print(f"::error::Discard failed: {e}") + return CleanupOutcome(success=False, error_message=str(e)) + return CleanupOutcome(success=True, renamed_review_branch=preserve_review_branch(gh, review_branch)) + + +def delete_draft(gh, release_tag: str, snapshot_branch: str, review_branch: str) -> CleanupOutcome: + """/delete-draft: delete the draft release, its tag if any, and the snapshot branch.""" + print(f"Deleting draft release for {release_tag}") + print(f" Snapshot branch: {snapshot_branch}") + try: + release_id = gh.get_release_id(release_tag, draft_only=True) + if release_id: + print(f"Deleting draft release {release_tag} (ID: {release_id})") + gh.delete_release(release_id) + print("Draft release deleted") + else: + print(f"Warning: No draft release found for tag {release_tag}") + + try: + if gh.delete_tag(release_tag): + print(f"Deleted tag: {release_tag}") + else: + print(f"Tag {release_tag} not found or already deleted") + except GitHubClientError as e: + # A draft has no tag; any tag error is informational, as before + print(f"Tag {release_tag} not found or already deleted ({e})") + + if snapshot_branch: + _delete_snapshot_branch(gh, snapshot_branch) + except GitHubClientError as e: + print(f"::error::Delete draft failed: {e}") + return CleanupOutcome(success=False, error_message=str(e)) + return CleanupOutcome(success=True, renamed_review_branch=preserve_review_branch(gh, review_branch)) diff --git a/release_automation/scripts/finalize.py b/release_automation/scripts/finalize.py index a0272f0..db969d2 100644 --- a/release_automation/scripts/finalize.py +++ b/release_automation/scripts/finalize.py @@ -264,20 +264,14 @@ class Report: def post_comment(clients: Clients, run: Run, template: str, issue: str, delta: Dict[str, Any], comment_id: str = "") -> None: - from .bot_responder import BotResponder + from .bot_responder import post_comment as post try: base_context = json.loads(run.plan("base_context") or "{}") except json.JSONDecodeError: print("::warning::Invalid base_context JSON, ignoring") base_context = {} - body = BotResponder().render_comment(template, base_context, delta, run.plan("release_tag")) - if comment_id: - clients.gh.update_comment(int(comment_id), body) - print(f"Updated comment {comment_id} ({template})") - else: - new_id = clients.gh.create_comment(int(issue), body) - print(f"Created comment {new_id} on #{issue} ({template})") + post(clients.gh, template, issue, base_context, delta, run.plan("release_tag"), comment_id=comment_id) def run_sync_pr(run: Run, clients: Clients) -> SyncPrOutcome: diff --git a/release_automation/scripts/github_client.py b/release_automation/scripts/github_client.py index 6003f11..d330666 100644 --- a/release_automation/scripts/github_client.py +++ b/release_automation/scripts/github_client.py @@ -1250,6 +1250,83 @@ def create_branch_at_sha(self, branch_name: str, sha: str) -> bool: return False raise + def get_branch_sha(self, branch_name: str) -> Optional[str]: + """Commit SHA a branch points to, or None if the branch doesn't exist.""" + try: + output = self._run_gh([ + "api", + f"repos/{self.repo}/git/ref/heads/{branch_name}", + "--jq", ".object.sha" + ]) + return output.strip() or None + except GitHubClientError as e: + error_msg = str(e).lower() + if "404" in error_msg or "not found" in error_msg: + return None + raise + + def create_release( + self, + tag: str, + target: str, + name: str, + body: str, + draft: bool, + prerelease: bool, + make_latest: Optional[str] = None + ) -> dict: + """Create a release; a published one creates its tag at the target's head. + + Returns: + Release data ('id', 'html_url', ...) + + Raises: + GitHubClientError: If creation fails + """ + args = [ + "api", f"repos/{self.repo}/releases", "-X", "POST", + "-f", f"tag_name={tag}", + "-f", f"target_commitish={target}", + "-f", f"name={name}", + "-f", f"body={body}", + "-F", f"draft={str(draft).lower()}", + "-F", f"prerelease={str(prerelease).lower()}", + ] + if make_latest is not None: + args.extend(["-f", f"make_latest={make_latest}"]) + return json.loads(self._run_gh(args)) + + def delete_release(self, release_id: int) -> None: + """Delete a release (its tag, if any, stays). + + Raises: + GitHubClientError: If deletion fails + """ + self._run_gh(["api", f"repos/{self.repo}/releases/{release_id}", "-X", "DELETE"]) + + def delete_tag(self, tag: str) -> bool: + """Delete a tag; False if it didn't exist. + + Raises: + GitHubClientError: If deletion fails for other reasons + """ + try: + self._run_gh(["api", f"repos/{self.repo}/git/refs/tags/{tag}", "-X", "DELETE"]) + return True + except GitHubClientError as e: + error_msg = str(e).lower() + if "422" in error_msg or "404" in error_msg or "not found" in error_msg: + return False + raise + + def reopen_issue(self, issue_number: int) -> None: + """Reopen a closed issue. + + Raises: + GitHubClientError: If the update fails + """ + self._run_gh(["api", f"repos/{self.repo}/issues/{issue_number}", "-X", "PATCH", "-f", "state=open"]) + def close_issue( self, issue_number: int, diff --git a/release_automation/scripts/issue_events.py b/release_automation/scripts/issue_events.py new file mode 100644 index 0000000..71c378d --- /dev/null +++ b/release_automation/scripts/issue_events.py @@ -0,0 +1,38 @@ +""" +Release Issue close events for CAMARA release automation. + +A Release Issue closed while its release is in progress (snapshot-active or +draft-ready; the plan decides) is reopened with an issue_reopened comment. +Closes in other states and reopens need no action here; finalize syncs the issue. +""" + +from dataclasses import dataclass +from typing import Any + +from .bot_responder import post_comment +from .github_client import GitHubClientError + +REOPEN_REASON = "Cannot close Release Issue while release is in progress" + + +@dataclass(frozen=True) +class IssueEventOutcome: + success: bool + action_taken: str = "" + error_message: str = "" + + +def reopen_release_issue(gh, issue_number: int, release_tag: str, base_context: Any) -> IssueEventOutcome: + print(f"Auto-reopening issue #{issue_number}") + try: + gh.reopen_issue(issue_number) + except GitHubClientError as e: + print(f"::error::Could not reopen #{issue_number}: {e}") + return IssueEventOutcome(success=False, error_message=str(e)) + print("Issue reopened successfully") + try: + post_comment(gh, "issue_reopened", issue_number, base_context, {"reason": REOPEN_REASON}, release_tag) + except GitHubClientError as e: + print(f"::error::Could not post issue_reopened: {e}") + return IssueEventOutcome(success=False, action_taken="reopened", error_message=str(e)) + return IssueEventOutcome(success=True, action_taken="reopened") diff --git a/release_automation/scripts/release_publisher.py b/release_automation/scripts/release_publisher.py index 8cbe9ca..17cbc37 100644 --- a/release_automation/scripts/release_publisher.py +++ b/release_automation/scripts/release_publisher.py @@ -1,7 +1,9 @@ -"""Release publisher module for publishing draft releases. +"""Release publisher module for publishing releases. -This module handles the publication of draft GitHub releases as part -of the CAMARA release automation workflow. +This module handles the publication of GitHub releases as part of the CAMARA +release automation workflow: a draft on /publish-release (publish_draft), or a +new release on the Release PR merge (draft_release.publish_on_merge). Both end +in complete_publication(): reference tag, pointer branch, branch cleanup. """ import logging @@ -28,6 +30,20 @@ class PublishResult: error_message: Optional[str] = None +@dataclass(frozen=True) +class PublishOutcome: + """Result of a publication for the act outcome. + + error_message is the failure reason, or after a successful publication the + warnings of the post-publication steps joined by '; '. + """ + success: bool + release_url: str = "" + reference_tag: str = "" + pointer_branch: str = "" + error_message: str = "" + + class ReleasePublisher: """Publishes draft releases to GitHub. @@ -200,6 +216,47 @@ def publish_release( is_prerelease=is_prerelease ) + def publish_new_release( + self, + release_tag: str, + snapshot_branch: str, + body: str, + is_prerelease: bool + ) -> PublishResult: + """Publish a release without a draft (publish_mode on_merge). + + Steps: + 1. Finalize release-metadata.yaml (set release_date) + 2. Create the published release on the snapshot branch (creates tag) + + Returns: + PublishResult with success status and details + """ + commit_sha = self.finalize_metadata(snapshot_branch, release_tag) + if not commit_sha: + return PublishResult( + success=False, + error_message="Failed to finalize release-metadata.yaml" + ) + try: + created = self.gh.create_release( + release_tag, snapshot_branch, release_tag, body, + draft=False, + prerelease=is_prerelease, + make_latest=None if is_prerelease else "true", + ) + except GitHubClientError as e: + return PublishResult( + success=False, + error_message=f"Failed to publish release: {e}" + ) + return PublishResult( + success=True, + release_url=created.get("html_url"), + release_id=created.get("id"), + is_prerelease=is_prerelease + ) + def create_reference_tag( self, release_tag: str, @@ -329,3 +386,70 @@ def cleanup_branches( result["review_deleted"] = "error" return result + + +def complete_publication( + publisher: ReleasePublisher, + result: PublishResult, + release_tag: str, + src_commit_sha: str, + snapshot_branch: str, + release_review_branch: str, +) -> PublishOutcome: + """After the publish step: reference tag, pointer branch, branch cleanup (all non-critical).""" + if not result.success: + message = result.error_message or "Unknown error" + print(f"::error::Publication failed: {message}") + return PublishOutcome(success=False, error_message=message) + + release_url = result.release_url or "" + print(f"Release published: {release_url}") + warnings = [] + + print("Step 2: Creating reference tag...") + reference_tag = publisher.create_reference_tag(release_tag, src_commit_sha) or "" + if reference_tag: + print(f"Reference tag created: {reference_tag}") + else: + warnings.append("Failed to create reference tag") + print("::warning::Failed to create reference tag") + + print("Step 3: Creating pointer branch...") + pointer_branch = publisher.create_pointer_branch(release_tag, result.is_prerelease) or "" + if pointer_branch: + print(f"Pointer branch created: {pointer_branch}") + else: + warnings.append("Failed to create pointer branch") + print("::warning::Failed to create pointer branch") + + print("Step 4: Cleaning up branches...") + for op, status in publisher.cleanup_branches(snapshot_branch, release_review_branch).items(): + if status == "error": + warnings.append(f"Branch cleanup issue: {op}") + print(f" {op}: {status}") + + print("Publish flow completed successfully") + return PublishOutcome( + success=True, + release_url=release_url, + reference_tag=reference_tag, + pointer_branch=pointer_branch, + error_message="; ".join(warnings), + ) + + +def publish_draft( + gh: GitHubClient, + release_tag: str, + snapshot_branch: str, + release_review_branch: str, + src_commit_sha: str, +) -> PublishOutcome: + """/publish-release --confirm: publish the draft, then complete the publication. + + The post-release sync PR and the Release Issue close follow in finalize. + """ + publisher = ReleasePublisher(gh) + print("Step 1: Publishing release...") + result = publisher.publish_release(release_tag, snapshot_branch) + return complete_publication(publisher, result, release_tag, src_commit_sha, snapshot_branch, release_review_branch) diff --git a/release_automation/tests/fake_github.py b/release_automation/tests/fake_github.py new file mode 100644 index 0000000..dcf790f --- /dev/null +++ b/release_automation/tests/fake_github.py @@ -0,0 +1,148 @@ +"""In-memory stand-in for GitHubClient, for the act-phase modules. + +State is plain dicts the tests set up and inspect. ``fail`` maps a method name +to a list of exceptions raised by its next calls, in order. +""" + +import itertools +from typing import Any, Dict, List, Optional + +from release_automation.scripts.github_client import GitHubClientError, Release + +REPO_URL = "https://github.com/camaraproject/Example" + + +class FakeGitHub: + def __init__(self, files=None, branches=None, tags=None, releases=None, fail=None): + self.repo = "camaraproject/Example" + self.files: Dict[tuple, str] = dict(files or {}) # (ref, path) -> content + self.branches: Dict[str, str] = dict(branches or {}) # name -> sha + self.tags: Dict[str, str] = dict(tags or {}) # name -> sha + self.releases: List[Dict[str, Any]] = list(releases or []) + self.fail: Dict[str, List[Exception]] = {k: list(v) for k, v in (fail or {}).items()} + self.commits: List[Dict[str, str]] = [] + self.closed_prs: List[int] = [] + self.reopened: List[int] = [] + self.comments: List[Dict[str, Any]] = [] + self.calls: List[str] = [] + self._ids = itertools.count(100) + + def _call(self, name: str) -> None: + self.calls.append(name) + queue = self.fail.get(name) + if queue: + raise queue.pop(0) + + # ── files ── + def get_file_content(self, path, ref="main", strict=False): + self._call("get_file_content") + return self.files.get((ref, path)) + + def update_file(self, path, content, message, branch): + self._call("update_file") + self.files[(branch, path)] = content + sha = f"c{next(self._ids)}" + self.branches[branch] = sha + self.commits.append({"branch": branch, "path": path, "message": message, "sha": sha}) + return {"commit": {"sha": sha}} + + # ── branches and tags ── + def get_branch_sha(self, branch_name): + self._call("get_branch_sha") + return self.branches.get(branch_name) + + def delete_branch(self, branch_name): + self._call("delete_branch") + return self.branches.pop(branch_name, None) is not None + + def create_branch_at_sha(self, branch_name, sha): + self._call("create_branch_at_sha") + if branch_name in self.branches: + return False + self.branches[branch_name] = sha + return True + + def tag_exists(self, tag): + return tag in self.tags + + def get_tag_sha(self, tag): + return self.tags.get(tag) + + def create_tag(self, tag_name, sha): + self._call("create_tag") + self.tags[tag_name] = sha + return {"ref": f"refs/tags/{tag_name}"} + + def delete_tag(self, tag): + self._call("delete_tag") + return self.tags.pop(tag, None) is not None + + # ── releases ── + def _release(self, tag, draft_only=False): + for r in self.releases: + if r["tag"] == tag and (r["draft"] or not draft_only): + return r + return None + + def get_draft_release(self, tag) -> Optional[Release]: + r = self._release(tag, draft_only=True) + if not r: + return None + return Release(tag_name=r["tag"], name=r["tag"], draft=True, prerelease=r["prerelease"], html_url=r["html_url"]) + + def get_release_id(self, tag, draft_only=False): + r = self._release(tag, draft_only) + return r["id"] if r else None + + def create_release(self, tag, target, name, body, draft, prerelease, make_latest=None): + self._call("create_release") + rid = next(self._ids) + release = { + "id": rid, "tag": tag, "target": target, "name": name, "body": body, + "draft": draft, "prerelease": prerelease, "make_latest": make_latest, + "html_url": f"{REPO_URL}/releases/{'untagged-' + str(rid) if draft else 'tag/' + tag}", + } + if not draft: + self.tags[tag] = self.branches[target] + self.releases.append(release) + return {"id": rid, "html_url": release["html_url"]} + + def update_release(self, release_id, draft=None, prerelease=None, name=None, body=None, make_latest=None): + self._call("update_release") + r = next(r for r in self.releases if r["id"] == release_id) + if draft is False and r["draft"]: + r["draft"] = False + self.tags[r["tag"]] = self.branches[r["target"]] + r["html_url"] = f"{REPO_URL}/releases/tag/{r['tag']}" + if prerelease is not None: + r["prerelease"] = prerelease + if make_latest is not None: + r["make_latest"] = make_latest + return {"html_url": r["html_url"]} + + def delete_release(self, release_id): + self._call("delete_release") + self.releases = [r for r in self.releases if r["id"] != release_id] + + # ── PRs, issues, comments ── + def close_pr(self, pr_number, comment=""): + self._call("close_pr") + self.closed_prs.append(pr_number) + + def reopen_issue(self, issue_number): + self._call("reopen_issue") + self.reopened.append(issue_number) + + def create_comment(self, issue_number, body): + self._call("create_comment") + cid = next(self._ids) + self.comments.append({"id": cid, "issue": issue_number, "body": body}) + return cid + + def update_comment(self, comment_id, body): + self._call("update_comment") + self.comments.append({"id": comment_id, "issue": None, "body": body}) + + +def gh_error(text: str) -> GitHubClientError: + return GitHubClientError(f"gh command failed: {text}") diff --git a/release_automation/tests/test_bot_responder.py b/release_automation/tests/test_bot_responder.py index be9e8ba..d71c19d 100644 --- a/release_automation/tests/test_bot_responder.py +++ b/release_automation/tests/test_bot_responder.py @@ -674,3 +674,16 @@ def test_apis_json_becomes_apis(self, bot_responder, apis_json, expected): @pytest.mark.parametrize("base", [None, [], "x"]) def test_non_dict_base_is_ignored(self, base): assert merge_comment_context(base, {"release_tag": "r4.2"})["release_tag"] == "r4.2" + + +def test_post_comment_creates_or_updates(): + from release_automation.scripts.bot_responder import post_comment + from release_automation.tests.fake_github import FakeGitHub + + gh = FakeGitHub() + new_id = post_comment(gh, "issue_reopened", 40, {"state": "draft-ready"}, {"reason": "x"}, "r1.4") + assert gh.comments[0]["id"] == new_id and gh.comments[0]["issue"] == 40 + assert gh.comments[0]["body"].startswith("\n**🔄 Issue reopened — State: `draft-ready`**") + + assert post_comment(gh, "issue_reopened", 40, {}, {}, "r1.4", comment_id="991") == 991 + assert gh.calls[-1] == "update_comment" diff --git a/release_automation/tests/test_draft_release.py b/release_automation/tests/test_draft_release.py new file mode 100644 index 0000000..94dc40b --- /dev/null +++ b/release_automation/tests/test_draft_release.py @@ -0,0 +1,420 @@ +"""Tests for draft_release.py. + +Expected values reproduce the former inline steps of +release-automation-reusable.yml: Extract CHANGELOG release notes and Create +Draft Release (handle-pr-merge), Discard Snapshot, Delete Draft Release and +both Cleanup review branch steps. +""" + +import pytest + +from release_automation.scripts import draft_release +from release_automation.scripts.draft_release import ( + CleanupOutcome, + DraftOutcome, + changelog_candidates, + create_draft, + delete_draft, + discard_snapshot, + extract_release_notes, + prepare_changelog, + preserve_review_branch, + publish_on_merge, + strip_candidate_block, +) +from release_automation.tests.fake_github import REPO_URL, FakeGitHub, gh_error + +START = "" +END = "" +SNAPSHOT = "release-snapshot/r1.4-abc1234" +REVIEW = "release-review/r1.4-abc1234" +TIMEOUT = gh_error( + "HTTP 409: Unable to validate rules for the branch. " + "Rule was unable to be completed in 10 seconds" +) + +CHANGELOG = ( + "# r1.4\n" + "\n" + "## Release Notes\n" + "\n" + "This release contains the definition and documentation of\n" + "* Example v1.1.0\n" + "\n" + f"{START}\n" + "candidate entries\n" + f"{END}\n" + "\n" + "\n" + "## Example v1.1.0\n" + "\n" + "* change\n" + "\n" + "\n" + "# r1.3\n" + "\n" + "## Release Notes\n" + "older\n" +) +STRIPPED = ( + "# r1.4\n" + "\n" + "## Release Notes\n" + "\n" + "This release contains the definition and documentation of\n" + "* Example v1.1.0\n" + "\n" + "## Example v1.1.0\n" + "\n" + "* change\n" + "\n" + "# r1.3\n" + "\n" + "## Release Notes\n" + "older\n" +) +NOTES = ( + "## Release Notes\n" + "\n" + "This release contains the definition and documentation of\n" + "* Example v1.1.0\n" + "\n" + "## Example v1.1.0\n" + "\n" + "* change" +) +METADATA = "repository:\n release_tag: r1.4\n release_date: null\n" + + +@pytest.fixture(autouse=True) +def no_sleep(monkeypatch): + slept = [] + monkeypatch.setattr(draft_release, "_sleep", slept.append) + return slept + + +def snapshot_repo(changelog=CHANGELOG, path="CHANGELOG/CHANGELOG-r1.md", **kw): + files = {(SNAPSHOT, "release-metadata.yaml"): METADATA} + if changelog is not None: + files[(SNAPSHOT, path)] = changelog + return FakeGitHub(files=files, branches={SNAPSHOT: "s1", REVIEW: "v1", "main": "m1"}, **kw) + + +# ── CHANGELOG ──────────────────────────────────────────────────────────────── + + +def test_changelog_candidates_prefer_the_cycle_file(): + assert changelog_candidates("r4.1") == ["CHANGELOG/CHANGELOG-r4.md", "CHANGELOG.md"] + assert changelog_candidates("unknown") == ["CHANGELOG.md"] + + +def test_strip_full_block_collapses_blank_runs(): + assert strip_candidate_block(CHANGELOG) == (STRIPPED, True) + + +@pytest.mark.parametrize("orphan", [START, END]) +def test_strip_orphan_marker_line_only(orphan): + text = f"# r1.4\n\n{orphan}\n\n\n## Release Notes\n" + assert strip_candidate_block(text) == ("# r1.4\n\n\n\n## Release Notes\n", True) + + +def test_no_markers_is_unchanged(): + assert strip_candidate_block(STRIPPED) == (STRIPPED, False) + + +def test_markers_in_wrong_order_strip_the_start_marker(): + text = f"{END}\nx\n{START}\n" + assert strip_candidate_block(text) == (f"{END}\nx\n", True) + + +def test_extract_release_notes_up_to_next_release_heading(): + assert extract_release_notes(STRIPPED) == NOTES + + +def test_extract_release_notes_heading_is_case_insensitive_and_runs_to_eof(): + assert extract_release_notes("# r1.4\n## release notes\nText\n\n\n") == "## release notes\nText" + + +def test_extract_release_notes_without_section(): + assert extract_release_notes("# r1.4\n## Example v1\n") == "" + + +def test_prepare_changelog_commits_the_stripped_file(capsys): + gh = snapshot_repo() + + result = prepare_changelog(gh, "r1.4", SNAPSHOT) + + assert result.notes == NOTES and result.stripped + assert gh.commits == [{ + "branch": SNAPSHOT, "path": "CHANGELOG/CHANGELOG-r1.md", + "message": "chore: auto-remove candidate changes block from CHANGELOG", "sha": gh.commits[0]["sha"], + }] + assert gh.files[(SNAPSHOT, "CHANGELOG/CHANGELOG-r1.md")] == STRIPPED + + +def test_prepare_changelog_falls_back_to_flat_changelog(): + gh = snapshot_repo(changelog=STRIPPED, path="CHANGELOG.md") + + result = prepare_changelog(gh, "r1.4", SNAPSHOT) + + assert result.notes == NOTES and not result.stripped + assert gh.commits == [] + + +def test_prepare_changelog_without_file_warns(capsys): + result = prepare_changelog(snapshot_repo(changelog=None), "r1.4", SNAPSHOT) + + assert (result.notes, result.stripped) == ("", False) + assert "::warning::No CHANGELOG file found on snapshot branch" in capsys.readouterr().out + + +def test_prepare_changelog_without_notes_section_warns(capsys): + result = prepare_changelog(snapshot_repo(changelog="# r1.4\n"), "r1.4", SNAPSHOT) + + assert result.notes == "" + assert '::warning::No "## Release Notes" section found in CHANGELOG/CHANGELOG-r1.md' in capsys.readouterr().out + + +def test_ruleset_timeout_on_the_commit_is_retried(no_sleep): + gh = snapshot_repo(fail={"update_file": [TIMEOUT, TIMEOUT]}) + + result = prepare_changelog(gh, "r1.4", SNAPSHOT) + + assert result.stripped and len(gh.commits) == 1 + assert no_sleep == [5, 10] + + +def test_ruleset_timeout_gives_up_after_two_retries(): + gh = snapshot_repo(fail={"update_file": [TIMEOUT, TIMEOUT, TIMEOUT]}) + + with pytest.raises(draft_release.GitHubClientError): + prepare_changelog(gh, "r1.4", SNAPSHOT) + assert gh.commits == [] + + +def test_other_commit_errors_are_not_retried(no_sleep): + gh = snapshot_repo(fail={"update_file": [gh_error("HTTP 409: sha does not match")]}) + + with pytest.raises(draft_release.GitHubClientError): + prepare_changelog(gh, "r1.4", SNAPSHOT) + assert no_sleep == [] + + +# ── draft creation ─────────────────────────────────────────────────────────── + + +def test_create_draft_from_changelog_notes(): + gh = snapshot_repo() + + outcome = create_draft(gh, "r1.4", SNAPSHOT, "pre-release-rc") + + release = gh.releases[0] + assert outcome == DraftOutcome(success=True, draft_release_url=release["html_url"], candidate_block_stripped=True) + assert (release["tag"], release["target"], release["name"]) == ("r1.4", SNAPSHOT, "r1.4") + assert release["body"] == NOTES + assert release["draft"] and release["prerelease"] + assert "r1.4" not in gh.tags + assert gh.calls.index("update_file") < gh.calls.index("create_release") + + +def test_create_draft_public_release_with_fallback_body(): + gh = snapshot_repo(changelog=None) + + outcome = create_draft(gh, "r1.4", SNAPSHOT, "public-release") + + assert outcome.success and not outcome.candidate_block_stripped + assert not gh.releases[0]["prerelease"] + assert gh.releases[0]["body"] == ( + "## Release r1.4\n\n**Release type:** public-release\n\n---\n\n" + "_CHANGELOG.md content was not available — please add release notes manually._\n" + ) + + +def test_create_draft_failure(capsys): + gh = snapshot_repo(fail={"create_release": [gh_error("HTTP 422: Validation Failed")]}) + + outcome = create_draft(gh, "r1.4", SNAPSHOT, "pre-release-alpha") + + assert outcome == DraftOutcome( + success=False, candidate_block_stripped=True, + error_message="gh command failed: HTTP 422: Validation Failed", + ) + assert "::error::Draft release creation failed: gh command failed: HTTP 422" in capsys.readouterr().out + + +def test_create_draft_changelog_commit_failure_creates_no_draft(): + gh = snapshot_repo(fail={"update_file": [gh_error("HTTP 403: protected")]}) + + outcome = create_draft(gh, "r1.4", SNAPSHOT, "pre-release-alpha") + + assert not outcome.success + assert outcome.error_message == "CHANGELOG cleanup failed: gh command failed: HTTP 403: protected" + assert gh.releases == [] + + +# ── discard and delete ─────────────────────────────────────────────────────── + + +def test_discard_snapshot_closes_pr_deletes_snapshot_and_preserves_review(): + gh = snapshot_repo() + + outcome = discard_snapshot(gh, "24", SNAPSHOT, REVIEW) + + assert outcome == CleanupOutcome(success=True, renamed_review_branch=f"{REVIEW}-preserved") + assert gh.closed_prs == [24] + assert SNAPSHOT not in gh.branches and REVIEW not in gh.branches + assert gh.branches[f"{REVIEW}-preserved"] == "v1" + + +def test_discard_snapshot_failure_keeps_review_branch(capsys): + gh = snapshot_repo(fail={"close_pr": [gh_error("HTTP 404: Not Found")]}) + + outcome = discard_snapshot(gh, "24", SNAPSHOT, REVIEW) + + assert outcome == CleanupOutcome(success=False, error_message="gh command failed: HTTP 404: Not Found") + assert REVIEW in gh.branches and SNAPSHOT in gh.branches + assert "::error::Discard failed: gh command failed: HTTP 404" in capsys.readouterr().out + + +def test_discard_without_pr_or_snapshot_branch_only_preserves_review(): + gh = snapshot_repo() + + outcome = discard_snapshot(gh, "", "", REVIEW) + + assert outcome.success and gh.closed_prs == [] and SNAPSHOT in gh.branches + assert outcome.renamed_review_branch == f"{REVIEW}-preserved" + + +def test_missing_snapshot_branch_fails_the_discard(): + gh = snapshot_repo() + del gh.branches[SNAPSHOT] + + outcome = discard_snapshot(gh, "24", SNAPSHOT, REVIEW) + + assert not outcome.success + assert outcome.error_message == f"Snapshot branch {SNAPSHOT} not found" + + +def test_delete_draft_deletes_release_tag_and_snapshot(): + gh = snapshot_repo(releases=[{"id": 7, "tag": "r1.4", "target": SNAPSHOT, "draft": True, "prerelease": True, "html_url": "u"}]) + gh.tags["r1.4"] = "s1" + + outcome = delete_draft(gh, "r1.4", SNAPSHOT, REVIEW) + + assert outcome == CleanupOutcome(success=True, renamed_review_branch=f"{REVIEW}-preserved") + assert gh.releases == [] and "r1.4" not in gh.tags and SNAPSHOT not in gh.branches + + +def test_delete_draft_without_draft_or_tag_still_cleans_up(capsys): + gh = snapshot_repo() + + outcome = delete_draft(gh, "r1.4", SNAPSHOT, REVIEW) + + assert outcome.success and SNAPSHOT not in gh.branches + out = capsys.readouterr().out + assert "Warning: No draft release found for tag r1.4" in out + assert "Tag r1.4 not found or already deleted" in out + + +def test_delete_draft_tag_error_is_logged_not_fatal(capsys): + gh = snapshot_repo(fail={"delete_tag": [gh_error("HTTP 500")]}) + + assert delete_draft(gh, "r1.4", SNAPSHOT, REVIEW).success + + +def test_delete_draft_release_error_fails(capsys): + gh = snapshot_repo( + releases=[{"id": 7, "tag": "r1.4", "target": SNAPSHOT, "draft": True, "prerelease": True, "html_url": "u"}], + fail={"delete_release": [gh_error("HTTP 403")]}, + ) + + outcome = delete_draft(gh, "r1.4", SNAPSHOT, REVIEW) + + assert outcome == CleanupOutcome(success=False, error_message="gh command failed: HTTP 403") + assert "::error::Delete draft failed: gh command failed: HTTP 403" in capsys.readouterr().out + + +def test_preserve_review_branch_replaces_an_older_preserved_copy(capsys): + gh = snapshot_repo() + gh.branches[f"{REVIEW}-preserved"] = "old" + + assert preserve_review_branch(gh, REVIEW) == f"{REVIEW}-preserved" + assert gh.branches[f"{REVIEW}-preserved"] == "v1" + out = capsys.readouterr().out + assert f"Deleted previous {REVIEW}-preserved" in out + assert f"Renamed: {REVIEW} → {REVIEW}-preserved" in out + + +def test_preserve_missing_review_branch_leaves_preserved_copy(capsys): + gh = snapshot_repo() + del gh.branches[REVIEW] + gh.branches[f"{REVIEW}-preserved"] = "old" + + assert preserve_review_branch(gh, REVIEW) == "" + assert gh.branches[f"{REVIEW}-preserved"] == "old" + assert f"Could not rename review branch: {REVIEW} not found" in capsys.readouterr().out + + +def test_preserve_rename_error_is_logged(): + gh = snapshot_repo(fail={"create_branch_at_sha": [gh_error("HTTP 403")]}) + + assert preserve_review_branch(gh, REVIEW) == "" + + +def test_preserve_without_review_branch_is_a_no_op(): + gh = snapshot_repo() + + assert preserve_review_branch(gh, "") == "" + assert gh.calls == [] + + +# ── publish on merge ───────────────────────────────────────────────────────── + + +def test_publish_on_merge_commits_changelog_then_release_date_then_tags(): + gh = snapshot_repo() + + outcome = publish_on_merge(gh, "r1.4", SNAPSHOT, REVIEW, "pre-release-rc", "abc1234def") + + assert [c["path"] for c in gh.commits] == ["CHANGELOG/CHANGELOG-r1.md", "release-metadata.yaml"] + release = gh.releases[0] + assert not release["draft"] and release["prerelease"] and release["make_latest"] is None + assert release["body"] == NOTES and release["target"] == SNAPSHOT + # The tag is at the release_date commit, which follows the CHANGELOG cleanup. + assert gh.tags["r1.4"] == gh.commits[1]["sha"] + assert "release_date: '20" in gh.files[(SNAPSHOT, "release-metadata.yaml")] + assert outcome.success + assert outcome.release_url == f"{REPO_URL}/releases/tag/r1.4" + assert outcome.reference_tag == "source/r1.4" and gh.tags["source/r1.4"] == "abc1234def" + assert outcome.pointer_branch == "pre-release/r1.4" + assert gh.branches["pre-release/r1.4"] == gh.tags["r1.4"] + assert SNAPSHOT not in gh.branches and REVIEW not in gh.branches + assert outcome.error_message == "" + + +def test_publish_on_merge_public_release_is_latest(): + gh = snapshot_repo() + + outcome = publish_on_merge(gh, "r1.4", SNAPSHOT, REVIEW, "public-release", "abc1234def") + + assert gh.releases[0]["make_latest"] == "true" and not gh.releases[0]["prerelease"] + assert outcome.pointer_branch == "release/r1.4" + + +def test_publish_on_merge_failure_before_tag(): + gh = snapshot_repo(fail={"create_release": [gh_error("HTTP 422: tag protected")]}) + + outcome = publish_on_merge(gh, "r1.4", SNAPSHOT, REVIEW, "pre-release-rc", "abc1234def") + + assert not outcome.success + assert outcome.error_message == "Failed to publish release: gh command failed: HTTP 422: tag protected" + assert SNAPSHOT in gh.branches and "source/r1.4" not in gh.tags + + +def test_publish_on_merge_changelog_failure_does_not_publish(): + gh = snapshot_repo(fail={"update_file": [gh_error("HTTP 403")]}) + + outcome = publish_on_merge(gh, "r1.4", SNAPSHOT, REVIEW, "pre-release-rc", "abc1234def") + + assert not outcome.success and outcome.error_message.startswith("CHANGELOG cleanup failed") + assert gh.releases == [] diff --git a/release_automation/tests/test_github_client.py b/release_automation/tests/test_github_client.py index 2ad7030..7f1a524 100644 --- a/release_automation/tests/test_github_client.py +++ b/release_automation/tests/test_github_client.py @@ -324,5 +324,58 @@ def test_get_file_content_default_swallows_other_errors(self, mock_run_gh): mock_run_gh.side_effect = GitHubClientError("gh command failed: HTTP 500") self.assertIsNone(self.client.get_file_content("CODEOWNERS")) + @patch("release_automation.scripts.github_client.GitHubClient._run_gh") + def test_create_release_draft(self, mock_run_gh): + mock_run_gh.return_value = json.dumps({"id": 7, "html_url": "https://github.com/owner/repo/releases/tag/untagged-1"}) + result = self.client.create_release("r1.4", "release-snapshot/r1.4-abc1234", "r1.4", "## Release Notes", draft=True, prerelease=True) + self.assertEqual(result["id"], 7) + self.assertEqual(mock_run_gh.call_args[0][0], [ + "api", "repos/owner/repo/releases", "-X", "POST", + "-f", "tag_name=r1.4", "-f", "target_commitish=release-snapshot/r1.4-abc1234", + "-f", "name=r1.4", "-f", "body=## Release Notes", + "-F", "draft=true", "-F", "prerelease=true", + ]) + + @patch("release_automation.scripts.github_client.GitHubClient._run_gh") + def test_create_release_published_latest(self, mock_run_gh): + mock_run_gh.return_value = json.dumps({"id": 8, "html_url": "u"}) + self.client.create_release("r2.1", "b", "r2.1", "x", draft=False, prerelease=False, make_latest="true") + self.assertEqual(mock_run_gh.call_args[0][0][-6:], [ + "-F", "draft=false", "-F", "prerelease=false", "-f", "make_latest=true", + ]) + + @patch("release_automation.scripts.github_client.GitHubClient._run_gh") + def test_delete_release(self, mock_run_gh): + self.client.delete_release(7) + self.assertEqual(mock_run_gh.call_args[0][0], ["api", "repos/owner/repo/releases/7", "-X", "DELETE"]) + + @patch("release_automation.scripts.github_client.GitHubClient._run_gh") + def test_delete_tag(self, mock_run_gh): + self.assertTrue(self.client.delete_tag("r1.4")) + self.assertEqual(mock_run_gh.call_args[0][0], ["api", "repos/owner/repo/git/refs/tags/r1.4", "-X", "DELETE"]) + mock_run_gh.side_effect = GitHubClientError("gh command failed: HTTP 422: Reference does not exist") + self.assertFalse(self.client.delete_tag("r1.4")) + + @patch("release_automation.scripts.github_client.GitHubClient._run_gh") + def test_delete_tag_raises_other_errors(self, mock_run_gh): + mock_run_gh.side_effect = GitHubClientError("gh command failed: HTTP 500") + with self.assertRaises(GitHubClientError): + self.client.delete_tag("r1.4") + + @patch("release_automation.scripts.github_client.GitHubClient._run_gh") + def test_reopen_issue(self, mock_run_gh): + self.client.reopen_issue(40) + self.assertEqual(mock_run_gh.call_args[0][0], ["api", "repos/owner/repo/issues/40", "-X", "PATCH", "-f", "state=open"]) + + @patch("release_automation.scripts.github_client.GitHubClient._run_gh") + def test_get_branch_sha(self, mock_run_gh): + mock_run_gh.return_value = "abc123\n" + self.assertEqual(self.client.get_branch_sha("release-review/r1.4-abc1234"), "abc123") + self.assertEqual(mock_run_gh.call_args[0][0], [ + "api", "repos/owner/repo/git/ref/heads/release-review/r1.4-abc1234", "--jq", ".object.sha", + ]) + mock_run_gh.side_effect = GitHubClientError("gh command failed: HTTP 404: Not Found") + self.assertIsNone(self.client.get_branch_sha("gone")) + if __name__ == '__main__': unittest.main() diff --git a/release_automation/tests/test_issue_events.py b/release_automation/tests/test_issue_events.py new file mode 100644 index 0000000..0263439 --- /dev/null +++ b/release_automation/tests/test_issue_events.py @@ -0,0 +1,43 @@ +"""Tests for issue_events.py (former Handle Issue Event and Post Auto-Reopen Comment steps).""" + +from release_automation.scripts.issue_events import REOPEN_REASON, IssueEventOutcome, reopen_release_issue +from release_automation.tests.fake_github import FakeGitHub, gh_error + +BASE = {"release_tag": "r1.4", "state": "snapshot-active", "release_pr_number": "41", + "release_pr_url": "https://github.com/camaraproject/Example/pull/41"} + + +def test_reopen_posts_issue_reopened(): + gh = FakeGitHub() + + outcome = reopen_release_issue(gh, 40, "r1.4", BASE) + + assert outcome == IssueEventOutcome(success=True, action_taken="reopened") + assert gh.reopened == [40] + assert gh.calls == ["reopen_issue", "create_comment"] + body = gh.comments[0]["body"] + assert gh.comments[0]["issue"] == 40 + assert body.startswith("\n**🔄 Issue reopened — State: `snapshot-active`**") + assert "**Release PR:** [#41](https://github.com/camaraproject/Example/pull/41)" in body + + +def test_reopen_failure_posts_nothing(capsys): + gh = FakeGitHub(fail={"reopen_issue": [gh_error("HTTP 403")]}) + + outcome = reopen_release_issue(gh, 40, "r1.4", BASE) + + assert outcome == IssueEventOutcome(success=False, action_taken="", error_message="gh command failed: HTTP 403") + assert gh.comments == [] + assert "::error::Could not reopen #40: gh command failed: HTTP 403" in capsys.readouterr().out + + +def test_comment_failure_fails_the_outcome(): + gh = FakeGitHub(fail={"create_comment": [gh_error("HTTP 500")]}) + + outcome = reopen_release_issue(gh, 40, "r1.4", BASE) + + assert outcome == IssueEventOutcome(success=False, action_taken="reopened", error_message="gh command failed: HTTP 500") + + +def test_reason_text_is_unchanged(): + assert REOPEN_REASON == "Cannot close Release Issue while release is in progress" diff --git a/release_automation/tests/test_release_publisher.py b/release_automation/tests/test_release_publisher.py index 06fe456..1b7bdd0 100644 --- a/release_automation/tests/test_release_publisher.py +++ b/release_automation/tests/test_release_publisher.py @@ -528,3 +528,78 @@ def test_pointer_branch_field(self): """pointer_branch can be set.""" result = PublishResult(success=True, pointer_branch="release/r4.1") assert result.pointer_branch == "release/r4.1" + + +# ── publish entry point (former Execute Publish Flow heredoc) ──────────────── + +from release_automation.scripts.release_publisher import PublishOutcome, publish_draft +from release_automation.tests.fake_github import REPO_URL, FakeGitHub, gh_error + +SNAPSHOT = "release-snapshot/r1.4-abc1234" +REVIEW = "release-review/r1.4-abc1234" + + +def draft_repo(prerelease=True, **kw): + return FakeGitHub( + files={(SNAPSHOT, "release-metadata.yaml"): "repository:\n release_tag: r1.4\n release_date: null\n"}, + branches={SNAPSHOT: "s1", REVIEW: "v1"}, + releases=[{"id": 7, "tag": "r1.4", "target": SNAPSHOT, "draft": True, "prerelease": prerelease, + "html_url": f"{REPO_URL}/releases/untagged-7"}], + **kw, + ) + + +def test_publish_draft_runs_the_whole_flow(capsys): + gh = draft_repo() + + outcome = publish_draft(gh, "r1.4", SNAPSHOT, REVIEW, "abc1234def") + + assert outcome == PublishOutcome( + success=True, + release_url=f"{REPO_URL}/releases/tag/r1.4", + reference_tag="source/r1.4", + pointer_branch="pre-release/r1.4", + ) + assert gh.tags["r1.4"] == gh.commits[0]["sha"] + assert gh.commits[0]["message"] == "chore: finalize release-metadata.yaml for r1.4" + assert gh.branches["pre-release/r1.4"] == gh.tags["r1.4"] + assert SNAPSHOT not in gh.branches and REVIEW not in gh.branches + out = capsys.readouterr().out + for line in ("Step 1: Publishing release...", "Step 2: Creating reference tag...", + "Step 3: Creating pointer branch...", "Step 4: Cleaning up branches...", + "Publish flow completed successfully"): + assert line in out + + +def test_publish_draft_public_release_is_marked_latest(): + gh = draft_repo(prerelease=False) + + outcome = publish_draft(gh, "r1.4", SNAPSHOT, REVIEW, "abc1234def") + + assert outcome.pointer_branch == "release/r1.4" + assert gh.releases[0]["make_latest"] == "true" + + +def test_publish_draft_without_draft_fails(capsys): + gh = draft_repo() + gh.releases = [] + + outcome = publish_draft(gh, "r1.4", SNAPSHOT, REVIEW, "abc1234def") + + assert outcome == PublishOutcome(success=False, error_message="No draft release found for tag `r1.4`") + assert "::error::Publication failed: No draft release found for tag `r1.4`" in capsys.readouterr().out + assert SNAPSHOT in gh.branches + + +def test_post_publication_failures_are_warnings(capsys): + gh = draft_repo(fail={"create_tag": [gh_error("HTTP 403")], "delete_branch": [gh_error("HTTP 500")]}) + + outcome = publish_draft(gh, "r1.4", SNAPSHOT, REVIEW, "abc1234def") + + assert outcome.success and outcome.reference_tag == "" + assert outcome.error_message == ( + "Failed to create reference tag; Branch cleanup issue: snapshot_deleted" + ) + out = capsys.readouterr().out + assert "::warning::Failed to create reference tag" in out + assert " snapshot_deleted: error" in out