diff --git a/release_notes_generator/data/miner.py b/release_notes_generator/data/miner.py index 584c27bf..98bdd2b0 100644 --- a/release_notes_generator/data/miner.py +++ b/release_notes_generator/data/miner.py @@ -27,6 +27,7 @@ from github import Github from github.GitRelease import GitRelease from github.Issue import Issue +from github.PullRequest import PullRequest from github.Repository import Repository from release_notes_generator.action_inputs import ActionInputs @@ -83,19 +84,25 @@ def mine_data(self) -> MinedData: return de_duplicated_data - def mine_missing_sub_issues(self, data: MinedData) -> dict[Issue, Repository]: + def mine_missing_sub_issues(self, data: MinedData) -> tuple[dict[Issue, Repository], dict[str, list[PullRequest]]]: """ Mines missing sub-issues from GitHub. Parameters: data (MinedData): The mined data containing origin sets of issues and pull requests. Returns: dict[Issue, Repository]: A dictionary mapping fetched issues to their repositories. + dict[str, list[PullRequest]]: A dictionary mapping fetched cross-repo issue with its pull requests. """ logger.info("Mapping sub-issues...") data.parents_sub_issues = self._scan_sub_issues_for_parents([get_id(i, r) for i, r in data.issues.items()]) logger.info("Fetching missing issues...") - return self._fetch_missing_issues_and_prs(data) + fetched_issues = self._fetch_missing_issues(data) + + logger.info("Getting PRs and Commits for missing issues...") + prs_of_fetched_cross_repo_issues = self._fetch_prs_for_fetched_cross_issues(fetched_issues) + + return fetched_issues, prs_of_fetched_cross_repo_issues def _scan_sub_issues_for_parents(self, parents_to_check: list[str]) -> dict[str, list[str]]: """ @@ -119,7 +126,7 @@ def _scan_sub_issues_for_parents(self, parents_to_check: list[str]) -> dict[str, return parents_sub_issues - def _fetch_missing_issues_and_prs(self, data: MinedData) -> dict[Issue, Repository]: + def _fetch_missing_issues(self, data: MinedData) -> dict[Issue, Repository]: """ Fetch missing issues. @@ -167,6 +174,8 @@ def _fetch_missing_issues_and_prs(self, data: MinedData) -> dict[Issue, Reposito else: logger.debug("Skipping issue %s since it does not meet criteria.", parent_id) issues_for_remove.append(parent_id) + else: + logger.error("Cannot get repository for issue %s. Skipping...", parent_id) # remove issue which does not meet criteria for iid in issues_for_remove: @@ -350,3 +359,20 @@ def __filter_duplicated_issues(data: MinedData) -> "MinedData": data.issues = filtered_issues return data + + def _fetch_prs_for_fetched_cross_issues(self, issues: dict[Issue, Repository]) -> dict[str, list[PullRequest]]: + prs_of_cross_repo_issues: dict[str, list[PullRequest]] = {} + for i, repo in issues.items(): + prs_of_cross_repo_issues[iid := get_id(i, repo)] = [] + try: + for ev in i.get_timeline(): # timeline includes cross-references + if ev.event == "cross-referenced" and getattr(ev, "source", None): + # <- this is a github.Issue.Issue + src_issue = ev.source.issue # type: ignore[union-attr] + if getattr(src_issue, "pull_request", None): + pr = src_issue.as_pull_request() # github.PullRequest.PullRequest + prs_of_cross_repo_issues[iid].append(pr) + except Exception as e: + logger.warning("Failed to fetch timeline events for issue %s: %s", iid, str(e)) + + return prs_of_cross_repo_issues diff --git a/release_notes_generator/generator.py b/release_notes_generator/generator.py index 7b0832b0..bb8cc989 100644 --- a/release_notes_generator/generator.py +++ b/release_notes_generator/generator.py @@ -91,7 +91,9 @@ def generate(self) -> Optional[str]: # data expansion when hierarchy is enabled if ActionInputs.get_hierarchy(): - data_filtered_by_release.issues.update(miner.mine_missing_sub_issues(data_filtered_by_release)) + fetched_issues, prs_of_fetched_issues = miner.mine_missing_sub_issues(data_filtered_by_release) + data_filtered_by_release.issues.update(fetched_issues) + data_filtered_by_release.pull_requests_of_fetched_cross_issues = prs_of_fetched_issues else: # fill flat structure with empty lists, no hierarchy for i, repo in data_filtered_by_release.issues.items(): diff --git a/release_notes_generator/model/mined_data.py b/release_notes_generator/model/mined_data.py index a47c2a2e..ed09c1bb 100644 --- a/release_notes_generator/model/mined_data.py +++ b/release_notes_generator/model/mined_data.py @@ -50,6 +50,8 @@ def __init__(self, repository: Repository): self.commits: dict[Commit, Repository] = {} self.parents_sub_issues: dict[str, list[str]] = {} # parent issue id -> list of its sub-issues ids + # dictionary of fetched cross issues and their pull requests + self.pull_requests_of_fetched_cross_issues: dict[str, list[PullRequest]] = {} @property def home_repository(self) -> Repository: diff --git a/release_notes_generator/record/factory/default_record_factory.py b/release_notes_generator/record/factory/default_record_factory.py index 4a5deeaa..c54b2f21 100644 --- a/release_notes_generator/record/factory/default_record_factory.py +++ b/release_notes_generator/record/factory/default_record_factory.py @@ -97,6 +97,11 @@ def generate(self, data: MinedData) -> dict[str, Record]: for pull, repo in data.pull_requests.items(): self._register_pull_and_its_commits_to_issue(pull, get_id(pull, repo), data, target_repository=repo) + if data.pull_requests_of_fetched_cross_issues: + logger.debug("Register cross-repo Pull Requests to its issues") + for iid, prs in data.pull_requests_of_fetched_cross_issues.items(): + self._register_cross_repo_prs_to_issue(iid, prs) + logger.debug("Registering direct commits to records...") for commit, repo in data.commits.items(): if commit.sha not in self.__registered_commits: @@ -180,6 +185,14 @@ def _register_pull_and_its_commits_to_issue( self._records[pid] = pr_rec logger.debug("Created record for PR %s: %s", pid, pull.title) + def _register_cross_repo_prs_to_issue(self, iid: str, prs: list[PullRequest]) -> None: + if iid not in self.__registered_issues: + logger.error("Issue '%s' not found among collected records.", iid) + return + + for pr in prs: + cast(IssueRecord, self._records[iid]).register_pull_request(pr) + def _create_record_for_hierarchy_issue(self, i: Issue, iid: str, issue_labels: Optional[list[str]] = None) -> None: """ Create a hierarchy issue record and register sub-issues.