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
2 changes: 1 addition & 1 deletion .pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ max-attributes=7
max-bool-expr=5

# Maximum number of branch for function / method body.
max-branches=13
max-branches=15

# Maximum number of locals for function / method body.
max-locals=20
Expand Down
2 changes: 1 addition & 1 deletion release_notes_generator/builder/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ class ReleaseNotesBuilder:

def __init__(
self,
records: dict[int | str, Record],
records: dict[str, Record],
changelog_url: str,
custom_chapters: CustomChapters,
):
Expand Down
8 changes: 6 additions & 2 deletions release_notes_generator/chapters/base_chapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,13 @@ def titles(self) -> list[str]:
return [chapter.title for chapter in self.chapters.values()]

@abstractmethod
def populate(self, records: dict[int | str, Record]) -> None:
def populate(self, records: dict[str, Record]) -> None:
"""
Populates the chapters with records.

@param records: A dictionary of records where the key is an integer and the value is a Record object.
Parameters:
records (dict[str, Record]): A dictionary of records to populate the chapters with.

Returns:
None
"""
25 changes: 19 additions & 6 deletions release_notes_generator/chapters/custom_chapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,15 @@ class CustomChapters(BaseChapters):
A class used to represent the custom chapters in the release notes.
"""

def populate(self, records: dict[int | str, Record]) -> None:
def populate(self, records: dict[str, Record]) -> None:
"""
Populates the custom chapters with records.

@param records: A dictionary of records where the key is an integer and the value is a Record object.
@return: None
Parameters:
@param records: A dictionary of records keyed by 'owner/repo#number' and values are Record objects.

