From d330de2a91c3f311c457f9cbabc8b1cc34608265 Mon Sep 17 00:00:00 2001 From: Ryan Williams Date: Sat, 8 Nov 2025 18:47:08 -0500 Subject: [PATCH 1/5] Improve create command help text and add gist push after finalization - Add validation error when using -d without -y (draft incompatible with web editor) - Update command help text to clarify web editor vs API modes - Update -d flag help to note it requires -y - Update -y flag help to explain the three modes clearly - Add automatic gist push after PR/Issue finalization --- src/ghpr/commands/create.py | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/ghpr/commands/create.py b/src/ghpr/commands/create.py index 18db342..86c151c 100644 --- a/src/ghpr/commands/create.py +++ b/src/ghpr/commands/create.py @@ -164,6 +164,18 @@ def _finalize_created_item( proc.run('git', 'commit', '-m', f'Rename to {new_filename} and add {item_label} #{number} link', log=None) err(f"Updated {new_filename} with {item_label} link") + # Push to gist remote if it exists + from ..gist import find_gist_remote + gist_remote = find_gist_remote() + if gist_remote: + try: + proc.run('git', 'push', gist_remote, 'main', log=None) + err(f"Pushed to gist remote '{gist_remote}'") + except Exception as e: + err(f"Warning: Could not push to gist: {e}") + else: + err("No gist remote found, skipping gist push") + # Rename directory from gh/new to gh/{number} current_dir = Path.cwd() parent_dir = current_dir.parent @@ -300,7 +312,17 @@ def create( yes: int, dry_run: bool, ) -> None: - """Create a new PR or Issue from the current draft.""" + """Create a new PR or Issue from the current draft. + + By default, opens GitHub's web editor for interactive creation. + Use -y to skip the web editor and create via API instead. + """ + # Validate draft flag with web editor mode + if draft and yes == 0: + err("Error: --draft flag requires -y (cannot use draft mode with web editor)") + err("Use: ghpr create -d -y (or -yy for silent creation)") + exit(1) + if issue: create_new_issue(repo, yes, dry_run) else: @@ -633,14 +655,14 @@ def register(cli): ) # Register create command - cli.command()( + cli.command(help='Create PR/Issue (default: web editor; use -y for API mode)')( opt('-b', '--base', help='Base branch (default: repo default branch)')( - flag('-d', '--draft', help='Create as draft PR')( + flag('-d', '--draft', help='Create as draft PR (requires -y; incompatible with web editor)')( opt('-h', '--head', help='Head branch (default: auto-detect from parent repo)')( flag('-i', '--issue', help='Create an issue instead of a PR')( flag('-n', '--dry-run', help='Show what would be done without creating')( opt('-r', '--repo', help='Repository (owner/repo format, default: auto-detect)')( - opt('-y', '--yes', count=True, default=0, help='Skip prompt: once = create then view, twice = create silently (default: open web editor during creation)')( + opt('-y', '--yes', count=True, default=0, help='Skip web editor: -y = create via API then view, -yy = create silently (default: interactive web editor)')( create ) ) From 2f22d1d4652b08301d94797802e2a28d9984524f Mon Sep 17 00:00:00 2001 From: Ryan Williams Date: Sat, 8 Nov 2025 18:47:25 -0500 Subject: [PATCH 2/5] Refactor all command registrations to use decorator syntax Convert all register() functions from nested function calls to clean vertical decorator stacks for better readability. Affected commands: create, shell-integration, pull, clone, diff, push, ingest-attachments, upload, open, show --- src/ghpr/commands/clone.py | 19 +++++------- src/ghpr/commands/create.py | 40 ++++++++++--------------- src/ghpr/commands/diff.py | 14 ++++----- src/ghpr/commands/ingest_attachments.py | 16 +++++----- src/ghpr/commands/open.py | 10 +++---- src/ghpr/commands/pull.py | 25 +++++++--------- src/ghpr/commands/push.py | 34 ++++++++------------- src/ghpr/commands/shell_integration.py | 10 +++---- src/ghpr/commands/show.py | 10 +++---- src/ghpr/commands/upload.py | 20 +++++-------- 10 files changed, 83 insertions(+), 115 deletions(-) diff --git a/src/ghpr/commands/clone.py b/src/ghpr/commands/clone.py index db4bd6d..e1bfc77 100644 --- a/src/ghpr/commands/clone.py +++ b/src/ghpr/commands/clone.py @@ -267,14 +267,11 @@ def clone( def register(cli): """Register command with CLI.""" - cli.command()( - opt('-d', '--directory', help='Directory to clone into (default: gh/{number})')( - flag('-G', '--no-gist', help='Skip creating a gist')( - flag('--no-comments', help='Skip cloning comments')( - arg('spec', required=False)( - clone - ) - ) - ) - ) - ) + + @cli.command() + @arg('spec', required=False) + @flag('--no-comments', help='Skip cloning comments') + @flag('-G', '--no-gist', help='Skip creating a gist') + @opt('-d', '--directory', help='Directory to clone into (default: gh/{number})') + def clone_cmd(spec, no_comments, no_gist, directory): + clone(spec, directory, no_gist, no_comments) diff --git a/src/ghpr/commands/create.py b/src/ghpr/commands/create.py index 86c151c..041863b 100644 --- a/src/ghpr/commands/create.py +++ b/src/ghpr/commands/create.py @@ -645,30 +645,22 @@ def create_new_issue( def register(cli): """Register commands with CLI.""" + # Register init command - cli.command()( - opt('-r', '--repo', help='Repository (owner/repo format)')( - opt('-b', '--base', help='Base branch (default: repo default branch)')( - init - ) - ) - ) + @cli.command() + @opt('-r', '--repo', help='Repository (owner/repo format)') + @opt('-b', '--base', help='Base branch (default: repo default branch)') + def init_cmd(repo, base): + init(repo, base) # Register create command - cli.command(help='Create PR/Issue (default: web editor; use -y for API mode)')( - opt('-b', '--base', help='Base branch (default: repo default branch)')( - flag('-d', '--draft', help='Create as draft PR (requires -y; incompatible with web editor)')( - opt('-h', '--head', help='Head branch (default: auto-detect from parent repo)')( - flag('-i', '--issue', help='Create an issue instead of a PR')( - flag('-n', '--dry-run', help='Show what would be done without creating')( - opt('-r', '--repo', help='Repository (owner/repo format, default: auto-detect)')( - opt('-y', '--yes', count=True, default=0, help='Skip web editor: -y = create via API then view, -yy = create silently (default: interactive web editor)')( - create - ) - ) - ) - ) - ) - ) - ) - ) + @cli.command(name='create', help='Create PR/Issue (default: web editor; use -y for API mode)') + @opt('-y', '--yes', count=True, default=0, help='Skip web editor: -y = create via API then view, -yy = create silently (default: interactive web editor)') + @opt('-r', '--repo', help='Repository (owner/repo format, default: auto-detect)') + @flag('-n', '--dry-run', help='Show what would be done without creating') + @flag('-i', '--issue', help='Create an issue instead of a PR') + @opt('-h', '--head', help='Head branch (default: auto-detect from parent repo)') + @flag('-d', '--draft', help='Create as draft PR (requires -y; incompatible with web editor)') + @opt('-b', '--base', help='Base branch (default: repo default branch)') + def create_cmd(yes, repo, dry_run, issue, head, draft, base): + create(head, base, draft, issue, repo, yes, dry_run) diff --git a/src/ghpr/commands/diff.py b/src/ghpr/commands/diff.py index e046275..e10ac57 100644 --- a/src/ghpr/commands/diff.py +++ b/src/ghpr/commands/diff.py @@ -122,11 +122,9 @@ def diff( def register(cli): """Register command with CLI.""" - cli.command()( - opt('-c', '--color', type=Choice(['auto', 'always', 'never']), default='auto', - help='When to use colored output (default: auto)')( - flag('--no-comments', help='Skip diffing comments')( - diff - ) - ) - ) + + @cli.command() + @flag('--no-comments', help='Skip diffing comments') + @opt('-c', '--color', type=Choice(['auto', 'always', 'never']), default='auto', help='When to use colored output (default: auto)') + def diff_cmd(no_comments, color): + diff(color, no_comments) diff --git a/src/ghpr/commands/ingest_attachments.py b/src/ghpr/commands/ingest_attachments.py index 2f32c22..b8b15a6 100644 --- a/src/ghpr/commands/ingest_attachments.py +++ b/src/ghpr/commands/ingest_attachments.py @@ -203,12 +203,10 @@ def ingest_attachments( def register(cli): """Register command with CLI.""" - cli.command(name='ingest-attachments')( - opt('-b', '--branch', help='Branch name for attachments (default: $GHPR_INGEST_BRANCH or "attachments")')( - flag('--no-ingest', help='Disable attachment ingestion')( - flag('-n', '--dry-run', help='Show what would be done without making changes')( - ingest_attachments - ) - ) - ) - ) + + @cli.command(name='ingest-attachments') + @flag('-n', '--dry-run', help='Show what would be done without making changes') + @flag('--no-ingest', help='Disable attachment ingestion') + @opt('-b', '--branch', help='Branch name for attachments (default: $GHPR_INGEST_BRANCH or "attachments")') + def ingest_attachments_cmd(dry_run, no_ingest, branch): + ingest_attachments(branch, no_ingest, dry_run) diff --git a/src/ghpr/commands/open.py b/src/ghpr/commands/open.py index 3652791..b3eb887 100644 --- a/src/ghpr/commands/open.py +++ b/src/ghpr/commands/open.py @@ -68,8 +68,8 @@ def open_pr(gist: bool) -> None: def register(cli): """Register command with CLI.""" - cli.command(name='open')( - flag('-g', '--gist', help='Open gist instead of PR')( - open_pr - ) - ) + + @cli.command(name='open') + @flag('-g', '--gist', help='Open gist instead of PR') + def open_cmd(gist): + open_pr(gist) diff --git a/src/ghpr/commands/pull.py b/src/ghpr/commands/pull.py index 94ed39e..2c2ba89 100644 --- a/src/ghpr/commands/pull.py +++ b/src/ghpr/commands/pull.py @@ -150,18 +150,13 @@ def pull( def register(cli): """Register command with CLI.""" - cli.command()( - flag('-g', '--gist', help='Also sync to gist')( - flag('-n', '--dry-run', help='Show what would be done')( - opt('-f/-F', '--footer/--no-footer', default=None, help='Add gist footer to PR (default: auto - add if gist exists)')( - flag('-o', '--open', 'open_browser', help='Open PR in browser after pulling')( - opt('-p/-P', '--private/--public', 'gist_private', default=None, help='Gist visibility: -p = private, -P = public (default: match repo visibility)')( - flag('--no-comments', help='Skip syncing comments')( - pull - ) - ) - ) - ) - ) - ) - ) + + @cli.command() + @flag('--no-comments', help='Skip syncing comments') + @opt('-p/-P', '--private/--public', 'gist_private', default=None, help='Gist visibility: -p = private, -P = public (default: match repo visibility)') + @flag('-o', '--open', 'open_browser', help='Open PR in browser after pulling') + @opt('-f/-F', '--footer/--no-footer', default=None, help='Add gist footer to PR (default: auto - add if gist exists)') + @flag('-n', '--dry-run', help='Show what would be done') + @flag('-g', '--gist', help='Also sync to gist') + def pull_cmd(no_comments, gist_private, open_browser, footer, dry_run, gist): + pull(gist, dry_run, footer, open_browser, gist_private, no_comments) diff --git a/src/ghpr/commands/push.py b/src/ghpr/commands/push.py index 16d2bae..52027a7 100644 --- a/src/ghpr/commands/push.py +++ b/src/ghpr/commands/push.py @@ -625,24 +625,16 @@ def sync_to_gist( def register(cli): """Register command with CLI.""" - cli.command()( - flag('-g', '--gist', help='Also sync to gist')( - flag('-n', '--dry-run', help='Show what would be done without making changes')( - opt('-f', '--footer', count=True, help='Footer level: -f = hidden footer, -ff = visible footer')( - flag('-F', '--no-footer', help='Disable footer completely')( - flag('-o', '--open', 'open_browser', help='Open PR in browser after pushing')( - flag('-i', '--images', help='Upload local images and replace references')( - opt('-p/-P', '--private/--public', 'gist_private', default=None, help='Gist visibility: -p = private, -P = public (default: match repo visibility)')( - flag('--no-comments', help='Skip pushing comment changes')( - flag('-C', '--force-others', help='Allow pushing edits to other users\' comments (may fail at API level)')( - push - ) - ) - ) - ) - ) - ) - ) - ) - ) - ) + + @cli.command() + @flag('-C', '--force-others', help='Allow pushing edits to other users\' comments (may fail at API level)') + @flag('--no-comments', help='Skip pushing comment changes') + @opt('-p/-P', '--private/--public', 'gist_private', default=None, help='Gist visibility: -p = private, -P = public (default: match repo visibility)') + @flag('-i', '--images', help='Upload local images and replace references') + @flag('-o', '--open', 'open_browser', help='Open PR in browser after pushing') + @flag('-F', '--no-footer', help='Disable footer completely') + @opt('-f', '--footer', count=True, help='Footer level: -f = hidden footer, -ff = visible footer') + @flag('-n', '--dry-run', help='Show what would be done without making changes') + @flag('-g', '--gist', help='Also sync to gist') + def push_cmd(force_others, no_comments, gist_private, images, open_browser, no_footer, footer, dry_run, gist): + push(gist, dry_run, footer, no_footer, open_browser, images, gist_private, no_comments, force_others) diff --git a/src/ghpr/commands/shell_integration.py b/src/ghpr/commands/shell_integration.py index 346249f..8b8cfb0 100644 --- a/src/ghpr/commands/shell_integration.py +++ b/src/ghpr/commands/shell_integration.py @@ -45,8 +45,8 @@ def shell_integration(shell: str | None) -> None: def register(cli): """Register command with CLI.""" - cli.command(name='shell-integration')( - arg('shell', type=Choice(['bash', 'zsh', 'fish']), required=False)( - shell_integration - ) - ) + + @cli.command(name='shell-integration') + @arg('shell', type=Choice(['bash', 'zsh', 'fish']), required=False) + def shell_integration_cmd(shell): + shell_integration(shell) diff --git a/src/ghpr/commands/show.py b/src/ghpr/commands/show.py index 16080ec..1e71921 100644 --- a/src/ghpr/commands/show.py +++ b/src/ghpr/commands/show.py @@ -71,8 +71,8 @@ def show(gist: bool) -> None: def register(cli): """Register command with CLI.""" - cli.command()( - flag('-g', '--gist', help='Only show gist URL')( - show - ) - ) + + @cli.command() + @flag('-g', '--gist', help='Only show gist URL') + def show_cmd(gist): + show(gist) diff --git a/src/ghpr/commands/upload.py b/src/ghpr/commands/upload.py index 8331f21..ebdb0d2 100644 --- a/src/ghpr/commands/upload.py +++ b/src/ghpr/commands/upload.py @@ -99,15 +99,11 @@ def upload( def register(cli): """Register command with CLI.""" - cli.command()( - arg('files', nargs=-1, required=True)( - opt('-b', '--branch', default='assets', help='Branch name in gist (default: assets)')( - opt('-f', '--format', type=Choice(['url', 'markdown', 'img', 'auto']), default='auto', - help='Output format (default: auto - img for images, url for others)')( - opt('-a', '--alt', help='Alt text for markdown/img format')( - upload - ) - ) - ) - ) - ) + + @cli.command() + @opt('-a', '--alt', help='Alt text for markdown/img format') + @opt('-f', '--format', type=Choice(['url', 'markdown', 'img', 'auto']), default='auto', help='Output format (default: auto - img for images, url for others)') + @opt('-b', '--branch', default='assets', help='Branch name in gist (default: assets)') + @arg('files', nargs=-1, required=True) + def upload_cmd(alt, format, branch, files): + upload(files, branch, format, alt) From e6dffc9ade2d436cd9703e6d4f42ed1ad4153b83 Mon Sep 17 00:00:00 2001 From: Ryan Williams Date: Sat, 8 Nov 2025 19:11:25 -0500 Subject: [PATCH 3/5] Add push to GitHub after PR/Issue creation to sync link-def and footer After creating a PR/Issue and updating the local file with link-reference format, push the changes back to GitHub so the PR body includes: - Link-reference definition ([owner/repo#N]: url) - Gist footer (if gist remote exists) This ensures GitHub, local, and gist are all in sync after creation. --- src/ghpr/commands/create.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/ghpr/commands/create.py b/src/ghpr/commands/create.py index 041863b..9f4417d 100644 --- a/src/ghpr/commands/create.py +++ b/src/ghpr/commands/create.py @@ -164,6 +164,21 @@ def _finalize_created_item( proc.run('git', 'commit', '-m', f'Rename to {new_filename} and add {item_label} #{number} link', log=None) err(f"Updated {new_filename} with {item_label} link") + # Push updates to GitHub (link-def and footer) + from . import push as push_module + err("Pushing updates to GitHub...") + push_module.push( + gist=False, + dry_run=False, + footer=0, # Auto-detect footer based on gist existence + no_footer=False, + open_browser=False, + images=False, + gist_private=None, + no_comments=True, # Don't sync comments during create + force_others=False + ) + # Push to gist remote if it exists from ..gist import find_gist_remote gist_remote = find_gist_remote() From a9cd684a6e8ff1462e0358ccb55d61ed966d884a Mon Sep 17 00:00:00 2001 From: Ryan Williams Date: Sat, 8 Nov 2025 19:32:06 -0500 Subject: [PATCH 4/5] Fix test: use precise regex matching for command names The test was doing naive substring matching which incorrectly found 'comment' in command descriptions like 'Clone a PR or Issue description and comments...' Now uses regex to extract exact command names from the Commands: section, preventing false positives from substring matches in help text. Also added docstring to clone_cmd wrapper for better documentation. --- src/ghpr/commands/clone.py | 1 + tests/test_import.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/ghpr/commands/clone.py b/src/ghpr/commands/clone.py index e1bfc77..9e6c929 100644 --- a/src/ghpr/commands/clone.py +++ b/src/ghpr/commands/clone.py @@ -274,4 +274,5 @@ def register(cli): @flag('-G', '--no-gist', help='Skip creating a gist') @opt('-d', '--directory', help='Directory to clone into (default: gh/{number})') def clone_cmd(spec, no_comments, no_gist, directory): + """Clone a PR or Issue description and comments to a local directory.""" clone(spec, directory, no_gist, no_comments) diff --git a/tests/test_import.py b/tests/test_import.py index 22dcbf1..335a4ac 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -29,6 +29,8 @@ def test_cli_loads(): def test_all_commands_present(): """Test that all expected commands are present.""" + import re + result = subprocess.run( ["ghpr", "--help"], capture_output=True, @@ -37,7 +39,6 @@ def test_all_commands_present(): expected_commands = [ "clone", - "comment", "create", "diff", "init", @@ -50,8 +51,13 @@ def test_all_commands_present(): "upload", ] + # Extract command names from the Commands: section + # Commands appear as lines starting with " command-name" + command_pattern = re.compile(r'^ (\S+)', re.MULTILINE) + actual_commands = command_pattern.findall(result.stdout) + for cmd in expected_commands: - assert cmd in result.stdout, f"Command '{cmd}' not found in CLI help" + assert cmd in actual_commands, f"Command '{cmd}' not found in CLI commands. Found: {actual_commands}" def test_shell_integration_outputs(): From 65268be88fec6f23793d1a6ffaeb2e3c6921cd2e Mon Sep 17 00:00:00 2001 From: Ryan Williams Date: Sat, 8 Nov 2025 20:06:41 -0500 Subject: [PATCH 5/5] Fix all test assertions to use precise equality checks - Replace substring matching (assert x in y) with exact equality checks - Replace partial list checks with full list/tuple comparisons - Update test expectations to match exact command arguments - Fix test mocking to include new push() call in _finalize_created_item - Use precise regex matching for shell integration output This makes tests more robust and prevents false positives from substring matches in help text or command descriptions. --- tests/test_create.py | 76 ++++++++++++++++++++++++++++---------------- tests/test_files.py | 35 ++++++++++++++------ tests/test_gist.py | 43 +++++++++++++++---------- tests/test_import.py | 14 ++++++-- 4 files changed, 111 insertions(+), 57 deletions(-) diff --git a/tests/test_create.py b/tests/test_create.py index 56856c0..e599ba0 100644 --- a/tests/test_create.py +++ b/tests/test_create.py @@ -24,13 +24,20 @@ def test_init_with_explicit_repo(self, tmp_path): assert result.exit_code == 0 - # Check git init was called - assert any('git' in str(call) and 'init' in str(call) for call in mock_proc.run.call_args_list) - - # Check git config calls - config_calls = [str(call) for call in mock_proc.run.call_args_list if 'config' in str(call)] - assert any('pr.owner' in call and 'test-owner' in call for call in config_calls) - assert any('pr.repo' in call and 'test-repo' in call for call in config_calls) + # Extract all git commands called + git_commands = [ + call[0][0] if call[0] else None + for call in mock_proc.run.call_args_list + ] + assert 'git' in git_commands + + # Find specific git config calls + git_config_calls = [ + call[0] for call in mock_proc.run.call_args_list + if len(call[0]) > 2 and call[0][0] == 'git' and call[0][1] == 'config' + ] + assert ('git', 'config', 'pr.owner', 'test-owner') in git_config_calls + assert ('git', 'config', 'pr.repo', 'test-repo') in git_config_calls # Check gh/new/ directory and DESCRIPTION.md were created assert Path('gh/new').is_dir() @@ -80,6 +87,7 @@ def test_create_issue_success(self, tmp_path): patch('ghpr.commands.create.get_owner_repo') as mock_get_repo, \ patch('ghpr.commands.create.read_description_file') as mock_read_desc, \ patch('ghpr.commands.create.write_description_with_link_ref') as mock_write, \ + patch('ghpr.commands.push.push') as mock_push, \ patch('ghpr.commands.create.err'), \ patch('os.rename'): @@ -89,19 +97,19 @@ def test_create_issue_success(self, tmp_path): create_new_issue(repo_arg=None, yes=2, dry_run=False) - # Verify gh issue create was called + # Verify gh issue create was called with exact arguments mock_proc.text.assert_called_once() call_args = mock_proc.text.call_args[0] - assert 'gh' in call_args - assert 'issue' in call_args - assert 'create' in call_args - assert '--title' in call_args - assert 'Test Issue' in call_args + expected_args = ('gh', 'issue', 'create', '-R', 'test-owner/test-repo', '--title', 'Test Issue', '--body', 'Test body content') + assert call_args == expected_args - # Verify git config was set - config_calls = [call[0] for call in mock_proc.run.call_args_list if 'config' in call[0]] - assert any('pr.number' in call and '42' in call for call in config_calls) - assert any('pr.type' in call and 'issue' in call for call in config_calls) + # Verify git config was set with exact calls + config_calls = [ + call[0] for call in mock_proc.run.call_args_list + if len(call[0]) > 1 and call[0][0] == 'git' and call[0][1] == 'config' + ] + assert ('git', 'config', 'pr.number', '42') in config_calls + assert ('git', 'config', 'pr.type', 'issue') in config_calls # Verify file was written with link reference mock_write.assert_called_once() @@ -120,6 +128,7 @@ def test_create_issue_with_explicit_repo(self, tmp_path): patch('ghpr.commands.create.get_owner_repo') as mock_get_repo, \ patch('ghpr.commands.create.read_description_file') as mock_read_desc, \ patch('ghpr.commands.create.write_description_with_link_ref'), \ + patch('ghpr.commands.push.push'), \ patch('ghpr.commands.create.err'), \ patch('os.rename'): @@ -151,6 +160,7 @@ def test_create_pr_with_explicit_args(self, tmp_path): with patch('ghpr.commands.create.proc') as mock_proc, \ patch('ghpr.commands.create.read_description_file') as mock_read_desc, \ patch('ghpr.commands.create.write_description_with_link_ref') as mock_write, \ + patch('ghpr.commands.push.push'), \ patch('ghpr.commands.create.err'), \ patch('os.rename'): @@ -177,16 +187,18 @@ def line_side_effect(*args, **kwargs): dry_run=False ) - # Verify gh pr create was called with correct args + # Verify gh pr create was called with exact arguments mock_proc.text.assert_called_once() call_args = mock_proc.text.call_args[0] - assert 'gh' in call_args - assert 'pr' in call_args - assert 'create' in call_args - assert '--head' in call_args - assert 'feature-branch' in call_args - assert '--base' in call_args - assert 'main' in call_args + expected_args = ( + 'gh', 'pr', 'create', + '-R', 'test-owner/test-repo', + '--title', 'Test PR', + '--body', 'Test body', + '--base', 'main', + '--head', 'feature-branch' + ) + assert call_args == expected_args def test_create_draft_pr(self, tmp_path): """Test draft PR includes --draft flag.""" @@ -197,6 +209,7 @@ def test_create_draft_pr(self, tmp_path): with patch('ghpr.commands.create.proc') as mock_proc, \ patch('ghpr.commands.create.read_description_file') as mock_read_desc, \ patch('ghpr.commands.create.write_description_with_link_ref'), \ + patch('ghpr.commands.push.push'), \ patch('ghpr.commands.create.err'), \ patch('os.rename'): @@ -221,6 +234,15 @@ def line_side_effect(*args, **kwargs): dry_run=False ) - # Verify --draft flag was passed + # Verify --draft flag was passed with exact arguments call_args = mock_proc.text.call_args[0] - assert '--draft' in call_args + expected_args = ( + 'gh', 'pr', 'create', + '-R', 'test-owner/test-repo', + '--title', 'Draft PR', + '--body', 'Draft body', + '--base', 'main', + '--head', 'feature', + '--draft' + ) + assert call_args == expected_args diff --git a/tests/test_files.py b/tests/test_files.py index e8ad6da..1c63f48 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -116,9 +116,14 @@ def test_write_basic_description(self): ) content = filepath.read_text() - assert '# [owner/myrepo#123] Test PR' in content - assert 'This is the body.' in content - assert '[owner/myrepo#123]: https://github.com/owner/myrepo/pull/123' in content + expected = ( + '# [owner/myrepo#123] Test PR\n' + '\n' + 'This is the body.\n' + '\n' + '[owner/myrepo#123]: https://github.com/owner/myrepo/pull/123\n' + ) + assert content == expected def test_write_empty_body(self): """Test writing description with empty body.""" @@ -137,8 +142,12 @@ def test_write_empty_body(self): ) content = filepath.read_text() - assert '# [owner/myrepo#456] Empty Body' in content - assert '[owner/myrepo#456]: https://github.com/owner/myrepo/pull/456' in content + expected = ( + '# [owner/myrepo#456] Empty Body\n' + '\n' + '[owner/myrepo#456]: https://github.com/owner/myrepo/pull/456\n' + ) + assert content == expected def test_write_multiline_body(self): """Test writing description with multiline body.""" @@ -158,8 +167,15 @@ def test_write_multiline_body(self): ) content = filepath.read_text() - assert '# [owner/myrepo#789] Multi Line' in content - assert body in content + # Body already has trailing newline, don't add another + expected = ( + '# [owner/myrepo#789] Multi Line\n' + '\n' + f'{body}' + '\n' + '[owner/myrepo#789]: https://github.com/owner/myrepo/pull/789\n' + ) + assert content == expected def test_body_with_existing_link_def(self): """Test that existing link definition in body is not duplicated.""" @@ -258,9 +274,8 @@ def test_read_multiline_body(self): assert title == 'Multi Line' # Note: Link definitions are stripped, trailing whitespace removed - assert 'Line 1' in body - assert 'Line 2 with **markdown**' in body - assert '- List item' in body + expected_body = 'Line 1\n\nLine 2 with **markdown**\n\n- List item' + assert body == expected_body def test_read_no_description_file(self): """Test reading when no description file exists.""" diff --git a/tests/test_gist.py b/tests/test_gist.py index 54f3a17..0245fb9 100644 --- a/tests/test_gist.py +++ b/tests/test_gist.py @@ -102,10 +102,14 @@ def test_multiline_body_with_footer(self): body_without, gist_url = extract_gist_footer(body) - assert 'Line 1' in body_without - assert 'Line 2 with **markdown**' in body_without - assert '- List item' in body_without - assert '' in result + expected = 'This is the body.\n\n' + assert result == expected def test_add_visible_footer(self): """Test adding visible footer.""" @@ -129,9 +133,8 @@ def test_add_visible_footer(self): result = add_gist_footer(body, gist_url, visible=True) - assert 'This is the body.' in result - assert '---' in result - assert 'Synced with [gist](https://gist.github.com/abc123def456) via [ghpr](https://github.com/runsascoded/ghpr)' in result + expected = 'This is the body.\n\n\n---\n\nSynced with [gist](https://gist.github.com/abc123def456) via [ghpr](https://github.com/runsascoded/ghpr)' + assert result == expected def test_add_footer_to_empty_body(self): """Test adding footer to empty body.""" @@ -160,10 +163,8 @@ def test_replace_existing_footer(self): result = add_gist_footer(body, new_gist_url, visible=False) - assert 'This is the body.' in result - assert '0123456789ab' not in result - assert 'fedcba987654' in result - assert result.count('' + assert result == expected def test_visible_footer_with_revision(self): """Test visible footer preserves revision in URL.""" @@ -172,7 +173,8 @@ def test_visible_footer_with_revision(self): result = add_gist_footer(body, gist_url, visible=True) - assert 'Synced with [gist](https://gist.github.com/abc123def456/0fedcba987654321)' in result + expected = 'This is the body.\n\n\n---\n\nSynced with [gist](https://gist.github.com/abc123def456/0fedcba987654321) via [ghpr](https://github.com/runsascoded/ghpr)' + assert result == expected def test_add_footer_preserves_body_formatting(self): """Test that adding footer preserves body formatting.""" @@ -187,9 +189,16 @@ def test_add_footer_preserves_body_formatting(self): result = add_gist_footer(body, gist_url, visible=False) - assert 'Line 1' in result - assert 'Line 2 with **markdown**' in result - assert '- List item' in result + expected = ( + 'Line 1\n' + '\n' + 'Line 2 with **markdown**\n' + '\n' + '- List item\n' + '\n' + '' + ) + assert result == expected class TestExtractThenAddRoundtrip: diff --git a/tests/test_import.py b/tests/test_import.py index 335a4ac..acbd22e 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -24,7 +24,11 @@ def test_cli_loads(): text=True, ) assert result.returncode == 0 - assert "Clone and sync GitHub PR descriptions" in result.stdout + # Check that help output contains expected structure + assert result.stdout.startswith("Usage: ghpr [OPTIONS] COMMAND [ARGS]...") + assert "Clone and sync GitHub PR descriptions." in result.stdout + assert "Options:" in result.stdout + assert "Commands:" in result.stdout def test_all_commands_present(): @@ -68,8 +72,12 @@ def test_shell_integration_outputs(): text=True, ) assert result.returncode == 0 - assert "ghpri()" in result.stdout # ghpri is now a function, not alias - assert "alias ghprc=" in result.stdout + # Check for specific function/alias definitions + lines = result.stdout.strip().split('\n') + # ghpri should be a function (allow for comments after the opening brace) + assert any(line.strip().startswith('ghpri() {') for line in lines), "ghpri function definition not found" + # ghprc should be an alias + assert any('alias ghprc=' in line for line in lines), "ghprc alias definition not found" def test_patterns_regex():