diff --git a/oar/core/const.py b/oar/core/const.py index 9c2c4d2e3a43..8ba80c986859 100644 --- a/oar/core/const.py +++ b/oar/core/const.py @@ -135,6 +135,10 @@ ENV_APP_PASSWD = "GOOGLE_APP_PASSWD" ENV_JENKINS_USER = "JENKINS_USER" ENV_JENKINS_TOKEN = "JENKINS_TOKEN" +ENV_VAR_GITHUB_APP_WRITER_ID = "GITHUB_APP_WRITER_ID" +ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY = "GITHUB_APP_WRITER_PRIVATE_KEY" +ENV_VAR_GITHUB_APP_READER_ID = "GITHUB_APP_READER_ID" +ENV_VAR_GITHUB_APP_READER_PRIVATE_KEY = "GITHUB_APP_READER_PRIVATE_KEY" # jira status JIRA_STATUS_CLOSED = "Closed" JIRA_STATUS_IN_PROGRESS = "In Progress" diff --git a/oar/core/github_app.py b/oar/core/github_app.py new file mode 100644 index 000000000000..9df9391467a8 --- /dev/null +++ b/oar/core/github_app.py @@ -0,0 +1,45 @@ +"""GitHub App auth for ERT (Writer: release-tests, Reader: openshift/*).""" + +from pathlib import Path + +from github import Auth, Github, GithubIntegration + + +class GitHubApp: + """PyGithub client via GitHub App installation token.""" + + def __init__(self, app_id: str, private_key_path: str): + """ + Initialize GitHub App authentication. + + Args: + app_id: Application ID (not Client ID). + private_key_path: Path to the App private key ``.pem`` file. + """ + if "\n" in private_key_path or "-----BEGIN" in private_key_path: + raise ValueError( + "private_key_path must be a path to a .pem file, not inline key content" + ) + key_file = Path(private_key_path).expanduser() + if not key_file.is_file(): + raise FileNotFoundError("GitHub App private key file not found") + key = key_file.read_text() + auth = Auth.AppAuth(app_id, key) + self._integration = GithubIntegration(auth=auth) + + def client_for_repo(self, owner: str, repo: str) -> Github: + """ + Return a Github client for ``owner/repo``. + + Args: + owner: GitHub org or user (e.g. ``openshift``). + repo: Repository name (e.g. ``release-tests``). + + Returns: + Installation-scoped ``Github`` client. + + Raises: + GithubException: App not installed on the repo or invalid credentials. + """ + installation = self._integration.get_repo_installation(owner, repo) + return self._integration.get_github_for_installation(installation.id) diff --git a/oar/notificator/jira_notificator.py b/oar/notificator/jira_notificator.py index 464b2cb5d04a..9f623ac6959f 100644 --- a/oar/notificator/jira_notificator.py +++ b/oar/notificator/jira_notificator.py @@ -9,7 +9,8 @@ from jira.resources import User from datetime import datetime, timedelta, timezone from dateutil import parser -from github import Auth, Github +from oar.core.const import ENV_VAR_GITHUB_APP_READER_ID, ENV_VAR_GITHUB_APP_READER_PRIVATE_KEY +from oar.core.github_app import GitHubApp from oar.core.jira import JiraIssue from oar.core.ldap import LdapHelper @@ -30,6 +31,7 @@ class NotificationType(Enum): REPORTER = (24, "Reporter Action Request") def __init__(self, hours: int, label: str): + """Set escalation threshold in weekday hours and notification label.""" self.hours = hours self.label = label @@ -57,6 +59,12 @@ class Notification: text: str def __init__(self, issue, type, text): + """ + Args: + issue: Jira issue for the notification. + type: Notification type. + text: Comment body text. + """ self.issue = issue self.type = type self.text = text @@ -71,11 +79,60 @@ class NotificationService: """ def __init__(self, jira, dry_run=False): + """ + Args: + jira: JIRA API client instance. + dry_run: If True, log notifications without posting Jira comments. + """ self.jira = jira self.dry_run = dry_run self.ldap = LdapHelper() - github_token = os.environ.get("GITHUB_TOKEN", "") - self.github = Github(auth=Auth.Token(github_token)) if github_token else None + self._github_app = self._init_github_app() + + def _init_github_app(self) -> GitHubApp | None: + """ + Create GitHub App Reader client from environment variables. + + Returns: + GitHubApp instance, or None if credentials are missing or initialization fails. + """ + app_id = os.environ.get(ENV_VAR_GITHUB_APP_READER_ID) + private_key_path = os.environ.get(ENV_VAR_GITHUB_APP_READER_PRIVATE_KEY) + if not app_id or not private_key_path: + return None + try: + return GitHubApp(app_id, private_key_path) + except Exception as e: + logger.error( + "Failed to initialize GitHub App Reader (%s)", + type(e).__name__, + ) + return None + + def _github_client_for_repo(self, org: str, repo: str): + """ + Return a PyGithub client for a linked PR repository. + + Args: + org: GitHub org (e.g. ``openshift``). + repo: Repository name from the PR URL. + + Returns: + Installation-scoped ``Github`` client, or None if the app is unavailable + or not installed on the repository. + """ + if not self._github_app: + return None + try: + return self._github_app.client_for_repo(org, repo) + except Exception as e: + logger.warning( + "Failed to get GitHub client for %s/%s (%s)", + org, + repo, + type(e).__name__, + ) + return None def get_user_email(self, user: User) -> Optional[str]: """ @@ -688,8 +745,11 @@ def is_pre_merge_verified(self, issue: Issue) -> bool: Returns: bool: True if all valid PRs are pre-merge verified, False otherwise. """ - if not self.github: - logger.warning("GITHUB_TOKEN not set, skipping pre-merge verification check.") + if not self._github_app: + logger.warning( + f"{ENV_VAR_GITHUB_APP_READER_ID} or {ENV_VAR_GITHUB_APP_READER_PRIVATE_KEY} " + "not set, skipping pre-merge verification check." + ) return False try: @@ -710,7 +770,10 @@ def is_pre_merge_verified(self, issue: Issue) -> bool: for url, org, repo, pr_number in valid_prs: try: - pr = self.github.get_repo(f"{org}/{repo}").get_pull(pr_number) + github = self._github_client_for_repo(org, repo) + if not github: + return False + pr = github.get_repo(f"{org}/{repo}").get_pull(pr_number) labels = {label.name for label in pr.get_labels()} if "verified-later" in labels: logger.info(f"Issue {issue.key} PR {url} has 'verified-later' label, post-merge verification needed.") diff --git a/tests/test_jira_notificator.py b/tests/test_jira_notificator.py index 40f8fd58ccfa..50de302737e2 100644 --- a/tests/test_jira_notificator.py +++ b/tests/test_jira_notificator.py @@ -485,10 +485,10 @@ def test_is_pre_merge_verified(self): issue_no_links = self.jira.issue("OCPBUGS-59288") self.assertFalse(self.ns.is_pre_merge_verified(issue_no_links)) - # No GITHUB_TOKEN -> skip check, return False - ns_no_token = NotificationService(self.jira, True) - ns_no_token.github = None - self.assertFalse(ns_no_token.is_pre_merge_verified(issue_pre_merge_verified)) + # No GitHub App Reader credentials -> skip check, return False + ns_no_github = NotificationService(self.jira, True) + ns_no_github._github_app = None + self.assertFalse(ns_no_github.is_pre_merge_verified(issue_pre_merge_verified)) def test_process_on_qa_issues(self): day_ago = datetime.now() - timedelta(hours=24)