diff --git a/src/github_integration/gh_api_caller.py b/src/github_integration/gh_api_caller.py deleted file mode 100644 index f9f08700..00000000 --- a/src/github_integration/gh_api_caller.py +++ /dev/null @@ -1,115 +0,0 @@ -import logging -import time - -from datetime import datetime -from typing import Optional - -from github import Github -from github.GitRelease import GitRelease -from github.RateLimit import RateLimit -from github.Repository import Repository - -from .model.commit import Commit -from .model.issue import Issue -from .model.pull_request import PullRequest - - -def get_gh_repository(g: Github, repository_id: str) -> Optional[Repository]: - try: - logging.info(f"Fetching repository: {repository_id}") - return g.get_repo(repository_id) - except Exception as e: - if "Not Found" in str(e): - logging.error(f"Repository not found: {repository_id}") - return None - else: - logging.error(f"Fetching repository failed for {repository_id}: {str(e)}") - return None - - -def fetch_latest_release(repo: Repository) -> Optional[GitRelease]: - try: - logging.info(f"Fetching latest release for {repo.full_name}") - release = repo.get_latest_release() - logging.debug(f"Found latest release: {release.tag_name}, created at: {release.created_at}, " - f"published at: {release.published_at}") - return release - - except Exception as e: - if "Not Found" in str(e): - logging.error(f"Latest release not found for {repo.full_name}. 1st release for repository!") - return None - else: - logging.error(f"Fetching latest release failed for {repo.full_name}: {str(e)}. " - f"Expected first release for repository.") - return None - - -def fetch_all_issues(repo: Repository, release: Optional[GitRelease]) -> list[Issue]: - if release is None: - logging.info(f"Fetching all issues for {repo.full_name}") - issues = repo.get_issues(state="all") - else: - logging.info(f"Fetching all issues since {release.published_at} for {repo.full_name}") - issues = repo.get_issues(state="all", since=release.published_at) - - parsed_issues = [] - logging.info(f"Found {len(list(issues))} issues for {repo.full_name}") - for issue in list(issues): - parsed_issues.append(Issue(issue)) - - return parsed_issues - - -def fetch_finished_pull_requests(repo: Repository) -> list[PullRequest]: - # TODO - decide: pulls = repo.get_pulls(state='closed', sort='created', direction='desc') - logging.info(f"Fetching all closed PRs for {repo.full_name}") - pulls = repo.get_pulls(state='closed') - - pull_requests = [] - logging.info(f"Found {len(list(pulls))} PRs for {repo.full_name}") - for pull in list(pulls): - pull_requests.append(PullRequest(pull)) - - return pull_requests - - -def fetch_commits(repo: Repository) -> list[Commit]: - logging.info(f"Fetching all commits {repo.full_name}") - raw_commits = repo.get_commits() - - commits = [] - for raw_commit in raw_commits: - # for reference commit auhtor - use raw_commit.author - - # logging.debug(f"Raw Commit: {raw_commit}, Author: {raw_commit.author}, Commiter: {raw_commit.committer}.") - # logging.debug(f"Raw Commit.commit: Message: {raw_commit.commit.message}, Author: {raw_commit.commit.author}, Commiter: {raw_commit.commit.committer}.") - - commits.append(Commit(raw_commit)) - - return commits - - -def generate_change_url(repo: Repository, release: Optional[GitRelease], tag_name: str) -> str: - if release: - # If there is a latest release, create a URL pointing to commits since the latest release - changelog_url = f"https://github.com/{repo.full_name}/compare/{release.tag_name}...{tag_name}" - - else: - # If there is no latest release, create a URL pointing to all commits - changelog_url = f"https://github.com/{repo.full_name}/commits/{tag_name}" - - logging.debug(f"Changelog URL: {changelog_url}") - return changelog_url - - -def show_rate_limit(g: Github): - rate_limit: RateLimit = g.get_rate_limit() - - if rate_limit.core.remaining < 10: - reset_time = rate_limit.core.reset - sleep_time = (reset_time - datetime.utcnow()).total_seconds() + 10 - logging.debug(f"Rate limit reached. Sleeping for {sleep_time} seconds.") - time.sleep(sleep_time) - else: - logging.debug(f"Rate limit: {rate_limit.core.remaining} remaining of {rate_limit.core.limit}") diff --git a/src/github_integration/github_manager.py b/src/github_integration/github_manager.py index 7b28e02a..6804a575 100644 --- a/src/github_integration/github_manager.py +++ b/src/github_integration/github_manager.py @@ -1,8 +1,18 @@ +import logging +import time + +from datetime import datetime from typing import Optional +from github import Github from github.GitRelease import GitRelease +from github.RateLimit import RateLimit from github.Repository import Repository +from github_integration.model.commit import Commit +from github_integration.model.issue import Issue +from github_integration.model.pull_request import PullRequest + def singleton(cls): """ @@ -28,45 +38,169 @@ class GithubManager: """ A singleton class used to manage GitHub interactions. """ - def __init__(self): """ Constructs all the necessary attributes for the GithubManager object. """ - self.repository = None - self.git_release = None + self.__g = None + self.__repository = None + self.__git_release = None - def set_repository(self, repository: Repository): + + @property + def github(self) -> Github: """ - Sets the repository attribute. + Gets the g attribute. - :param repository: The Repository object to set. + :return: The Github object. """ - self.repository = repository + return self.__g - def set_git_release(self, release: GitRelease): + @github.setter + def github(self, g: Github): """ - Sets the git_release attribute. + Sets the g attribute. - :param release: The GitRelease object to set. + :return: The Github object. """ - self.git_release = release + self.__g = g - def get_repository(self) -> Optional[Repository]: + @property + def repository(self) -> Optional[Repository]: """ Gets the repository attribute. :return: The Repository object, or None if it is not set. """ - return self.repository + return self.__repository - def get_git_release(self) -> Optional[GitRelease]: + @property + def git_release(self) -> Optional[GitRelease]: """ Gets the git_release attribute. :return: The GitRelease object, or None if it is not set. """ - return self.git_release + return self.__git_release + + # fetch method + + def fetch_repository(self, repository_id: str) -> Optional[Repository]: + """ + Fetches a repository from GitHub using the provided repository ID. + + :param repository_id: The ID of the repository to fetch. + :return: The fetched Repository object, or None if the repository could not be fetched. + """ + try: + logging.info(f"Fetching repository: {repository_id}") + self.__repository = self.__g.get_repo(repository_id) + except Exception as e: + if "Not Found" in str(e): + logging.error(f"Repository not found: {repository_id}") + self.__repository = None + else: + logging.error(f"Fetching repository failed for {repository_id}: {str(e)}") + self.__repository = None + + return self.__repository + + def fetch_latest_release(self) -> Optional[GitRelease]: + """ + Fetches the latest release from a current repository. + + :return: The fetched GitRelease object representing the latest release, or None if there is no release or the fetch failed. + """ + try: + logging.info(f"Fetching latest release for {repo.full_name}") + release = self.__repository.get_latest_release() + logging.debug(f"Found latest release: {release.tag_name}, created at: {release.created_at}, " + f"published at: {release.published_at}") + self.__git_release = release + + except Exception as e: + if "Not Found" in str(e): + logging.error(f"Latest release not found for {self.__repository.full_name}. 1st release for repository!") + self.__git_release = None + else: + logging.error(f"Fetching latest release failed for {self.__repository.full_name}: {str(e)}. " + f"Expected first release for repository.") + self.__git_release = None + + return self.__git_release + + def fetch_issues(self, since: datetime = None, state: str = None) -> list[Issue]: + """ + Fetches all issues from the current repository. + If a since is set, fetches all issues since the defined time. + + :return: A list of Issue objects. + """ + if self.__git_release is None: + logging.info(f"Fetching all issues for {self.__repository.full_name}") + issues = self.__repository.get_issues(state="all") + else: + logging.info(f"Fetching all issues since {self.__git_release.published_at} for {self.__repository.full_name}") + issues = self.__repository.get_issues(state="all", since=self.__git_release.published_at) + + parsed_issues = [] + logging.info(f"Found {len(list(issues))} issues for {self.__repository.full_name}") + for issue in list(issues): + parsed_issues.append(Issue(issue)) + + return parsed_issues + + def fetch_pull_requests(self, since: datetime = None, state: str = None) -> list[PullRequest]: + """ + Fetches all pull requests from the current repository. + If a 'since' datetime is provided, fetches all pull requests since that time. + If a 'state' is provided, fetches pull requests with that state. + + :param since: The datetime to fetch pull requests since. If None, fetches all pull requests. + :param state: The state of the pull requests to fetch. If None, fetches pull requests of all states. + :return: A list of PullRequest objects. + """ + # TODO - decide: pulls = repo.get_pulls(state='closed', sort='created', direction='desc') + logging.info(f"Fetching all closed PRs for {self.__repository.full_name}") + if state is None: + pulls = self.__repository.get_pulls() + else: + pulls = self.__repository.get_pulls(state=state) + + pull_requests = [] + logging.info(f"Found {len(list(pulls))} PRs for {self.__repository.full_name}") + for pull in list(pulls): + pull_requests.append(PullRequest(pull)) + + return pull_requests + + def fetch_commits(self, since: datetime = None) -> list[Commit]: + logging.info(f"Fetching all commits {self.__repository.full_name}") + raw_commits = self.__repository.get_commits() + + commits = [] + for raw_commit in raw_commits: + # for reference commit author - use raw_commit.author + + # logging.debug(f"Raw Commit: {raw_commit}, Author: {raw_commit.author}, Commiter: {raw_commit.committer}.") + # logging.debug(f"Raw Commit.commit: Message: {raw_commit.commit.message}, Author: {raw_commit.commit.author}, Commiter: {raw_commit.commit.committer}.") + + commits.append(Commit(raw_commit)) + + return commits + + # get methods + + def get_change_url(self, tag_name: str) -> str: + if self.__git_release: + # If there is a latest release, create a URL pointing to commits since the latest release + changelog_url = f"https://github.com/{self.__repository.full_name}/compare/{self.__git_release.tag_name}...{tag_name}" + + else: + # If there is no latest release, create a URL pointing to all commits + changelog_url = f"https://github.com/{self.__repository.full_name}/commits/{tag_name}" + + return changelog_url def get_repository_full_name(self) -> Optional[str]: """ @@ -74,6 +208,26 @@ def get_repository_full_name(self) -> Optional[str]: :return: The full name of the repository as a string, or None if the repository is not set. """ - if self.repository is not None: - return self.repository.full_name + if self.__repository is not None: + return self.__repository.full_name return None + + # others + + def show_rate_limit(self): + if logging.getLogger().isEnabledFor(logging.DEBUG): + return + + if self.__g is None: + logging.error("GitHub object is not set.") + return + + rate_limit: RateLimit = self.__g.get_rate_limit() + + if rate_limit.core.remaining < rate_limit.core.limit/10: + reset_time = rate_limit.core.reset + sleep_time = (reset_time - datetime.utcnow()).total_seconds() + 10 + logging.debug(f"Rate limit reached. Sleeping for {sleep_time} seconds.") + time.sleep(sleep_time) + else: + logging.debug(f"Rate limit: {rate_limit.core.remaining} remaining of {rate_limit.core.limit}") diff --git a/src/release_notes_generator.py b/src/release_notes_generator.py index d083cbbb..7d353db4 100644 --- a/src/release_notes_generator.py +++ b/src/release_notes_generator.py @@ -6,15 +6,13 @@ from github import Github, Auth from github_integration.gh_action import get_action_input, set_action_output, set_action_failed -from github_integration.gh_api_caller import (get_gh_repository, fetch_latest_release, fetch_all_issues, - fetch_finished_pull_requests, generate_change_url, show_rate_limit, - fetch_commits) +from github_integration.github_manager import GithubManager + from release_notes.formatter.record_formatter import RecordFormatter from release_notes.model.custom_chapters import CustomChapters from release_notes.model.record import Record from release_notes.release_notes_builder import ReleaseNotesBuilder from release_notes.factory.record_factory import RecordFactory -from github_integration.github_manager import GithubManager # Configure logging @@ -83,7 +81,7 @@ def validate_inputs(owner: str, repo_name: str, tag_name: str, chapters_json: st logging.debug(f'Verbose logging: {verbose}') -def release_notes_generator(g: Github, repository_id: str, tag_name: str, custom_chapters: CustomChapters, warnings: bool, +def release_notes_generator(repository_id: str, tag_name: str, custom_chapters: CustomChapters, warnings: bool, published_at: bool, skip_release_notes_label: str, print_empty_chapters: bool, chapters_to_pr_without_issue: bool) -> Optional[str]: """ @@ -101,25 +99,25 @@ def release_notes_generator(g: Github, repository_id: str, tag_name: str, custom :return: The generated release notes as a string, or None if the repository could not be found. """ # get GitHub repository object (1 API call) - if (repository := get_gh_repository(g, repository_id)) is None: return None + if (repository := GithubManager().fetch_repository(repository_id)) is None: return None # get latest release (1 API call) - release = fetch_latest_release(repository) - show_rate_limit(g) + release = GithubManager().fetch_latest_release() + GithubManager().show_rate_limit() # get closed issues since last release (N API calls - pagination) - issues = fetch_all_issues(repository, release) - show_rate_limit(g) + issues = GithubManager().fetch_issues() + GithubManager().show_rate_limit() # get finished PRs since last release - pulls = fetch_finished_pull_requests(repository) - show_rate_limit(g) + pulls = GithubManager().fetch_pull_requests() + GithubManager().show_rate_limit() # get commits since last release - commits = fetch_commits(repository) + commits = GithubManager().fetch_commits() # generate change url - changelog_url = generate_change_url(repository, release, tag_name) + changelog_url = GithubManager().get_change_url(tag_name) # merge data to Release Notes records form rls_notes_records: dict[int, Record] = RecordFactory.generate( @@ -129,8 +127,6 @@ def release_notes_generator(g: Github, repository_id: str, tag_name: str, custom ) formatter = RecordFormatter() - github_manager.set_repository(repository) - github_manager.set_git_release(release) # build rls notes return ReleaseNotesBuilder( @@ -173,7 +169,8 @@ def run(): # Init GitHub instance auth = Auth.Token(token=github_token) g = Github(auth=auth, per_page=100) - show_rate_limit(g) + GithubManager().g = g # creat singleton instance and init with g (Github) + GithubManager().show_rate_limit() validate_inputs(owner, repo_name, tag_name, chapters_json, warnings, published_at, skip_release_notes_label, print_empty_chapters, chapters_to_pr_without_issue, verbose) @@ -181,13 +178,13 @@ def run(): custom_chapters = CustomChapters(print_empty_chapters=print_empty_chapters) custom_chapters.from_json(chapters_json) - rls_notes = release_notes_generator(g, local_repository_id, tag_name, custom_chapters, warnings, published_at, + rls_notes = release_notes_generator(local_repository_id, tag_name, custom_chapters, warnings, published_at, skip_release_notes_label, print_empty_chapters, chapters_to_pr_without_issue) logging.debug(f"Release notes: \n{rls_notes}") set_action_output('release-notes', rls_notes) logging.info("GitHub Action 'Release Notes Generator' completed successfully") - show_rate_limit(g) + GithubManager().show_rate_limit() except Exception as error: stack_trace = traceback.format_exc() diff --git a/tests/test_release_notes_generator.py b/tests/test_release_notes_generator.py index f949d911..22a9b616 100644 --- a/tests/test_release_notes_generator.py +++ b/tests/test_release_notes_generator.py @@ -4,7 +4,7 @@ import pytest from unittest.mock import Mock, patch -from release_notes_generator import validate_inputs, release_notes_generator, run +from release_notes_generator import validate_inputs # validate_inputs