Skip to content
Closed
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
4 changes: 4 additions & 0 deletions oar/core/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
45 changes: 45 additions & 0 deletions oar/core/github_app.py
Original file line number Diff line number Diff line change
@@ -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)
75 changes: 69 additions & 6 deletions oar/notificator/jira_notificator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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]:
"""
Expand Down Expand Up @@ -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:
Expand All @@ -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.")
Expand Down
8 changes: 4 additions & 4 deletions tests/test_jira_notificator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down