From d6f30bdddfd3f8e22f52875c940f053895276798 Mon Sep 17 00:00:00 2001 From: Tomas David Date: Mon, 1 Jun 2026 14:58:23 +0200 Subject: [PATCH 01/10] OCPERT-368: Replace GitHub token with Github apps rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- AGENTS.md | 25 ++-- CLAUDE.md | 7 +- .../release-progress-dashboard.service | 2 +- mcp_server/check_env.sh | 5 +- oar/core/configstore.py | 2 +- oar/core/const.py | 4 + oar/core/github_app.py | 64 ++++++++ oar/core/release_discovery.py | 42 ++++-- oar/core/statebox.py | 61 ++++++-- oar/notificator/jira_notificator.py | 75 +++++++++- prow/README.md | 19 +-- prow/job/controller.py | 46 ++++-- prow/job/github_auth.py | 138 ++++++++++++++++++ prow/job/job.py | 39 ++--- prow/job/selector.py | 102 ++++--------- tests/test_jira_notificator.py | 8 +- tests/test_release_discovery.py | 95 ++++++------ tests/test_statebox.py | 28 ++-- tools/auto_release_test_dashboard.py | 35 ++++- tools/auto_release_test_result_checker.py | 45 ++++-- 20 files changed, 591 insertions(+), 251 deletions(-) mode change 100644 => 100755 mcp_server/check_env.sh create mode 100644 oar/core/github_app.py create mode 100644 prow/job/github_auth.py diff --git a/AGENTS.md b/AGENTS.md index 84f71c6a1d44..eb580c3c626d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,8 @@ jobctl start-controller -r --nightly --arch - `GithubUtil` - Handles file operations in the GitHub repository **Environment Variables Required:** -- `GITHUB_TOKEN` - GitHub authentication +- `GITHUB_APP_WRITER_ID` - ERT Writer GitHub App ID (`openshift/release-tests`) +- `GITHUB_APP_WRITER_PRIVATE_KEY` - Path to Writer App private key `.pem` file - `APITOKEN` - Prow/Gangway API authentication --- @@ -122,7 +123,7 @@ jobctl start-aggregator --arch - `Artifacts` - Fetches test reports from GCS **Environment Variables Required:** -- `GITHUB_TOKEN` - GitHub authentication +- `GITHUB_APP_WRITER_ID` / `GITHUB_APP_WRITER_PRIVATE_KEY` - Writer GitHub App for `openshift/release-tests` - `APITOKEN` - Prow/Gangway API authentication - `GCS_CRED_FILE` - Google Cloud Storage credentials for artifact access @@ -248,7 +249,7 @@ python tools/auto_release_test_result_checker.py \ - File tracking system to avoid duplicate notifications **Environment Variables Required:** -- `GITHUB_TOKEN` - GitHub authentication +- `GITHUB_APP_WRITER_ID` / `GITHUB_APP_WRITER_PRIVATE_KEY` - Writer GitHub App for `openshift/release-tests` - `SLACK_BOT_TOKEN` - Slack bot token **Options:** @@ -296,19 +297,23 @@ pip3 install -e . **Release Detector:** - All OAR CLI environment variables (calls `create-test-report` command) -- **GITHUB_TOKEN** - GitHub personal access token for monitoring repository file changes +- No GitHub token required (uses public raw.githubusercontent.com URL) + +**OAR / StateBox / Release Discovery:** +- **GITHUB_APP_WRITER_ID** / **GITHUB_APP_WRITER_PRIVATE_KEY** - Writer GitHub App for `openshift/release-tests` **Job Controller:** -- **GITHUB_TOKEN** - GitHub personal access token for repository operations +- **GITHUB_APP_WRITER_ID** / **GITHUB_APP_WRITER_PRIVATE_KEY** - Writer GitHub App for `openshift/release-tests` - **APITOKEN** - Prow/Gangway API token for triggering test jobs **Test Result Aggregator:** -- **GITHUB_TOKEN** - GitHub personal access token for repository operations +- **GITHUB_APP_WRITER_ID** / **GITHUB_APP_WRITER_PRIVATE_KEY** - Writer GitHub App for `openshift/release-tests` - **APITOKEN** - Prow/Gangway API token for triggering test jobs - **GCS_CRED_FILE** - Google Cloud Storage credentials file path for test artifact access **Jira Notificator:** - **JIRA_TOKEN** - Jira personal access token for API access +- **GITHUB_APP_READER_ID** / **GITHUB_APP_READER_PRIVATE_KEY** - Reader GitHub App for PR label checks - **Kerberos ticket** - For LDAP manager lookup **Slack Message Receiver (Release Bot):** @@ -317,7 +322,7 @@ pip3 install -e . - All OAR CLI environment variables (executes OAR commands) **Test Result Checker:** -- **GITHUB_TOKEN** - GitHub personal access token for repository operations +- **GITHUB_APP_WRITER_ID** / **GITHUB_APP_WRITER_PRIVATE_KEY** - Writer GitHub App for `openshift/release-tests` - **SLACK_BOT_TOKEN** - Slack bot token for sending notifications **Note:** `OAR_SLACK_CHANNEL` and `OAR_SLACK_THREAD` are set internally by the Slack bot when executing commands and should not be configured manually by users. @@ -1018,9 +1023,9 @@ See individual agent sections above for specific environment variables required. - **Solution:** Renew your Kerberos ticket: `kinit $kid@$domain` - **Verify:** Check ticket status with `klist` -**Problem:** GitHub token permissions insufficient -- **Solution:** Ensure token has `repo` scope for private repositories -- **Verify:** Test with `gh auth status` +**Problem:** GitHub App credentials missing or invalid +- **Solution:** Set `GITHUB_APP_WRITER_ID` / `GITHUB_APP_WRITER_PRIVATE_KEY` (and Reader vars if needed); ensure `.pem` path is valid and the app is installed on the target repo +- **Verify:** Check env vars with `env | grep GITHUB_APP`; confirm app installation on `openshift/release-tests` or `openshift/release` #### Agent-Specific Issues diff --git a/CLAUDE.md b/CLAUDE.md index 78e309b3f3b2..e252a48c81ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -348,7 +348,8 @@ For remote servers: - Kerberos ticket required: `kinit $kid@$domain` **For Controllers/Agents:** -- `GITHUB_TOKEN` - GitHub API access +- `GITHUB_APP_WRITER_ID` / `GITHUB_APP_WRITER_PRIVATE_KEY` - Writer App (`openshift/release-tests`) +- `GITHUB_APP_READER_ID` / `GITHUB_APP_READER_PRIVATE_KEY` - Reader App (`openshift/*`, e.g. release, PR checks) - `APITOKEN` - Prow/Gangway API token - `GCS_CRED_FILE` - Google Cloud Storage credentials (for test artifacts) @@ -518,8 +519,8 @@ When adding new version support, update: - **Kerberos required** for Errata Tool and LDAP access: `kinit $kid@$domain` - **Bugzilla credentials** cached in `~/.config/python-bugzilla/bugzillarc` -- **GitHub token** needs `repo` scope for private repositories -- All tokens should be kept in secure storage (Bitwarden, environment variables) +- **GitHub Apps** — Writer (`GITHUB_APP_WRITER_*`) for `openshift/release-tests`; Reader (`GITHUB_APP_READER_*`) for `openshift/*` read access +- All tokens and keys should be kept in secure storage (Bitwarden, environment variables) ## Common Pitfalls diff --git a/deployment/systemd/release-progress-dashboard.service b/deployment/systemd/release-progress-dashboard.service index 0ca7b264be08..82aeaf83df4d 100644 --- a/deployment/systemd/release-progress-dashboard.service +++ b/deployment/systemd/release-progress-dashboard.service @@ -19,7 +19,7 @@ Environment="STREAMLIT_SERVER_ADDRESS=0.0.0.0" Environment="STREAMLIT_SERVER_HEADLESS=true" # Use bash login shell to load .bash_profile -# This inherits environment variables (GITHUB_TOKEN, etc.) +# This inherits environment variables (GITHUB_APP_WRITER_*, etc.) ExecStart=/bin/bash -l -c 'cd /home/your-username/release-tests && streamlit run tools/release_progress_dashboard/release_progress_dashboard.py --server.port=${STREAMLIT_SERVER_PORT} --server.address=${STREAMLIT_SERVER_ADDRESS} --server.headless=${STREAMLIT_SERVER_HEADLESS}' # Restart on failure diff --git a/mcp_server/check_env.sh b/mcp_server/check_env.sh old mode 100644 new mode 100755 index 5e41dd6867de..abca263ff162 --- a/mcp_server/check_env.sh +++ b/mcp_server/check_env.sh @@ -59,7 +59,10 @@ echo "Checking controller/agent environment variables..." echo "(These are not required for OAR CLI, but needed for separate components)" echo -check_optional "GITHUB_TOKEN" "Release detector, job controller" +check_optional "GITHUB_APP_WRITER_ID" "StateBox, job controller, release discovery" +check_optional "GITHUB_APP_WRITER_PRIVATE_KEY" "StateBox, job controller, release discovery" +check_optional "GITHUB_APP_READER_ID" "Jira notificator, job CLI (openshift/release)" +check_optional "GITHUB_APP_READER_PRIVATE_KEY" "Jira notificator, job CLI (openshift/release)" check_optional "APITOKEN" "Prow/Gangway job triggering" check_optional "GCS_CRED_FILE" "GCS test result storage" diff --git a/oar/core/configstore.py b/oar/core/configstore.py index 47276fa428e5..4cc8dc2964af 100644 --- a/oar/core/configstore.py +++ b/oar/core/configstore.py @@ -38,7 +38,7 @@ def validate_environment(): - Prow controllers/aggregators: Validated separately See: prow/job/controller.py (validate_environment function) - Vars: GITHUB_TOKEN, APITOKEN, GCS_CRED_FILE + Vars: GITHUB_APP_WRITER_*, GITHUB_APP_READER_*, APITOKEN, GCS_CRED_FILE - MCP server: Uses this validation (wraps OAR CLI commands) See: mcp_server/server.py 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..7310a6f558fa --- /dev/null +++ b/oar/core/github_app.py @@ -0,0 +1,64 @@ +"""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 installation_token(self, owner: str, repo: str) -> str: + """ + Return a short-lived installation access token for ``owner/repo``. + + Uses ``GithubIntegration.get_access_token`` (standard GitHub App API). + + Args: + owner: GitHub org or user (e.g. ``openshift``). + repo: Repository name (e.g. ``release-tests``). + + Returns: + Installation access token string for REST/GraphQL ``Authorization: Bearer``. + + Raises: + GithubException: App not installed on the repo or invalid credentials. + """ + installation = self._integration.get_repo_installation(owner, repo) + return self._integration.get_access_token(installation.id).token + + 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/core/release_discovery.py b/oar/core/release_discovery.py index 7610e1a260a8..830f773a70f5 100644 --- a/oar/core/release_discovery.py +++ b/oar/core/release_discovery.py @@ -9,8 +9,8 @@ Usage: from oar.core.release_discovery import ReleaseDiscovery - # Initialize with GitHub token - discovery = ReleaseDiscovery() # Uses GITHUB_TOKEN env var + # Initialize with GitHub App Writer credentials + discovery = ReleaseDiscovery() # Get all supported y-streams y_streams = discovery.get_supported_ystreams() @@ -32,10 +32,11 @@ from typing import List, Optional import yaml -from github import Auth, Github from semver import VersionInfo +from oar.core.const import ENV_VAR_GITHUB_APP_WRITER_ID, ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY from oar.core.exceptions import ReleaseDiscoveryException +from oar.core.github_app import GitHubApp logger = logging.getLogger(__name__) @@ -53,37 +54,52 @@ class ReleaseDiscovery: def __init__( self, - github_token: Optional[str] = None, repo_name: Optional[str] = None, - branch: Optional[str] = None + branch: Optional[str] = None, ): """ Initialize ReleaseDiscovery with authenticated GitHub API. Args: - github_token: GitHub personal access token (default: from GITHUB_TOKEN env) repo_name: GitHub repository name (default: "openshift/release-tests") branch: Branch name (default: "z-stream") Raises: - ReleaseDiscoveryException: If GitHub token is missing + ReleaseDiscoveryException: If GitHub App Writer credentials are missing """ - token = github_token or os.environ.get("GITHUB_TOKEN") - if not token: - raise ReleaseDiscoveryException("GitHub token not found. Set GITHUB_TOKEN environment variable.") - self.repo_name = repo_name or self.DEFAULT_REPO self.branch = branch or self.DEFAULT_BRANCH + if self.repo_name != self.DEFAULT_REPO: + raise ReleaseDiscoveryException( + f"ReleaseDiscovery only supports {self.DEFAULT_REPO}, got {self.repo_name}" + ) + # Split repo_name into owner and repository for GraphQL queries self.git_repo_owner, self.git_repo_name = self.repo_name.split('/', 1) - auth = Auth.Token(token) - self._github = Github(auth=auth) + self._github = self._init_github_client() # Tracking files data (fetched via GraphQL) self._tracking_data: Optional[dict] = None + def _init_github_client(self): + app_id = os.environ.get(ENV_VAR_GITHUB_APP_WRITER_ID) + private_key_path = os.environ.get(ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY) + if not app_id or not private_key_path: + raise ReleaseDiscoveryException( + f"{ENV_VAR_GITHUB_APP_WRITER_ID} and " + f"{ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY} must be set." + ) + try: + return GitHubApp(app_id, private_key_path).client_for_repo( + self.git_repo_owner, self.git_repo_name + ) + except Exception as e: + raise ReleaseDiscoveryException( + f"Failed to initialize GitHub App Writer ({type(e).__name__})" + ) from e + def get_supported_ystreams(self) -> List[str]: """ Get all supported y-streams by discovering directories in _releases/. diff --git a/oar/core/statebox.py b/oar/core/statebox.py index bf1c8efa7dc6..962775066cfd 100644 --- a/oar/core/statebox.py +++ b/oar/core/statebox.py @@ -73,17 +73,16 @@ API Reference: Core Operations: - StateBox(configstore, repo_name="openshift/release-tests", branch="z-stream", github_token=None) + StateBox(configstore, repo_name="openshift/release-tests", branch="z-stream") Initialize StateBox for a specific release. Args: configstore: ConfigStore instance (provides release version and configuration) repo_name: GitHub repository name (default: "openshift/release-tests") branch: Branch name (default: "z-stream") - github_token: GitHub token (default: from GITHUB_TOKEN env) Raises: - StateBoxException: If GitHub token is missing + StateBoxException: If GitHub App Writer credentials are missing Note: Release version is extracted from configstore.release. @@ -280,12 +279,20 @@ from typing import Optional, Dict, Any, List import yaml -from github import Auth, Github from github.GithubException import UnknownObjectException, GithubException from oar.core.configstore import ConfigStore -from oar.core.const import SUPPORTED_TASK_NAMES, TASK_STATUS_PASS, TASK_STATUS_FAIL, TASK_STATUS_INPROGRESS, TASK_STATUS_NOT_STARTED +from oar.core.const import ( + ENV_VAR_GITHUB_APP_WRITER_ID, + ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY, + SUPPORTED_TASK_NAMES, + TASK_STATUS_PASS, + TASK_STATUS_FAIL, + TASK_STATUS_INPROGRESS, + TASK_STATUS_NOT_STARTED, +) from oar.core.exceptions import StateBoxException +from oar.core.github_app import GitHubApp from oar.core.util import validate_release_version, get_current_timestamp logger = logging.getLogger(__name__) @@ -333,6 +340,39 @@ class StateBoxDumper(yaml.SafeDumper): MAX_BACKOFF = 10.0 # seconds +def _github_client_for_repo(repo_name: str): + """ + Initialize GitHub App Writer client for StateBox repository access. + + Args: + repo_name: Repository in ``owner/repo`` format. + + Returns: + PyGithub client scoped to the repository installation. + + Raises: + StateBoxException: If credentials are missing or client initialization fails. + """ + if repo_name != DEFAULT_REPO_NAME: + raise StateBoxException( + f"StateBox only supports {DEFAULT_REPO_NAME}, got {repo_name}" + ) + app_id = os.environ.get(ENV_VAR_GITHUB_APP_WRITER_ID) + private_key_path = os.environ.get(ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY) + if not app_id or not private_key_path: + raise StateBoxException( + f"{ENV_VAR_GITHUB_APP_WRITER_ID} and " + f"{ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY} must be set." + ) + owner, repo = repo_name.split("/", 1) + try: + return GitHubApp(app_id, private_key_path).client_for_repo(owner, repo) + except Exception as e: + raise StateBoxException( + f"Failed to initialize GitHub App Writer ({type(e).__name__})" + ) from e + + def extract_start_timestamp(text: Optional[str]) -> Optional[str]: """ Extract the first timestamp from CLI command output. @@ -449,7 +489,6 @@ def __init__( configstore: ConfigStore, repo_name: str = DEFAULT_REPO_NAME, branch: str = DEFAULT_BRANCH, - github_token: Optional[str] = None ): """ Initialize StateBox for a specific release. @@ -458,10 +497,9 @@ def __init__( configstore: ConfigStore instance (provides release version and configuration) repo_name: GitHub repository name (default: "openshift/release-tests") branch: Branch name (default: "z-stream") - github_token: GitHub personal access token (default: from GITHUB_TOKEN env) Raises: - StateBoxException: If GitHub token is missing + StateBoxException: If GitHub App Writer credentials are missing Note: Release version is extracted from configstore.release. @@ -478,12 +516,7 @@ def __init__( self.file_path = f"{STATEBOX_PATH_PREFIX}/{y_stream}/statebox/{self.release}.yaml" # Initialize GitHub client - token = github_token or os.environ.get("GITHUB_TOKEN") - if not token: - raise StateBoxException("GitHub token not found. Set GITHUB_TOKEN environment variable.") - - auth = Auth.Token(token) - self._github = Github(auth=auth) + self._github = _github_client_for_repo(repo_name) self._repo = self._github.get_repo(repo_name) # Cache for current state and SHA 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/prow/README.md b/prow/README.md index 3035e90bb45b..9779e840f5b2 100644 --- a/prow/README.md +++ b/prow/README.md @@ -41,18 +41,19 @@ Login https://console-openshift-console.apps.ci.l2s4.p1.openshiftapps.com/ and t oc login --token= --server=https://api.ci.l2s4.p1.openshiftapps.com:6443 ``` -- Get the Github token - -Click your personal Github account -> `Settings` -> `Developer settings` -> `Personal access tokens (classic)` -> `Create new token`, you will get it like: +- Set environment variables ```console -ghp_xxx +$ export APITOKEN= +$ export GITHUB_APP_WRITER_ID= +$ export GITHUB_APP_WRITER_PRIVATE_KEY=/path/to/ert-writer.pem +$ export GITHUB_APP_READER_ID= +$ export GITHUB_APP_READER_PRIVATE_KEY=/path/to/ert-reader.pem ``` -- Set token ENVs -```console -$ export APITOKEN= -$ export GITHUB_TOKEN= -``` +- `jobctl` uses the **Writer** App on `openshift/release-tests`. +- `job` uses **Writer** for `release-tests` Contents API and **Reader** for `openshift/release`. +- `selector.AutoReleaseJobs` uses **Reader** on `openshift/release`. +- `selector.TestJobRegistryUpdater` uses **Writer** on `openshift/release-tests` (branch + PR to `main`). - An example to run a job on a specific payload for **e2e test**. ```console diff --git a/prow/job/controller.py b/prow/job/controller.py index f26378f3842a..1fc7274ca352 100755 --- a/prow/job/controller.py +++ b/prow/job/controller.py @@ -12,8 +12,13 @@ import click import requests import yaml -from github import Auth, Github from github.GithubException import UnknownObjectException, GithubException + +from oar.core.const import ( + ENV_VAR_GITHUB_APP_WRITER_ID, + ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY, +) +from .github_auth import REPO_OPENSHIFT_RELEASE, REPO_RELEASE_TESTS, github_client_for_repo from requests.adapters import HTTPAdapter from requests.exceptions import RequestException from semver import VersionInfo @@ -27,16 +32,23 @@ # declare global constants JOB_TYPE_NIGHTLY = "nightly" JOB_TYPE_STABLE = "stable" -REPO_RELEASE_TESTS = "openshift/release-tests" BRANCH_RECORD = "record" DIR_RELEASE = "_releases" -SYS_ENV_VAR_GITHUB_TOKEN = "GITHUB_TOKEN" +SYS_ENV_VAR_GITHUB_APP_WRITER_ID = ENV_VAR_GITHUB_APP_WRITER_ID +SYS_ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY = ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY SYS_ENV_VAR_API_TOKEN = "APITOKEN" SYS_ENV_VAR_GCS_CRED_FILE = "GCS_CRED_FILE" REQUIRED_ENV_VARS_FOR_CONTROLLER = [ - SYS_ENV_VAR_GITHUB_TOKEN, SYS_ENV_VAR_API_TOKEN] + SYS_ENV_VAR_GITHUB_APP_WRITER_ID, + SYS_ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY, + SYS_ENV_VAR_API_TOKEN, +] REQUIRED_ENV_VARS_FOR_AGGREGATOR = [ - SYS_ENV_VAR_GITHUB_TOKEN, SYS_ENV_VAR_API_TOKEN, SYS_ENV_VAR_GCS_CRED_FILE] + SYS_ENV_VAR_GITHUB_APP_WRITER_ID, + SYS_ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY, + SYS_ENV_VAR_API_TOKEN, + SYS_ENV_VAR_GCS_CRED_FILE, +] def create_session() -> requests.Session: @@ -414,13 +426,19 @@ def start(self): class GithubUtil: def __init__(self, repo, branch="main"): - token = os.environ.get("GITHUB_TOKEN") - auth = Auth.Token(token) - self._client = Github(auth=auth) + if repo not in (REPO_RELEASE_TESTS, REPO_OPENSHIFT_RELEASE): + raise ValueError( + f"GithubUtil supports {REPO_RELEASE_TESTS} and {REPO_OPENSHIFT_RELEASE}, " + f"got {repo}" + ) + self._repo_name = repo + self._client = github_client_for_repo(repo) self._repo = self._client.get_repo(repo) self._branch = branch def push_file(self, data, path): + if self._repo_name != REPO_RELEASE_TESTS: + raise ValueError(f"push_file is only supported for {REPO_RELEASE_TESTS}") if isinstance(data, dict): data = json.dumps(data, indent=2) if self.file_exists(path): @@ -462,6 +480,8 @@ def file_exists(self, path): return False def delete_file(self, path): + if self._repo_name != REPO_RELEASE_TESTS: + raise ValueError(f"delete_file is only supported for {REPO_RELEASE_TESTS}") if self.file_exists(path): content = self._repo.get_contents(path=path, ref=self._branch) logger.info(f"Deleting file {path}") @@ -1048,7 +1068,10 @@ def start_aggregator(arch): @click.option("--arch", help="architecture used to filter test result", default=Architectures.AMD64, type=click.Choice(Architectures.VALID_ARCHS)) @click.option("--build", help="build version e.g. 4.16.20", required=True) def promote_test_results(arch, build): - validate_environment([SYS_ENV_VAR_GITHUB_TOKEN]) + validate_environment([ + SYS_ENV_VAR_GITHUB_APP_WRITER_ID, + SYS_ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY, + ]) TestResultAggregator(arch).promote_test_results_for_build(build) @@ -1059,7 +1082,10 @@ def promote_test_results(arch, build): @click.option("--current-job-id", help="current job run id", required=True) @click.option("--new-job-id", help="new job run id used to replace current job id", required=True) def update_retried_job_run(arch, build, job_name, current_job_id, new_job_id): - validate_environment([SYS_ENV_VAR_GITHUB_TOKEN]) + validate_environment([ + SYS_ENV_VAR_GITHUB_APP_WRITER_ID, + SYS_ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY, + ]) TestResultAggregator(arch).update_retried_job_run( build, job_name, current_job_id, new_job_id) diff --git a/prow/job/github_auth.py b/prow/job/github_auth.py new file mode 100644 index 000000000000..1adaa4edbe88 --- /dev/null +++ b/prow/job/github_auth.py @@ -0,0 +1,138 @@ +"""GitHub App auth helpers for prow/job (Writer and Reader).""" + +import os +import sys + +from github import Github + +from oar.core.const import ( + ENV_VAR_GITHUB_APP_READER_ID, + ENV_VAR_GITHUB_APP_READER_PRIVATE_KEY, + ENV_VAR_GITHUB_APP_WRITER_ID, + ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY, +) +from oar.core.github_app import GitHubApp + +OPENSHIFT_OWNER = "openshift" +RELEASE_REPO = "release" +RELEASE_TESTS_REPO = "release-tests" +REPO_OPENSHIFT_RELEASE = f"{OPENSHIFT_OWNER}/{RELEASE_REPO}" +REPO_RELEASE_TESTS = f"{OPENSHIFT_OWNER}/{RELEASE_TESTS_REPO}" + + +def _github_app(app_id_env: str, key_env: str) -> GitHubApp: + app_id = os.environ.get(app_id_env) + private_key_path = os.environ.get(key_env) + if not app_id or not private_key_path: + print(f"Missing {app_id_env} or {key_env}, exit...") + sys.exit(1) + try: + return GitHubApp(app_id, private_key_path) + except Exception as e: + print(f"Failed to initialize GitHub App ({type(e).__name__}), exit...") + sys.exit(1) + + +def _installation_token(app_id_env: str, key_env: str, owner: str, repo: str) -> str: + try: + return _github_app(app_id_env, key_env).installation_token(owner, repo) + except Exception as e: + print(f"Failed to get GitHub App installation token ({type(e).__name__}), exit...") + sys.exit(1) + + +def _github_client(app_id_env: str, key_env: str, owner: str, repo: str) -> Github: + try: + return _github_app(app_id_env, key_env).client_for_repo(owner, repo) + except Exception as e: + print(f"Failed to initialize GitHub App client ({type(e).__name__}), exit...") + sys.exit(1) + + +def release_tests_github_client() -> Github: + """Return PyGithub client for ``openshift/release-tests`` (Writer App).""" + return _github_client( + ENV_VAR_GITHUB_APP_WRITER_ID, + ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY, + OPENSHIFT_OWNER, + RELEASE_TESTS_REPO, + ) + + +def openshift_release_github_client() -> Github: + """Return PyGithub client for ``openshift/release`` (Reader App).""" + return _github_client( + ENV_VAR_GITHUB_APP_READER_ID, + ENV_VAR_GITHUB_APP_READER_PRIVATE_KEY, + OPENSHIFT_OWNER, + RELEASE_REPO, + ) + + +def github_client_for_repo(repo: str) -> Github: + """Return PyGithub client for a supported repository.""" + if repo == REPO_RELEASE_TESTS: + return release_tests_github_client() + if repo == REPO_OPENSHIFT_RELEASE: + return openshift_release_github_client() + raise ValueError( + f"Unsupported repo {repo!r}; expected {REPO_RELEASE_TESTS} or {REPO_OPENSHIFT_RELEASE}" + ) + + +def _bearer_headers(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +def release_tests_github_headers() -> dict: + """HTTP headers with Writer App token for ``openshift/release-tests`` API calls.""" + token = _installation_token( + ENV_VAR_GITHUB_APP_WRITER_ID, + ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY, + OPENSHIFT_OWNER, + RELEASE_TESTS_REPO, + ) + return _bearer_headers(token) + + +def openshift_release_github_headers() -> dict: + """HTTP headers with Reader App token for ``openshift/release`` API calls.""" + token = _installation_token( + ENV_VAR_GITHUB_APP_READER_ID, + ENV_VAR_GITHUB_APP_READER_PRIVATE_KEY, + OPENSHIFT_OWNER, + RELEASE_REPO, + ) + return _bearer_headers(token) + + +def release_tests_clone_url() -> str: + """Authenticated git clone URL for ``openshift/release-tests`` (Writer App).""" + token = _installation_token( + ENV_VAR_GITHUB_APP_WRITER_ID, + ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY, + OPENSHIFT_OWNER, + RELEASE_TESTS_REPO, + ) + return f"https://x-access-token:{token}@github.com/{REPO_RELEASE_TESTS}.git" + + +def release_tests_bot_email() -> str: + """Commit email for the Writer GitHub App bot on ``openshift/release-tests``.""" + app_id = os.environ.get(ENV_VAR_GITHUB_APP_WRITER_ID, "") + return f"{app_id}+release-tests-github-app-openshift[bot]@users.noreply.github.com" + + +def create_release_tests_pr_with_approval( + title: str, body: str, head_branch: str, base: str = "main" +) -> None: + """Open a PR on ``openshift/release-tests`` and add ``lgtm`` / ``approved`` labels.""" + repo = release_tests_github_client().get_repo(REPO_RELEASE_TESTS) + pr = repo.create_pull( + title=title, + body=body, + base=base, + head=head_branch, + maintainer_can_modify=False, + ) + pr.add_to_labels("lgtm", "approved") diff --git a/prow/job/job.py b/prow/job/job.py index 3aa3ceeeafc7..1575b8988d83 100644 --- a/prow/job/job.py +++ b/prow/job/job.py @@ -17,6 +17,11 @@ from semver import VersionInfo from urllib3.util import Retry +from .github_auth import ( + openshift_release_github_headers, + release_tests_github_headers, +) + class Jobs: """Class Jobs handle Prow job by calling the API""" @@ -175,7 +180,7 @@ def get_job_data(self, payload, upgrade_from, upgrade_to): def get_sha(self, url): """Function get returned sha data""" - res = requests.get(url=url, headers=self.get_github_headers()) + res = requests.get(url=url, headers=self.get_release_tests_github_headers()) if res.status_code == 200: sha = json.loads(res.text)["sha"] print(f"sha: {sha}") @@ -187,7 +192,7 @@ def get_sha(self, url): def push_action(self, url, data): """Function push data to the Github repo""" res = requests.put(url=url, json=data, - headers=self.get_github_headers()) + headers=self.get_release_tests_github_headers()) if res.status_code == 200: print(res.reason) else: @@ -201,7 +206,7 @@ def push_versions(self, content, file): ) # print(base64Content) # check if the file exist - res = requests.get(url=url, headers=self.get_github_headers()) + res = requests.get(url=url, headers=self.get_release_tests_github_headers()) if res.status_code == 200: old_version = self.get_recored_version(url) if VersionInfo.parse(old_version) < VersionInfo.parse(content): @@ -236,7 +241,7 @@ def push_versions(self, content, file): def get_recored_version(self, url): """Function get the stored OCP payload info""" # it will use the default main branch - res = requests.get(url=url, headers=self.get_github_headers()) + res = requests.get(url=url, headers=self.get_release_tests_github_headers()) if res.status_code != 200: print( f"Fail to get recored version! {res.status_code}:{res.reason}") @@ -279,15 +284,13 @@ def save_job_data(self, job_dict): line = list(job_dict.values()) writer.writerow(line) - # get_github_headers func adds Github Token in case rate limit - def get_github_headers(self): - """Function check the Github token""" - token = os.getenv("GITHUB_TOKEN") - if token: - headers = {"Authorization": "Bearer " + token.strip()} - return headers - print("No GITHUB_TOKEN env var found, exit...") - sys.exit(0) + def get_release_tests_github_headers(self): + """HTTP headers for ``openshift/release-tests`` Contents API (Writer App).""" + return release_tests_github_headers() + + def get_openshift_release_github_headers(self): + """HTTP headers for ``openshift/release`` Contents API (Reader App).""" + return openshift_release_github_headers() def get_required_jobs(self, file_path): """Function get the required Prow jobs from the file""" @@ -508,7 +511,7 @@ def search_job(self, job_name, ocp_version): """Function search the prow job from https://github.com/openshift/release/tree/main/ci-operator/jobs/openshift/openshift-tests-private""" print("Searching job...") jobs_url = "https://api.github.com/repos/openshift/release/contents/ci-operator/jobs/openshift/openshift-tests-private/?ref=main" - req = requests.get(url=jobs_url, timeout=3) + req = requests.get(url=jobs_url, headers=self.get_openshift_release_github_headers(), timeout=3) if req.status_code != 200: print(f"Error code: {req.status_code}, reason: {req.reason}") return None @@ -522,11 +525,11 @@ def search_job(self, job_name, ocp_version): print(">>>> " + file_name) url = f"https://api.github.com/repos/openshift/release/contents/ci-operator/jobs/openshift/openshift-tests-private/{file_name}?ref=main" res = requests.get( - url=url, headers=self.get_github_headers(), timeout=3) + url=url, headers=self.get_openshift_release_github_headers(), timeout=3) if res.status_code != 200: continue response = requests.get( - url=res.json()["git_url"], headers=self.get_github_headers(), timeout=3) + url=res.json()["git_url"], headers=self.get_openshift_release_github_headers(), timeout=3) if response.status_code != 200: continue # We have to get the git blobs when the size is very large, such as @@ -637,7 +640,7 @@ def list_jobs(self, component, branch): if branch is None: branch = "main" base_url = f"https://api.github.com/repos/openshift/release/contents/ci-operator/config/{component}/?ref={branch}" - req = requests.get(url=base_url, timeout=3) + req = requests.get(url=base_url, headers=self.get_openshift_release_github_headers(), timeout=3) if req.status_code == 200: file_dict = yaml.load(req.text, Loader=yaml.FullLoader) file_count = 0 @@ -655,7 +658,7 @@ def list_jobs(self, component, branch): def get_jobs(self, url): """Function get prow jobs""" res = requests.get( - url=url, headers=self.get_github_headers(), timeout=3) + url=url, headers=self.get_openshift_release_github_headers(), timeout=3) if res.status_code == 200: content = base64.b64decode( res.json()["content"].replace("\n", "")).decode("utf-8") diff --git a/prow/job/selector.py b/prow/job/selector.py index ddd7c560fda0..154eac1d547f 100644 --- a/prow/job/selector.py +++ b/prow/job/selector.py @@ -1,16 +1,18 @@ import logging -import os import tempfile from datetime import datetime -from pathlib import Path import requests import yaml -from git import Repo, Actor -from github import Auth, GithubIntegration +from git import Actor, Repo -from .controller import Architectures -from .controller import GithubUtil +from .controller import Architectures, GithubUtil +from .github_auth import ( + REPO_OPENSHIFT_RELEASE, + create_release_tests_pr_with_approval, + release_tests_bot_email, + release_tests_clone_url, +) logger = logging.getLogger(__name__) @@ -26,7 +28,7 @@ def __init__(self, release) -> None: if not release: raise ValueError("param is mandatory") self._release = release - self.release_main = GithubUtil("openshift/release") + self.release_main = GithubUtil(REPO_OPENSHIFT_RELEASE) def get_jobs(self) -> list[str]: # get automated release jobs from github repo openshift/release by different releases @@ -60,100 +62,43 @@ def get_jobs(self) -> list[str]: return auto_release_jobs -class GithubApp(): - - def __init__(self, app_private_key_path, app_id, app_owner, app_repo): - self.app_id = app_id - self.app_owner = app_owner - self.app_repo = app_repo - app_private_key_file = Path(app_private_key_path) - if app_private_key_file.exists(): - app_private_key = app_private_key_file.expanduser().read_text() - app_auth = Auth.AppAuth(app_id, app_private_key) - github_integ = GithubIntegration(auth=app_auth) - github_install = github_integ.get_repo_installation( - app_owner, app_repo) - self._github_api = github_install.get_github_for_installation() - self._repo = self._github_api.get_repo(f"{app_owner}/{app_repo}") - else: - raise FileNotFoundError( - f"app private key file not found: {app_private_key_path}") - - def create_pull_request_and_approve(self, title, body, base, head): - pr = self._repo.create_pull( - title, body, base, head, maintainer_can_modify=False) - pr.add_to_labels("lgtm", "approved") - - def get_repo_url(self): - return f"https://x-access-token:{self._github_api._Github__requester.auth.token}@github.com/{self.app_owner}/{self.app_repo}.git" - - def get_email(self): - return f"{self.app_id}+{self.app_repo}-github-app-{self.app_owner}[bot]@users.noreply.github.com" - - class LocalGitRepo(): - def __init__(self, repo_url): - self._default_branch = "main" - self._repo_name = repo_url.split()[-1].replace(".git", "") + def __init__(self, repo_url, default_branch="main"): + self._default_branch = default_branch + self._repo_name = repo_url.split("/")[-1].replace(".git", "") self._repo_local_dir = tempfile.NamedTemporaryFile( dir="/tmp", prefix="release-tests-").name - # repo initialization self._repo = Repo.clone_from(repo_url, self._repo_local_dir) - def add_remote(self, name, url): - self._repo.create_remote(name, url) - def commit_file_change(self, relative_file_path, file_content, actor_name, actor_email): if not file_content: raise ValueError("file content is empty, cannot apply this change") local_file = f"{self._repo_local_dir}/{relative_file_path}" - # if not Path(local_file).exists(): - # raise FileNotFoundError( - # f"file {relative_file_path} not found in repo {self._repo_name}") - # fetch all remotes for remote in self._repo.remotes: remote.fetch() - if remote.name.lower() == "upstream": - self._repo.git.merge(f"{remote.name}/{self._default_branch}") + self._repo.git.merge(f"origin/{self._default_branch}") - # set push config self._repo.config_writer().set_value("push", "default", "current") - # create new branch with datetime suffix self.branch_name = "-".join(["autobranch", datetime.now().strftime("%y%m%d%H%M%S")]) branch = self._repo.create_head(self.branch_name) branch.checkout() - # make file change and commit with open(local_file, "w") as f: f.write(file_content) - # add changed file and commit self._repo.index.add(relative_file_path) commit_actor = Actor(name=actor_name, email=actor_email) self._repo.index.commit(message=f"Changes for file {relative_file_path}", author=commit_actor, committer=commit_actor) - # push local change to remote self._repo.remote().push() class TestJobRegistryUpdater(): def __init__(self, release, arch=Architectures.AMD64): - app_private_key = os.environ.get("APP_PRIVATE_KEY") - if not app_private_key: - raise ValueError("env variable APP_PRIVATE_KEY is mandatory") - app_id = 897744 - app_local_account = "rioliu-rh" - app_upstream_account = "openshift" - app_repo = "release-tests" - self._app_of_forked_repo = GithubApp( - app_private_key, app_id, app_local_account, app_repo) - self._app_of_upstream_repo = GithubApp( - app_private_key, app_id, app_upstream_account, app_repo) - self._local_git_repo = LocalGitRepo( - self._app_of_forked_repo.get_repo_url()) + self._local_git_repo = LocalGitRepo(release_tests_clone_url()) self._release = release self._arch = arch self._file_path = f"_releases/ocp-{self._release}-test-jobs-{self._arch}.json" @@ -162,11 +107,16 @@ def update(self, file_content): if not file_content: raise ValueError("file content is mandatory") - self._local_git_repo.add_remote( - "upstream", self._app_of_upstream_repo.get_repo_url()) self._local_git_repo.commit_file_change( - self._file_path, file_content, "QE Github App", self._app_of_forked_repo.get_email()) - - self._app_of_upstream_repo.create_pull_request_and_approve( - f"Update test job registry for {self._release}-{self._arch} - {datetime.now().strftime('%y%m%d%H%M')}", - "Update job registry with rotated job list", "main", f"{self._app_of_forked_repo.app_owner}:{self._local_git_repo.branch_name}") + self._file_path, + file_content, + "ERT Writer App", + release_tests_bot_email(), + ) + + create_release_tests_pr_with_approval( + f"Update test job registry for {self._release}-{self._arch} - " + f"{datetime.now().strftime('%y%m%d%H%M')}", + "Update job registry with rotated job list", + self._local_git_repo.branch_name, + ) 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) diff --git a/tests/test_release_discovery.py b/tests/test_release_discovery.py index 420c15a820cd..0d85cdfc8336 100644 --- a/tests/test_release_discovery.py +++ b/tests/test_release_discovery.py @@ -6,6 +6,7 @@ from datetime import datetime, timedelta from unittest.mock import MagicMock, patch +from oar.core.const import ENV_VAR_GITHUB_APP_WRITER_ID, ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY from oar.core.exceptions import ReleaseDiscoveryException from oar.core.release_discovery import ReleaseDiscovery @@ -71,6 +72,11 @@ } } +WRITER_ENV = { + ENV_VAR_GITHUB_APP_WRITER_ID: '123', + ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY: '/fake/writer.pem', +} + def setup_graphql_mock(mock_github_instance): """ @@ -80,85 +86,74 @@ def setup_graphql_mock(mock_github_instance): mock_github_instance: Mocked Github instance Returns: - Tuple of (mock_repo, mock_requester) + Tuple of (mock_requester) """ - mock_repo = MagicMock() - mock_github_instance.get_repo.return_value = mock_repo - mock_requester = MagicMock() mock_github_instance._Github__requester = mock_requester mock_requester.requestJsonAndCheck.return_value = ({}, MOCK_GRAPHQL_RESPONSE) - return mock_repo, mock_requester + return mock_requester class TestReleaseDiscovery(unittest.TestCase): """Test ReleaseDiscovery class""" - @patch.dict('os.environ', {'GITHUB_TOKEN': 'fake_token'}) - @patch('oar.core.release_discovery.Github') - def test_init_with_token(self, mock_github): - """Test initialization with GitHub token""" - # Mocks - mock_github_instance = mock_github.return_value - mock_repo = MagicMock() - mock_github_instance.get_repo.return_value = mock_repo + @patch.dict('os.environ', WRITER_ENV) + @patch('oar.core.release_discovery.GitHubApp') + def test_init_with_writer_app(self, mock_github_app): + """Test initialization with GitHub App Writer credentials""" + mock_github = MagicMock() + mock_github_app.return_value.client_for_repo.return_value = mock_github - # Create class Instance discovery = ReleaseDiscovery() - # Assertions self.assertIsNotNone(discovery) - mock_github.assert_called_once() - mock_github_instance.get_repo.assert_called_once_with("openshift/release-tests") - self.assertEqual(discovery.repo, mock_repo) + mock_github_app.return_value.client_for_repo.assert_called_once_with( + "openshift", "release-tests" + ) + self.assertEqual(discovery._github, mock_github) @patch.dict('os.environ', {}, clear=True) - def test_init_without_token_raises_exception(self): - """Test initialization without token raises exception""" + def test_init_without_credentials_raises_exception(self): + """Test initialization without credentials raises exception""" with self.assertRaises(ReleaseDiscoveryException) as context: ReleaseDiscovery() - self.assertIn("GitHub token not found", str(context.exception)) + self.assertIn("must be set", str(context.exception)) - @patch.dict('os.environ', {'GITHUB_TOKEN': 'fake_token'}) - @patch('oar.core.release_discovery.Github') - def test_get_supported_ystreams(self, mock_github): + @patch.dict('os.environ', WRITER_ENV) + @patch('oar.core.release_discovery.GitHubApp') + def test_get_supported_ystreams(self, mock_github_app): """Test getting supported y-streams using GraphQL""" - # Setup GraphQL mock using common test data - setup_graphql_mock(mock_github.return_value) + mock_github = MagicMock() + mock_github_app.return_value.client_for_repo.return_value = mock_github + setup_graphql_mock(mock_github) discovery = ReleaseDiscovery() y_streams = discovery.get_supported_ystreams() - # Should return sorted directory names matching pattern self.assertEqual(y_streams, ["4.19", "4.20", "4.21"]) - @patch.dict('os.environ', {'GITHUB_TOKEN': 'fake_token'}) - @patch('oar.core.release_discovery.Github') - def test_get_latest_release_for_ystream(self, mock_github): + @patch.dict('os.environ', WRITER_ENV) + @patch('oar.core.release_discovery.GitHubApp') + def test_get_latest_release_for_ystream(self, mock_github_app): """Test getting latest release for y-stream using GraphQL""" - # Setup GraphQL mock using common test data - setup_graphql_mock(mock_github.return_value) + mock_github = MagicMock() + mock_github_app.return_value.client_for_repo.return_value = mock_github + setup_graphql_mock(mock_github) discovery = ReleaseDiscovery() latest = discovery.get_latest_release_for_ystream("4.20") - # Should return the highest version (4.20.17 > 4.20.16 > 4.20.15) self.assertEqual(latest, "4.20.17") - @patch.dict('os.environ', {'GITHUB_TOKEN': 'fake_token'}) - @patch('oar.core.release_discovery.Github') - def test_get_active_releases(self, mock_github): + @patch.dict('os.environ', WRITER_ENV) + @patch('oar.core.release_discovery.GitHubApp') + def test_get_active_releases(self, mock_github_app): """Test getting active releases using GraphQL""" - # Setup GraphQL mock for tracking files - mock_github_instance = mock_github.return_value - setup_graphql_mock(mock_github_instance) - - # Mock StateBox GraphQL responses - mock_requester = mock_github_instance._Github__requester + mock_github = MagicMock() + mock_github_app.return_value.client_for_repo.return_value = mock_github + mock_requester = setup_graphql_mock(mock_github) - # First call: tracking files (already mocked) - # Second call: StateBox files tomorrow = (datetime.now().date() + timedelta(days=1)).strftime("%Y-%b-%d") past = (datetime.now().date() - timedelta(days=5)).strftime("%Y-%b-%d") @@ -166,28 +161,26 @@ def test_get_active_releases(self, mock_github): "data": { "repository": { "release_0": { - "text": f"metadata:\n release_date: {past}\n" # 4.19.10 - past window + "text": f"metadata:\n release_date: {past}\n" }, "release_1": { - "text": f"metadata:\n release_date: {tomorrow}\n" # 4.20.17 - active + "text": f"metadata:\n release_date: {tomorrow}\n" }, "release_2": { - "text": f"metadata:\n release_date: {past}\n" # 4.21.8 - past window + "text": f"metadata:\n release_date: {past}\n" } } } } - # Mock requestJsonAndCheck to return tracking files first, then StateBox files mock_requester.requestJsonAndCheck.side_effect = [ - ({}, MOCK_GRAPHQL_RESPONSE), # First call: tracking files - ({}, statebox_response) # Second call: StateBox files + ({}, MOCK_GRAPHQL_RESPONSE), + ({}, statebox_response), ] discovery = ReleaseDiscovery() active_releases = discovery.get_active_releases() - # Only 4.20.17 should be active (tomorrow's date) self.assertEqual(active_releases, ["4.20.17"]) diff --git a/tests/test_statebox.py b/tests/test_statebox.py index c897a007e129..88d7364c3ff5 100644 --- a/tests/test_statebox.py +++ b/tests/test_statebox.py @@ -5,7 +5,7 @@ from oar.core.statebox import StateBox, SCHEMA_VERSION, DEFAULT_TASK_STATUS, VALID_TASK_STATUSES, mask_sensitive_data, extract_start_timestamp, extract_end_timestamp from oar.core.exceptions import StateBoxException, ConfigStoreException -from oar.core.configstore import ConfigStore +from oar.core.const import ENV_VAR_GITHUB_APP_WRITER_ID, ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY logger = logging.getLogger(__name__) @@ -15,18 +15,22 @@ class TestStateBox(unittest.TestCase): Integration tests for StateBox GitHub-backed state management. These tests perform real GitHub operations against the z-stream branch. - Requires GITHUB_TOKEN environment variable to be set. + Requires GITHUB_APP_WRITER_ID and GITHUB_APP_WRITER_PRIVATE_KEY to be set. Uses a dummy release version for testing and cleans up after each test. """ @classmethod def setUpClass(cls): - """Validate environment and skip tests if GITHUB_TOKEN not found""" - cls.github_token = os.environ.get("GITHUB_TOKEN") - - if not cls.github_token: - raise unittest.SkipTest("GITHUB_TOKEN not found in environment. Skipping StateBox integration tests.") + """Validate environment and skip tests if GitHub App Writer credentials not found""" + cls.writer_app_id = os.environ.get(ENV_VAR_GITHUB_APP_WRITER_ID) + cls.writer_private_key_path = os.environ.get(ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY) + + if not cls.writer_app_id or not cls.writer_private_key_path: + raise unittest.SkipTest( + f"{ENV_VAR_GITHUB_APP_WRITER_ID} or {ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY} " + "not found in environment. Skipping StateBox integration tests." + ) # Test configuration cls.test_release = "4.20.5" # Dummy release for testing (4.y.z format) @@ -45,7 +49,6 @@ def setUp(self): self.configstore, repo_name=self.repo_name, branch=self.branch, - github_token=self.github_token ) def tearDown(self): @@ -118,7 +121,6 @@ def test_load_file_not_exists(self): dummy_configstore, repo_name=self.repo_name, branch=self.branch, - github_token=self.github_token ) state = dummy_statebox.load() @@ -144,7 +146,6 @@ def test_invalid_release_configstore_error(self): invalid_configstore, repo_name=self.repo_name, branch=self.branch, - github_token=self.github_token ) # Verify the error message indicates download failed @@ -622,7 +623,6 @@ def test_context_manager(self): cm_configstore = ConfigStore(self.test_release) with StateBox( cm_configstore, - github_token=self.github_token ) as statebox: self.assertIsNotNone(statebox) self.assertEqual(statebox.release, self.test_release) @@ -645,13 +645,11 @@ def test_concurrent_writes_with_merge(self): cs1, repo_name=self.repo_name, branch=self.branch, - github_token=self.github_token ) statebox2 = StateBox( cs2, repo_name=self.repo_name, branch=self.branch, - github_token=self.github_token ) # Both load the same initial state @@ -690,13 +688,11 @@ def test_concurrent_task_updates_with_merge(self): cs1, repo_name=self.repo_name, branch=self.branch, - github_token=self.github_token ) statebox2 = StateBox( cs2, repo_name=self.repo_name, branch=self.branch, - github_token=self.github_token ) # Instance 1 updates image-consistency-check (use valid task names) @@ -732,13 +728,11 @@ def test_concurrent_issue_updates_with_merge(self): cs1, repo_name=self.repo_name, branch=self.branch, - github_token=self.github_token ) statebox2 = StateBox( cs2, repo_name=self.repo_name, branch=self.branch, - github_token=self.github_token ) # Instance 1 adds issue1 diff --git a/tools/auto_release_test_dashboard.py b/tools/auto_release_test_dashboard.py index 192a24b851c0..8e51e03316b1 100644 --- a/tools/auto_release_test_dashboard.py +++ b/tools/auto_release_test_dashboard.py @@ -1,9 +1,14 @@ import json +import logging import os import pandas as pd import streamlit as st -from github import Github, Auth + +from oar.core.const import ENV_VAR_GITHUB_APP_WRITER_ID, ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY +from oar.core.github_app import GitHubApp + +logger = logging.getLogger(__name__) # Define the github access variables repository_owner = 'openshift' @@ -11,15 +16,29 @@ directory_path = '_releases' branch_name = 'record' -# Read the GitHub token from a system environment variable -github_token = os.environ.get('GITHUB_TOKEN') -if not github_token: - st.warning("Oops, ENV VAR GITHUB_TOKEN NOT FOUND") +# Read the GitHub App credentials from environment variables +github_app_id = os.environ.get(ENV_VAR_GITHUB_APP_WRITER_ID) +github_app_private_key = os.environ.get(ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY) +if not github_app_id or not github_app_private_key: + st.warning( + f"Oops, ENV VAR {ENV_VAR_GITHUB_APP_WRITER_ID} or " + f"{ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY} NOT FOUND" + ) st.stop() - -# Create a GitHub instance with the token -gh = Github(auth=Auth.Token(github_token)) +# Create a GitHub instance with the App installation token +try: + gh = GitHubApp(github_app_id, github_app_private_key).client_for_repo( + repository_owner, repository_name) +except Exception: + logger.exception("Failed to initialize GitHub App client") + st.error( + "Failed to initialize GitHub App client. " + f"Please check `{ENV_VAR_GITHUB_APP_WRITER_ID}` and " + f"`{ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY}` " + "and verify the app is installed on `openshift/release-tests`." + ) + st.stop() # Get the repository object repo = gh.get_repo(f'{repository_owner}/{repository_name}') diff --git a/tools/auto_release_test_result_checker.py b/tools/auto_release_test_result_checker.py index cad6277242cb..c4825a9821ac 100644 --- a/tools/auto_release_test_result_checker.py +++ b/tools/auto_release_test_result_checker.py @@ -1,18 +1,25 @@ +import logging import os import re import time import click -from github import Github from slack_sdk import WebClient from slack_sdk.errors import SlackApiError +from oar.core.const import ( + ENV_VAR_GITHUB_APP_WRITER_ID, + ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY, +) +from oar.core.github_app import GitHubApp + +logger = logging.getLogger(__name__) + class TestResultChecker: - def __init__(self, github_token, repo_name, slack_token, slack_channel, notified_file_path, + def __init__(self, repo, slack_token, slack_channel, notified_file_path, limit=5, path="_releases", branch="record"): - self.github = Github(github_token) - self.repo = self.github.get_repo(repo_name) + self.repo = repo self.slack_client = WebClient(token=slack_token) self.slack_channel = slack_channel self.notified_file_path = notified_file_path @@ -95,6 +102,28 @@ def send_slack_notification(self, file): print(f"Error sending Slack notification: {e}") +def _github_repo(repo_name: str): + app_id = os.environ.get(ENV_VAR_GITHUB_APP_WRITER_ID) + private_key_path = os.environ.get(ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY) + if not app_id or not private_key_path: + raise click.ClickException( + f"{ENV_VAR_GITHUB_APP_WRITER_ID} and " + f"{ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY} must be set." + ) + try: + owner, repo = repo_name.split("/", 1) + except ValueError as e: + raise click.ClickException( + f"--repo-name must be in owner/repo format, got {repo_name!r}" + ) from e + try: + github = GitHubApp(app_id, private_key_path).client_for_repo(owner, repo) + return github.get_repo(repo_name) + except Exception as e: + logger.error("Failed to initialize GitHub App Writer (%s)", type(e).__name__) + raise click.ClickException("Failed to initialize GitHub App Writer client.") from e + + @click.command() @click.option('--repo-name', required=True, help='Name of the GitHub repository in the format "owner/repo"') @click.option('--slack-channel', required=True, help='Name of the Slack channel to send notifications to') @@ -104,15 +133,13 @@ def send_slack_notification(self, file): @click.option('--branch', default="record", help='Branch in the GitHub repository where test result files are located') def main(repo_name, slack_channel, notified_file_path, limit, path, branch): - github_token = os.getenv('GITHUB_TOKEN') slack_token = os.getenv('SLACK_BOT_TOKEN') - if not github_token: - raise click.ClickException("GITHUB_TOKEN environment variable is not set.") if not slack_token: - raise click.ClickException("SLACK_TOKEN environment variable is not set.") + raise click.ClickException("SLACK_BOT_TOKEN environment variable is not set.") - checker = TestResultChecker(github_token, repo_name, slack_token, slack_channel, notified_file_path, + repo = _github_repo(repo_name) + checker = TestResultChecker(repo, slack_token, slack_channel, notified_file_path, limit, path, branch) checker.iterate_test_result_files() From 08511665a9a278543df7d4bfec333f52a6a52088 Mon Sep 17 00:00:00 2001 From: Tomas David Date: Mon, 1 Jun 2026 15:31:37 +0200 Subject: [PATCH 02/10] Fix Jira Notificator env required rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- AGENTS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index eb580c3c626d..f66422d410bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -164,7 +164,9 @@ oarctl jira-notificator [--dry-run] [--from-date YYYY-MM-DD] - `LdapHelper` - LDAP integration for manager lookup **Environment Variables Required:** -- `JIRA_TOKEN` - Jira authentication token +- `JIRA_TOKEN` - Jira personal access token for API access +- `GITHUB_APP_READER_ID` / `GITHUB_APP_READER_PRIVATE_KEY` - Reader GitHub App for PR label checks +- Kerberos ticket - For LDAP manager lookup (`kinit $kid@$domain`) **Options:** - `--dry-run` - Test mode that doesn't send actual Jira comments From 33ea0f22eaee95d193ca650760a3032080891f56 Mon Sep 17 00:00:00 2001 From: Tomas David Date: Mon, 1 Jun 2026 15:39:27 +0200 Subject: [PATCH 03/10] Switch writer to reader for test result checker rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- AGENTS.md | 4 ++-- tools/auto_release_test_result_checker.py | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f66422d410bd..330aa47f084c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -251,7 +251,7 @@ python tools/auto_release_test_result_checker.py \ - File tracking system to avoid duplicate notifications **Environment Variables Required:** -- `GITHUB_APP_WRITER_ID` / `GITHUB_APP_WRITER_PRIVATE_KEY` - Writer GitHub App for `openshift/release-tests` +- `GITHUB_APP_READER_ID` / `GITHUB_APP_READER_PRIVATE_KEY` - Reader GitHub App for `openshift/release-tests` (read-only) - `SLACK_BOT_TOKEN` - Slack bot token **Options:** @@ -324,7 +324,7 @@ pip3 install -e . - All OAR CLI environment variables (executes OAR commands) **Test Result Checker:** -- **GITHUB_APP_WRITER_ID** / **GITHUB_APP_WRITER_PRIVATE_KEY** - Writer GitHub App for `openshift/release-tests` +- **GITHUB_APP_READER_ID** / **GITHUB_APP_READER_PRIVATE_KEY** - Reader GitHub App for `openshift/release-tests` (read-only) - **SLACK_BOT_TOKEN** - Slack bot token for sending notifications **Note:** `OAR_SLACK_CHANNEL` and `OAR_SLACK_THREAD` are set internally by the Slack bot when executing commands and should not be configured manually by users. diff --git a/tools/auto_release_test_result_checker.py b/tools/auto_release_test_result_checker.py index c4825a9821ac..78e67d67b48f 100644 --- a/tools/auto_release_test_result_checker.py +++ b/tools/auto_release_test_result_checker.py @@ -8,8 +8,8 @@ from slack_sdk.errors import SlackApiError from oar.core.const import ( - ENV_VAR_GITHUB_APP_WRITER_ID, - ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY, + ENV_VAR_GITHUB_APP_READER_ID, + ENV_VAR_GITHUB_APP_READER_PRIVATE_KEY, ) from oar.core.github_app import GitHubApp @@ -103,12 +103,12 @@ def send_slack_notification(self, file): def _github_repo(repo_name: str): - app_id = os.environ.get(ENV_VAR_GITHUB_APP_WRITER_ID) - private_key_path = os.environ.get(ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY) + 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: raise click.ClickException( - f"{ENV_VAR_GITHUB_APP_WRITER_ID} and " - f"{ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY} must be set." + f"{ENV_VAR_GITHUB_APP_READER_ID} and " + f"{ENV_VAR_GITHUB_APP_READER_PRIVATE_KEY} must be set." ) try: owner, repo = repo_name.split("/", 1) @@ -120,8 +120,8 @@ def _github_repo(repo_name: str): github = GitHubApp(app_id, private_key_path).client_for_repo(owner, repo) return github.get_repo(repo_name) except Exception as e: - logger.error("Failed to initialize GitHub App Writer (%s)", type(e).__name__) - raise click.ClickException("Failed to initialize GitHub App Writer client.") from e + logger.error("Failed to initialize GitHub App Reader (%s)", type(e).__name__) + raise click.ClickException("Failed to initialize GitHub App Reader client.") from e @click.command() From 7d910fb0d5da9fbd8330d55cdd615cc141201157 Mon Sep 17 00:00:00 2001 From: Tomas David Date: Mon, 1 Jun 2026 15:41:30 +0200 Subject: [PATCH 04/10] Switch writer to reader for dashboard rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- tools/auto_release_test_dashboard.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tools/auto_release_test_dashboard.py b/tools/auto_release_test_dashboard.py index 8e51e03316b1..8b3a9645c790 100644 --- a/tools/auto_release_test_dashboard.py +++ b/tools/auto_release_test_dashboard.py @@ -5,7 +5,7 @@ import pandas as pd import streamlit as st -from oar.core.const import ENV_VAR_GITHUB_APP_WRITER_ID, ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY +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 logger = logging.getLogger(__name__) @@ -17,12 +17,12 @@ branch_name = 'record' # Read the GitHub App credentials from environment variables -github_app_id = os.environ.get(ENV_VAR_GITHUB_APP_WRITER_ID) -github_app_private_key = os.environ.get(ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY) +github_app_id = os.environ.get(ENV_VAR_GITHUB_APP_READER_ID) +github_app_private_key = os.environ.get(ENV_VAR_GITHUB_APP_READER_PRIVATE_KEY) if not github_app_id or not github_app_private_key: st.warning( - f"Oops, ENV VAR {ENV_VAR_GITHUB_APP_WRITER_ID} or " - f"{ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY} NOT FOUND" + f"Oops, ENV VAR {ENV_VAR_GITHUB_APP_READER_ID} or " + f"{ENV_VAR_GITHUB_APP_READER_PRIVATE_KEY} NOT FOUND" ) st.stop() @@ -34,8 +34,8 @@ logger.exception("Failed to initialize GitHub App client") st.error( "Failed to initialize GitHub App client. " - f"Please check `{ENV_VAR_GITHUB_APP_WRITER_ID}` and " - f"`{ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY}` " + f"Please check `{ENV_VAR_GITHUB_APP_READER_ID}` and " + f"`{ENV_VAR_GITHUB_APP_READER_PRIVATE_KEY}` " "and verify the app is installed on `openshift/release-tests`." ) st.stop() From 7812304aae5641db088343804a15ba99aa698d4d Mon Sep 17 00:00:00 2001 From: Tomas David Date: Mon, 1 Jun 2026 15:45:33 +0200 Subject: [PATCH 05/10] Fix github auth in jira notificator rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- oar/notificator/jira_notificator.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/oar/notificator/jira_notificator.py b/oar/notificator/jira_notificator.py index 9f623ac6959f..9e82cb1d6633 100644 --- a/oar/notificator/jira_notificator.py +++ b/oar/notificator/jira_notificator.py @@ -94,20 +94,16 @@ 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. + GitHubApp instance, or None if credentials are missing. + + Raises: + Exception: If credentials are set but GitHub App 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 + return GitHubApp(app_id, private_key_path) def _github_client_for_repo(self, org: str, repo: str): """ From 0c4de60f75f847c9cae89058c6ba0a3bf0c6e8a8 Mon Sep 17 00:00:00 2001 From: Tomas David Date: Mon, 1 Jun 2026 15:51:15 +0200 Subject: [PATCH 06/10] Fix temp dir rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- prow/job/selector.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/prow/job/selector.py b/prow/job/selector.py index 154eac1d547f..b504d261e8d3 100644 --- a/prow/job/selector.py +++ b/prow/job/selector.py @@ -67,8 +67,7 @@ class LocalGitRepo(): def __init__(self, repo_url, default_branch="main"): self._default_branch = default_branch self._repo_name = repo_url.split("/")[-1].replace(".git", "") - self._repo_local_dir = tempfile.NamedTemporaryFile( - dir="/tmp", prefix="release-tests-").name + self._repo_local_dir = tempfile.mkdtemp(dir="/tmp", prefix="release-tests-") self._repo = Repo.clone_from(repo_url, self._repo_local_dir) def commit_file_change(self, relative_file_path, file_content, actor_name, actor_email): From 4b86867222f1828d41f71fe92763ccbb2b5b128f Mon Sep 17 00:00:00 2001 From: Tomas David Date: Mon, 1 Jun 2026 15:52:12 +0200 Subject: [PATCH 07/10] Fix configstore rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- tests/test_statebox.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_statebox.py b/tests/test_statebox.py index 88d7364c3ff5..0b2f7239c0c2 100644 --- a/tests/test_statebox.py +++ b/tests/test_statebox.py @@ -5,6 +5,7 @@ from oar.core.statebox import StateBox, SCHEMA_VERSION, DEFAULT_TASK_STATUS, VALID_TASK_STATUSES, mask_sensitive_data, extract_start_timestamp, extract_end_timestamp from oar.core.exceptions import StateBoxException, ConfigStoreException +from oar.core.configstore import ConfigStore from oar.core.const import ENV_VAR_GITHUB_APP_WRITER_ID, ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY logger = logging.getLogger(__name__) From ad70a35f814886041a80ece889c492c7e26f0697 Mon Sep 17 00:00:00 2001 From: Tomas David Date: Mon, 1 Jun 2026 15:55:33 +0200 Subject: [PATCH 08/10] Fix split rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- tools/auto_release_test_result_checker.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tools/auto_release_test_result_checker.py b/tools/auto_release_test_result_checker.py index 78e67d67b48f..31930193d532 100644 --- a/tools/auto_release_test_result_checker.py +++ b/tools/auto_release_test_result_checker.py @@ -116,6 +116,11 @@ def _github_repo(repo_name: str): raise click.ClickException( f"--repo-name must be in owner/repo format, got {repo_name!r}" ) from e + owner, repo = owner.strip(), repo.strip() + if not owner or not repo: + raise click.ClickException( + f"--repo-name must be in owner/repo format, got {repo_name!r}" + ) try: github = GitHubApp(app_id, private_key_path).client_for_repo(owner, repo) return github.get_repo(repo_name) From eafc3f6836f70f9d1738b48459424d712b0d09c0 Mon Sep 17 00:00:00 2001 From: Tomas David Date: Tue, 2 Jun 2026 09:04:16 +0200 Subject: [PATCH 09/10] Split prow and oar rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- prow/job/github_app.py | 34 ++++++++++++++++++++++++++++++++++ prow/job/github_auth.py | 14 +++++++------- 2 files changed, 41 insertions(+), 7 deletions(-) create mode 100644 prow/job/github_app.py diff --git a/prow/job/github_app.py b/prow/job/github_app.py new file mode 100644 index 000000000000..762637dc5454 --- /dev/null +++ b/prow/job/github_app.py @@ -0,0 +1,34 @@ +"""GitHub App auth utilities for prow/job. + +This module intentionally lives under ``prow/job`` so the prow "job/jobctl" +package can be installed standalone (without depending on the full ``oar`` app). +""" + +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): + 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 installation_token(self, owner: str, repo: str) -> str: + installation = self._integration.get_repo_installation(owner, repo) + return self._integration.get_access_token(installation.id).token + + def client_for_repo(self, owner: str, repo: str) -> Github: + installation = self._integration.get_repo_installation(owner, repo) + return self._integration.get_github_for_installation(installation.id) + diff --git a/prow/job/github_auth.py b/prow/job/github_auth.py index 1adaa4edbe88..1df840c02b57 100644 --- a/prow/job/github_auth.py +++ b/prow/job/github_auth.py @@ -5,13 +5,13 @@ from github import Github -from oar.core.const import ( - ENV_VAR_GITHUB_APP_READER_ID, - ENV_VAR_GITHUB_APP_READER_PRIVATE_KEY, - ENV_VAR_GITHUB_APP_WRITER_ID, - ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY, -) -from oar.core.github_app import GitHubApp +from .github_app import GitHubApp + +# Keep prow/job installable as a standalone package (do not depend on oar.*). +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" OPENSHIFT_OWNER = "openshift" RELEASE_REPO = "release" From d079ad38627ae4949197023fe889a8f4f3e32825 Mon Sep 17 00:00:00 2001 From: Tomas David Date: Tue, 2 Jun 2026 09:36:07 +0200 Subject: [PATCH 10/10] Remove oar dependencies rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- prow/job/controller.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/prow/job/controller.py b/prow/job/controller.py index 1fc7274ca352..fb79af8f1cc3 100755 --- a/prow/job/controller.py +++ b/prow/job/controller.py @@ -14,11 +14,13 @@ import yaml from github.GithubException import UnknownObjectException, GithubException -from oar.core.const import ( +from .github_auth import ( ENV_VAR_GITHUB_APP_WRITER_ID, ENV_VAR_GITHUB_APP_WRITER_PRIVATE_KEY, + REPO_OPENSHIFT_RELEASE, + REPO_RELEASE_TESTS, + github_client_for_repo, ) -from .github_auth import REPO_OPENSHIFT_RELEASE, REPO_RELEASE_TESTS, github_client_for_repo from requests.adapters import HTTPAdapter from requests.exceptions import RequestException from semver import VersionInfo