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
8 changes: 5 additions & 3 deletions release_notes_generator/filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def filter(self, data: MinedData) -> MinedData:
Returns:
MinedData: The filtered mined data.
"""
md = MinedData(data.repository)
md = MinedData(data.home_repository)
md.release = data.release
md.since = data.since

Expand Down Expand Up @@ -123,7 +123,8 @@ def _filter_issues(self, data: MinedData) -> list:
logger.debug("Used default issue filtering logic.")
return self._filter_issues_default(data)

def _filter_issues_default(self, data: MinedData) -> list:
@staticmethod
def _filter_issues_default(data: MinedData) -> list:
"""
Default filtering for issues: filter out closed issues before the release date.

Expand All @@ -135,7 +136,8 @@ def _filter_issues_default(self, data: MinedData) -> list:
"""
return [issue for issue in data.issues if (issue.closed_at is None) or (issue.closed_at >= data.since)]

def _filter_issues_issue_hierarchy(self, data: MinedData) -> list:
@staticmethod
def _filter_issues_issue_hierarchy(data: MinedData) -> list:
"""
Hierarchy filtering for issues: include issues closed since the release date
or still open at generation time.
Expand Down
12 changes: 6 additions & 6 deletions release_notes_generator/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,15 +92,14 @@ def generate(self) -> Optional[str]:

changelog_url: str = get_change_url(
tag_name=ActionInputs.get_tag_name(),
repository=data_filtered_by_release.repository,
repository=data_filtered_by_release.home_repository,
git_release=data_filtered_by_release.release,
)

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

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

return ReleaseNotesBuilder(
Expand All @@ -109,12 +108,13 @@ def generate(self) -> Optional[str]:
changelog_url=changelog_url,
).build()

