Skip to content

Commit 223c802

Browse files
Implement hidden flag for custom chapters (issue #102)
Co-authored-by: miroslavpojer <[email protected]>
1 parent 89b68c1 commit 223c802

File tree

3 files changed

+335
-3
lines changed

3 files changed

+335
-3
lines changed

release_notes_generator/chapters/custom_chapters.py

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,47 @@ def populate(self, records: dict[str, Record]) -> None:
9999
# Quick intersection check
100100
if any(lbl in ch.labels for lbl in record_labels):
101101
if record_id not in ch.rows:
102-
record.add_to_chapter_presence(ch.title)
103-
ch.add_row(record_id, record.to_chapter_row(True))
102+
# Only count toward duplicity for visible (non-hidden) chapters
103+
is_hidden = ch.hidden
104+
if not is_hidden:
105+
record.add_to_chapter_presence(ch.title)
106+
# For hidden chapters: don't increment duplicity counter
107+
# For visible chapters: increment duplicity counter (existing behavior)
108+
ch.add_row(record_id, record.to_chapter_row(not is_hidden))
104109
# Track for backward compatibility (not used for gating anymore)
105110
if record_id not in self.populated_record_numbers_list:
106111
self.populated_record_numbers_list.append(record_id)
112+
if is_hidden and ActionInputs.get_verbose():
113+
logger.debug(
114+
"Record %s assigned to hidden chapter '%s' (not counted for duplicity)",
115+
record_id,
116+
ch.title,
117+
)
118+
119+
def to_string(self) -> str:
120+
"""
121+
Converts the custom chapters to a string, excluding hidden chapters.
122+
123+
Returns:
124+
str: The chapters as a string with hidden chapters filtered out.
125+
"""
126+
result = ""
127+
for chapter in self.chapters.values():
128+
# Skip hidden chapters from output
129+
if chapter.hidden:
130+
record_count = len(chapter.rows)
131+
if ActionInputs.get_verbose():
132+
logger.debug("Skipping hidden chapter: %s (%d records tracked)", chapter.title, record_count)
133+
continue
134+
135+
chapter_string = chapter.to_string(
136+
sort_ascending=self.sort_ascending, print_empty_chapters=self.print_empty_chapters
137+
)
138+
if chapter_string:
139+
result += chapter_string + "\n\n"
140+
141+
# Note: strip is required to remove leading newline chars when empty chapters are not printed option
142+
return result.strip()
107143

108144
def from_yaml_array(self, chapters: list[dict[str, Any]]) -> "CustomChapters": # type: ignore[override]
109145
"""
@@ -119,7 +155,7 @@ def from_yaml_array(self, chapters: list[dict[str, Any]]) -> "CustomChapters":
119155
Returns:
120156
The CustomChapters instance for chaining.
121157
"""
122-
allowed_keys = {"title", "label", "labels"}
158+
allowed_keys = {"title", "label", "labels", "hidden"}
123159
for chapter in chapters:
124160
if not isinstance(chapter, dict):
125161
logger.warning("Skipping chapter definition with invalid type %s: %s", type(chapter), chapter)
@@ -129,6 +165,28 @@ def from_yaml_array(self, chapters: list[dict[str, Any]]) -> "CustomChapters":
129165
continue
130166
title = chapter["title"]
131167

168+
# Parse and validate hidden flag
169+
hidden = chapter.get("hidden", False)
170+
if not isinstance(hidden, bool):
171+
# Try to convert string "true"/"false" to boolean
172+
if isinstance(hidden, str):
173+
if hidden.lower() == "true":
174+
hidden = True
175+
elif hidden.lower() == "false":
176+
hidden = False
177+
else:
178+
logger.warning(
179+
"Chapter '%s' has invalid 'hidden' value: %s. Defaulting to false.",
180+
title,
181+
hidden,
182+
)
183+
hidden = False
184+
else:
185+
logger.warning(
186+
"Chapter '%s' has invalid 'hidden' value type: %s. Defaulting to false.", title, type(hidden)
187+
)
188+
hidden = False
189+
132190
has_labels = "labels" in chapter
133191
has_label = "label" in chapter
134192

@@ -167,11 +225,22 @@ def from_yaml_array(self, chapters: list[dict[str, Any]]) -> "CustomChapters":
167225

168226
if title not in self.chapters:
169227
self.chapters[title] = Chapter(title, normalized)
228+
self.chapters[title].hidden = hidden
229+
if hidden:
230+
logger.info(
231+
"Chapter '%s' marked as hidden, will not appear in output (but records will be tracked)", title
232+
)
170233
else:
171234
# Merge while preserving order & avoiding duplicates
172235
existing = self.chapters[title].labels
173236
for lbl in normalized:
174237
if lbl not in existing:
175238
existing.append(lbl)
239+
# Update hidden flag (last definition wins)
240+
self.chapters[title].hidden = hidden
241+
if hidden:
242+
logger.info(
243+
"Chapter '%s' marked as hidden, will not appear in output (but records will be tracked)", title
244+
)
176245

177246
return self

release_notes_generator/model/chapter.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ def __init__(
3232
self.labels: list[str] = labels if labels else []
3333
self.rows: dict[int | str, str] = {}
3434
self.empty_message = empty_message
35+
self.hidden: bool = False
3536

3637
def add_row(self, row_id: int | str, row: str) -> None:
3738
"""

tests/unit/release_notes_generator/chapters/test_custom_chapters.py

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,3 +331,265 @@ def test_populate_skips_for_record_conditions(scenario, record_stub, mocker):
331331
cc.populate(records)
332332
# Assert
333333
assert not cc.chapters["Bugs"].rows
334+
335+
336+
# Tests for hidden flag functionality
337+
338+
339+
def test_from_yaml_array_hidden_true():
340+
# Arrange
341+
cc = CustomChapters()
342+
# Act
343+
cc.from_yaml_array([{"title": "Hidden Chapter", "labels": "bug", "hidden": True}])
344+
# Assert
345+
assert "Hidden Chapter" in cc.chapters
346+
assert cc.chapters["Hidden Chapter"].hidden is True
347+
348+
349+
def test_from_yaml_array_hidden_false():
350+
# Arrange
351+
cc = CustomChapters()
352+
# Act
353+
cc.from_yaml_array([{"title": "Visible Chapter", "labels": "bug", "hidden": False}])
354+
# Assert
355+
assert "Visible Chapter" in cc.chapters
356+
assert cc.chapters["Visible Chapter"].hidden is False
357+
358+
359+
def test_from_yaml_array_hidden_omitted():
360+
# Arrange
361+
cc = CustomChapters()
362+
# Act
363+
cc.from_yaml_array([{"title": "Default Chapter", "labels": "bug"}])
364+
# Assert
365+
assert "Default Chapter" in cc.chapters
366+
assert cc.chapters["Default Chapter"].hidden is False
367+
368+
369+
@pytest.mark.parametrize(
370+
"hidden_value, expected_hidden, should_warn",
371+
[
372+
pytest.param("true", True, False, id="string-true-lowercase"),
373+
pytest.param("True", True, False, id="string-true-capitalized"),
374+
pytest.param("false", False, False, id="string-false-lowercase"),
375+
pytest.param("False", False, False, id="string-false-capitalized"),
376+
pytest.param("invalid", False, True, id="invalid-string"),
377+
pytest.param(123, False, True, id="integer-type"),
378+
pytest.param([], False, True, id="list-type"),
379+
],
380+
)
381+
def test_from_yaml_array_hidden_validation(hidden_value, expected_hidden, should_warn, caplog):
382+
# Arrange
383+
caplog.set_level("WARNING", logger="release_notes_generator.chapters.custom_chapters")
384+
cc = CustomChapters()
385+
# Act
386+
cc.from_yaml_array([{"title": "Test Chapter", "labels": "bug", "hidden": hidden_value}])
387+
# Assert
388+
assert "Test Chapter" in cc.chapters
389+
assert cc.chapters["Test Chapter"].hidden is expected_hidden
390+
if should_warn:
391+
assert any("invalid 'hidden' value" in r.message.lower() for r in caplog.records)
392+
else:
393+
assert not any("invalid 'hidden' value" in r.message.lower() for r in caplog.records)
394+
395+
396+
def test_from_yaml_array_multi_label_with_hidden():
397+
# Arrange
398+
cc = CustomChapters()
399+
# Act
400+
cc.from_yaml_array([{"title": "Multi", "labels": ["bug", "enhancement"], "hidden": True}])
401+
# Assert
402+
assert "Multi" in cc.chapters
403+
assert cc.chapters["Multi"].labels == ["bug", "enhancement"]
404+
assert cc.chapters["Multi"].hidden is True
405+
406+
407+
def test_from_yaml_array_legacy_single_label_with_hidden():
408+
# Arrange
409+
cc = CustomChapters()
410+
# Act
411+
cc.from_yaml_array([{"title": "Legacy", "label": "bug", "hidden": True}])
412+
# Assert
413+
assert "Legacy" in cc.chapters
414+
assert cc.chapters["Legacy"].labels == ["bug"]
415+
assert cc.chapters["Legacy"].hidden is True
416+
417+
418+
def test_populate_hidden_chapter_assigns_records(record_stub):
419+
# Arrange
420+
cc = CustomChapters()
421+
cc.from_yaml_array([{"title": "Hidden Bugs", "labels": "bug", "hidden": True}])
422+
record = record_stub("org/repo#1", ["bug"])
423+
records = {"org/repo#1": record}
424+
# Act
425+
cc.populate(records)
426+
# Assert - record is assigned to hidden chapter
427+
assert "org/repo#1" in cc.chapters["Hidden Bugs"].rows
428+
429+
430+
def test_populate_hidden_chapter_no_duplicity_count(record_stub, mocker):
431+
# Arrange
432+
cc = CustomChapters()
433+
cc.from_yaml_array([{"title": "Hidden", "labels": "bug", "hidden": True}])
434+
record = record_stub("org/repo#1", ["bug"])
435+
records = {"org/repo#1": record}
436+
# Mock to_chapter_row to track calls
437+
original_to_chapter_row = record.to_chapter_row
438+
mock_to_chapter_row = mocker.Mock(side_effect=original_to_chapter_row)
439+
record.to_chapter_row = mock_to_chapter_row
440+
# Act
441+
cc.populate(records)
442+
# Assert - to_chapter_row called with add_into_chapters=False for hidden chapter
443+
mock_to_chapter_row.assert_called_once_with(False)
444+
445+
446+
def test_populate_visible_chapter_duplicity_count(record_stub, mocker):
447+
# Arrange
448+
cc = CustomChapters()
449+
cc.from_yaml_array([{"title": "Visible", "labels": "bug", "hidden": False}])
450+
record = record_stub("org/repo#1", ["bug"])
451+
records = {"org/repo#1": record}
452+
# Mock to_chapter_row to track calls
453+
original_to_chapter_row = record.to_chapter_row
454+
mock_to_chapter_row = mocker.Mock(side_effect=original_to_chapter_row)
455+
record.to_chapter_row = mock_to_chapter_row
456+
# Act
457+
cc.populate(records)
458+
# Assert - to_chapter_row called with add_into_chapters=True for visible chapter
459+
mock_to_chapter_row.assert_called_once_with(True)
460+
461+
462+
def test_populate_mixed_visible_hidden_duplicity(record_stub):
463+
# Arrange
464+
cc = CustomChapters()
465+
cc.from_yaml_array([
466+
{"title": "Visible1", "labels": "bug", "hidden": False},
467+
{"title": "Hidden1", "labels": "bug", "hidden": True},
468+
{"title": "Visible2", "labels": "bug", "hidden": False},
469+
])
470+
record = record_stub("org/repo#1", ["bug"])
471+
records = {"org/repo#1": record}
472+
# Act
473+
cc.populate(records)
474+
# Assert - record appears in all chapters but only counted in visible ones
475+
assert "org/repo#1" in cc.chapters["Visible1"].rows
476+
assert "org/repo#1" in cc.chapters["Hidden1"].rows
477+
assert "org/repo#1" in cc.chapters["Visible2"].rows
478+
# Record should be marked as present in 2 chapters (only visible ones)
479+
assert record.chapter_presence_count() == 2
480+
481+
482+
def test_to_string_hidden_chapter_excluded():
483+
# Arrange
484+
cc = CustomChapters()
485+
cc.from_yaml_array([
486+
{"title": "Visible", "labels": "bug"},
487+
{"title": "Hidden", "labels": "feature", "hidden": True},
488+
])
489+
cc.chapters["Visible"].add_row(1, "Bug fix")
490+
cc.chapters["Hidden"].add_row(2, "Hidden feature")
491+
# Act
492+
result = cc.to_string()
493+
# Assert
494+
assert "Visible" in result
495+
assert "Bug fix" in result
496+
assert "Hidden" not in result
497+
assert "Hidden feature" not in result
498+
499+
500+
def test_to_string_all_hidden_returns_empty():
501+
# Arrange
502+
cc = CustomChapters()
503+
cc.from_yaml_array([
504+
{"title": "Hidden1", "labels": "bug", "hidden": True},
505+
{"title": "Hidden2", "labels": "feature", "hidden": True},
506+
])
507+
cc.chapters["Hidden1"].add_row(1, "Bug fix")
508+
cc.chapters["Hidden2"].add_row(2, "Feature")
509+
# Act
510+
result = cc.to_string()
511+
# Assert
512+
assert result == ""
513+
514+
515+
def test_to_string_hidden_empty_chapter_not_shown():
516+
# Arrange
517+
cc = CustomChapters()
518+
cc.print_empty_chapters = True
519+
cc.from_yaml_array([{"title": "Hidden Empty", "labels": "bug", "hidden": True}])
520+
# Act
521+
result = cc.to_string()
522+
# Assert - hidden chapters never shown, even when print_empty_chapters is True
523+
assert result == ""
524+
525+
526+
def test_to_string_debug_logging_for_hidden(caplog):
527+
# Arrange
528+
caplog.set_level("DEBUG")
529+
cc = CustomChapters()
530+
cc.from_yaml_array([{"title": "Hidden", "labels": "bug", "hidden": True}])
531+
cc.chapters["Hidden"].add_row(1, "Bug fix")
532+
# Mock verbose mode
533+
import release_notes_generator.action_inputs
534+
original_get_verbose = release_notes_generator.action_inputs.ActionInputs.get_verbose
535+
release_notes_generator.action_inputs.ActionInputs.get_verbose = staticmethod(lambda: True)
536+
# Act
537+
try:
538+
cc.to_string()
539+
# Assert
540+
assert any("Skipping hidden chapter" in r.message for r in caplog.records)
541+
assert any("Hidden" in r.message and "1 records tracked" in r.message for r in caplog.records)
542+
finally:
543+
# Restore
544+
release_notes_generator.action_inputs.ActionInputs.get_verbose = original_get_verbose
545+
546+
547+
def test_populate_debug_logging_for_hidden_assignment(record_stub, caplog):
548+
# Arrange
549+
caplog.set_level("DEBUG")
550+
cc = CustomChapters()
551+
cc.from_yaml_array([{"title": "Hidden", "labels": "bug", "hidden": True}])
552+
record = record_stub("org/repo#1", ["bug"])
553+
records = {"org/repo#1": record}
554+
# Mock verbose mode
555+
import release_notes_generator.action_inputs
556+
original_get_verbose = release_notes_generator.action_inputs.ActionInputs.get_verbose
557+
release_notes_generator.action_inputs.ActionInputs.get_verbose = staticmethod(lambda: True)
558+
# Act
559+
try:
560+
cc.populate(records)
561+
# Assert
562+
assert any("assigned to hidden chapter" in r.message.lower() for r in caplog.records)
563+
assert any("not counted for duplicity" in r.message.lower() for r in caplog.records)
564+
finally:
565+
# Restore
566+
release_notes_generator.action_inputs.ActionInputs.get_verbose = original_get_verbose
567+
568+
569+
def test_hidden_chapter_info_logging(caplog):
570+
# Arrange
571+
caplog.set_level("INFO")
572+
cc = CustomChapters()
573+
# Act
574+
cc.from_yaml_array([{"title": "Hidden", "labels": "bug", "hidden": True}])
575+
# Assert
576+
assert any(
577+
"marked as hidden" in r.message.lower() and "will not appear in output" in r.message.lower()
578+
for r in caplog.records
579+
)
580+
581+
582+
def test_backward_compatibility_no_hidden_field():
583+
# Arrange - test that chapters without hidden field work as before
584+
cc = CustomChapters()
585+
# Act
586+
cc.from_yaml_array([
587+
{"title": "Breaking Changes 💥", "label": "breaking-change"},
588+
{"title": "New Features 🎉", "labels": ["enhancement", "feature"]},
589+
])
590+
# Assert
591+
assert "Breaking Changes 💥" in cc.chapters
592+
assert "New Features 🎉" in cc.chapters
593+
assert cc.chapters["Breaking Changes 💥"].hidden is False
594+
assert cc.chapters["New Features 🎉"].hidden is False
595+

0 commit comments

Comments
 (0)