diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b810a9..680ad31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2.1.0 + +- Add the `-i` / `--issue` option to the 'blurb add' command. + This lets you pre-fill the `gh-issue` field in the template. + ## 2.0.0 * Move 'blurb test' subcommand into test suite by @hugovk in https://github.com/python/blurb/pull/37 diff --git a/README.md b/README.md index c5e6abc..f415378 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,13 @@ Here's how you interact with the file: * Add the GitHub issue number for this commit to the end of the `.. gh-issue:` line. + The issue can also be specified via the ``-i`` / ``--issue`` option: + + ```shell + $ blurb add -i 109198 + # or equivalently + $ blurb add -i https://github.com/python/cpython/issues/109198 + ``` * Uncomment the line with the relevant `Misc/NEWS` section for this entry. For example, if this should go in the `Library` section, uncomment diff --git a/src/blurb/blurb.py b/src/blurb/blurb.py index 7be4474..5e5eb75 100755 --- a/src/blurb/blurb.py +++ b/src/blurb/blurb.py @@ -766,7 +766,14 @@ def help(subcommand=None): for name, p in inspect.signature(fn).parameters.items(): if p.kind == inspect.Parameter.KEYWORD_ONLY: short_option = name[0] - options.append(f" [-{short_option}|--{name}]") + if isinstance(p.default, bool): + options.append(f" [-{short_option}|--{name}]") + else: + if p.default is None: + metavar = f'{name.upper()}' + else: + metavar = f'{name.upper()}[={p.default}]' + options.append(f" [-{short_option}|--{name} {metavar}]") elif p.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: positionals.append(" ") has_default = (p.default != inspect._empty) @@ -817,25 +824,65 @@ def find_editor(): error('Could not find an editor! Set the EDITOR environment variable.') -def _template_text_for_temp_file(): +def _extract_issue_number(issue, /): + if issue is None: + return None + issue = issue.strip() + + if issue.startswith(('GH-', 'gh-')): + stripped = issue[3:] + else: + stripped = issue.removeprefix('#') + try: + if stripped.isdecimal(): + return int(stripped) + except ValueError: + pass + + # Allow GitHub URL with or without the scheme + stripped = issue.removeprefix('https://') + stripped = stripped.removeprefix('github.com/python/cpython/issues/') + try: + if stripped.isdecimal(): + return int(stripped) + except ValueError: + pass + + sys.exit(f"Invalid GitHub issue number: {issue}") + + +def _blurb_template_text(*, issue): + issue_number = _extract_issue_number(issue) + text = template # Ensure that there is a trailing space after '.. gh-issue:' to make - # filling in the template easier. + # filling in the template easier, unless an issue number was given + # through the --issue command-line flag. issue_line = ".. gh-issue:" without_space = "\n" + issue_line + "\n" - with_space = "\n" + issue_line + " \n" if without_space not in text: - sys.exit("Can't find gh-issue line to ensure there's a space on the end!") - text = text.replace(without_space, with_space) + sys.exit("Can't find gh-issue line in the template!") + if issue_number is None: + with_space = "\n" + issue_line + " \n" + text = text.replace(without_space, with_space) + else: + with_issue_number = f"\n{issue_line} {issue_number}\n" + text = text.replace(without_space, with_issue_number) return text @subcommand -def add(): +def add(*, issue=None): """ Add a blurb (a Misc/NEWS.d/next entry) to the current CPython repo. + +Use -i/--issue to specify a GitHub issue number or link, e.g.: + + blurb add -i 12345 + # or + blurb add -i https://github.com/python/cpython/issues/12345 """ editor = find_editor() @@ -844,7 +891,7 @@ def add(): os.close(handle) atexit.register(lambda : os.unlink(tmp_path)) - text = _template_text_for_temp_file() + text = _blurb_template_text(issue=issue) with open(tmp_path, "w", encoding="utf-8") as file: file.write(text) @@ -1169,22 +1216,37 @@ def main(): kwargs = {} for name, p in inspect.signature(fn).parameters.items(): if p.kind == inspect.Parameter.KEYWORD_ONLY: - assert isinstance(p.default, bool), "blurb command-line processing only handles boolean options" + if (p.default is not None + and not isinstance(p.default, (bool, str))): + sys.exit("blurb command-line processing cannot handle " + f"options of type {type(p.default).__qualname__}") + kwargs[name] = p.default short_options[name[0]] = name long_options[name] = name filtered_args = [] done_with_options = False + consume_after = None def handle_option(s, dict): + nonlocal consume_after name = dict.get(s, None) if not name: sys.exit(f'blurb: Unknown option for {subcommand}: "{s}"') - kwargs[name] = not kwargs[name] + + value = kwargs[name] + if isinstance(value, bool): + kwargs[name] = not value + else: + consume_after = name # print(f"short_options {short_options} long_options {long_options}") for a in args: + if consume_after: + kwargs[consume_after] = a + consume_after = None + continue if done_with_options: filtered_args.append(a) continue @@ -1199,6 +1261,9 @@ def handle_option(s, dict): continue filtered_args.append(a) + if consume_after: + sys.exit(f"Error: blurb: {subcommand} {consume_after} " + f"must be followed by an option argument") sys.exit(fn(*filtered_args, **kwargs)) except TypeError as e: diff --git a/tests/test_blurb_add.py b/tests/test_blurb_add.py new file mode 100644 index 0000000..814d7b2 --- /dev/null +++ b/tests/test_blurb_add.py @@ -0,0 +1,87 @@ +import re + +import pytest + +from blurb import blurb + + +def test_valid_no_issue_number(): + assert blurb._extract_issue_number(None) is None + res = blurb._blurb_template_text(issue=None) + lines = frozenset(res.splitlines()) + assert '.. gh-issue:' not in lines + assert '.. gh-issue: ' in lines + + +@pytest.mark.parametrize('issue', ( + # issue given by their number + '12345', + ' 12345 ', + # issue given by their number and a 'GH-' prefix + 'GH-12345', + ' GH-12345 ', + # issue given by their number and a 'gh-' prefix + 'gh-12345', + ' gh-12345 ', + # issue given by their number and a '#' prefix + '#12345', + ' #12345 ', + # issue given by their URL (no scheme) + 'github.com/python/cpython/issues/12345', + ' github.com/python/cpython/issues/12345 ', + # issue given by their URL (with scheme) + '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 + + res = blurb._blurb_template_text(issue=issue) + lines = frozenset(res.splitlines()) + assert '.. gh-issue:' not in lines + assert '.. gh-issue: ' not in lines + assert '.. gh-issue: 12345' in lines + + +@pytest.mark.parametrize('issue', ( + '', + 'abc', + 'Gh-123', + 'gh-abc', + 'gh- 123', + 'gh -123', + '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 number: {issue}') + with pytest.raises(SystemExit, match=error_message): + blurb._blurb_template_text(issue=issue) + + +@pytest.mark.parametrize('invalid', ( + 'gh-issue: ', + 'gh-issue: 1', + 'gh-issue', +)) +def test_malformed_gh_issue_line(invalid, monkeypatch): + template = blurb.template.replace('.. gh-issue:', invalid) + error_message = re.escape("Can't find gh-issue line in the template!") + with monkeypatch.context() as cm: + cm.setattr(blurb, 'template', template) + with pytest.raises(SystemExit, match=error_message): + blurb._blurb_template_text(issue='1234')