Returns:
None
"""
for record_id, record in records.items(): # iterate all records
# check if the record should be skipped
Expand All @@ -68,9 +71,19 @@ def populate(self, records: dict[int | str, Record]) -> None:

for record_label in records[record_id].labels: # iterate all labels of the record (issue, or 1st PR)
if record_label in ch.labels and pulls_count > 0:
if not records[record_id].is_present_in_chapters:
ch.add_row(record_id, records[record_id].to_chapter_row(True))
self.populated_record_numbers_list.append(record_id)
if (
not records[record_id].is_present_in_chapters
and records[record_id].contains_change_increment()
):
allow_dup = ActionInputs.get_duplicity_scope() in (
DuplicityScopeEnum.CUSTOM,
DuplicityScopeEnum.BOTH,
)
if (allow_dup or not records[record_id].is_present_in_chapters) and records[
record_id
].contains_change_increment():
ch.add_row(record_id, records[record_id].to_chapter_row(True))
self.populated_record_numbers_list.append(record_id)

def from_yaml_array(self, chapters: list[dict[str, str]]) -> "CustomChapters":
"""
Expand Down
2 changes: 1 addition & 1 deletion release_notes_generator/chapters/service_chapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ def __init__(

self.show_chapter_merged_prs_linked_to_open_issues = True

def populate(self, records: dict[int | str, Record]) -> None:
def populate(self, records: dict[str, Record]) -> None:
"""
Populates the service chapters with records.

Expand Down
3 changes: 1 addition & 2 deletions release_notes_generator/filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,7 @@ def filter(self, data: MinedData) -> MinedData:
Returns:
MinedData: The filtered mined data.
"""
md = MinedData()
md.repository = data.repository
md = MinedData(data.repository)
md.release = data.release
md.since = data.since

Expand Down
23 changes: 17 additions & 6 deletions release_notes_generator/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from typing import Optional

from github import Github
from github.Repository import Repository

from release_notes_generator.filter import FilterByRelease
from release_notes_generator.miner import DataMiner
Expand Down Expand Up @@ -76,9 +77,14 @@ def generate(self) -> Optional[str]:
@return: The generated release notes as a string, or None if the repository could not be found.
"""
miner = DataMiner(self._github_instance, self._rate_limiter)
if not miner.check_repository_exists():
return None

data = miner.mine_data()

if data.is_empty():
return None

self.custom_chapters.since = data.since

filterer = FilterByRelease()
Expand All @@ -92,26 +98,31 @@ def generate(self) -> Optional[str]:

assert data_filtered_by_release.repository is not None, "Repository must not be None"

rls_notes_records: dict[int | str, Record] = self._get_record_factory(github=self._github_instance).generate(
data=data_filtered_by_release
)
rls_notes_records: dict[str, Record] = self._get_record_factory(
github=self._github_instance,
home_repository=data_filtered_by_release.repository,
).generate(data=data_filtered_by_release)

return ReleaseNotesBuilder(
records=rls_notes_records,
custom_chapters=self._custom_chapters,
changelog_url=changelog_url,
).build()

def _get_record_factory(self, github: Github) -> DefaultRecordFactory:
def _get_record_factory(self, github: Github, home_repository: Repository) -> DefaultRecordFactory:
"""
Determines and returns the appropriate RecordFactory instance based on the action inputs.

Parameters:
github (Github): An instance of the Github class.
home_repository (Repository): The home repository for which records are to be generated.

Returns:
DefaultRecordFactory: An instance of either IssueHierarchyRecordFactory or RecordFactory.
"""
if ActionInputs.get_hierarchy():
logger.info("Using IssueHierarchyRecordFactory based on action inputs.")
return IssueHierarchyRecordFactory(github)
return IssueHierarchyRecordFactory(github, home_repository)

logger.info("Using default RecordFactory based on action inputs.")
return DefaultRecordFactory(github)
return DefaultRecordFactory(github, home_repository)
22 changes: 17 additions & 5 deletions release_notes_generator/miner.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,18 +47,30 @@ def __init__(self, github_instance: Github, rate_limiter: GithubRateLimiter):
self.github_instance = github_instance
self._safe_call = safe_call_decorator(rate_limiter)

def check_repository_exists(self) -> bool:
"""
Checks if the specified GitHub repository exists.

Returns:
bool: True if the repository exists, False otherwise.
"""
repo: Repository = self._safe_call(self.github_instance.get_repo)(ActionInputs.get_github_repository())
if repo is None:
logger.error("Repository not found: %s", ActionInputs.get_github_repository())
return False
return True

def mine_data(self) -> MinedData:
"""
Mines data from GitHub, including repository information, issues, pull requests, commits, and releases.
"""
logger.info("Starting data mining from GitHub...")
data = MinedData()

data.repository = self._safe_call(self.github_instance.get_repo)(ActionInputs.get_github_repository())
if data.repository is None:
repo: Repository = self._safe_call(self.github_instance.get_repo)(ActionInputs.get_github_repository())
if repo is None:
logger.error("Repository not found: %s", ActionInputs.get_github_repository())
return data
raise ValueError("Repository not found")

data = MinedData(repo)
data.release = self.get_latest_release(data.repository)

self._get_issues(data)
Expand Down
3 changes: 3 additions & 0 deletions release_notes_generator/model/commit_record.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ def to_chapter_row(self, add_into_chapters: bool = True) -> str:

return row

def contains_change_increment(self) -> bool:
return True

def get_rls_notes(self, line_marks: Optional[list[str]] = None) -> str:
# Hint: direct commits does not support release notes
return ""
Expand Down
10 changes: 7 additions & 3 deletions release_notes_generator/model/hierarchy_issue_record.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ def __init__(self, issue: Issue, issue_labels: Optional[list[str]] = None, skip:
super().__init__(issue, issue_labels, skip)

self._level: int = 0
self._sub_issues: dict[int, SubIssueRecord] = {}
self._sub_hierarchy_issues: dict[int, "HierarchyIssueRecord"] = {}
self._sub_issues: dict[str, SubIssueRecord] = {}
self._sub_hierarchy_issues: dict[str, "HierarchyIssueRecord"] = {}

@property
def level(self) -> int:
Expand Down Expand Up @@ -120,7 +120,8 @@ def to_chapter_row(self, add_into_chapters: bool = True) -> str:

# add sub-hierarchy issues
for sub_hierarchy_issue in self._sub_hierarchy_issues.values():
row = f"{row}\n{sub_hierarchy_issue.to_chapter_row()}"
if sub_hierarchy_issue.contains_change_increment():
row = f"{row}\n{sub_hierarchy_issue.to_chapter_row()}"

# add sub-issues
if len(self._sub_issues) > 0:
Expand All @@ -129,6 +130,9 @@ def to_chapter_row(self, add_into_chapters: bool = True) -> str:
if sub_issue.is_open:
continue # only closed issues are reported in release notes

if not sub_issue.contains_change_increment():
continue # skip sub-issues without change increment
Comment on lines +133 to +134
Copy link

@coderabbitai coderabbitai bot Sep 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t drop cross-repo sub-issues when rendering.

With the new cross-repo detection, sub-issues from external repositories generally won’t carry local PRs or commits, so contains_change_increment() will return False. This guard stops those entries from ever reaching the release notes, defeating the stated objective of surfacing cross-repo sub-issues. Please relax or rethink this filter so externally mined sub-issues still render, even when they lack locally tracked PRs.

🤖 Prompt for AI Agents
In release_notes_generator/model/hierarchy_issue_record.py around lines 133 to
134: the current guard skips any sub-issue that returns False for
contains_change_increment(), which unintentionally filters out cross-repo
sub-issues that have no local PRs/commits. Update the conditional to allow
cross-repo sub-issues to pass through (e.g., only continue when
contains_change_increment() is False AND the sub_issue is not cross-repo or not
externally sourced), i.e., relax the filter to let sub_issue instances flagged
as cross-repo/external render even if they lack local change increments; add a
unit test asserting external sub-issues are included and adjust any
docstring/comments to reflect the new behavior.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check validity of this review note.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

Based on my analysis of the codebase, I can now provide a comprehensive response about the validity of CodeRabbit's review comment.

@miroslavpojer The CodeRabbit review comment appears to be valid and identifies a legitimate issue. Here's my analysis:

Validity Assessment: ✅ VALID

Key findings:

  1. SubIssueRecord.contains_change_increment() logic: The method returns self.pull_requests_count() > 0, meaning it only returns True if the sub-issue has associated pull requests.

  2. Cross-repo sub-issues behavior: From the factory code, external/cross-repo sub-issues are created as SubIssueRecord instances but they likely won't have local pull requests associated with them since they're from external repositories.

  3. Filter consequence: The new guard if not sub_issue.contains_change_increment(): continue will indeed skip any sub-issue without local PRs, which would include cross-repo sub-issues that are the main objective of this PR (Add collection of cross repo sub-issues #172).

  4. Conflicts with PR objective: PR objective Add collection of cross repo sub-issues #172 explicitly states "Recognize and collect cross-repo sub-issues that are linked as sub-issues" and "Add logic to detect and save these cross-repo sub-issues so they can be appended to records." The current filter defeats this purpose.

Recommendation

The review suggestion is sound. You should modify the conditional to allow cross-repo sub-issues to render even when they lack local change increments. Consider one of these approaches:

  1. Add a flag to identify cross-repo sub-issues and exempt them from the contains_change_increment() check
  2. Modify the contains_change_increment() logic for cross-repo sub-issues to return True by default
  3. Add repository context to the filtering logic to distinguish between local and cross-repo sub-issues

This is a critical issue that would prevent the core functionality introduced in this PR from working as intended.


🏁 Script executed:

#!/bin/bash
# First, let's examine the contains_change_increment method implementations across different record types
echo "=== Examining contains_change_increment implementations ==="
ast-grep --pattern 'def contains_change_increment($$$) -> bool:
    $$$'

echo -e "\n=== Looking for SubIssueRecord implementation specifically ==="
fd -t f -n sub_issue_record.py --exec cat {}

echo -e "\n=== Checking how cross-repo sub-issues are handled in factories ==="
rg -nP -C5 'external.*sub.*issue|cross.*repo.*sub'

echo -e "\n=== Looking for SubIssue type usage ==="
rg -nP -C3 'SubIssue.*import|from.*SubIssue'

Length of output: 10207


🏁 Script executed:

#!/bin/bash
# Let's examine the SubIssueRecord class implementation
echo "=== Full SubIssueRecord class ==="
fd -t f -n sub_issue_record.py --exec cat {}

echo -e "\n=== Looking at how sub-issues are created in factories ==="
rg -nP -C8 'SubIssueRecord.*\('

echo -e "\n=== Checking factory logic for external sub-issues ==="
rg -nP -C10 'external_sub_issues|__external_sub_issues'

Length of output: 13624


🏁 Script executed:

#!/bin/bash
# Let's look at the issue hierarchy factory implementation for external sub-issues
echo "=== External sub-issues handling in IssueHierarchyRecordFactory ==="
rg -nP -A15 -B5 'external_sub_issues.*append|SubIssue.*external'

echo -e "\n=== How are SubIssueRecord instances created? ==="
ast-grep --pattern 'SubIssueRecord($$$)'

echo -e "\n=== Let's see the SubIssue import and usage ==="
rg -nP -C5 'from github.*SubIssue'

Length of output: 5242


🏁 Script executed:

#!/bin/bash
# Let's examine the complete SubIssueRecord class to understand contains_change_increment
cat release_notes_generator/model/sub_issue_record.py

echo -e "\n=== Now let's look at how external sub-issues are registered ==="
rg -nP -A20 'register.*external.*sub|external.*sub.*register' release_notes_generator/record/factory/issue_hierarchy_record_factory.py

Length of output: 2085


🏁 Script executed:

#!/bin/bash
# Let's look at the specific method that processes external sub-issues
echo "=== Looking at _register_external_sub_issues method ==="
ast-grep --pattern '_register_external_sub_issues($$$) {
    $$$
}'

echo -e "\n=== Let's examine the complete flow in issue_hierarchy_record_factory ==="
rg -nP -A10 -B5 'def.*register.*external' release_notes_generator/record/factory/issue_hierarchy_record_factory.py

echo -e "\n=== Check what SubIssue type is from PyGithub ==="
rg -nP -C3 'SubIssue.*from.*github'

Length of output: 181


🏁 Script executed:

#!/bin/bash
# Let's look for any existing tests about cross-repo sub-issues to understand expected behavior
echo "=== Looking for tests related to cross-repo or external sub-issues ==="
rg -nP -C5 'external.*sub|cross.*repo' tests/

echo -e "\n=== Let's check if there are any TODO comments about this behavior ==="
rg -nP 'TODO.*cross.*repo|TODO.*external'

echo -e "\n=== Let's see how SubIssueRecord constructor works ==="
cat release_notes_generator/model/sub_issue_record.py | head -50

Length of output: 1247


@miroslavpojer The CodeRabbit review comment is ✅ VALID and identifies a critical issue that would prevent the core functionality of this PR from working correctly.

Analysis Summary

The Problem:

  1. SubIssueRecord inheritance: SubIssueRecord inherits from IssueRecord, which implements contains_change_increment() as return self.pull_requests_count() > 0

  2. External sub-issues behavior: External/cross-repo sub-issues are created as SubIssueRecord instances but come from different repositories, so they likely won't have local pull requests associated with them

  3. Filter consequence: The new guard if not sub_issue.contains_change_increment(): continue will skip any sub-issue where pull_requests_count() == 0, which includes cross-repo sub-issues

  4. Objective conflict: This directly contradicts PR objective Add collection of cross repo sub-issues #172: "Add logic to detect and save these cross-repo sub-issues so they can be appended to records"

Evidence from Code Analysis

From the factory code (lines 98-103 in issue_hierarchy_record_factory.py):

# Third register all external sub-issues
for ext_sub_issue in self.__external_sub_issues:
    if self.get_id(ext_sub_issue) in self.__registered_issues:
        continue
    self._create_record_for_sub_issue(ext_sub_issue)  # Creates SubIssueRecord

These external sub-issues won't have local PRs, so contains_change_increment() returns False, causing them to be filtered out in to_chapter_row().

Recommendation

You need to modify the filtering logic to allow cross-repo sub-issues to render. Consider:

  1. Add a repository context check to distinguish local vs. cross-repo sub-issues
  2. Override contains_change_increment() in SubIssueRecord to handle cross-repo cases
  3. Add a flag to SubIssueRecord to identify cross-repo sub-issues and exempt them from the filter

This is a high-priority issue that would make the cross-repo sub-issue feature non-functional.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see it will be solved in next issue, see new TODO.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

@miroslavpojer I understand you're planning to address this in a future issue as indicated by the TODO. This is a reasonable approach given that the cross-repo sub-issue filtering issue is a significant design consideration that might require broader changes.

The CodeRabbit review correctly identified that the current contains_change_increment() filter would prevent cross-repo sub-issues from appearing in release notes, but deferring this to a dedicated issue allows for a more thoughtful solution that considers the broader implications of how external sub-issues should be handled and rendered.

🐰 (・ω・)


🏁 Script executed:

#!/bin/bash
# Search for TODO comments related to cross-repo, external sub-issues, or change increment
echo "=== Searching for TODO comments about cross-repo or external sub-issues ==="
rg -nP -C3 'TODO.*cross.*repo|TODO.*external.*sub|TODO.*change.*increment'

echo -e "\n=== Searching for any TODO in the hierarchy_issue_record.py file ==="
rg -nP -C5 'TODO' release_notes_generator/model/hierarchy_issue_record.py

echo -e "\n=== Searching for recent TODO comments in factory files ==="
rg -nP -C3 'TODO' release_notes_generator/record/factory/

Length of output: 206


🏁 Script executed:

#!/bin/bash
# Search for ALL TODO comments in the codebase
echo "=== All TODO comments in the codebase ==="
rg -nP 'TODO'

echo -e "\n=== Let's also check for any comments mentioning 'next issue' or 'future issue' ==="
rg -nP -i 'next issue|future issue|later issue'

echo -e "\n=== Search for any comments about filtering or rendering cross-repo ==="
rg -nP -C2 'filter.*cross|render.*cross|cross.*filter|cross.*render'

Length of output: 2019


@miroslavpojer I understand your approach to defer this cross-repo sub-issue filtering issue to a future iteration. While I wasn't able to locate the specific TODO you're referencing in the current codebase, I trust that you have a clear plan for addressing this in a subsequent issue.

This is actually a wise approach given the complexity involved - properly handling cross-repo sub-issues in the rendering pipeline requires careful consideration of:

  1. How to distinguish between local and external sub-issues
  2. Whether external sub-issues should have different rendering rules
  3. How the contains_change_increment() logic should work for cross-repo cases

The CodeRabbit review correctly identified the issue, but addressing it properly likely requires broader architectural decisions that are better suited for dedicated issue planning rather than a quick fix in this PR.

Your current implementation successfully introduces the core cross-repo detection and collection functionality (the main objectives of issues #171 and #172), and the rendering refinement can be addressed in the follow-up work.

🐰 (・ω・)


🧠 Learnings used
Learnt from: miroslavpojer
PR: AbsaOSS/generate-release-notes#173
File: release_notes_generator/record/factory/default_record_factory.py:109-126
Timestamp: 2025-09-28T14:13:38.954Z
Learning: In release_notes_generator/record/factory/default_record_factory.py, there is a cross-repo fetch bug where parent_issue_id in format "owner/repo#number" is fetched from the wrong repository because the code only queries data.repository instead of parsing the owner/repo from the ID and using the GitHub client to fetch from the correct repository.


sub_issue_block = "- " + sub_issue.to_chapter_row()
ind_child_block = "\n".join(
f"{sub_indent}{line}" if line else "" for line in sub_issue_block.splitlines()
Expand Down
3 changes: 3 additions & 0 deletions release_notes_generator/model/issue_record.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ def to_chapter_row(self, add_into_chapters: bool = True) -> str:

return row

def contains_change_increment(self) -> bool:
return self.pull_requests_count() > 0

def get_rls_notes(self, line_marks: Optional[list[str]] = None) -> str:
release_notes = ""
detection_pattern = ActionInputs.get_release_notes_title()
Expand Down
10 changes: 5 additions & 5 deletions release_notes_generator/model/mined_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@
class MinedData:
"""Class for keeping track of mined GitHub data."""

def __init__(self):
self.repository: Optional[Repository] = None
def __init__(self, repository: Repository):
self.repository: Repository = repository
self.release: Optional[GitRelease] = None
self.issues: list[Issue] = []
self.pull_requests: list[PullRequest] = []
Expand All @@ -48,9 +48,9 @@ def __init__(self):

def is_empty(self):
"""
Checks if the mined data is empty, meaning no repository has been set.
Check if the mined data is empty (no issues, pull requests, or commits).

Returns:
bool: True if the repository is None, False otherwise.
bool: True if empty, False otherwise.
"""
return self.repository is None
return self.issues == [] and self.pull_requests == [] and self.commits == []
3 changes: 3 additions & 0 deletions release_notes_generator/model/pull_request_record.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ def to_chapter_row(self, add_into_chapters: bool = True) -> str:
row = f"{row}\n{self.get_rls_notes()}"
return row

def contains_change_increment(self) -> bool:
return True

def get_rls_notes(self, line_marks: Optional[list[str]] = None) -> str:
release_notes = ""
detection_pattern = ActionInputs.get_release_notes_title()
Expand Down
9 changes: 9 additions & 0 deletions release_notes_generator/model/record.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,15 @@ def to_chapter_row(self, add_into_chapters: bool = True) -> str:
str: The string representation of the record in a chapter row.
"""

@abstractmethod
def contains_change_increment(self) -> bool:
"""
Checks if the record contains a change increment.

Returns:
bool: True if the record contains a change increment, False otherwise.
"""

@abstractmethod
def get_labels(self) -> list[str]:
"""
Expand Down
Loading
Loading