diff --git a/CHANGELOG.md b/CHANGELOG.md index 21d2f7d..7f8044c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,8 @@ ## 2.1.0 (unreleased) * Add automation support to `blurb add` command: - * New `--gh-issue` option to specify GitHub issue number - * New `--section` option to specify NEWS section + * New `--issue` option to specify GitHub issue number (supports URLs and various formats) + * New `--section` option to specify NEWS section (with smart case-insensitive matching) * New `--rst-on-stdin` option to read entry content from stdin * Useful for CI systems and automated tools * Uses `cyclopts` for command line parsing instead of rolling our own to reduce our code size, this changes the help format and brings in a dependency. diff --git a/README.md b/README.md index 6040d6a..db6afc0 100644 --- a/README.md +++ b/README.md @@ -84,12 +84,22 @@ For automated tools and CI systems, `blurb add` supports non-interactive operati ```bash # Add a blurb entry from stdin echo 'Added beans to the :mod:`spam` module.' | blurb add \ - --gh-issue 123456 \ + --issue 123456 \ --section Library \ --rst-on-stdin ``` -When using `--rst_on_stdin`, both `--gh_issue` and `--section` are required. +When using `--rst-on-stdin`, both `--issue` and `--section` are required. + +The `--issue` parameter accepts various formats: +- Issue number: `--issue 12345` +- With gh- prefix: `--issue gh-12345` +- GitHub URL: `--issue https://github.com/python/cpython/issues/12345` + +The `--section` parameter supports smart matching: +- Case insensitive: `--section library` or `--section LIBRARY` +- Partial matching: `--section lib` (matches "Library") +- Common aliases: `--section api` (matches "C API"), `--section builtin` (matches "Core and Builtins") The template for the `blurb add` message looks like this: diff --git a/src/blurb/blurb.py b/src/blurb/blurb.py old mode 100755 new mode 100644 index 1472cfa..b49584f --- a/src/blurb/blurb.py +++ b/src/blurb/blurb.py @@ -747,31 +747,26 @@ def find_editor(): error('Could not find an editor! Set the EDITOR environment variable.') -def validate_add_parameters(section, gh_issue, rst_on_stdin): - """Validate parameters for the add command.""" - if section and section not in SECTIONS: - error(f"--section must be one of {SECTIONS} not {section!r}") - if gh_issue < 0: - error(f"--gh-issue must be a positive integer not {gh_issue!r}") - if rst_on_stdin and (gh_issue <= 0 or not section): - error("--gh-issue and --section required with --rst-on-stdin") - - -def prepare_template(tmp_path, gh_issue, section, rst_content): +def prepare_template(tmp_path, issue_number, section_name, rst_content): """Write the template file with substitutions.""" text = template - # Ensure gh-issue line ends with space + # Ensure gh-issue line ends with space (or fill in issue number) issue_line = ".. gh-issue:" - text = text.replace(f"\n{issue_line}\n", f"\n{issue_line} \n") + pattern = f"\n{issue_line}\n" + if issue_number: + replacement = f"\n{issue_line} {issue_number}\n" + else: + replacement = f"\n{issue_line} \n" + text = text.replace(pattern, replacement) + + # Apply section substitution + if section_name: + text = text.replace(f"#.. section: {section_name}\n", f".. section: {section_name}\n") - # Apply substitutions - if gh_issue: - text = text.replace(".. gh-issue: \n", f".. gh-issue: {gh_issue}\n") - if section: - text = text.replace(f"#.. section: {section}\n", f".. section: {section}\n") + # Apply content substitution if rst_content: marker = "#################\n\n" text = text.replace(marker, f"{marker}{rst_content}\n") @@ -815,25 +810,138 @@ def edit_until_valid(editor, tmp_path): print() +def _extract_issue_number(issue): + """Extract issue number from various formats like '12345', 'gh-12345', or GitHub URLs.""" + if issue is None: + return None + + issue = raw_issue = str(issue).strip() + if issue.startswith('gh-'): + issue = issue[3:] + if issue.isdigit(): + return issue + + match = re.match(r'^(?:https://)?github\.com/python/cpython/issues/(\d+)$', issue) + if match is None: + error(f"Invalid GitHub issue: {raw_issue}") + return match.group(1) + + +def _extract_section_name(section): + """Extract section name with smart matching.""" + if section is None: + return None + + section = raw_section = section.strip() + if not section: + error("Empty section name!") + + matches = [] + # Try simple case-insensitive substring matching + section_lower = section.lower() + for section_name in SECTIONS: + if section_lower in section_name.lower(): + matches.append(section_name) + + # If no matches, try more complex matching + if not matches: + matches = _find_smart_matches(section) + + if not matches: + sections_list = '\n'.join(f' - {s}' for s in SECTIONS) + error(f"Invalid section name: {raw_section!r}\n\nValid sections are:\n{sections_list}") + + if len(matches) > 1: + multiple_matches = ', '.join(map(repr, sorted(matches))) + error(f"More than one match for: {raw_section!r}\nMatches: {multiple_matches}") + + return matches[0] + + +def _find_smart_matches(section): + """Find matches using advanced pattern matching.""" + # Normalize separators + sanitized = re.sub(r'[_\- /]', ' ', section).strip() + if not sanitized: + return [] + + matches = [] + section_words = re.split(r'\s+', sanitized) + + # Build pattern to match against known sections + section_pattern = r'[\s/]*'.join(map(re.escape, section_words)) + section_pattern = re.compile(section_pattern, re.I) + + for section_name in SECTIONS: + if section_pattern.search(section_name): + matches.append(section_name) + + # Special cases and aliases + normalized = ''.join(section_words).lower() + + # Check special aliases + aliases = { + 'api': 'C API', + 'capi': 'C API', + 'builtin': 'Core and Builtins', + 'builtins': 'Core and Builtins', + 'core': 'Core and Builtins', + 'demo': 'Tools/Demos', + 'demos': 'Tools/Demos', + 'tool': 'Tools/Demos', + 'tools': 'Tools/Demos', + } + + for alias, section_name in aliases.items(): + if normalized.startswith(alias): + if section_name not in matches: + matches.append(section_name) + + # Try matching by removing spaces/separators + if not matches: + for section_name in SECTIONS: + section_normalized = re.sub(r'[^a-zA-Z0-9]', '', section_name).lower() + if section_normalized.startswith(normalized): + matches.append(section_name) + + return matches + + @app.command(name="add") -def add(*, gh_issue: int = 0, section: str = "", rst_on_stdin: bool = False): +def add(*, issue: Annotated[Optional[str], Parameter(alias=["-i"])] = None, + section: Annotated[Optional[str], Parameter(alias=["-s"])] = None, + rst_on_stdin: bool = False): # This docstring template is formatted after the function definition. """Add a new Misc/NEWS entry. Opens an editor to create a new entry for Misc/NEWS unless all automation parameters are provided. + Use -i/--issue to specify a GitHub issue number or link. + Use -s/--section to specify the NEWS section (case insensitive with partial matching). + Parameters ---------- - gh_issue : int, optional - GitHub issue number (optional, must be >= {lowest_possible_gh_issue_number}). + issue : str, optional + GitHub issue number or URL (e.g. '12345', 'gh-12345', or 'https://github.com/python/cpython/issues/12345'). section : str, optional - NEWS section. One of {sections_csv}. + NEWS section. Can use partial matching (e.g. 'lib' for 'Library'). One of {sections_csv}. rst_on_stdin : bool - Read restructured text entry from stdin (requires gh issue and section). + Read restructured text entry from stdin (requires issue and section). """ - validate_add_parameters(section, gh_issue, rst_on_stdin) + # Extract and validate issue number + issue_number = _extract_issue_number(issue) if issue else None + if issue_number and int(issue_number) < LOWEST_POSSIBLE_GH_ISSUE_NUMBER: + error(f"Invalid issue number: {issue_number} (must be >= {LOWEST_POSSIBLE_GH_ISSUE_NUMBER})") + + # Extract and validate section + section_name = _extract_section_name(section) if section else None + + # Validate parameters for stdin mode + if rst_on_stdin and (not issue_number or not section_name): + error("--issue and --section required with --rst-on-stdin") + chdir_to_repo_root() # Prepare content source @@ -852,7 +960,7 @@ def add(*, gh_issue: int = 0, section: str = "", rst_on_stdin: bool = False): atexit.register(lambda: os.path.exists(tmp_path) and os.unlink(tmp_path)) # Prepare template - prepare_template(tmp_path, gh_issue, section, rst_content) + prepare_template(tmp_path, issue_number, section_name, rst_content) # Get blurb content if editor: @@ -874,7 +982,6 @@ def add(*, gh_issue: int = 0, section: str = "", rst_on_stdin: bool = False): add.__doc__ = add.__doc__.format( - lowest_possible_gh_issue_number=LOWEST_POSSIBLE_GH_ISSUE_NUMBER, sections_csv=", ".join(repr(s) for s in SECTIONS) ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e45e7ed --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,11 @@ +"""pytest configuration and fixtures.""" + +import pytest +from pyfakefs.fake_filesystem_unittest import Patcher + + +@pytest.fixture +def fs(): + """Pyfakefs fixture compatible with pytest.""" + with Patcher() as patcher: + yield patcher.fs diff --git a/tests/test_add_command.py b/tests/test_add_command.py deleted file mode 100644 index 01a8e67..0000000 --- a/tests/test_add_command.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Tests for the blurb add command with automation features.""" - -import io -import os -from unittest import mock -import pytest -from pyfakefs.fake_filesystem import FakeFilesystem - -from blurb import blurb - - -class TestAddCommand: - """Test cases for the add command's automation features.""" - - def setup_method(self): - """Set up test environment before each test.""" - # Save original values - self.original_dir = os.getcwd() - self.original_root = blurb.root - - def teardown_method(self): - """Clean up after each test.""" - # Restore original values - os.chdir(self.original_dir) - blurb.root = self.original_root - - def test_add_help_parameter(self, capsys): - """Test that add command has proper help text.""" - # With cyclopts, help is handled by the framework, not a parameter - # We'll test the docstring is properly formatted instead - assert blurb.add.__doc__ is not None - assert "Add a new Misc/NEWS entry" in blurb.add.__doc__ - assert "gh_issue" in blurb.add.__doc__ - assert "section" in blurb.add.__doc__ - assert "rst_on_stdin" in blurb.add.__doc__ - assert str(blurb.LOWEST_POSSIBLE_GH_ISSUE_NUMBER) in blurb.add.__doc__ - - @mock.patch.object(blurb, 'chdir_to_repo_root') - def test_invalid_section_parameter(self, mock_chdir, capsys): - """Test that invalid section names are rejected.""" - with pytest.raises(SystemExit) as exc_info: - blurb.add(section="InvalidSection") - - # error() function exits with string message, not code - assert "--section must be one of" in str(exc_info.value) - assert "InvalidSection" in str(exc_info.value) - - @mock.patch.object(blurb, 'chdir_to_repo_root') - def test_negative_gh_issue(self, mock_chdir, capsys): - """Test that negative GitHub issue numbers are rejected.""" - with pytest.raises(SystemExit) as exc_info: - blurb.add(gh_issue=-123) - - # error() function exits with string message, not code - assert "--gh-issue must be a positive integer" in str(exc_info.value) - - @mock.patch.object(blurb, 'chdir_to_repo_root') - def test_rst_on_stdin_requires_other_params(self, mock_chdir, capsys): - """Test that --rst-on-stdin requires --gh-issue and --section.""" - with pytest.raises(SystemExit) as exc_info: - blurb.add(rst_on_stdin=True) - - # error() function exits with string message, not code - assert "--gh-issue and --section required with --rst-on-stdin" in str(exc_info.value) - - @mock.patch.object(blurb, 'chdir_to_repo_root') - def test_rst_on_stdin_missing_section(self, mock_chdir, capsys): - """Test that --rst-on-stdin fails without --section.""" - with pytest.raises(SystemExit) as exc_info: - blurb.add(rst_on_stdin=True, gh_issue=12345) - - # error() function exits with string message, not code - assert "--gh-issue and --section required with --rst-on-stdin" in str(exc_info.value) - - @mock.patch.object(blurb, 'chdir_to_repo_root') - def test_rst_on_stdin_missing_gh_issue(self, mock_chdir, capsys): - """Test that --rst-on-stdin fails without --gh-issue.""" - with pytest.raises(SystemExit) as exc_info: - blurb.add(rst_on_stdin=True, section="Library") - - # error() function exits with string message, not code - assert "--gh-issue and --section required with --rst-on-stdin" in str(exc_info.value) - - @mock.patch('blurb.blurb.chdir_to_repo_root') - @mock.patch('blurb.blurb.flush_git_add_files') - @mock.patch('sys.stdin', new_callable=io.StringIO) - def test_add_with_all_automation_params(self, mock_stdin, mock_flush_git, mock_chdir, fs: FakeFilesystem): - """Test successful add with all automation parameters.""" - # Set up fake filesystem - fs.create_dir("/fake_repo") - fs.create_dir("/fake_repo/Misc/NEWS.d/next/Library") - os.chdir("/fake_repo") - blurb.root = "/fake_repo" - - # Mock stdin content with a Monty Python reference - mock_stdin.write("Fixed spam module to properly handle eggs, bacon, and spam repetition counts.") - mock_stdin.seek(0) - - # Mock chdir_to_repo_root to do nothing since we're in fake fs - mock_chdir.return_value = None - - # Call add with automation parameters - with mock.patch('blurb.blurb.sortable_datetime', return_value='2024-01-01-12-00-00'): - with mock.patch('blurb.blurb.nonceify', return_value='abc123'): - blurb.add( - gh_issue=123456, - section="Library", - rst_on_stdin=True - ) - - # Verify the file was created - expected_path = "/fake_repo/Misc/NEWS.d/next/Library/2024-01-01-12-00-00.gh-issue-123456.abc123.rst" - assert os.path.exists(expected_path) - - # Verify file contents - the metadata is in the filename, not the file content - with open(expected_path) as f: - content = f.read() - - # The file should only contain the body text (which may be wrapped) - assert "Fixed spam module to properly handle eggs, bacon, and spam repetition" in content - assert "counts." in content - - # Verify git add was called - assert expected_path in blurb.git_add_files - mock_flush_git.assert_called_once() diff --git a/tests/test_blurb_add.py b/tests/test_blurb_add.py new file mode 100644 index 0000000..b9713ab --- /dev/null +++ b/tests/test_blurb_add.py @@ -0,0 +1,373 @@ +import io +import os +import re +import sys +import tempfile +from unittest import mock + +import pytest + +from blurb import blurb + + +def test_valid_no_issue_number(): + assert blurb._extract_issue_number(None) is None + + +@pytest.mark.parametrize('issue', [ + # issue given by their number + '12345', + '12345 ', + ' 12345', + ' 12345 ', + # issue given by their number and a 'gh-' prefix + 'gh-12345', + 'gh-12345 ', + ' gh-12345', + ' gh-12345 ', + # issue given by their URL (no protocol) + 'github.com/python/cpython/issues/12345', + 'github.com/python/cpython/issues/12345 ', + ' github.com/python/cpython/issues/12345', + ' github.com/python/cpython/issues/12345 ', + # issue given by their URL (with protocol) + 'https://github.com/python/cpython/issues/12345', + 'https://github.com/python/cpython/issues/12345 ', + ' https://github.com/python/cpython/issues/12345', + ' https://github.com/python/cpython/issues/12345 ', +]) +def test_valid_issue_number_12345(issue): + actual = blurb._extract_issue_number(issue) + assert actual == '12345' + + +@pytest.mark.parametrize('issue', [ + '', + 'abc', + 'gh-abc', + 'gh-', + 'bpo-', + 'bpo-12345', + 'github.com/python/cpython/issues', + 'github.com/python/cpython/issues/', + 'github.com/python/cpython/issues/abc', + 'github.com/python/cpython/issues/gh-abc', + 'github.com/python/cpython/issues/gh-123', + 'github.com/python/cpython/issues/1234?param=1', + 'https://github.com/python/cpython/issues', + 'https://github.com/python/cpython/issues/', + 'https://github.com/python/cpython/issues/abc', + 'https://github.com/python/cpython/issues/gh-abc', + 'https://github.com/python/cpython/issues/gh-123', + 'https://github.com/python/cpython/issues/1234?param=1', +]) +def test_invalid_issue_number(issue): + error_message = re.escape(f'Invalid GitHub issue: {issue}') + with pytest.raises(SystemExit, match=error_message): + blurb._extract_issue_number(issue) + + +class TestValidSectionNames: + @staticmethod + def check(section, expect): + actual = blurb._extract_section_name(section) + assert actual == expect + + @pytest.mark.parametrize( + ('section', 'expect'), + tuple(zip(blurb.SECTIONS, blurb.SECTIONS)) + ) + def test_exact_names(self, section, expect): + self.check(section, expect) + + @pytest.mark.parametrize( + ('section', 'expect'), [ + ('Sec', 'Security'), + ('sec', 'Security'), + ('security', 'Security'), + ('Core And', 'Core and Builtins'), + ('Core And Built', 'Core and Builtins'), + ('Core And Builtins', 'Core and Builtins'), + ('Lib', 'Library'), + ('doc', 'Documentation'), + ('document', 'Documentation'), + ('Tes', 'Tests'), + ('tes', 'Tests'), + ('Test', 'Tests'), + ('Tests', 'Tests'), + # 'Buil' and 'bui' are ambiguous with 'Core and Builtins' + ('build', 'Build'), + ('Tool', 'Tools/Demos'), + ('Tools', 'Tools/Demos'), + ('Tools/', 'Tools/Demos'), + ('core', 'Core and Builtins'), + ] + ) + def test_partial_words(self, section, expect): + self.check(section, expect) + + @pytest.mark.parametrize( + ('section', 'expect'), [ + ('builtin', 'Core and Builtins'), + ('builtins', 'Core and Builtins'), + ('api', 'C API'), + ('c-api', 'C API'), + ('c/api', 'C API'), + ('c api', 'C API'), + ('dem', 'Tools/Demos'), + ('demo', 'Tools/Demos'), + ('demos', 'Tools/Demos'), + ] + ) + def test_partial_special_names(self, section, expect): + self.check(section, expect) + + @pytest.mark.parametrize( + ('section', 'expect'), [ + ('Core-and-Builtins', 'Core and Builtins'), + ('Core_and_Builtins', 'Core and Builtins'), + ('Core_and-Builtins', 'Core and Builtins'), + ('Core and', 'Core and Builtins'), + ('Core_and', 'Core and Builtins'), + ('core_and', 'Core and Builtins'), + ('core-and', 'Core and Builtins'), + ('Core and Builtins', 'Core and Builtins'), + ('cOre _ and - bUILtins', 'Core and Builtins'), + ('Tools/demo', 'Tools/Demos'), + ('Tools-demo', 'Tools/Demos'), + ('Tools demo', 'Tools/Demos'), + ] + ) + def test_partial_separators(self, section, expect): + # normalize the separtors '_', '-', ' ' and '/' + self.check(section, expect) + + @pytest.mark.parametrize( + ('prefix', 'expect'), [ + ('corean', 'Core and Builtins'), + ('coreand', 'Core and Builtins'), + ('coreandbuilt', 'Core and Builtins'), + ('coreand Builtins', 'Core and Builtins'), + ('coreand Builtins', 'Core and Builtins'), + ('coreAnd Builtins', 'Core and Builtins'), + ('CoreAnd Builtins', 'Core and Builtins'), + ('Coreand', 'Core and Builtins'), + ('Coreand Builtins', 'Core and Builtins'), + ('Coreand builtin', 'Core and Builtins'), + ('Coreand buil', 'Core and Builtins'), + ] + ) + def test_partial_prefix_words(self, prefix, expect): + # try to find a match using prefixes (without separators and lowercase) + self.check(prefix, expect) + + @pytest.mark.parametrize( + ('section', 'expect'), + [(name.lower(), name) for name in blurb.SECTIONS], + ) + def test_exact_names_lowercase(self, section, expect): + self.check(section, expect) + + @pytest.mark.parametrize( + ('section', 'expect'), + [(name.upper(), name) for name in blurb.SECTIONS], + ) + def test_exact_names_uppercase(self, section, expect): + self.check(section, expect) + + +@pytest.mark.parametrize('section', ['', ' ', ' ']) +def test_empty_section_name(section): + error_message = re.escape('Empty section name!') + with pytest.raises(SystemExit, match=error_message): + blurb._extract_section_name(section) + + +@pytest.mark.parametrize('section', [ + # invalid + '_', + '-', + 'invalid', + 'Not a section', + # non-special names + 'c?api', + 'cXapi', + 'C+API', + # super-strings + 'Library and more', + 'library3', + 'librari', +]) +def test_invalid_section_name(section): + error_message = re.escape(f'Invalid section name: {section!r}') + error_message = re.compile(rf'{error_message}\n\n.+', re.MULTILINE) + with pytest.raises(SystemExit, match=error_message): + blurb._extract_section_name(section) + + +@pytest.mark.parametrize(('section', 'matches'), [ + # 'matches' must be a sorted sequence of matching section names + ('c', ['C API', 'Core and Builtins']), + ('C', ['C API', 'Core and Builtins']), + ('buil', ['Build', 'Core and Builtins']), + ('BUIL', ['Build', 'Core and Builtins']), +]) +def test_ambiguous_section_name(section, matches): + matching_list = ', '.join(map(repr, matches)) + error_message = re.escape(f'More than one match for: {section!r}\n' + f'Matches: {matching_list}') + error_message = re.compile(rf'{error_message}', re.MULTILINE) + with pytest.raises(SystemExit, match=error_message): + blurb._extract_section_name(section) + + +def test_prepare_template_with_issue(): + """Test that prepare_template correctly fills in issue number.""" + with tempfile.NamedTemporaryFile(mode='w+', suffix='.rst', delete=False) as tmp: + blurb.prepare_template(tmp.name, "12345", None, None) + tmp.seek(0) + content = tmp.read() + + assert ".. gh-issue: 12345" in content + assert ".. gh-issue: \n" not in content + + +def test_prepare_template_with_section(): + """Test that prepare_template correctly uncomments section.""" + with tempfile.NamedTemporaryFile(mode='w+', suffix='.rst', delete=False) as tmp: + blurb.prepare_template(tmp.name, None, "Library", None) + tmp.seek(0) + content = tmp.read() + + assert ".. section: Library" in content + assert "#.. section: Library" not in content + # Other sections should still be commented + assert "#.. section: Tests" in content + + +def test_prepare_template_with_content(): + """Test that prepare_template correctly adds content.""" + with tempfile.NamedTemporaryFile(mode='w+', suffix='.rst', delete=False) as tmp: + test_content = "Fixed spam module to handle eggs." + blurb.prepare_template(tmp.name, None, None, test_content) + tmp.seek(0) + content = tmp.read() + + assert test_content in content + # The marker is followed by content, so check that the content appears after it + assert "#################\n\n" + test_content in content + + +class TestAddCommandAutomation: + """Test cases for the add command's automation features.""" + + def setup_method(self): + """Set up test environment before each test.""" + # Save original values + self.original_dir = os.getcwd() + self.original_root = blurb.root + + def teardown_method(self): + """Clean up after each test.""" + # Restore original values + os.chdir(self.original_dir) + blurb.root = self.original_root + + def test_add_help_parameter(self, capsys): + """Test that add command has proper help text.""" + # With cyclopts, help is handled by the framework, not a parameter + # We'll test the docstring is properly formatted instead + assert blurb.add.__doc__ is not None + assert "Add a new Misc/NEWS entry" in blurb.add.__doc__ + assert "issue" in blurb.add.__doc__ + assert "section" in blurb.add.__doc__ + assert "rst_on_stdin" in blurb.add.__doc__ + + @pytest.fixture + def mock_chdir(self): + """Mock chdir_to_repo_root for tests.""" + with mock.patch.object(blurb, 'chdir_to_repo_root') as m: + yield m + + def test_invalid_section_parameter(self, mock_chdir, capsys): + """Test that invalid section names are rejected.""" + with pytest.raises(SystemExit) as exc_info: + blurb.add(section="InvalidSection") + + # error() function exits with string message, not code + assert "Invalid section name: 'InvalidSection'" in str(exc_info.value) + + def test_invalid_gh_issue_number(self, mock_chdir, capsys): + """Test that invalid GitHub issue numbers are rejected.""" + # Test issue number below threshold + with pytest.raises(SystemExit) as exc_info: + blurb.add(issue="123") + assert "Invalid issue number: 123" in str(exc_info.value) + + # Test invalid formats + with pytest.raises(SystemExit) as exc_info: + blurb.add(issue="not-a-number") + assert "Invalid GitHub issue: not-a-number" in str(exc_info.value) + + def test_rst_on_stdin_requires_other_params(self, mock_chdir, capsys): + """Test that --rst-on-stdin requires --issue and --section.""" + with pytest.raises(SystemExit) as exc_info: + blurb.add(rst_on_stdin=True) + + # error() function exits with string message, not code + assert "--issue and --section required with --rst-on-stdin" in str(exc_info.value) + + def test_rst_on_stdin_missing_section(self, mock_chdir, capsys): + """Test that --rst-on-stdin fails without --section.""" + with pytest.raises(SystemExit) as exc_info: + blurb.add(rst_on_stdin=True, issue="123456") + + # error() function exits with string message, not code + assert "--issue and --section required with --rst-on-stdin" in str(exc_info.value) + + def test_rst_on_stdin_missing_issue(self, mock_chdir, capsys): + """Test that --rst-on-stdin fails without --issue.""" + with pytest.raises(SystemExit) as exc_info: + blurb.add(rst_on_stdin=True, section="Library") + + # error() function exits with string message, not code + assert "--issue and --section required with --rst-on-stdin" in str(exc_info.value) + + def test_add_with_all_automation_params(self, tmp_path): + """Test successful add with all automation parameters.""" + # Set up filesystem + (tmp_path / "Misc" / "NEWS.d" / "next" / "Library").mkdir(parents=True) + os.chdir(tmp_path) + blurb.root = str(tmp_path) + + with mock.patch.object(blurb, 'chdir_to_repo_root'): + with mock.patch.object(blurb, 'flush_git_add_files') as mock_flush_git: + with mock.patch.object(sys, 'stdin', new_callable=io.StringIO) as mock_stdin: + # Mock stdin content with a Monty Python reference + mock_stdin.write("Fixed spam module to properly handle eggs, bacon, and spam repetition counts.") + mock_stdin.seek(0) + + # Call add with automation parameters + with mock.patch.object(blurb, 'sortable_datetime', return_value='2024-01-01-12-00-00'): + with mock.patch.object(blurb, 'nonceify', return_value='abc123'): + blurb.add( + issue="123456", + section="Library", + rst_on_stdin=True + ) + + # Verify the file was created + expected_filename = "2024-01-01-12-00-00.gh-issue-123456.abc123.rst" + expected_path = tmp_path / "Misc" / "NEWS.d" / "next" / "Library" / expected_filename + assert expected_path.exists() + + # Verify file contents - the metadata is in the filename, not the file content + content = expected_path.read_text() + + # The file should only contain the body text (which may be wrapped) + assert "Fixed spam module to properly handle eggs, bacon, and spam repetition" in content + assert "counts." in content + + # Verify git add was called + assert str(expected_path) in blurb.git_add_files + mock_flush_git.assert_called_once() diff --git a/tests/test_cli_integration.py b/tests/test_cli_integration.py index f987992..d8ff02d 100644 --- a/tests/test_cli_integration.py +++ b/tests/test_cli_integration.py @@ -110,13 +110,13 @@ def test_help_content(self, mock_cpython_repo, blurb_executable): result = run_blurb(blurb_executable, ["add", "--help"], cwd=mock_cpython_repo) assert result.returncode == 0 output = result.stdout + result.stderr - required_content = ["Add a new Misc/NEWS entry", "--gh-issue", "--section", "--rst-on-stdin", "Library"] + required_content = ["Add a new Misc/NEWS entry", "--issue", "--section", "--rst-on-stdin"] assert all(content in output for content in required_content) @pytest.mark.parametrize("args,error_text", [ - (["--section", "InvalidSection"], "must be one of"), - (["--gh-issue", "-123"], "must be a positive integer"), - (["--rst-on-stdin"], "--gh-issue and --section required"), + (["--section", "InvalidSection"], "Invalid section name"), + (["--issue", "not-a-number"], "Invalid GitHub issue"), + (["--rst-on-stdin"], "--issue and --section required"), ]) def test_validation_errors(self, mock_cpython_repo, blurb_executable, args, error_text): result = run_blurb(blurb_executable, ["add"] + args, cwd=mock_cpython_repo) @@ -128,7 +128,7 @@ def test_stdin_automation(self, mock_cpython_repo, blurb_executable, section): blurb_text = f"Fixed a bug in the {section.lower()} that improves spam handling." result = run_blurb( blurb_executable, - ["add", "--gh-issue", "123456", "--section", section, "--rst-on-stdin"], + ["add", "--issue", "123456", "--section", section, "--rst-on-stdin"], cwd=mock_cpython_repo, input_text=blurb_text )