From 89b62cd7d5b0537116c06f827dbefde26e1d30fe Mon Sep 17 00:00:00 2001 From: Ben Du Date: Sat, 18 Jul 2026 16:29:26 -0700 Subject: [PATCH] feat(pr_content): improve title generation for .github and non-conventional commits --- github_rest_api/pr_content.py | 25 +++++++++++++----- tests/test_pr_content.py | 48 +++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/github_rest_api/pr_content.py b/github_rest_api/pr_content.py index 0a1a29a..3263c42 100644 --- a/github_rest_api/pr_content.py +++ b/github_rest_api/pr_content.py @@ -83,9 +83,15 @@ def deterministic_title(compare: dict[str, Any]) -> str: """Derive a Conventional-Commits title from a comparison result. The type is the most significant type present across the commits - (``feat`` > ``fix`` > the most frequent parsed type > ``chore``); the scope - is the common top-level directory of the changed files; ``!`` is appended - for breaking changes. + (``feat`` > ``fix`` > the most frequent parsed type). When no commit follows + the Conventional-Commits grammar, the type is ``ci`` for changes confined to + ``.github`` and ``chore`` otherwise. The scope is the common top-level + directory of the changed files; ``!`` is appended for breaking changes. + + The description reuses a matching commit's description when available and + otherwise falls back to the first commit's subject line, so a genuinely + descriptive (if non-conventional) subject is preserved rather than replaced + by a generic placeholder. :param compare: The comparison result from `Repository.compare`. """ @@ -94,17 +100,22 @@ def deterministic_title(compare: dict[str, Any]) -> str: parsed = [parse_conventional(subject) for subject in subjects] types = [item[0] for item in parsed if item] scope = _common_scope(compare) - prefix_scope = f"({scope})" if scope else "" - if not subjects: - return f"chore{prefix_scope}: update" if "feat" in types: type_ = "feat" elif "fix" in types: type_ = "fix" elif types: type_ = Counter(types).most_common(1)[0][0] + elif scope == ".github": + # Changes confined to `.github` (e.g. workflow files) are conventionally + # `ci`; the type already conveys the location, so drop the (redundant) + # `.github` scope. + type_, scope = "ci", None else: type_ = "chore" + prefix_scope = f"({scope})" if scope else "" + if not subjects: + return f"{type_}{prefix_scope}: update" breaking = any(item[2] for item in parsed if item) or any( _BREAKING_CHANGE_PATTERN.search(message) for message in messages ) @@ -113,7 +124,7 @@ def deterministic_title(compare: dict[str, Any]) -> str: else: description = next( (item[3] for item in parsed if item and item[0] == type_), - f"update {len(subjects)} commits", + subjects[0], ) return f"{type_}{prefix_scope}{'!' if breaking else ''}: {description}" diff --git a/tests/test_pr_content.py b/tests/test_pr_content.py index c49acab..f1a8ecd 100644 --- a/tests/test_pr_content.py +++ b/tests/test_pr_content.py @@ -136,6 +136,54 @@ def test_deterministic_title_no_scope_for_root_files(): assert deterministic_title(compare) == "chore: bump" +def test_deterministic_title_non_conventional_multi_uses_first_subject(): + compare = { + "commits": [ + _commit("1", "did some work"), + _commit("2", "did more work"), + ], + "files": [_file("pkg/a.py"), _file("pkg/b.py")], + } + # No conventional commits: fall back to the first subject rather than the + # generic "update N commits" placeholder. + assert deterministic_title(compare) == "chore(pkg): did some work" + + +def test_deterministic_title_github_scope_maps_to_ci(): + compare = { + "commits": [ + _commit("1", "Claude PR Assistant workflow"), + _commit("2", "Claude Code Review workflow"), + ], + "files": [ + _file(".github/workflows/claude.yml", "added"), + _file(".github/workflows/claude-review.yml", "added"), + ], + } + # `.github`-only changes become `ci` (with the redundant scope dropped), and + # the first subject supplies the description. + assert deterministic_title(compare) == "ci: Claude PR Assistant workflow" + + +def test_deterministic_title_github_scope_respects_conventional_type(): + compare = { + "commits": [_commit("1", "docs: update issue template")], + "files": [_file(".github/ISSUE_TEMPLATE/bug.md")], + } + # An explicit conventional type wins over the `.github` -> `ci` fallback. + assert deterministic_title(compare) == "docs(.github): update issue template" + + +def test_deterministic_title_github_ci_not_applied_when_mixed_with_other_dir(): + compare = { + "commits": [_commit("1", "tweak things")], + "files": [_file(".github/workflows/ci.yml"), _file("pkg/a.py")], + } + # Changes span `.github` and another top-level dir, so no common scope and no + # `ci` upgrade -- the generic `chore` fallback applies. + assert deterministic_title(compare) == "chore: tweak things" + + def test_deterministic_body_sections(): body = deterministic_body(COMPARE) assert "## Summary" in body