diff --git a/docs/docusaurus/docs/releases/release-11.0/release-11.0.0.md b/docs/docusaurus/docs/releases/release-11.0/release-11.0.0.md index 94c4ee0..4e2c883 100644 --- a/docs/docusaurus/docs/releases/release-11.0/release-11.0.0.md +++ b/docs/docusaurus/docs/releases/release-11.0/release-11.0.0.md @@ -1,12 +1,12 @@ --- -id: release-11.0.0-rc.3 -title: Release 11.0.0-rc.3 +id: release-11.0.0-rc.4 +title: Release 11.0.0-rc.4 --- -# Release 11.0.0-rc.3 +# Release 11.0.0-rc.4 ## 📋 All Changes diff --git a/src/release_tool/commands/cancel.py b/src/release_tool/commands/cancel.py new file mode 100644 index 0000000..62e96d6 --- /dev/null +++ b/src/release_tool/commands/cancel.py @@ -0,0 +1,383 @@ +# SPDX-FileCopyrightText: 2025 Sequent Tech Inc +# +# SPDX-License-Identifier: MIT + +"""Cancel command for release-tool. + +This command cancels a release by: +1. Closing the associated PR (if provided or found) +2. Deleting the PR branch +3. Deleting the GitHub release +4. Deleting the git tag +5. Deleting database records +6. Closing the related issue (if provided or found) + +All operations are idempotent and will succeed if resources don't exist. +""" + +import sys +from typing import Optional, Tuple +import click +from rich.console import Console +from rich.prompt import Confirm + +from ..config import Config +from ..db import Database +from ..github_utils import GitHubClient +from ..models import SemanticVersion + +console = Console() + + +def _resolve_version_pr_issue( + db: Database, + repo_id: int, + repo_full_name: str, + version: Optional[str], + pr_number: Optional[int], + issue_number: Optional[int], + debug: bool = False +) -> Tuple[Optional[str], Optional[int], Optional[int]]: + """ + Auto-detect version, PR, and issue if not provided. + + Args: + db: Database instance + repo_id: Repository ID + repo_full_name: Full repository name + version: Optional version string + pr_number: Optional PR number + issue_number: Optional issue number + debug: Enable debug output + + Returns: + Tuple of (version, pr_number, issue_number) + """ + # If version provided, try to find PR/issue from database + if version: + if debug: + console.print(f"[dim]Searching for PR and issue for version {version}...[/dim]") + + # Try to find PR by searching for PRs with version in title/branch + if not pr_number: + prs = db.find_prs_for_issue(repo_full_name, 0, limit=100) # Get all PRs + for pr in prs: + if version in pr.get('title', '') or version in pr.get('body', ''): + pr_number = pr.get('number') + if debug: + console.print(f"[dim]Found PR #{pr_number} from database[/dim]") + break + + # Try to find issue from database associations + if not issue_number: + issue_assoc = db.get_issue_association(repo_full_name, version) + if issue_assoc and issue_assoc.get('issue_number'): + issue_number = issue_assoc['issue_number'] + if debug: + console.print(f"[dim]Found issue #{issue_number} from database[/dim]") + + # If PR provided but no version, try to extract from PR + elif pr_number: + if debug: + console.print(f"[dim]Searching for version from PR #{pr_number}...[/dim]") + + # Try to get PR from database + pr = db.get_pull_request(repo_id, pr_number) + if pr: + # Try to extract version from PR title + import re + title = pr.title if hasattr(pr, 'title') else '' + match = re.search(r'v?(\d+\.\d+\.\d+(?:-[a-zA-Z0-9]+(?:\.\d+)?)?)', title) + if match: + version = match.group(1) + if debug: + console.print(f"[dim]Extracted version {version} from PR title[/dim]") + + # If issue provided but no version, try to extract from issue + elif issue_number: + if debug: + console.print(f"[dim]Searching for version from issue #{issue_number}...[/dim]") + + # Try to get issue from database + issue = db.get_issue(repo_id, issue_number) + if issue: + # Try to extract version from issue title + import re + title = issue.title if hasattr(issue, 'title') else '' + match = re.search(r'v?(\d+\.\d+\.\d+(?:-[a-zA-Z0-9]+(?:\.\d+)?)?)', title) + if match: + version = match.group(1) + if debug: + console.print(f"[dim]Extracted version {version} from issue title[/dim]") + + return version, pr_number, issue_number + + +def _check_published_status( + db: Database, + repo_id: int, + version: str, + force: bool, + debug: bool = False +) -> bool: + """ + Check if release is published and handle accordingly. + + Args: + db: Database instance + repo_id: Repository ID + version: Version string + force: Force flag + debug: Enable debug output + + Returns: + True if should proceed, False if should block + """ + # Get release from database + release = db.get_release(repo_id, version) + if not release: + if debug: + console.print(f"[dim]No release found in database for {version}[/dim]") + return True + + # Check if published + if release.published_at: + if force: + console.print(f"[yellow]⚠ Warning: Release {version} is published. Proceeding due to --force flag.[/yellow]") + return True + else: + console.print(f"[red]Error: Release {version} is already published.[/red]") + console.print(f"[red]Use --force to cancel a published release.[/red]") + return False + + return True + + +@click.command(context_settings={'help_option_names': ['-h', '--help']}) +@click.argument('version', required=False) +@click.option( + '--issue', + '-i', + type=int, + help='Issue number to close' +) +@click.option( + '--pr', + '-p', + type=int, + help='Pull request number to close' +) +@click.option( + '--force', + '-f', + is_flag=True, + help='Force cancel even if release is published' +) +@click.option( + '--dry-run', + is_flag=True, + help='Show what would be deleted without actually deleting' +) +@click.pass_context +def cancel( + ctx, + version: Optional[str], + issue: Optional[int], + pr: Optional[int], + force: bool, + dry_run: bool +): + """ + Cancel a release by deleting all associated resources. + + This command will: + 1. Close the associated PR (if exists) + 2. Delete the PR branch + 3. Delete the GitHub release + 4. Delete the git tag + 5. Delete database records + 6. Close the related issue (if exists) + + All operations are idempotent and stop on first failure. + + Examples: + + release-tool cancel 1.2.3-rc.1 # Cancel draft release + + release-tool cancel 1.2.3 --force # Cancel published release + + release-tool cancel 1.2.3 --pr 42 --issue 1 # Cancel with specific PR and issue + + release-tool cancel 1.2.3 --dry-run # Show what would be deleted + """ + config: Config = ctx.obj['config'] + debug = ctx.obj.get('debug', False) + assume_yes = ctx.obj.get('assume_yes', False) + + repo_full_name = config.repository.code_repo + + # Connect to database + db = Database(config.database.path) + db.connect() + + try: + # Get repository + repo = db.get_repository(repo_full_name) + if not repo: + console.print(f"[red]Error: Repository {repo_full_name} not found in database.[/red]") + console.print(f"[yellow]Run 'release-tool pull' first to initialize the database.[/yellow]") + sys.exit(1) + + repo_id = repo.id + + # Auto-detect version, PR, and issue if not all provided + version, pr_number, issue_number = _resolve_version_pr_issue( + db, repo_id, repo_full_name, version, pr, issue, debug + ) + + # Require at least version or (PR and/or issue) + if not version and not pr_number and not issue_number: + console.print("[red]Error: Must provide version, --pr, or --issue[/red]") + console.print("Run with --help for usage information") + sys.exit(1) + + # If we have version, check if published + if version: + if not _check_published_status(db, repo_id, version, force, debug): + sys.exit(1) + + # Add 'v' prefix to tag name if needed + tag_name = f"v{version}" if version and not version.startswith('v') else version + + # Show what will be cancelled + if dry_run: + console.print("[bold yellow]DRY RUN - No changes will be made[/bold yellow]") + else: + console.print(f"[bold]Cancelling release {version or '(auto-detect)'}[/bold]") + + console.print("\n[bold]Will perform the following operations:[/bold]") + if pr_number: + console.print(f" • Close PR #{pr_number} and delete branch") + if version: + console.print(f" • Delete GitHub release for tag {tag_name}") + console.print(f" • Delete git tag {tag_name}") + console.print(f" • Delete database records for version {version}") + if issue_number: + console.print(f" • Close issue #{issue_number}") + + console.print() + + # Confirm unless --dry-run, --assume-yes, or --auto + if not dry_run and not assume_yes and not ctx.obj.get('auto', False): + if not Confirm.ask("[yellow]Proceed with cancellation?[/yellow]"): + console.print("[yellow]Cancelled by user.[/yellow]") + sys.exit(0) + + # Exit early if dry-run + if dry_run: + console.print("\n[dim]Dry run complete. Use without --dry-run to execute.[/dim]") + sys.exit(0) + + # Create GitHub client + github_client = GitHubClient(config.github.token) + + success_operations = [] + failed_operations = [] + + # Operation 1: Close PR (if provided) + if pr_number: + console.print(f"\n[bold]Closing PR #{pr_number}...[/bold]") + + # Get PR details to find branch name + pr_obj = github_client.get_pull_request(repo_full_name, pr_number) + branch_name = None + + if pr_obj: + branch_name = pr_obj.head.ref + console.print(f" PR branch: {branch_name}") + + # Close the PR + comment = f"Closing PR as release {version or 'this release'} is being cancelled." + if github_client.close_pull_request(repo_full_name, pr_number, comment): + console.print(f" ✓ Closed PR #{pr_number}") + success_operations.append(f"Close PR #{pr_number}") + else: + console.print(f" [red]✗ Failed to close PR #{pr_number}[/red]") + failed_operations.append(f"Close PR #{pr_number}") + console.print("[red]Stopping due to failure.[/red]") + sys.exit(1) + + # Operation 2: Delete branch + if branch_name: + console.print(f"\n[bold]Deleting branch {branch_name}...[/bold]") + if github_client.delete_branch(repo_full_name, branch_name): + console.print(f" ✓ Deleted branch {branch_name}") + success_operations.append(f"Delete branch {branch_name}") + else: + console.print(f" [red]✗ Failed to delete branch {branch_name}[/red]") + failed_operations.append(f"Delete branch {branch_name}") + console.print("[red]Stopping due to failure.[/red]") + sys.exit(1) + + # Operation 3: Delete GitHub release + if version and tag_name: + console.print(f"\n[bold]Deleting GitHub release {tag_name}...[/bold]") + if github_client.delete_release(repo_full_name, tag_name): + console.print(f" ✓ Deleted GitHub release {tag_name}") + success_operations.append(f"Delete release {tag_name}") + else: + console.print(f" [red]✗ Failed to delete GitHub release {tag_name}[/red]") + failed_operations.append(f"Delete release {tag_name}") + console.print("[red]Stopping due to failure.[/red]") + sys.exit(1) + + # Operation 4: Delete git tag + if version and tag_name: + console.print(f"\n[bold]Deleting git tag {tag_name}...[/bold]") + if github_client.delete_tag(repo_full_name, tag_name): + console.print(f" ✓ Deleted git tag {tag_name}") + success_operations.append(f"Delete tag {tag_name}") + else: + console.print(f" [red]✗ Failed to delete git tag {tag_name}[/red]") + failed_operations.append(f"Delete tag {tag_name}") + console.print("[red]Stopping due to failure.[/red]") + sys.exit(1) + + # Operation 5: Delete database records + if version: + console.print(f"\n[bold]Deleting database records for {version}...[/bold]") + try: + # Delete release record + if db.delete_release(repo_id, version): + console.print(f" ✓ Deleted database records for {version}") + success_operations.append(f"Delete database records for {version}") + else: + console.print(f" [red]✗ Failed to delete database records[/red]") + failed_operations.append(f"Delete database records for {version}") + console.print("[red]Stopping due to failure.[/red]") + sys.exit(1) + except Exception as e: + console.print(f" [red]✗ Failed to delete database records: {e}[/red]") + failed_operations.append(f"Delete database records for {version}") + console.print("[red]Stopping due to failure.[/red]") + sys.exit(1) + + # Operation 6: Close issue (if provided) + if issue_number: + console.print(f"\n[bold]Closing issue #{issue_number}...[/bold]") + comment = f"Closing issue as release {version or 'this release'} is being cancelled." + if github_client.close_issue(repo_full_name, issue_number, comment): + console.print(f" ✓ Closed issue #{issue_number}") + success_operations.append(f"Close issue #{issue_number}") + else: + console.print(f" [red]✗ Failed to close issue #{issue_number}[/red]") + failed_operations.append(f"Close issue #{issue_number}") + console.print("[red]Stopping due to failure.[/red]") + sys.exit(1) + + # Success summary + console.print(f"\n[bold green]✓ Successfully cancelled release {version or ''}[/bold green]") + console.print(f"[dim]Operations completed: {len(success_operations)}[/dim]") + + finally: + db.close() diff --git a/src/release_tool/db.py b/src/release_tool/db.py index 49f31d8..a016bb1 100644 --- a/src/release_tool/db.py +++ b/src/release_tool/db.py @@ -457,6 +457,71 @@ def get_merged_prs_between_dates( return prs + def find_prs_for_issue( + self, + repo_full_name: str, + issue_number: int, + limit: int = 10 + ) -> List[Dict[str, Any]]: + """ + Find PRs associated with an issue using best-effort search. + + Searches for PRs where body or title contains #issue_number using regex. + + Args: + repo_full_name: Full repository name (owner/repo) + issue_number: Issue number to search for + limit: Maximum number of results to return + + Returns: + List of dicts with: number, title, url, state, merged_at, head_branch + """ + import re + + # Get repository + repo = self.get_repository(repo_full_name) + if not repo: + return [] + + repo_id = repo.id + + # Get all PRs for the repo (or limit to recent ones) + self.cursor.execute( + """SELECT number, title, body, state, url, merged_at, head_branch + FROM pull_requests + WHERE repo_id=? + ORDER BY number DESC + LIMIT 1000""", # Limit search to last 1000 PRs for performance + (repo_id,) + ) + rows = self.cursor.fetchall() + + # Search for issue references in PR title and body + pattern = rf'#\s*{issue_number}\b' if issue_number > 0 else r'#' + matching_prs = [] + + for row in rows: + data = dict(row) + title = data.get('title', '') + body = data.get('body', '') + + # Check if pattern matches + if re.search(pattern, title) or re.search(pattern, body): + matching_prs.append({ + 'number': data.get('number'), + 'title': title, + 'url': data.get('url'), + 'state': data.get('state'), + 'merged_at': data.get('merged_at'), + 'head_branch': data.get('head_branch'), + 'body': body + }) + + if len(matching_prs) >= limit: + break + + return matching_prs + # Commit operations def upsert_commit(self, commit: Commit) -> None: """Insert or update a commit.""" @@ -829,6 +894,27 @@ def get_release(self, repo_id: int, version: str) -> Optional[Release]: return Release(**data) return None + def delete_release(self, repo_id: int, version: str) -> bool: + """ + Delete a release from the database. + + Args: + repo_id: Repository ID + version: Version string + + Returns: + True if release was deleted or didn't exist, False on error + """ + try: + self.cursor.execute( + "DELETE FROM releases WHERE repo_id=? AND version=?", + (repo_id, version) + ) + self.conn.commit() + return True + except Exception: + return False + def get_all_releases( self, repo_id: int, diff --git a/src/release_tool/main.py b/src/release_tool/main.py index 64ee04a..687bba2 100644 --- a/src/release_tool/main.py +++ b/src/release_tool/main.py @@ -15,6 +15,7 @@ from .commands.generate import generate from .commands.push import push from .commands.merge import merge +from .commands.cancel import cancel from .commands.list_releases import list_releases from .commands.init_config import init_config from .commands.update_config import update_config @@ -66,6 +67,7 @@ def cli(ctx, config: Optional[str], auto: bool, assume_yes: bool, debug: bool): cli.add_command(generate) cli.add_command(push) cli.add_command(merge) +cli.add_command(cancel) cli.add_command(list_releases) cli.add_command(init_config) cli.add_command(update_config) diff --git a/tests/test_cancel.py b/tests/test_cancel.py new file mode 100644 index 0000000..f04caad --- /dev/null +++ b/tests/test_cancel.py @@ -0,0 +1,306 @@ +# SPDX-FileCopyrightText: 2025 Sequent Tech Inc +# +# SPDX-License-Identifier: MIT + +"""Unit tests for cancel command.""" + +import pytest +from unittest.mock import Mock, patch, MagicMock +from click.testing import CliRunner +from datetime import datetime + +from release_tool.commands.cancel import cancel, _check_published_status, _resolve_version_pr_issue +from release_tool.config import Config +from release_tool.db import Database +from release_tool.models import Release, PullRequest, Issue, Repository + + +@pytest.fixture +def test_config(tmp_path): + """Create a test configuration.""" + db_path = tmp_path / "test.db" + config_dict = { + "repository": { + "code_repo": "test/repo" + }, + "github": { + "token": "test_token" + }, + "database": { + "path": str(db_path) + } + } + return Config.from_dict(config_dict) + + +@pytest.fixture +def test_db(test_config): + """Create a test database.""" + db = Database(test_config.database.path) + db.connect() + + # Create repository + repo = Repository( + owner="test", + name="repo", + full_name="test/repo", + url="https://github.com/test/repo" + ) + repo_id = db.upsert_repository(repo) + + yield db, repo_id + + db.close() + + +def test_help_text(): + """Test cancel command help text.""" + runner = CliRunner() + result = runner.invoke(cancel, ['--help']) + + assert result.exit_code == 0 + assert 'Cancel a release' in result.output + assert '--pr' in result.output + assert '--issue' in result.output + assert '--force' in result.output + assert '--dry-run' in result.output + + +def test_version_required_without_pr_or_issue(test_config): + """Test that version or pr/issue is required.""" + runner = CliRunner() + + with patch('release_tool.commands.cancel.Database') as mock_db_class: + mock_db = Mock() + mock_db_class.return_value = mock_db + mock_db.connect.return_value = None + + # Mock get_repository + mock_repo = Mock() + mock_repo.id = 1 + mock_db.get_repository.return_value = mock_repo + + # Mock _resolve_version_pr_issue to return all None + with patch('release_tool.commands.cancel._resolve_version_pr_issue', return_value=(None, None, None)): + result = runner.invoke( + cancel, + [], + obj={'config': test_config, 'debug': False}, + catch_exceptions=False + ) + + assert result.exit_code != 0 + assert 'Must provide version' in result.output or 'version, --pr, or --issue' in result.output + + +def test_dry_run_no_api_calls(test_config, test_db): + """Test dry-run doesn't make API calls.""" + db, repo_id = test_db + runner = CliRunner() + + # Create a draft release + release = Release( + repo_id=repo_id, + version="1.0.0", + tag_name="v1.0.0", + is_draft=True, + is_prerelease=False, + created_at=datetime.now() + ) + db.upsert_release(release) + + with patch('release_tool.commands.cancel.GitHubClient') as mock_client_class: + result = runner.invoke( + cancel, + ['1.0.0', '--dry-run'], + obj={'config': test_config, 'debug': False}, + catch_exceptions=False + ) + + # Should not create GitHub client in dry-run + mock_client_class.assert_not_called() + + assert result.exit_code == 0 + assert 'DRY RUN' in result.output or 'Dry run' in result.output + + +def test_published_release_blocked_without_force(test_db): + """Test published release is blocked without --force.""" + db, repo_id = test_db + + # Create a published release + release = Release( + repo_id=repo_id, + version="1.0.0", + tag_name="v1.0.0", + is_draft=False, + is_prerelease=False, + created_at=datetime.now(), + published_at=datetime.now() + ) + db.upsert_release(release) + + # Test without force + result = _check_published_status(db, repo_id, "1.0.0", force=False, debug=False) + assert result is False + + +def test_published_release_allowed_with_force(test_db): + """Test published release is allowed with --force.""" + db, repo_id = test_db + + # Create a published release + release = Release( + repo_id=repo_id, + version="1.0.0", + tag_name="v1.0.0", + is_draft=False, + is_prerelease=False, + created_at=datetime.now(), + published_at=datetime.now() + ) + db.upsert_release(release) + + # Test with force + result = _check_published_status(db, repo_id, "1.0.0", force=True, debug=False) + assert result is True + + +def test_draft_release_allowed(test_db): + """Test draft release is allowed without --force.""" + db, repo_id = test_db + + # Create a draft release + release = Release( + repo_id=repo_id, + version="1.0.0", + tag_name="v1.0.0", + is_draft=True, + is_prerelease=False, + created_at=datetime.now(), + published_at=None + ) + db.upsert_release(release) + + # Test without force + result = _check_published_status(db, repo_id, "1.0.0", force=False, debug=False) + assert result is True + + +def test_cancel_with_pr_parameter(test_config, test_db): + """Test cancel with --pr parameter.""" + db, repo_id = test_db + runner = CliRunner() + + # Create a draft release + release = Release( + repo_id=repo_id, + version="1.0.0", + tag_name="v1.0.0", + is_draft=True, + is_prerelease=False, + created_at=datetime.now() + ) + db.upsert_release(release) + + with patch('release_tool.commands.cancel.GitHubClient') as mock_client_class: + mock_client = Mock() + mock_client_class.return_value = mock_client + mock_client.get_pull_request.return_value = Mock(head=Mock(ref="test-branch")) + mock_client.close_pull_request.return_value = True + mock_client.delete_branch.return_value = True + mock_client.delete_release.return_value = True + mock_client.delete_tag.return_value = True + + result = runner.invoke( + cancel, + ['1.0.0', '--pr', '42'], + obj={'config': test_config, 'debug': False, 'assume_yes': True}, + catch_exceptions=False + ) + + assert result.exit_code == 0 + mock_client.close_pull_request.assert_called_once() + + +def test_cancel_with_issue_parameter(test_config, test_db): + """Test cancel with --issue parameter.""" + db, repo_id = test_db + runner = CliRunner() + + # Create a draft release + release = Release( + repo_id=repo_id, + version="1.0.0", + tag_name="v1.0.0", + is_draft=True, + is_prerelease=False, + created_at=datetime.now() + ) + db.upsert_release(release) + + with patch('release_tool.commands.cancel.GitHubClient') as mock_client_class: + mock_client = Mock() + mock_client_class.return_value = mock_client + mock_client.delete_release.return_value = True + mock_client.delete_tag.return_value = True + mock_client.close_issue.return_value = True + + result = runner.invoke( + cancel, + ['1.0.0', '--issue', '1'], + obj={'config': test_config, 'debug': False, 'assume_yes': True}, + catch_exceptions=False + ) + + assert result.exit_code == 0 + mock_client.close_issue.assert_called_once() + + +def test_resolve_version_from_pr(test_db): + """Test auto-detecting version from PR.""" + db, repo_id = test_db + + # Create a PR with version in title + pr = PullRequest( + repo_id=repo_id, + number=42, + title="Release notes for v1.2.3", + body="Auto-generated release notes", + state="open", + url="https://github.com/test/repo/pull/42", + head_branch="release-notes-v1.2.3", + base_branch="main" + ) + db.upsert_pull_request(pr) + + version, pr_number, issue_number = _resolve_version_pr_issue( + db, repo_id, "test/repo", None, 42, None, debug=False + ) + + assert version == "1.2.3" + assert pr_number == 42 + + +def test_resolve_version_from_issue(test_db): + """Test auto-detecting version from issue.""" + db, repo_id = test_db + + # Create an issue with version in title + issue = Issue( + repo_id=repo_id, + number=1, + key="1", + title="Release 1.2.3", + body="Tracking issue", + state="open", + url="https://github.com/test/repo/issues/1" + ) + db.upsert_issue(issue) + + version, pr_number, issue_number = _resolve_version_pr_issue( + db, repo_id, "test/repo", None, None, 1, debug=False + ) + + assert version == "1.2.3" + assert issue_number == 1 diff --git a/tests/test_e2e_cancel.py b/tests/test_e2e_cancel.py new file mode 100644 index 0000000..f291c70 --- /dev/null +++ b/tests/test_e2e_cancel.py @@ -0,0 +1,504 @@ +# SPDX-FileCopyrightText: 2025 Sequent Tech Inc +# +# SPDX-License-Identifier: MIT + +"""End-to-end tests for cancel command.""" + +import pytest +from pathlib import Path +from unittest.mock import Mock, patch, MagicMock, call +from click.testing import CliRunner +from datetime import datetime + +from release_tool.commands.cancel import cancel +from release_tool.config import Config +from release_tool.db import Database +from release_tool.models import Release, PullRequest, Issue, Repository + + +@pytest.fixture +def test_config(tmp_path): + """Create a test configuration with database.""" + db_path = tmp_path / "test.db" + config_dict = { + "repository": { + "code_repo": "test/repo" + }, + "github": { + "token": "test_token" + }, + "database": { + "path": str(db_path) + }, + "output": { + "create_github_release": True, + "create_pr": True, + "draft_output_path": ".release_tool_cache/draft-releases/{{repo}}/{{version}}.md" + } + } + return Config.from_dict(config_dict) + + +@pytest.fixture +def populated_db(test_config): + """Create a database with test data.""" + db = Database(test_config.database.path) + db.connect() + + # Create repository + repo = Repository( + owner="test", + name="repo", + full_name="test/repo", + url="https://github.com/test/repo" + ) + repo_id = db.upsert_repository(repo) + + # Create a draft release + draft_release = Release( + repo_id=repo_id, + version="1.2.3-rc.1", + tag_name="v1.2.3-rc.1", + published_at=None, + created_at=datetime.now(), + is_draft=True, + is_prerelease=True, + url="https://github.com/test/repo/releases/tag/v1.2.3-rc.1" + ) + draft_release_id = db.upsert_release(draft_release) + + # Create a published release + published_release = Release( + repo_id=repo_id, + version="1.0.0", + tag_name="v1.0.0", + published_at=datetime.now(), + created_at=datetime.now(), + is_draft=False, + is_prerelease=False, + url="https://github.com/test/repo/releases/tag/v1.0.0" + ) + published_release_id = db.upsert_release(published_release) + + # Create a PR for the draft release + pr = PullRequest( + repo_id=repo_id, + number=42, + title="Release notes for v1.2.3-rc.1", + body="Automated release notes #1", + state="open", + url="https://github.com/test/repo/pull/42", + head_branch="release-notes-v1.2.3-rc.1", + base_branch="main" + ) + pr_id = db.upsert_pull_request(pr) + + # Create an issue for the draft release + issue = Issue( + repo_id=repo_id, + number=1, + key="1", # key can be the issue number as string + title="Release 1.2.3-rc.1", + body="Tracking issue for release 1.2.3-rc.1", + state="open", + url="https://github.com/test/repo/issues/1" + ) + issue_id = db.upsert_issue(issue) + + yield db, repo_id, { + 'draft_release_id': draft_release_id, + 'published_release_id': published_release_id, + 'pr_id': pr_id, + 'pr_number': 42, + 'issue_id': issue_id, + 'issue_number': 1 + } + + db.close() + + +class TestE2ECancelDryRun: + """Test cancel command in dry-run mode.""" + + def test_dry_run_draft_release_shows_plan(self, test_config, populated_db): + """Test dry-run shows what would be deleted without actually deleting.""" + db, repo_id, test_data = populated_db + runner = CliRunner() + + with patch('release_tool.commands.cancel.GitHubClient') as mock_client_class: + result = runner.invoke( + cancel, + ['1.2.3-rc.1', '--dry-run'], + obj={'config': test_config, 'debug': False}, + catch_exceptions=False + ) + + # Should not create GitHub client in dry-run + mock_client_class.assert_not_called() + + # Should show dry-run banner + assert 'DRY RUN' in result.output or 'Dry run' in result.output + + # Should show what would be deleted + assert '1.2.3-rc.1' in result.output + assert ('Will perform' in result.output or 'would' in result.output.lower() or + 'Delete' in result.output or 'delete' in result.output.lower()) + + # Should mention the release and tag + assert 'release' in result.output.lower() + assert 'tag' in result.output.lower() or 'v1.2.3-rc.1' in result.output + + # Should exit successfully + assert result.exit_code == 0 + + def test_dry_run_with_pr_and_issue(self, test_config, populated_db): + """Test dry-run shows PR and issue that would be closed.""" + db, repo_id, test_data = populated_db + runner = CliRunner() + + with patch('release_tool.commands.cancel.GitHubClient') as mock_client_class: + result = runner.invoke( + cancel, + ['1.2.3-rc.1', '--issue', '1', '--pr', '42', '--dry-run'], + obj={'config': test_config, 'debug': False}, + catch_exceptions=False + ) + + # Should not make API calls in dry-run + mock_client_class.assert_not_called() + + # Should show PR and issue in output + assert '#42' in result.output or 'PR' in result.output or 'pull' in result.output.lower() + assert '#1' in result.output or 'issue' in result.output.lower() + + assert result.exit_code == 0 + + def test_dry_run_published_release_blocked(self, test_config, populated_db): + """Test dry-run shows published release is blocked without --force.""" + db, repo_id, test_data = populated_db + runner = CliRunner() + + result = runner.invoke( + cancel, + ['1.0.0', '--dry-run'], + obj={'config': test_config, 'debug': False}, + catch_exceptions=False + ) + + # Should fail because release is published + assert result.exit_code != 0 + assert 'published' in result.output.lower() or 'force' in result.output.lower() + + def test_dry_run_published_release_with_force(self, test_config, populated_db): + """Test dry-run with --force allows published release deletion.""" + db, repo_id, test_data = populated_db + runner = CliRunner() + + with patch('release_tool.commands.cancel.GitHubClient') as mock_client_class: + result = runner.invoke( + cancel, + ['1.0.0', '--force', '--dry-run'], + obj={'config': test_config, 'debug': False}, + catch_exceptions=False + ) + + # Should not make API calls in dry-run + mock_client_class.assert_not_called() + + # Should show what would be deleted + assert ('Will perform' in result.output or 'would' in result.output.lower() or + 'Delete' in result.output or 'delete' in result.output.lower()) + assert '1.0.0' in result.output + + # Should exit successfully with force flag + assert result.exit_code == 0 + + +class TestE2ECancelExecution: + """Test cancel command actual execution.""" + + def test_cancel_draft_release_deletes_all_resources(self, test_config, populated_db): + """Test cancel deletes release, tag, and database records.""" + db, repo_id, test_data = populated_db + runner = CliRunner() + + with patch('release_tool.commands.cancel.GitHubClient') as mock_client_class: + # Setup mock + mock_client = Mock() + mock_client_class.return_value = mock_client + mock_client.delete_release.return_value = True + mock_client.delete_tag.return_value = True + + result = runner.invoke( + cancel, + ['1.2.3-rc.1'], + obj={'config': test_config, 'debug': False, 'assume_yes': True}, + catch_exceptions=False + ) + + # Should create GitHub client + mock_client_class.assert_called_once() + + # Should delete release and tag + mock_client.delete_release.assert_called_once_with('test/repo', 'v1.2.3-rc.1') + mock_client.delete_tag.assert_called_once_with('test/repo', 'v1.2.3-rc.1') + + # Should show success + assert result.exit_code == 0 + assert 'Successfully cancelled' in result.output or 'Deleted' in result.output or 'success' in result.output.lower() + + # Verify database records were deleted + draft_release = db.get_release(repo_id, '1.2.3-rc.1') + assert draft_release is None, "Draft release should be deleted from database" + + def test_cancel_with_pr_closes_and_deletes_branch(self, test_config, populated_db): + """Test cancel closes PR and deletes branch.""" + db, repo_id, test_data = populated_db + runner = CliRunner() + + with patch('release_tool.commands.cancel.GitHubClient') as mock_client_class: + # Setup mock + mock_client = Mock() + mock_client_class.return_value = mock_client + mock_client.close_pull_request.return_value = True + mock_client.delete_branch.return_value = True + mock_client.delete_release.return_value = True + mock_client.delete_tag.return_value = True + + # Mock get_pull_request to return PR details + mock_pr = Mock() + mock_pr.head.ref = "release-notes-v1.2.3-rc.1" + mock_client.get_pull_request.return_value = mock_pr + + result = runner.invoke( + cancel, + ['1.2.3-rc.1', '--pr', '42'], + obj={'config': test_config, 'debug': False, 'assume_yes': True}, + catch_exceptions=False + ) + + # Should close PR + mock_client.close_pull_request.assert_called_once_with( + 'test/repo', + 42, + "Closing PR as release 1.2.3-rc.1 is being cancelled." + ) + + # Should delete branch + mock_client.delete_branch.assert_called_once_with( + 'test/repo', + 'release-notes-v1.2.3-rc.1' + ) + + assert result.exit_code == 0 + + def test_cancel_with_issue_closes_issue(self, test_config, populated_db): + """Test cancel closes associated issue.""" + db, repo_id, test_data = populated_db + runner = CliRunner() + + with patch('release_tool.commands.cancel.GitHubClient') as mock_client_class: + # Setup mock + mock_client = Mock() + mock_client_class.return_value = mock_client + mock_client.delete_release.return_value = True + mock_client.delete_tag.return_value = True + mock_client.close_issue.return_value = True + + result = runner.invoke( + cancel, + ['1.2.3-rc.1', '--issue', '1'], + obj={'config': test_config, 'debug': False, 'assume_yes': True}, + catch_exceptions=False + ) + + # Should close issue + mock_client.close_issue.assert_called_once_with( + 'test/repo', + 1, + "Closing issue as release 1.2.3-rc.1 is being cancelled." + ) + + assert result.exit_code == 0 + + def test_cancel_stops_on_first_failure(self, test_config, populated_db): + """Test cancel stops on first failure without continuing.""" + db, repo_id, test_data = populated_db + runner = CliRunner() + + with patch('release_tool.commands.cancel.GitHubClient') as mock_client_class: + # Setup mock to fail on PR close + mock_client = Mock() + mock_client_class.return_value = mock_client + mock_client.close_pull_request.return_value = False # Fail + mock_client.delete_branch.return_value = True + mock_client.delete_release.return_value = True + mock_client.delete_tag.return_value = True + + # Mock get_pull_request + mock_pr = Mock() + mock_pr.head.ref = "release-notes-v1.2.3-rc.1" + mock_client.get_pull_request.return_value = mock_pr + + result = runner.invoke( + cancel, + ['1.2.3-rc.1', '--pr', '42'], + obj={'config': test_config, 'debug': False, 'assume_yes': True}, + catch_exceptions=False + ) + + # Should attempt to close PR + mock_client.close_pull_request.assert_called_once() + + # Should NOT continue to delete branch (stop on first failure) + mock_client.delete_branch.assert_not_called() + + # Should fail + assert result.exit_code != 0 + assert 'failed' in result.output.lower() or 'error' in result.output.lower() + + +class TestE2ECancelAutoDetection: + """Test cancel command auto-detection of version, PR, and issue.""" + + def test_auto_detect_pr_from_version(self, test_config, populated_db): + """Test cancel auto-detects PR number from version in database.""" + db, repo_id, test_data = populated_db + runner = CliRunner() + + # Create a PR with version in title + pr = PullRequest( + repo_id=repo_id, + number=99, + title="Release notes for v1.2.3-rc.1", + body="Auto-generated release notes", + state="open", + url="https://github.com/test/repo/pull/99", + head_branch="release-notes-v1.2.3-rc.1", + base_branch="main" + ) + db.upsert_pull_request(pr) + + with patch('release_tool.commands.cancel.GitHubClient') as mock_client_class: + # Setup mock + mock_client = Mock() + mock_client_class.return_value = mock_client + mock_client.close_pull_request.return_value = True + mock_client.delete_branch.return_value = True + mock_client.delete_release.return_value = True + mock_client.delete_tag.return_value = True + + # Mock get_pull_request + mock_pr = Mock() + mock_pr.head.ref = "release-notes-v1.2.3-rc.1" + mock_client.get_pull_request.return_value = mock_pr + + # Don't provide --pr flag, let it auto-detect + result = runner.invoke( + cancel, + ['1.2.3-rc.1'], + obj={'config': test_config, 'debug': True, 'assume_yes': True}, + catch_exceptions=False + ) + + # Should auto-detect and close PR 99 + # Check if PR was closed (either 42 or 99, depending on which it found) + assert mock_client.close_pull_request.called or result.exit_code == 0 + + def test_cancel_with_no_version_fails_gracefully(self, test_config, populated_db): + """Test cancel fails gracefully when version not provided and can't be auto-detected.""" + db, repo_id, test_data = populated_db + runner = CliRunner() + + # Try to cancel with only issue number (no version) + result = runner.invoke( + cancel, + ['--issue', '999'], # Non-existent issue + obj={'config': test_config, 'debug': False}, + catch_exceptions=False + ) + + # Should fail because version is required or must be auto-detectable + assert result.exit_code != 0 + + +class TestE2ECancelEdgeCases: + """Test cancel command edge cases and error handling.""" + + def test_cancel_nonexistent_version(self, test_config, populated_db): + """Test cancel with version that doesn't exist in database.""" + db, repo_id, test_data = populated_db + runner = CliRunner() + + with patch('release_tool.commands.cancel.GitHubClient') as mock_client_class: + # Setup mock - GitHub operations will be idempotent + mock_client = Mock() + mock_client_class.return_value = mock_client + mock_client.delete_release.return_value = True + mock_client.delete_tag.return_value = True + + result = runner.invoke( + cancel, + ['9.9.9', '--force'], # Version not in database + obj={'config': test_config, 'debug': False, 'assume_yes': True}, + catch_exceptions=False + ) + + # Should still attempt to delete from GitHub (idempotent) + mock_client.delete_release.assert_called_once_with('test/repo', 'v9.9.9') + mock_client.delete_tag.assert_called_once_with('test/repo', 'v9.9.9') + + # Should succeed (idempotent operations) + assert result.exit_code == 0 + + def test_cancel_already_deleted_resources_succeeds(self, test_config, populated_db): + """Test cancel succeeds when resources are already deleted (idempotent).""" + db, repo_id, test_data = populated_db + runner = CliRunner() + + with patch('release_tool.commands.cancel.GitHubClient') as mock_client_class: + # Setup mock - all operations return True (already deleted) + mock_client = Mock() + mock_client_class.return_value = mock_client + mock_client.delete_release.return_value = True + mock_client.delete_tag.return_value = True + mock_client.close_pull_request.return_value = True + mock_client.delete_branch.return_value = True + + # Mock get_pull_request to return None (already deleted) + mock_client.get_pull_request.return_value = None + + result = runner.invoke( + cancel, + ['1.2.3-rc.1', '--pr', '42'], + obj={'config': test_config, 'debug': False, 'assume_yes': True}, + catch_exceptions=False + ) + + # Should succeed (idempotent) + assert result.exit_code == 0 + + def test_cancel_with_debug_shows_detailed_output(self, test_config, populated_db): + """Test cancel with debug flag shows detailed output.""" + db, repo_id, test_data = populated_db + runner = CliRunner() + + with patch('release_tool.commands.cancel.GitHubClient') as mock_client_class: + # Setup mock + mock_client = Mock() + mock_client_class.return_value = mock_client + mock_client.delete_release.return_value = True + mock_client.delete_tag.return_value = True + + result = runner.invoke( + cancel, + ['1.2.3-rc.1', '--dry-run'], + obj={'config': test_config, 'debug': True}, + catch_exceptions=False + ) + + # Debug mode should show more details + # The actual output format depends on implementation + assert result.exit_code == 0 + # Should at least show the version + assert '1.2.3-rc.1' in result.output