def _get_record_factory(self, github: Github, home_repository: Repository) -> DefaultRecordFactory:
@staticmethod
def _get_record_factory(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.
github (GitHub): An instance of the GitHub class.
home_repository (Repository): The home repository for which records are to be generated.

Returns:
Expand Down
36 changes: 25 additions & 11 deletions release_notes_generator/miner.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,24 +60,36 @@ def check_repository_exists(self) -> bool:
return False
return True

def get_repository(self, full_name: str) -> Optional[Repository]:
"""
Retrieves the specified GitHub repository.

Returns:
Optional[Repository]: The GitHub repository if found, None otherwise.
"""
repo: Optional[Repository] = self._safe_call(self.github_instance.get_repo)(full_name)
if repo is None:
logger.error("Repository not found: %s", full_name)
return None
return repo

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...")
repo: Repository = self._safe_call(self.github_instance.get_repo)(ActionInputs.get_github_repository())
repo: Optional[Repository] = self.get_repository(ActionInputs.get_github_repository())
if repo is None:
logger.error("Repository not found: %s", ActionInputs.get_github_repository())
raise ValueError("Repository not found")

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

self._get_issues(data)

# pulls and commits, and then reduce them by the latest release since time
data.pull_requests = list(self._safe_call(data.repository.get_pulls)(state=PullRequestRecord.PR_STATE_CLOSED))
data.commits = list(self._safe_call(data.repository.get_commits)())
data.pull_requests = list(self._safe_call(repo.get_pulls)(state=PullRequestRecord.PR_STATE_CLOSED))
data.commits = list(self._safe_call(repo.get_commits)())

logger.info("Data mining from GitHub completed.")

Expand Down Expand Up @@ -136,11 +148,11 @@ def _get_issues(self, data: MinedData):
- If release exists: fetch issues updated since the release timestamp AND all currently open issues
(to include long-lived open issues not updated recently). De-duplicate by issue.number.
"""
assert data.repository is not None, "Repository must not be None"
assert data.home_repository is not None, "Repository must not be None"
logger.info("Fetching issues from repository...")

if data.release is None:
data.issues = list(self._safe_call(data.repository.get_issues)(state=IssueRecord.ISSUE_STATE_ALL))
data.issues = list(self._safe_call(data.home_repository.get_issues)(state=IssueRecord.ISSUE_STATE_ALL))
logger.info("Fetched %d issues", len(data.issues))
return

Expand All @@ -151,11 +163,11 @@ def _get_issues(self, data: MinedData):
if prefer_published and getattr(data.release, "published_at", None)
else data.release.created_at
)
issues_since = self._safe_call(data.repository.get_issues)(
issues_since = self._safe_call(data.home_repository.get_issues)(
state=IssueRecord.ISSUE_STATE_ALL,
since=data.since,
)
open_issues = self._safe_call(data.repository.get_issues)(
open_issues = self._safe_call(data.home_repository.get_issues)(
state=IssueRecord.ISSUE_STATE_OPEN,
)

Expand All @@ -175,7 +187,8 @@ def _get_issues(self, data: MinedData):
data.issues = list(by_number.values())
logger.info("Fetched %d issues (deduplicated).", len(data.issues))

def __get_latest_semantic_release(self, releases) -> Optional[GitRelease]:
@staticmethod
def __get_latest_semantic_release(releases) -> Optional[GitRelease]:
published_releases = [release for release in releases if not release.draft and not release.prerelease]
latest_version: Optional[semver.Version] = None
rls: Optional[GitRelease] = None
Expand All @@ -199,7 +212,8 @@ def __get_latest_semantic_release(self, releases) -> Optional[GitRelease]:

return rls

def __filter_duplicated_issues(self, data: MinedData) -> "MinedData":
@staticmethod
def __filter_duplicated_issues(data: MinedData) -> "MinedData":
"""
Filters out duplicated issues from the list of issues.
This method address problem in output of GitHub API where issues list contains PR values.
Expand Down
15 changes: 13 additions & 2 deletions release_notes_generator/model/hierarchy_issue_record.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,16 @@ def pull_requests_count(self) -> int:
count = super().pull_requests_count()

for sub_issue in self._sub_issues.values():
count += sub_issue.pull_requests_count()
if sub_issue.is_cross_repo:
count += 1
else:
count += sub_issue.pull_requests_count()

for sub_hierarchy_issue in self._sub_hierarchy_issues.values():
count += sub_hierarchy_issue.pull_requests_count()
if sub_hierarchy_issue.is_cross_repo:
count += 1
else:
count += sub_hierarchy_issue.pull_requests_count()

return count

Expand All @@ -85,6 +91,7 @@ def get_labels(self) -> list[str]:

# methods - override ancestor methods
def to_chapter_row(self, add_into_chapters: bool = True) -> str:
logger.debug("Rendering hierarchy issue row for issue #%s", self.issue.number)
if add_into_chapters:
self.added_into_chapters()
row_prefix = f"{ActionInputs.get_duplicity_icon()} " if self.present_in_chapters() > 1 else ""
Expand Down Expand Up @@ -120,19 +127,23 @@ 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():
logger.debug("Rendering hierarchy issue row for sub-issue #%s", sub_hierarchy_issue.issue.number)
if sub_hierarchy_issue.contains_change_increment():
logger.debug("Sub-hierarchy issue #%s contains change increment", sub_hierarchy_issue.issue.number)
row = f"{row}\n{sub_hierarchy_issue.to_chapter_row()}"

# add sub-issues
if len(self._sub_issues) > 0:
sub_indent = " " * (self._level + 1)
for sub_issue in self._sub_issues.values():
logger.debug("Rendering sub-issue row for issue #%d", sub_issue.issue.number)
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

logger.debug("Sub-issue #%s contains change increment", sub_issue.issue.number)
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 @@ -123,6 +123,9 @@ def to_chapter_row(self, add_into_chapters: bool = True) -> str:
return row

def contains_change_increment(self) -> bool:
if self.is_cross_repo:
return True

return self.pull_requests_count() > 0

def get_rls_notes(self, line_marks: Optional[list[str]] = None) -> str:
Expand Down
20 changes: 19 additions & 1 deletion release_notes_generator/model/mined_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,31 @@ class MinedData:
"""Class for keeping track of mined GitHub data."""

def __init__(self, repository: Repository):
self.repository: Repository = repository
self._home_repository_full_name: str = repository.full_name
self._repositories: dict[str, Repository] = {repository.full_name: repository}
self.release: Optional[GitRelease] = None
self.issues: list[Issue] = []
self.pull_requests: list[PullRequest] = []
self.commits: list[Commit] = []
self.since = datetime(1970, 1, 1) # Default to epoch start

@property
def home_repository(self) -> Repository:
"""Get the home repository."""
return self._repositories[self._home_repository_full_name]

def add_repository(self, repository: Repository) -> None:
"""Add a repository to the mined data if not already present."""
if repository.full_name not in self._repositories:
self._repositories[repository.full_name] = repository
logger.debug(f"Added repository {repository.full_name} to mined data.")

def get_repository(self, full_name: str) -> Optional[Repository]:
if full_name not in self._repositories:
return None

return self._repositories[full_name]

def is_empty(self):
"""
Check if the mined data is empty (no issues, pull requests, or commits).
Expand Down
19 changes: 19 additions & 0 deletions release_notes_generator/model/record.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class Record(metaclass=ABCMeta):
def __init__(self, labels: Optional[list[str]] = None, skip: bool = False):
self._present_in_chapters = 0
self._skip = skip
self._is_cross_repo: bool = False
self._is_release_note_detected: Optional[bool] = None
self._labels: Optional[list[str]] = labels
self._rls_notes: Optional[str] = None # single annotation here
Expand All @@ -50,6 +51,24 @@ def is_present_in_chapters(self) -> bool:
"""
return self._present_in_chapters > 0

@property
def is_cross_repo(self) -> bool:
"""
Checks if the record is a cross-repo record.
Returns:
bool: True if the record is a cross-repo record, False otherwise.
"""
return self._is_cross_repo

@is_cross_repo.setter
def is_cross_repo(self, value: bool) -> None:
"""
Sets the cross-repo status of the record.
Parameters:
value (bool): The cross-repo status to set.
"""
self._is_cross_repo = value

@property
def skip(self) -> bool:
"""Check if the record should be skipped during output generation process."""
Expand Down
10 changes: 0 additions & 10 deletions release_notes_generator/model/sub_issue_record.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,4 @@ def __init__(self, sub_issue: SubIssue | Issue, issue_labels: Optional[list[str]

# properties - override IssueRecord properties

@property
def issue(self) -> SubIssue:
"""
Gets the issue associated with the record.
Returns: The issue associated with the record.
"""
if not isinstance(self._issue, SubIssue):
raise TypeError("Expected SubIssue")
return self._issue

# properties - specific to IssueRecord
47 changes: 36 additions & 11 deletions release_notes_generator/record/factory/default_record_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class DefaultRecordFactory(RecordFactory):
"""

def __init__(self, github: Github, home_repository: Repository) -> None:
self._github = github
rate_limiter = GithubRateLimiter(github)
self._safe_call = safe_call_decorator(rate_limiter)
self._home_repository = home_repository
Expand Down Expand Up @@ -84,7 +85,20 @@ def _(self, pull_request: PullRequest) -> str:

@get_id.register
def _(self, commit: Commit) -> str:
return f"{commit.repository.full_name}@{commit.sha}"
return f"{commit.sha}"

def get_repository(self, full_name: str) -> Optional[Repository]:
"""
Retrieves the specified GitHub repository.

Returns:
Optional[Repository]: The GitHub repository if found, None otherwise.
"""
repo: Optional[Repository] = self._safe_call(self._github.get_repo)(full_name)
if repo is None:
logger.error("Repository not found: %s", full_name)
return None
return repo

def generate(self, data: MinedData) -> dict[str, Record]:
"""
Expand All @@ -96,10 +110,10 @@ def generate(self, data: MinedData) -> dict[str, Record]:
"""

def register_pull_request(pr: PullRequest, skip_rec: bool) -> None:
pid = self.get_id(pr)
pull_labels = [label.name for label in pr.get_labels()]
l_pid = self.get_id(pr)
l_pull_labels = [label.name for label in pr.get_labels()]
attached_any = False
detected_issues = extract_issue_numbers_from_body(pr, repository=data.repository)
detected_issues = extract_issue_numbers_from_body(pr, repository=data.home_repository)
logger.debug("Detected issues - from body: %s", detected_issues)
linked = self._safe_call(get_issues_for_pr)(pull_number=pr.number)
if linked:
Expand All @@ -116,10 +130,21 @@ def register_pull_request(pr: PullRequest, skip_rec: bool) -> None:
parent_issue_id,
)
# dev note: here we expect that PR links to an issue in the same repository !!!
parent_issue_number = int(parent_issue_id.split("#")[1])
parent_issue = (
self._safe_call(data.repository.get_issue)(parent_issue_number) if data.repository else None
)
pi_repo_name, pi_number_str = parent_issue_id.split("#", 1)
try:
pi_number = int(pi_number_str)
except ValueError:
logger.error("Invalid parent issue id: %s", parent_issue_id)
continue
parent_repository = data.get_repository(pi_repo_name) or self.get_repository(pi_repo_name)
if parent_repository is not None:
# cache for subsequent lookups
if data.get_repository(pi_repo_name) is None:
data.add_repository(parent_repository)
parent_issue = self._safe_call(parent_repository.get_issue)(pi_number)
else:
parent_issue = None

if parent_issue is not None:
self._create_record_for_issue(parent_issue)

Expand All @@ -136,8 +161,8 @@ def register_pull_request(pr: PullRequest, skip_rec: bool) -> None:
)

if not attached_any:
self._records[pid] = PullRequestRecord(pr, pull_labels, skip=skip_rec)
logger.debug("Created stand-alone PR record %s: %s (fallback)", pid, pr.title)
self._records[l_pid] = PullRequestRecord(pr, l_pull_labels, skip=skip_rec)
logger.debug("Created stand-alone PR record %s: %s (fallback)", l_pid, pr.title)

logger.debug("Registering issues to records...")
for issue in data.issues:
Expand All @@ -150,7 +175,7 @@ def register_pull_request(pr: PullRequest, skip_rec: bool) -> None:
skip_record: bool = any(item in pull_labels for item in ActionInputs.get_skip_release_notes_labels())

linked_from_api = self._safe_call(get_issues_for_pr)(pull_number=pull.number) or set()
linked_from_body = extract_issue_numbers_from_body(pull, data.repository)
linked_from_body = extract_issue_numbers_from_body(pull, data.home_repository)
if not linked_from_api and not linked_from_body:
self._records[pid] = PullRequestRecord(pull, pull_labels, skip=skip_record)
logger.debug("Created record for PR %s: %s", pid, pull.title)
Expand Down
Loading
Loading