Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ Testing
- Must test return values, exceptions, log messages, and exit codes.
- Prefer unit tests under tests/unit/.
- Must mock GitHub API interactions and environment variables in unit tests.
- Must not access private members (names starting with `_`) of the class under test directly in tests.
- Must place shared test helper functions and factory fixtures in the nearest `conftest.py` and reuse them across tests.
- Must annotate pytest fixture parameters with `MockerFixture` (from `pytest_mock`) and return types with `Callable[..., T]` (from `collections.abc`) when the fixture returns a factory function.

Tooling
- Must format with Black (pyproject.toml).
Expand Down
23 changes: 23 additions & 0 deletions docs/features/issue_hierarchy_support.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,29 @@ Represent issue → sub-issue relationships directly in release notes, aggregati
```
(1st four indented bullets under Epic line represent the extracted release notes from the parent hierarchy issue's body.)

## `{progress}` Format Token

The `{progress}` token is available in `row-format-hierarchy-issue`. It renders the sub-issue completion count for each hierarchy node as `"X/Y done"`, where X and Y count **direct children only** — no recursive aggregation.

- Counts both `SubIssueRecord` and nested `HierarchyIssueRecord` direct children.
- Each hierarchy level computes its own count independently when `to_chapter_row()` recurses.
- Leaf nodes (zero direct sub-issues) return an empty string; the token is suppressed, producing no extra whitespace.

### Example

Configuration required to enable `{progress}` in hierarchy rows:
```yaml
row-format-hierarchy-issue: "{type}: _{title}_ {number} {progress}"
```

Resulting output (hierarchy issues only — sub-issue rows use `row-format-issue` and do not carry `{progress}`):
```markdown
- Epic: _Make Login Service releasable_ #140 1/2 done
- Feature: _Add user MFA enrollment flow_ #123 1/1 done
- Add user MFA enrollment flow
- Feature: _Add OAuth2 login_ #125 0/1 done
```

## Related Features
- [Custom Row Formats](./custom_row_formats.md) – controls hierarchy line rendering.
- [Service Chapters](./service_chapters.md) – flags missing change increments if hierarchy parents lack qualifying sub-issues.
Expand Down
45 changes: 45 additions & 0 deletions release_notes_generator/model/record/hierarchy_issue_record.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,23 @@ def sub_hierarchy_issues(self):
"""
return self._sub_hierarchy_issues

@property
def progress(self) -> str:
"""
The sub-issue completion count for this hierarchy node as 'X/Y done'.

Returns:
'' when this node has no direct sub-issues; otherwise 'X/Y done'
counting direct children only (sub-issues + sub-hierarchy-issues, no recursion).
Note: adjacent delimiter characters are not stripped when empty.
"""
total = len(self._sub_issues) + len(self._sub_hierarchy_issues)
if total == 0:
return ""
closed = sum(1 for s in self._sub_issues.values() if s.is_closed)
closed += sum(1 for s in self._sub_hierarchy_issues.values() if s.is_closed)
return f"{closed}/{total} done"

@property
def developers(self) -> list[str]:
issue = self._issue
Expand Down Expand Up @@ -115,6 +132,33 @@ def pull_requests_count(self) -> int:

return count

def contains_change_increment(self) -> bool:
"""
Returns True only when this hierarchy sub-tree has at least one closed descendant with a change.

A closed descendant with a PR (or a cross-repo placeholder) is the only evidence of finished
work that belongs in release notes. Open sub-issues whose PRs have not yet been merged must
not cause the parent to appear in the output.
"""
if self.is_cross_repo:
return True

# Direct PRs attached to this hierarchy issue itself (IssueRecord level, no sub-tree)
if super().pull_requests_count() > 0:
return True

# Only closed leaf sub-issues contribute; recurse to check their own PRs/cross-repo flag
for sub_issue in self._sub_issues.values():
if sub_issue.is_closed and sub_issue.contains_change_increment():
return True

# Recurse into sub-hierarchy-issues; the same closed-descendant rule applies at every level
for sub_hierarchy_issue in self._sub_hierarchy_issues.values():
if sub_hierarchy_issue.contains_change_increment():
return True

return False

def get_labels(self) -> list[str]:
labels: set[str] = set()
labels.update(label.name for label in self._issue.get_labels())
Expand Down Expand Up @@ -146,6 +190,7 @@ def to_chapter_row(self, add_into_chapters: bool = True) -> str:
format_values["type"] = self.issue_type
else:
format_values["type"] = ""
format_values["progress"] = self.progress

list_pr_links = self.get_pr_links()
if len(list_pr_links) > 0:
Expand Down
2 changes: 1 addition & 1 deletion release_notes_generator/utils/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
ROW_FORMAT_ISSUE = "row-format-issue"
ROW_FORMAT_PR = "row-format-pr"
ROW_FORMAT_LINK_PR = "row-format-link-pr"
SUPPORTED_ROW_FORMAT_KEYS_HIERARCHY_ISSUE = ["type", "number", "title", "author", "assignees", "developers"]
SUPPORTED_ROW_FORMAT_KEYS_HIERARCHY_ISSUE = ["type", "number", "title", "author", "assignees", "developers", "progress"]
SUPPORTED_ROW_FORMAT_KEYS_ISSUE = ["type", "number", "title", "author", "assignees", "developers", "pull-requests"]
SUPPORTED_ROW_FORMAT_KEYS_PULL_REQUEST = ["number", "title", "author", "assignees", "developers"]

Expand Down
5 changes: 1 addition & 4 deletions tests/integration/integration_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,7 @@

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

pytestmark = pytest.mark.skipif(
not os.getenv("GITHUB_TOKEN"), reason="GITHUB_TOKEN not set for integration test"
)
pytestmark = pytest.mark.skipif(not os.getenv("GITHUB_TOKEN"), reason="GITHUB_TOKEN not set for integration test")


def test_bulk_sub_issue_collector_smoke():
Expand All @@ -41,4 +39,3 @@ def test_bulk_sub_issue_collector_smoke():
iterations += 1
# Collector internal state should be dict-like even if empty
assert hasattr(collector, "parents_sub_issues")

13 changes: 5 additions & 8 deletions tests/integration/test_release_notes_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,7 @@ def test_legacy_single_label_snapshot():
custom.populate(records)
output = custom.to_string()

expected = (
"### Bugfixes 🛠️\n"
"- org/repo#1 row\n\n"
"### Enhancements 🎉\n"
"- org/repo#2 row"
)
expected = "### Bugfixes 🛠️\n" "- org/repo#1 row\n\n" "### Enhancements 🎉\n" "- org/repo#2 row"

assert output == expected, f"Legacy snapshot changed.\nExpected:\n{expected}\nActual:\n{output}"

Expand All @@ -79,10 +74,12 @@ def test_multi_label_integration_snapshot(): # T019
custom.from_yaml_array(chapters_yaml)

records = {
"org/repo#10": build_mock_record("org/repo#10", ["bug", "enhancement"]), # appears once in Changes, once in Mixed
"org/repo#10": build_mock_record(
"org/repo#10", ["bug", "enhancement"]
), # appears once in Changes, once in Mixed
"org/repo#11": build_mock_record("org/repo#11", ["platform"]), # appears in Platform only
"org/repo#12": build_mock_record("org/repo#12", ["infra", "platform"]), # Platform only once
"org/repo#13": build_mock_record("org/repo#13", ["feature"]) # Mixed only
"org/repo#13": build_mock_record("org/repo#13", ["feature"]), # Mixed only
}
custom.populate(records)
out = custom.to_string()
Expand Down
Loading