From f6afa35a31e8a9a3b3e48903ab96c6538ecceea5 Mon Sep 17 00:00:00 2001 From: Joseph Macaranas Date: Fri, 9 May 2025 22:10:02 -0400 Subject: [PATCH 01/10] [GitHub Actions] Authenticate with GitHub App and Organization Detection - Classify PRs with special label if creator is in ROCm organization or not. - GitHub App has been created https://github.com/settings/apps/assistant-librarian - Key operations in our workflows like creating pull requests and writing check runs is not supported by gh cli with a github app token. - Instead of mixing and matching gh cli operations and REST API calls in python, just stick to REST API calls outside of raw git commands. - gh cli usage can continue within the yml scripts. - TODO: Rename the utility class. Keeping same file name for code review purposes to help diff detection. --- .github/scripts/github_cli_client.py | 301 ++++++++++++++++--------- .github/workflows/pr-auto-label.yml | 41 +++- .github/workflows/pr-close-fanouts.yml | 19 +- .github/workflows/pr-fanout.yml | 29 ++- 4 files changed, 266 insertions(+), 124 deletions(-) diff --git a/.github/scripts/github_cli_client.py b/.github/scripts/github_cli_client.py index 401e470c94c..29836987cab 100644 --- a/.github/scripts/github_cli_client.py +++ b/.github/scripts/github_cli_client.py @@ -1,142 +1,241 @@ #!/usr/bin/env python3 """ -GitHub CLI Client Utility -------------------------- -This utility provides a GitHubCLIClient class that wraps GitHub CLI (gh) operations +GitHub Client Utility +--------------------- +This utility provides a GitHubClient class that wraps GitHub REST API operations used across automation scripts, such as retrieving pull request file changes and labels. -When doing manual testing, you can run the same gh commands directly in the terminal. -These commands will be output by the debug logging in debug mode. +When doing manual testing, you can run the same REST API calls through curl in the terminal. +These REST API URLs, without the authentication header, will be output by the debug logging. + +This includes: +- Fetching PR details +- Creating PRs +- Closing PRs Requirements: - - GitHub CLI (`gh`) must be installed and authenticated. - - NOTE: GH_TOKEN environment variable hands authentication token to the CLI in a runner. - - The repository must be accessible to the authenticated user. + - NOTE: GH_TOKEN environment variable hands authentication token to this script in a runner. + - The token is created by the GitHub App and is passed to the script via the environment variable. + +Manual curl testing: + +To fetch PR details: + curl -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \ + https://api.github.com/repos/OWNER/REPO/pulls/NUMBER + +To list PRs by head branch: + curl -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/OWNER/REPO/pulls?head=OWNER:branch-name&state=open" + +To fetch changed files in a PR: + curl -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \ + https://api.github.com/repos/OWNER/REPO/pulls/NUMBER/files + +To create a PR: + curl -X POST -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \ + https://api.github.com/repos/OWNER/REPO/pulls \ + -d '{"title":"Title","body":"Description","head":"branch-name","base":"main"}' + +To apply labels: + curl -X POST -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \ + https://api.github.com/repos/OWNER/REPO/issues/NUMBER/labels \ + -d '{"labels": ["bug", "needs-review"]}' """ -import subprocess -import json +import os +import requests +import time import logging from typing import List, Optional logger = logging.getLogger(__name__) class GitHubCLIClient: + def __init__(self) -> None: - """Initialize the GitHub CLI client.""" - if not self._gh_available(): - raise EnvironmentError("GitHub CLI (`gh`) is not installed or not in PATH.") - - def _gh_available(self) -> bool: - """Check if GitHub CLI is available.""" - try: - subprocess.run(["gh", "--version"], check=True, stdout=subprocess.DEVNULL) - return True - except subprocess.CalledProcessError: - return False - - def _run_gh_command(self, args: List[str], dry_run: Optional[bool] = False) -> subprocess.CompletedProcess: - """Run a `gh` CLI command and return the result.""" - cmd = ["gh"] + args - logger.debug(f"Running command: {' '.join(cmd)}") - # dry_run option only matters for operations that write to GitHub - if dry_run: - result = subprocess.CompletedProcess(cmd, 0, stdout="Dry run enabled. No changes made.", stderr="") - else: - result = subprocess.run(cmd, capture_output=True, text=True) - if result.returncode != 0: - logger.error(f"Command failed: {' '.join(cmd)}\n{result.stderr.strip()}") - raise subprocess.CalledProcessError(result.returncode, cmd, result.stdout, result.stderr) - return result + """Initialize the GitHub API client using GitHub App authentication.""" + self.api_url = "https://api.github.com" + self.session = requests.Session() + self.session.headers.update({ + "Authorization": f"Bearer {self._get_token()}", + "Accept": "application/vnd.github+json", + }) + + def _get_token(self) -> str: + """Helper method to retrieve the GitHub token from environment variable.""" + token = os.getenv("GH_TOKEN") + if not token: + raise EnvironmentError("GH_TOKEN environment variable is not set") + return token + + def _get_with_retries(self, url: str, error_msg: str, retries: int = 3, + backoff: int = 2, timeout: int = 10) -> Optional[requests.Response]: + """Internal helper to retry a GET request with exponential backoff.""" + # no logging the actual request to avoid leaking sensitive information + for attempt in range(retries): + try: + response = self.session.get(url, timeout=timeout) + if response.status_code == 200: + return response + # for api rate limiting, we check the headers for remaining requests and reset time + elif response.status_code == 403 and response.headers.get("X-RateLimit-Remaining") == "0": + reset_time = int(response.headers.get("X-RateLimit-Reset", 0)) + sleep_seconds = max(1, reset_time - int(time.time()) + 1) + logger.warning(f"Rate limited. Sleeping for {sleep_seconds} seconds...") + time.sleep(sleep_seconds) + continue + # other errors will use exponential backoff timeout + elif response.status_code in {403, 429, 500, 502, 503, 504}: + logger.warning(f"Retryable error {response.status_code} on attempt {attempt}.") + else: + response.raise_for_status() + except requests.RequestException as e: + logger.warning(f"Request failed on attempt {attempt}: {e}") + logger.error(f"{error_msg} for {url} (Attempt {attempt + 1}/{retries})") + if attempt < retries - 1: + time.sleep(backoff ** attempt) # Exponential backoff + else: + logger.error(f"Max retries reached for GET at {url}. Giving up.") + return None + + def _get_json(self, url: str, error_msg: str) -> dict: + """Helper method to perform a simple GET request and return a single JSON object.""" + response = self._get_with_retries(url, error_msg) + return response.json() if response else {} + + def _get_paginated_json(self, url: str, error_msg: str) -> List[dict]: + """Helper method to perform a sequence of GET requests with pagination.""" + results = [] + while url: + response = self._get_with_retries(url, error_msg) + if not response: + return results + results.extend(response.json()) + url = response.links.get("next", {}).get("url") + return results + + def _request_json(self, method: str, url: str, json: Optional[dict] = None, + error_msg: str = "", retries: int = 3, backoff: int = 2) -> dict: + """Helper method to perform a request with retries and return JSON response.""" + # no logging the actual request to avoid leaking sensitive information + for attempt in range(retries): + response = self.session.request(method, url, json=json) + if response.ok: + return response.json() + else: + # for api rate limiting, we check the headers for remaining requests and reset time + if response.status_code == 403 and response.headers.get("X-RateLimit-Remaining") == "0": + reset_time = int(response.headers.get("X-RateLimit-Reset", 0)) + sleep_seconds = max(1, reset_time - int(time.time()) + 1) + logger.warning(f"Rate limited. Sleeping for {sleep_seconds} seconds...") + time.sleep(sleep_seconds) + # other errors will use exponential backoff timeout + else: + logger.error(f"{error_msg} for method {method} at {url} (Attempt {attempt + 1}/{retries})") + if attempt < retries - 1: + time.sleep(backoff ** attempt) # Exponential backoff + else: + logger.error(f"Max retries reached for method {method} at {url}. Giving up.") + return {} def get_changed_files(self, repo: str, pr: int) -> List[str]: - """Fetch the changed files in a pull request using `gh` CLI.""" - result = self._run_gh_command( - ["pr", "view", str(pr), "--repo", repo, "--json", "files"], - ) - data = json.loads(result.stdout) - files = [f["path"] for f in data.get("files", [])] + """Fetch the changed files in a pull request using GitHub API.""" + url = f"{self.api_url}/repos/{repo}/pulls/{pr}/files?per_page=100" + logger.debug(f"Request URL: {url}") + files_data = self._get_paginated_json(url, f"Failed to fetch files for PR #{pr} in {repo}") + files = [file["filename"] for file in files_data] logger.debug(f"Changed files in PR #{pr}: {files}") return files def get_defined_labels(self, repo: str) -> List[str]: """Get all labels defined in the given repository.""" - result = self._run_gh_command(["label", "list", "--repo", repo, "--json", "name"]) - return [label["name"] for label in json.loads(result.stdout)] + url = f"{self.api_url}/repos/{repo}/labels?per_page=100" + logger.debug(f"Request URL: {url}") + labels_data = self._get_paginated_json(url, f"Failed to fetch labels from {repo}") + labels = [label["name"] for label in labels_data] + logger.debug(f"Defined labels in {repo}: {labels}") + return labels def get_existing_labels_on_pr(self, repo: str, pr: int) -> List[str]: """Fetch current labels on a PR.""" - result = self._run_gh_command( - ["pr", "view", str(pr), "--repo", repo, "--json", "labels"] - ) - data = json.loads(result.stdout) - labels = [label["name"] for label in data.get("labels", [])] + url = f"{self.api_url}/repos/{repo}/issues/{pr}/labels?per_page=100" + logger.debug(f"Request URL: {url}") + labels_data = self._get_paginated_json(url, f"Failed to fetch labels for PR #{pr} in {repo}") + labels = [label["name"] for label in labels_data] logger.debug(f"Existing labels on PR #{pr}: {labels}") return labels def pr_view(self, repo: str, head: str) -> Optional[int]: """Check if a PR exists for the given repo and branch.""" - try: - result = self._run_gh_command(["pr", "list", "--json", "number", "--repo", repo, "--head", head]) - pr_list = json.loads(result.stdout) - return pr_list[0]["number"] if pr_list else None - except subprocess.CalledProcessError: - logger.warning(f"Failed to retrieve PR from {repo} with head {head}") - return None # PR does not exist + url = f"{self.api_url}/repos/{repo}/pulls?head={head}&per_page=100" + logger.debug(f"Request URL: {url}") + result = self._get_paginated_json(url, f"Failed to retrieve PR for head branch {head} in repo {repo}") + return result[0]["number"] if result else None def get_pr_by_head_branch(self, repo: str, head: str) -> Optional[dict]: - """Get the PR object for a given head branch in a repository, if it exists.""" - try: - result = self._run_gh_command(["pr", "list", "--json", "number,title,state", "--repo", repo, "--head", head]) - pr_list = json.loads(result.stdout) - return pr_list[0] if pr_list else None - except subprocess.CalledProcessError: - logger.warning(f"Failed to retrieve PR from {repo} with head {head}") - return None - - def pr_create(self, repo: str, base: str, head: str, title: str, body: str, dry_run: Optional[bool] = False) -> None: + """Fetch the PR object for a given head branch in a repository, if it exists.""" + url = f"{self.api_url}/repos/{repo}/pulls?head={repo.split('/')[0]}:{head}&state=open&per_page=100" + logger.debug(f"Request URL: {url}") + data = self._get_paginated_json(url, f"Failed to get PRs for {repo} with head {head}") + return data[0] if data else None + + def pr_create(self, repo: str, base: str, head: str, title: str, body: str, dry_run: bool = False) -> None: """Create a new pull request.""" - cmd = [ - "pr", "create", - "--repo", repo, - "--base", base, - "--head", head, - "--title", title, - "--body", body - ] - self._run_gh_command(cmd, dry_run=dry_run) + url = f"{self.api_url}/repos/{repo}/pulls" + payload = { + "title": title, + "body": body, + "head": head, + "base": base + } + logger.debug(f"Request URL: {url}") + logger.debug(f"Request Payload: {payload}") + if dry_run: + logger.info(f"Dry run: The pull request would be created from {head} to {base} in {repo}") + return + self._request_json("POST", url, payload, f"Failed to create PR from {head} to {base} in {repo}") logger.info(f"Created PR from {head} to {base} in {repo}.") - def close_pr_and_delete_branch(self, repo: str, pr_number: int, dry_run: Optional[bool] = False) -> None: - """Close a pull request and delete the associated branch using the GitHub CLI.""" - cmd = ["pr", "close", str(pr_number), "--repo", repo, "--delete-branch"] + def close_pr_and_delete_branch(self, repo: str, pr_number: int, dry_run: bool = False) -> None: + """Close a pull request and delete the associated branch using the GitHub API.""" + pr_url = f"{self.api_url}/repos/{repo}/pulls/{pr_number}" + logger.debug(f"Request URL: {pr_url}") + pr_data = self._get_json(pr_url, f"Failed to fetch PR #{pr_number} in {repo}") + head_ref = pr_data.get("head", {}).get("ref") + if not head_ref: + logger.error(f"Could not determine head branch for PR #{pr_number} in {repo}") + return + logger.debug(f"PR #{pr_number} head branch: {head_ref}") + close_payload = {"state": "closed"} + logger.debug(f"Request Payload: {close_payload}") if dry_run: - logger.info(f"Dry run: The pull request #{pr_number} would be closed and the branch would be deleted in repo '{repo}'") - else: - self._run_gh_command(cmd) - logger.info(f"Closed pull request #{pr_number} and deleted the associated branch in repo '{repo}'") - - def sync_labels(self, target_repo: str, pr_number: int, labels: List[str], dry_run: Optional[bool] = False) -> None: + logger.info(f"Dry run: The pull request #{pr_number} would be closed and the branch '{head_ref}' would be deleted in repo '{repo}'") + return + self._request_json("PATCH", pr_url, close_payload, f"Failed to close PR #{pr_number} in {repo}") + branch_url = f"{self.api_url}/repos/{repo}/git/refs/heads/{head_ref}" + logger.debug(f"Branch DELETE URL: {branch_url}") + self._request_json("DELETE", branch_url, None, f"Failed to delete branch '{head_ref}' for PR #{pr_number}") + logger.info(f"Closed pull request #{pr_number} and deleted the branch '{head_ref}' in {repo}.") + + def sync_labels(self, target_repo: str, pr_number: int, labels: List[str], dry_run: bool = False) -> None: """Sync labels from the source repo to the target repo (only apply existing labels).""" - logger.debug(f"Syncing labels to {target_repo} PR #{pr_number}.") - result = self._run_gh_command( - ["label", "list", "--repo", target_repo, "--json", "name"] - ) - target_repo_labels = {label["name"] for label in json.loads(result.stdout)} + url = f"{self.api_url}/repos/{target_repo}/labels?per_page=100" + logger.debug(f"Request URL: {url}") + target_repo_labels = {label["name"] for label in self._get_paginated_json(url, f"Failed to fetch labels for {target_repo}")} labels_set = set(labels) labels_to_apply = labels_set & target_repo_labels - # Apply labels that exist in both source PR and target repos - # Wrap in quotes if label contains spaces - labels_arg = ",".join(f'"{label}"' if " " in label else label for label in labels_to_apply) - cmd = [ - "pr", "edit", - str(pr_number), - "--repo", target_repo, - "--add-label", labels_arg - ] - if not dry_run: - self._run_gh_command(cmd, dry_run=dry_run) - logger.info(f"Applied labels '{labels_arg}' to PR #{pr_number} in {target_repo}.") + labels_for_logging = ",".join(labels_to_apply) + if labels_to_apply: + # note: using issues endpoint for labels as PRs are a subset of issues + url = f"{self.api_url}/repos/{target_repo}/issues/{pr_number}/labels" + payload = {"labels": list(labels_to_apply)} + logger.debug(f"Request URL: {url}") + logger.debug(f"Request Payload: {payload}") + if not dry_run: + self._request_json("POST", url, payload, f"Failed to apply labels to PR #{pr_number} in {target_repo}") + logger.info(f"Applied labels '{labels_for_logging}' to PR #{pr_number} in {target_repo}.") + else: + logger.info(f"Dry run: Labels '{labels_for_logging}' would be applied to PR #{pr_number} in {target_repo}.") else: - logger.info(f"Dry run: Labels '{labels_arg}' would be applied to PR #{pr_number} in {target_repo}.") + logger.info(f"No valid labels to apply to PR #{pr_number} in {target_repo}.") diff --git a/.github/workflows/pr-auto-label.yml b/.github/workflows/pr-auto-label.yml index c7e8cf34896..4ac076aed07 100644 --- a/.github/workflows/pr-auto-label.yml +++ b/.github/workflows/pr-auto-label.yml @@ -6,6 +6,7 @@ # Steps: # - Run pr_category_label.py to determine which category labels to add/remove # - Update labels on the PR using GitHub CLI (gh) +# - Check if the PR creator is a member of the specified organization and add/remove labels accordingly # - Run pr_fanout_sync_labels.py to sync custom labels from the monorepo PR to the subrepo PRs name: Auto Label PR @@ -18,21 +19,35 @@ on: pull_request: types: - labeled + - unlabeled # ensure that the workflow is not running for the same PR multiple times at once concurrency: group: pr-auto-label-${{ github.event.pull_request.number }} cancel-in-progress: false +# the monorepo and subrepos assumed to have these labels created in advance +env: + ORG_TO_CHECK: ROCm + ORG_LABEL: "organization: ROCm" + EXTERNAL_LABEL: "external contribution" + jobs: auto-label-pr: runs-on: ubuntu-24.04 steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + - name: Checkout workflows uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: sparse-checkout: '.github' - token: ${{ secrets.MONOREPO_BOT_TOKEN }} # since we are touching labels on subrepository PRs + token: ${{ steps.generate-token.outputs.token }} # since we are touching labels on subrepository PRs - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 @@ -42,17 +57,17 @@ jobs: - name: Install python dependencies run: | python -m pip install --upgrade pip - pip install pydantic + pip install pydantic requests - name: Set up Git user run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "assistant-librarian[bot]" + git config user.email "assistant-librarian[bot]@users.noreply.github.com" - name: Determine PR Number (workflow_run case only) id: pr_number env: - GH_TOKEN: ${{ secrets.MONOREPO_BOT_TOKEN }} + GH_TOKEN: ${{ steps.generate-token.outputs.token }} run: | if [ "${{ github.event_name }}" = "workflow_run" ]; then BRANCH_NAME="${{ github.event.workflow_run.head_branch }}" @@ -73,7 +88,7 @@ jobs: - name: Update labels env: - GH_TOKEN: ${{ secrets.MONOREPO_BOT_TOKEN }} + GH_TOKEN: ${{ steps.generate-token.outputs.token }} run: | if [ -n "${{ steps.compute_labels.outputs.label_remove }}" ]; then gh pr edit "${{ steps.pr_number.outputs.pr_number }}" --remove-label "${{ steps.compute_labels.outputs.label_remove }}" @@ -82,9 +97,21 @@ jobs: gh pr edit "${{ steps.pr_number.outputs.pr_number }}" --add-label "${{ steps.compute_labels.outputs.label_add }}" fi + - name: Check if PR creator is in org and label accordingly + env: + GH_TOKEN: ${{ steps.generate-token.outputs.token }} + run: | + if gh api orgs/${{ env.ORG_TO_CHECK }}/members/${{ github.event.pull_request.user.login }} --silent; then + gh pr edit "${{ github.event.pull_request.number }}" --repo "${{ github.repository }}" --add-label "${{ env.ORG_LABEL }}" + else + gh pr edit "${{ github.event.pull_request.number }}" --repo "${{ github.repository }}" --add-label "${{ env.EXTERNAL_LABEL}}" + # force remove the org label when the user is not in the org + gh pr edit "${{ github.event.pull_request.number }}" --repo "${{ github.repository }}" --remove-label "${{ env.ORG_LABEL }}" + fi + - name: Sync Custom Labels from Monorepo PR env: - GH_TOKEN: ${{ secrets.MONOREPO_BOT_TOKEN }} + GH_TOKEN: ${{ steps.generate-token.outputs.token }} run: | python .github/scripts/pr_fanout_sync_labels.py \ --repo ${{ github.repository }} \ diff --git a/.github/workflows/pr-close-fanouts.yml b/.github/workflows/pr-close-fanouts.yml index 577c8e1fe93..05256281792 100644 --- a/.github/workflows/pr-close-fanouts.yml +++ b/.github/workflows/pr-close-fanouts.yml @@ -15,11 +15,18 @@ jobs: close-fanouts: runs-on: ubuntu-24.04 steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + - name: Checkout workflows uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: sparse-checkout: '.github' - token: ${{ secrets.MONOREPO_BOT_TOKEN }} # since we are touching subrepository PRs + token: ${{ steps.generate-token.outputs.token }} # since we are touching subrepository PRs - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 @@ -27,16 +34,18 @@ jobs: python-version: '3.12' - name: Install python requirements - run: pip install pydantic + run: | + python -m pip install --upgrade pip + pip install pydantic requests - name: Set up Git user run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "assistant-librarian[bot]" + git config user.email "assistant-librarian[bot]@users.noreply.github.com" - name: Close fanned out PRs and delete branches env: - GH_TOKEN: ${{ secrets.MONOREPO_BOT_TOKEN }} + GH_TOKEN: ${{ steps.generate-token.outputs.token }} run: | python .github/scripts/pr_close_fanouts.py \ --repo "${{ github.repository }}" \ diff --git a/.github/workflows/pr-fanout.yml b/.github/workflows/pr-fanout.yml index 90cd60d890e..33668f95ae7 100644 --- a/.github/workflows/pr-fanout.yml +++ b/.github/workflows/pr-fanout.yml @@ -14,11 +14,11 @@ name: Fanout Subtree PRs on: - pull_request: - types: - - opened - - synchronize - - reopened + pull_request: + types: + - opened + - synchronize + - reopened # ensure that the workflow is not running for the same PR multiple times at once concurrency: @@ -27,14 +27,21 @@ concurrency: jobs: fanout: - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: sparse-checkout: .github sparse-checkout-cone-mode: true - token: ${{ secrets.MONOREPO_BOT_TOKEN }} # since we are touching subrepository PRs + token: ${{ secrets.MONOREPO_BOT_TOKEN }} # github app token does not work with subtree commands - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 @@ -48,13 +55,13 @@ jobs: - name: Set up Git user run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "assistant-librarian[bot]" + git config user.email "assistant-librarian[bot]@users.noreply.github.com" - name: Detect changed subtrees id: detect env: - GH_TOKEN: ${{ secrets.MONOREPO_BOT_TOKEN }} + GH_TOKEN: ${{ steps.generate-token.outputs.token }} run: | python .github/scripts/pr_detect_changed_subtrees.py \ --repo "${{ github.repository }}" \ @@ -74,7 +81,7 @@ jobs: - name: Fan out child PRs if: steps.detect.outputs.subtrees env: - GH_TOKEN: ${{ secrets.MONOREPO_BOT_TOKEN }} + GH_TOKEN: ${{ steps.generate-token.outputs.token }} run: | python .github/scripts/pr_fanout.py \ --repo "${{ github.repository }}" \ From 58c45852df74c67cbbf38e9d872b0bb9f4de9ca3 Mon Sep 17 00:00:00 2001 From: Joseph Macaranas Date: Sat, 10 May 2025 04:03:38 -0400 Subject: [PATCH 02/10] Replace all PAT instances with GitHub App-created token - Additional adjustments after running tests on forks. --- .github/workflows/initial-setup.yml | 18 +++++++++++++----- .github/workflows/pr-auto-label.yml | 10 ++++++---- .github/workflows/pr-close-fanouts.yml | 1 + .github/workflows/pr-fanout.yml | 5 +++-- .github/workflows/update-staging-subtree.yml | 16 ++++++++++++---- .github/workflows/update-subtrees.yml | 18 +++++++++++++----- 6 files changed, 48 insertions(+), 20 deletions(-) diff --git a/.github/workflows/initial-setup.yml b/.github/workflows/initial-setup.yml index 709d22ca7a4..673ced33dff 100644 --- a/.github/workflows/initial-setup.yml +++ b/.github/workflows/initial-setup.yml @@ -9,17 +9,25 @@ env: jobs: setup-monorepo: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + - name: Checkout the Monorepo - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - token: ${{ secrets.MONOREPO_BOT_TOKEN }} + token: ${{ steps.generate-token.outputs.token }} - name: Set up Git user and Git LFS run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "assistant-librarian[bot]" + git config user.email "assistant-librarian[bot]@users.noreply.github.com" - name: Add Repositories to the Monorepo run: | diff --git a/.github/workflows/pr-auto-label.yml b/.github/workflows/pr-auto-label.yml index 4ac076aed07..efc183ac430 100644 --- a/.github/workflows/pr-auto-label.yml +++ b/.github/workflows/pr-auto-label.yml @@ -42,6 +42,7 @@ jobs: with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} - name: Checkout workflows uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -101,12 +102,13 @@ jobs: env: GH_TOKEN: ${{ steps.generate-token.outputs.token }} run: | - if gh api orgs/${{ env.ORG_TO_CHECK }}/members/${{ github.event.pull_request.user.login }} --silent; then - gh pr edit "${{ github.event.pull_request.number }}" --repo "${{ github.repository }}" --add-label "${{ env.ORG_LABEL }}" + PR_USER=$(gh pr view "${{ steps.pr_number.outputs.pr_number }}" --json author -q .author.login) + if gh api orgs/${{ env.ORG_TO_CHECK }}/members/$PR_USER --silent; then + gh pr edit "${{ steps.pr_number.outputs.pr_number }}" --add-label "${{ env.ORG_LABEL }}" else - gh pr edit "${{ github.event.pull_request.number }}" --repo "${{ github.repository }}" --add-label "${{ env.EXTERNAL_LABEL}}" + gh pr edit "${{ steps.pr_number.outputs.pr_number }}" --add-label "${{ env.EXTERNAL_LABEL}}" # force remove the org label when the user is not in the org - gh pr edit "${{ github.event.pull_request.number }}" --repo "${{ github.repository }}" --remove-label "${{ env.ORG_LABEL }}" + gh pr edit "${{ steps.pr_number.outputs.pr_number }}" --remove-label "${{ env.ORG_LABEL }}" fi - name: Sync Custom Labels from Monorepo PR diff --git a/.github/workflows/pr-close-fanouts.yml b/.github/workflows/pr-close-fanouts.yml index 05256281792..a7547c2f083 100644 --- a/.github/workflows/pr-close-fanouts.yml +++ b/.github/workflows/pr-close-fanouts.yml @@ -21,6 +21,7 @@ jobs: with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} - name: Checkout workflows uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 diff --git a/.github/workflows/pr-fanout.yml b/.github/workflows/pr-fanout.yml index 33668f95ae7..e8d25e68fbb 100644 --- a/.github/workflows/pr-fanout.yml +++ b/.github/workflows/pr-fanout.yml @@ -35,13 +35,14 @@ jobs: with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: sparse-checkout: .github sparse-checkout-cone-mode: true - token: ${{ secrets.MONOREPO_BOT_TOKEN }} # github app token does not work with subtree commands + token: ${{ steps.generate-token.outputs.token }} - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 @@ -76,7 +77,7 @@ jobs: .github ${{ steps.detect.outputs.subtrees }} fetch-depth: 0 # Needed for subtree splitting - token: ${{ secrets.MONOREPO_BOT_TOKEN }} + token: ${{ steps.generate-token.outputs.token }} - name: Fan out child PRs if: steps.detect.outputs.subtrees diff --git a/.github/workflows/update-staging-subtree.yml b/.github/workflows/update-staging-subtree.yml index ea9fc6fb94f..44bb7f10cff 100644 --- a/.github/workflows/update-staging-subtree.yml +++ b/.github/workflows/update-staging-subtree.yml @@ -11,19 +11,27 @@ env: jobs: synchronize-subtrees: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + - name: Checkout the Monorepo uses: actions/checkout@v4 with: fetch-depth: 0 # needed for git subtree pull/push - token: ${{ secrets.MONOREPO_BOT_TOKEN }} + token: ${{ steps.generate-token.outputs.token }} ref: ${{ env.MONOREPO_BRANCH }} - name: Set up Git user run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "assistant-librarian[bot]" + git config user.email "assistant-librarian[bot]@users.noreply.github.com" - name: Update Repositories in the Monorepo run: | diff --git a/.github/workflows/update-subtrees.yml b/.github/workflows/update-subtrees.yml index 7cfafa42991..d9bdbc525f8 100644 --- a/.github/workflows/update-subtrees.yml +++ b/.github/workflows/update-subtrees.yml @@ -11,18 +11,26 @@ env: jobs: synchronize-subtrees: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + - name: Checkout the Monorepo - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 # needed for git subtree pull/push - token: ${{ secrets.MONOREPO_BOT_TOKEN }} + token: ${{ steps.generate-token.outputs.token }} - name: Set up Git user run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "assistant-librarian[bot]" + git config user.email "assistant-librarian[bot]@users.noreply.github.com" - name: Update Repositories in the Monorepo run: | From ca0ef1667a7bbed64ea8c058058101d4224334fa Mon Sep 17 00:00:00 2001 From: Joseph Macaranas Date: Sat, 10 May 2025 04:10:33 -0400 Subject: [PATCH 03/10] Update pr-fanout.yml --- .github/workflows/pr-fanout.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pr-fanout.yml b/.github/workflows/pr-fanout.yml index e8d25e68fbb..edeed42a833 100644 --- a/.github/workflows/pr-fanout.yml +++ b/.github/workflows/pr-fanout.yml @@ -14,11 +14,11 @@ name: Fanout Subtree PRs on: - pull_request: - types: - - opened - - synchronize - - reopened + pull_request: + types: + - opened + - synchronize + - reopened # ensure that the workflow is not running for the same PR multiple times at once concurrency: From 005326c83512309a3ae3005d0d664f245cbb1e1b Mon Sep 17 00:00:00 2001 From: Joseph Macaranas Date: Sat, 10 May 2025 04:12:03 -0400 Subject: [PATCH 04/10] Minor mixups when comparing diffs between forks --- .github/workflows/pr-fanout.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-fanout.yml b/.github/workflows/pr-fanout.yml index edeed42a833..e8721c5f3df 100644 --- a/.github/workflows/pr-fanout.yml +++ b/.github/workflows/pr-fanout.yml @@ -27,7 +27,7 @@ concurrency: jobs: fanout: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Generate a token id: generate-token From c9bc576dce9b4976e0575a32380b81bcc058238e Mon Sep 17 00:00:00 2001 From: Joseph Macaranas Date: Sat, 10 May 2025 15:28:15 -0400 Subject: [PATCH 05/10] Missed one instance of PAT to replace --- .github/workflows/pr-auto-label.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-auto-label.yml b/.github/workflows/pr-auto-label.yml index efc183ac430..cc41374eeef 100644 --- a/.github/workflows/pr-auto-label.yml +++ b/.github/workflows/pr-auto-label.yml @@ -81,7 +81,7 @@ jobs: - name: Compute Category Labels for PR id: compute_labels env: - GH_TOKEN: ${{ secrets.MONOREPO_BOT_TOKEN }} + GH_TOKEN: ${{ steps.generate-token.outputs.token }} run: | python .github/scripts/pr_category_label.py \ --repo ${{ github.repository }} \ From 8de337b9e5fc1f4dc950db0747c673a019a10e33 Mon Sep 17 00:00:00 2001 From: Joseph Macaranas Date: Sat, 10 May 2025 15:33:20 -0400 Subject: [PATCH 06/10] DELETE requests will not have json content in response --- .github/scripts/github_cli_client.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/scripts/github_cli_client.py b/.github/scripts/github_cli_client.py index 29836987cab..bccdc40123f 100644 --- a/.github/scripts/github_cli_client.py +++ b/.github/scripts/github_cli_client.py @@ -122,7 +122,10 @@ def _request_json(self, method: str, url: str, json: Optional[dict] = None, for attempt in range(retries): response = self.session.request(method, url, json=json) if response.ok: - return response.json() + if response.status_code == 204 or not response.text.strip(): + return {} # DELETE requests have no json content + else: + return response.json() else: # for api rate limiting, we check the headers for remaining requests and reset time if response.status_code == 403 and response.headers.get("X-RateLimit-Remaining") == "0": From d3f3a58243a8b499cdb36ae94cebd672f0d8a054 Mon Sep 17 00:00:00 2001 From: Joseph Macaranas Date: Sat, 10 May 2025 15:41:32 -0400 Subject: [PATCH 07/10] PR number detection in auto-label workflow - Depending on trigger for workflow, fields used for concurrency naming could be blank. - End debug mode in fanout for production. --- .github/workflows/pr-auto-label.yml | 2 +- .github/workflows/pr-fanout.yml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/pr-auto-label.yml b/.github/workflows/pr-auto-label.yml index cc41374eeef..371b800a0f1 100644 --- a/.github/workflows/pr-auto-label.yml +++ b/.github/workflows/pr-auto-label.yml @@ -23,7 +23,7 @@ on: # ensure that the workflow is not running for the same PR multiple times at once concurrency: - group: pr-auto-label-${{ github.event.pull_request.number }} + group: pr-auto-label-${{ github.event.pull_request.number || github.event.workflow_run.pull_requests[0].number || github.run_id }} cancel-in-progress: false # the monorepo and subrepos assumed to have these labels created in advance diff --git a/.github/workflows/pr-fanout.yml b/.github/workflows/pr-fanout.yml index e8721c5f3df..80e28bbbe65 100644 --- a/.github/workflows/pr-fanout.yml +++ b/.github/workflows/pr-fanout.yml @@ -88,4 +88,3 @@ jobs: --repo "${{ github.repository }}" \ --pr "${{ github.event.pull_request.number }}" \ --subtrees "${{ steps.detect.outputs.subtrees }}" \ - --debug From f830206a531f1e8572256ece8966b767385dd29e Mon Sep 17 00:00:00 2001 From: Joseph Macaranas Date: Sat, 10 May 2025 13:45:11 -0400 Subject: [PATCH 08/10] [GitHub Actions] Reflect checks from fanned out PRs Feature: Reflect the check runs from fanned out pull requests back to the monorepo pull request. - python client class adds support for the new rest api calls required. Note the sha-based functions since checks are attached to sha, and not pull requests directly. - There are workflows to support polling (pull), and push for subrepos where the GitHub App and its secrets can be set. --- .github/scripts/github_cli_client.py | 64 ++++++++++ .github/scripts/pr_reflect_checks.py | 126 +++++++++++++++++++ .github/workflows/pr-reflect-checks-pull.yml | 77 ++++++++++++ .github/workflows/pr-reflect-checks-push.yml | 62 +++++++++ .github/workflows/subrepo/pr-push-checks.yml | 52 ++++++++ 5 files changed, 381 insertions(+) create mode 100644 .github/scripts/pr_reflect_checks.py create mode 100644 .github/workflows/pr-reflect-checks-pull.yml create mode 100644 .github/workflows/pr-reflect-checks-push.yml create mode 100644 .github/workflows/subrepo/pr-push-checks.yml diff --git a/.github/scripts/github_cli_client.py b/.github/scripts/github_cli_client.py index bccdc40123f..4ed7dbccc88 100644 --- a/.github/scripts/github_cli_client.py +++ b/.github/scripts/github_cli_client.py @@ -142,6 +142,23 @@ def _request_json(self, method: str, url: str, json: Optional[dict] = None, logger.error(f"Max retries reached for method {method} at {url}. Giving up.") return {} + def _generate_check_run_payload(self, name: str, head_sha: str, status: str, + details_url: str, conclusion: str, completed_at: str, + title: str, summary: str) -> dict: + """Create the payload for a check run with potentially empty fields safely coerced to strings.""" + return { + "name": name, + "head_sha": head_sha, + "status": status, + "details_url": details_url, + "conclusion": conclusion if status == "completed" else "", + "completed_at": completed_at if status == "completed" else "", + "output": { + "title": title or name, + "summary": summary or "", + } + } + def get_changed_files(self, repo: str, pr: int) -> List[str]: """Fetch the changed files in a pull request using GitHub API.""" url = f"{self.api_url}/repos/{repo}/pulls/{pr}/files?per_page=100" @@ -242,3 +259,50 @@ def sync_labels(self, target_repo: str, pr_number: int, labels: List[str], dry_r logger.info(f"Dry run: Labels '{labels_for_logging}' would be applied to PR #{pr_number} in {target_repo}.") else: logger.info(f"No valid labels to apply to PR #{pr_number} in {target_repo}.") + + def get_head_sha_for_pr(self, repo: str, pr_number: int) -> Optional[str]: + """Fetch the head SHA for a given pull request number in a repository.""" + url = f"{self.api_url}/repos/{repo}/pulls/{pr_number}" + logger.debug(f"Request URL: {url}") + data = self._get_json(url, f"Failed to fetch PR #{pr_number} in {repo}") + return data.get("head", {}).get("sha") + + def get_branch_name_for_pr(self, repo: str, pr_number: int) -> Optional[str]: + """Fetch the head branch name for a given pull request number in a repository.""" + url = f"{self.api_url}/repos/{repo}/pulls/{pr_number}" + logger.debug(f"Request URL: {url}") + data = self._get_json(url, f"Failed to fetch PR #{pr_number} in {repo}") + return data.get("head", {}).get("ref") + + def get_check_runs_for_ref(self, repo: str, ref: str) -> list: + """Fetch check runs for a specific reference in a repository.""" + url = f"{self.api_url}/repos/{repo}/commits/{ref}/check-runs?per_page=100" + logger.debug(f"Request URL: {url}") + data = self._get_paginated_json(url, f"Failed to get check runs for {repo}@{ref}") + return data.get("check_runs", []) + + def get_check_run_by_name(self, repo: str, sha: str, name: str) -> Optional[dict]: + """Return the check run with a given name for a specific commit SHA, if it exists.""" + check_runs = self.get_check_runs_for_ref(repo, sha) + for check in check_runs: + if check["name"] == name: + return check + return None + + def upsert_check_run(self, repo: str, name: str, sha: str, status: str, + details_url: str, conclusion: str, completed_at: str, + title: str, summary: str) -> dict: + """Create or update a check run for a specific commit SHA.""" + existing = self.get_check_run_by_name(repo, sha, name) + payload = self._generate_check_run_payload(name, sha, status, details_url, + conclusion, completed_at, title, summary) + logger.debug(f"Check run payload: {payload}") + if existing: + check_id = existing["id"] + url = f"{self.api_url}/repos/{repo}/check-runs/{check_id}" + logger.debug(f"Updating check run '{name}' for {repo}@{sha}") + return self._request_json("PATCH", url, payload, f"Failed to update check run '{name}'") + else: + url = f"{self.api_url}/repos/{repo}/check-runs" + logger.debug(f"Creating new check run '{name}' for {repo}@{sha}") + return self._request_json("POST", url, payload, f"Failed to create check run '{name}'") diff --git a/.github/scripts/pr_reflect_checks.py b/.github/scripts/pr_reflect_checks.py new file mode 100644 index 00000000000..e81b33dc804 --- /dev/null +++ b/.github/scripts/pr_reflect_checks.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 + +""" +PR Checks Reflection Script +----------------------------- +This script polls the status of checks on fanned-out pull requests (in sub-repositories) +and reflects them as synthetic checks on the original monorepo pull request. +The branch name convention for fanned-out PRs is computed using the FanoutNaming class. + +Steps: + 1. Fetch the status of checks for the monorepo PR. + 2. Load the subtree mapping from repos-config.json. + 3. For each sub-repo, check if there is an open PR with the expected branch name. + 4. For each check in the sub-repo PR, reflect it as a synthetic check on the monorepo PR. + +Arguments: + --repo : Full repository name (e.g., org/repo) + --pr : Pull request number + --config : OPTIONAL, path to the repos-config.json file + --subrepo : OPTIONAL, only process this subrepo by its repo name (e.g., ROCm/hipBLASlt). + --dry-run : If set, will only log actions without making changes. + --debug : If set, enables detailed debug logging. + +Example Usage: + To run in debug mode and perform a dry-run (no changes made): + python pr_reflect_checks.py --repo ROCm/rocm-libraries --pr 123 --debug --dry-run + To run in debug mode and perform a dry-run for a specific subrepo: + python pr_reflect_checks.py --repo ROCm/rocm-libraries --pr 123 --subrepo ROCm/hipBLASlt --debug --dry-run +""" + +import argparse +import logging +from typing import List, Optional + +from github_cli_client import GitHubCLIClient +from config_loader import load_repo_config +from utils_fanout_naming import FanoutNaming + +logger = logging.getLogger(__name__) + +def parse_arguments(argv: Optional[List[str]] = None) -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser(description="Reflect fanned-out PR checks onto the monorepo PR.") + parser.add_argument("--repo", required=True, help="Full repository name (e.g., org/repo)") + parser.add_argument("--pr", required=True, type=int, help="Pull request number") + parser.add_argument("--config", required=False, default=".github/repos-config.json", help="Path to the repos-config.json file") + parser.add_argument("--subrepo", required=False, help="If set, only process this subrepo.") + parser.add_argument("--dry-run", action="store_true", help="If set, only logs actions without making changes.") + parser.add_argument("--debug", action="store_true", help="If set, enables detailed debug logging.") + return parser.parse_args(argv) + +def reflect_checks_from_subrepos(client: GitHubCLIClient, config: list, monorepo_repo: str, + monorepo_pr_number: int, subrepo_filter: Optional[str] = None, + dry_run: bool = False) -> None: + """Reflect checks from subrepo PRs onto the corresponding monorepo PR.""" + monorepo_branch = client.get_branch_name_for_pr(monorepo_repo, monorepo_pr_number) + monorepo_pr_sha = client.get_head_sha_for_pr(monorepo_repo, monorepo_pr_number) + monorepo_checks = { + check["name"]: check + for check in client.get_check_runs_for_ref(monorepo_repo, monorepo_branch) + } + for entry in config: + if subrepo_filter and entry.url != subrepo_filter: + continue + reflect_checks_for_subrepo( + client, entry, monorepo_repo, monorepo_pr_sha, + monorepo_checks, monorepo_pr_number, dry_run + ) + +def reflect_checks_for_subrepo(client: GitHubCLIClient, entry, monorepo_repo: str, monorepo_pr_sha: str, + monorepo_checks: dict, monorepo_pr_number: int, dry_run: bool) -> None: + """Reflect checks for a single subrepo entry onto the monorepo PR.""" + subrepo = entry.url + branch = FanoutNaming.compute_branch_name(monorepo_pr_number, entry.name) + pr = client.get_pr_by_head_branch(subrepo, branch) + if not pr: + logger.info(f"No open PR found in {subrepo} for branch {branch}") + return + checks = client.get_check_runs_for_ref(subrepo, branch) + for check in checks: + synthetic_name = f"{entry.name}: {check['name']}" + status = check["status"] + details_url = check.get("details_url", "") + conclusion = check.get("conclusion") + completed_at = check.get("completed_at") + title = check.get("output", {}).get("title", synthetic_name) + summary = check.get("output", {}).get("summary", "") + existing = monorepo_checks.get(synthetic_name) + needs_update = ( + not existing or + existing["status"] != status or + existing.get("conclusion") != conclusion or + existing.get("output", {}).get("summary") != summary + ) + if not needs_update: + logger.debug(f"Skipped unchanged check: {synthetic_name}") + continue + logger.info(f"Reflecting check: {synthetic_name}") + if not dry_run: + client.upsert_check_run( + monorepo_repo, synthetic_name, monorepo_pr_sha, + status, details_url, conclusion, completed_at, + title, summary + ) + else: + logger.info(f"Dry run: would reflect check: {check}") + +def main(argv: Optional[List[str]] = None) -> None: + """Main function to execute the PR checks reflection logic.""" + args = parse_arguments(argv) + logging.basicConfig( + level=logging.DEBUG if args.debug else logging.INFO + ) + client = GitHubCLIClient() + config = load_repo_config(args.config) + reflect_checks_from_subrepos( + client, + config, + monorepo_repo = args.repo, + monorepo_pr_number = args.pr, + subrepo_filter = args.subrepo, + dry_run = args.dry_run + ) + +if __name__ == "__main__": + main() diff --git a/.github/workflows/pr-reflect-checks-pull.yml b/.github/workflows/pr-reflect-checks-pull.yml new file mode 100644 index 00000000000..2bbbefe59aa --- /dev/null +++ b/.github/workflows/pr-reflect-checks-pull.yml @@ -0,0 +1,77 @@ +# Reflect Fanout Checks GitHub Action +# ------------------------------------ +# This workflow periodically runs a Python script that reflects the check status of +# fanout pull requests (in sub-repositories) back onto the monorepo pull request. +# Using polling mechanism for migration, as getting push events from all the +# sub-repositories is not feasible. +# +# Key Features: +# - Runs every 30 minutes using a cron schedule +# - Also supports manual triggering via workflow_dispatch +# - Uses the GitHub CLI to list all open PRs on the monorepo +# - For each open PR, executes `pr-reflect-checks.py` to post synthetic checks +# - Skips closed or merged PRs automatically +# +# Prerequisites: +# - Your Python script must support CLI args: --repo and --pr +# - The `gh` CLI tool is preinstalled in GitHub-hosted runners +# - The script must handle cases where fanout branches or checks are missing +# +# Example Invocation: +# python scripts/reflect/pr-reflect-checks.py --repo ROCm/rocm-libraries --pr 123 + + +name: Reflect Subrepo Checks (Polling) + +on: + schedule: + - cron: '*/10 * * * *' # every 10 minutes + workflow_dispatch: + +jobs: + reflect: + runs-on: ubuntu-24.04 + steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + sparse-checkout: .github + token: ${{ steps.generate-token.outputs.token }} + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.12' + + - name: Install python dependencies + run: | + python -m pip install --upgrade pip + pip install pydantic requests + + - name: Set up Git user + run: | + git config user.name "assistant-librarian[bot]" + git config user.email "assistant-librarian[bot]@users.noreply.github.com" + + - name: Reflect Checks + env: + GH_TOKEN: ${{ steps.generate-token.outputs.token }} + run: | + prs=$(gh pr list --repo "${{ github.repository }}" --state open --json number --jq '.[].number') + if [ -z "$prs" ]; then + echo "No open pull requests found — skipping." + exit 0 + fi + # Iterate over each pull request and reflect the checks + for pr in $prs; do + echo "Processing PR #${pr}" + python .github/scripts/pr_reflect_checks.py --repo "${{ github.repository }}" --pr "${pr}" --debug + done diff --git a/.github/workflows/pr-reflect-checks-push.yml b/.github/workflows/pr-reflect-checks-push.yml new file mode 100644 index 00000000000..1884cc4c91b --- /dev/null +++ b/.github/workflows/pr-reflect-checks-push.yml @@ -0,0 +1,62 @@ +# Reflect Fanout Checks via Repository Dispatch +# --------------------------------------------- +# This workflow listens for repository_dispatch events from sub-repositories, +# indicating that a check run was updated on a fanout PR. It reflects that +# check status back onto the corresponding monorepo pull request as a synthetic check. +# +# Triggered by: repository_dispatch from subrepos when a check run is updated +# +# Expected Payload: +# { +# "subrepo": "org/name", +# "subrepo_pr": 42, +# "monorepo_pr": 123 +# } + +name: Reflect Subrepo Checks (Dispatch) + +on: + repository_dispatch: + types: [reflect-checks-push] + +jobs: + reflect-dispatched-checks: + runs-on: ubuntu-latest + steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + - name: Checkout .github directory + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + sparse-checkout: .github + token: ${{ steps.generate-token.outputs.token }} + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.12' + + - name: Install python dependencies + run: | + python -m pip install --upgrade pip + pip install pydantic requests + + - name: Set up Git user + run: | + git config user.name "assistant-librarian[bot]" + git config user.email "assistant-librarian[bot]@users.noreply.github.com" + + - name: Reflect Checks from Subrepo + env: + GH_TOKEN: ${{ steps.generate-token.outputs.token }} + run: | + pr="${{ github.event.client_payload.monorepo_pr }}" + subrepo="${{ github.event.client_payload.subrepo }}" + echo "Dispatch received for monorepo PR #$pr from subrepo $subrepo" + python .github/scripts/pr_reflect_checks.py --repo "${{ github.repository }}" --pr "${pr}" --subrepo "${subrepo}" diff --git a/.github/workflows/subrepo/pr-push-checks.yml b/.github/workflows/subrepo/pr-push-checks.yml new file mode 100644 index 00000000000..1de92842328 --- /dev/null +++ b/.github/workflows/subrepo/pr-push-checks.yml @@ -0,0 +1,52 @@ +# Reflect Checks to Monorepo (Push-style) +# ---------------------------------------- +# This GitHub Actions workflow runs on check updates in subrepo PRs that are part of a +# fanout from a monorepo. When a check is updated (e.g., CI passes/fails), this workflow: +# +# - Parses the branch name to extract the monorepo PR number +# - Sends a `repository_dispatch` event to the monorepo to reflect the updated check +# - Requires a GitHub App token (`GH_APP_TOKEN`) with repo/workflow access to monorepo +# +# Triggered on: check_run events (completed) +# Target repo: monorepo, which listens for `repository_dispatch` events + +name: Reflect Check Updates to Monorepo + +on: + check_run: + +jobs: + dispatch: + # only run if the check_run event is for a pull request + if: github.event.check_run.pull_requests != [] + runs-on: ubuntu-24.04 + steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + - name: Extract monorepo PR number + id: extract + run: | + head_branch="${{ github.event.check_run.check_suite.head_branch }}" + # Extract monorepo PR number from branch (monorepo-pr//) + if [[ "$head_branch" =~ ^monorepo-pr/([0-9]+)/.+$ ]]; then + MONOREPO_PR="${BASH_REMATCH[1]}" + else + echo "Branch '$head_branch' does not match expected pattern. Not a fanout branch." + exit 0 # Skip silently + fi + echo "monorepo_pr=$MONOREPO_PR" >> "$GITHUB_OUTPUT" + + - name: Send repository_dispatch to monorepo + env: + GH_TOKEN: ${{ steps.generate-token.outputs.token }} + run: | + gh api repos/ROCm/rocm-libraries/dispatches \ + --method POST \ + --field event_type="reflect-checks-push" \ + --field client_payload='{"subrepo": "'"${{ github.repository }}"'", "monorepo_pr": ${{ steps.extract.outputs.monorepo_pr }}}' From 73405079bc35eb28a198ed115d1119354fc28d96 Mon Sep 17 00:00:00 2001 From: Joseph Macaranas <145489236+jayhawk-commits@users.noreply.github.com> Date: Mon, 12 May 2025 11:51:54 -0400 Subject: [PATCH 09/10] Add trigger for commit status changes - Jenkins and other platforms do not interact with check_run, but instead use commit status updates. --- .github/workflows/subrepo/pr-push-checks.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/subrepo/pr-push-checks.yml b/.github/workflows/subrepo/pr-push-checks.yml index 1de92842328..b27f3a0b71e 100644 --- a/.github/workflows/subrepo/pr-push-checks.yml +++ b/.github/workflows/subrepo/pr-push-checks.yml @@ -14,6 +14,7 @@ name: Reflect Check Updates to Monorepo on: check_run: + status: jobs: dispatch: From 9f43f091a43a13d3ff11c393baf1be78000aec09 Mon Sep 17 00:00:00 2001 From: amd-jmacaran Date: Tue, 13 May 2025 13:16:44 -0400 Subject: [PATCH 10/10] Standardize schedule --- .github/workflows/pr-reflect-checks-pull.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-reflect-checks-pull.yml b/.github/workflows/pr-reflect-checks-pull.yml index 2bbbefe59aa..ee6927dd074 100644 --- a/.github/workflows/pr-reflect-checks-pull.yml +++ b/.github/workflows/pr-reflect-checks-pull.yml @@ -6,7 +6,7 @@ # sub-repositories is not feasible. # # Key Features: -# - Runs every 30 minutes using a cron schedule +# - Runs every 15 minutes using a cron schedule # - Also supports manual triggering via workflow_dispatch # - Uses the GitHub CLI to list all open PRs on the monorepo # - For each open PR, executes `pr-reflect-checks.py` to post synthetic checks @@ -25,7 +25,7 @@ name: Reflect Subrepo Checks (Polling) on: schedule: - - cron: '*/10 * * * *' # every 10 minutes + - cron: '*/15 * * * *' # every 15 minutes workflow_dispatch: jobs: