diff --git a/.github/scripts/github_cli_client.py b/.github/scripts/github_cli_client.py index 99e24c24cf8..d196d384c82 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" @@ -244,3 +261,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..ee6927dd074 --- /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 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 +# - 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: '*/15 * * * *' # every 15 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..b27f3a0b71e --- /dev/null +++ b/.github/workflows/subrepo/pr-push-checks.yml @@ -0,0 +1,53 @@ +# 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: + status: + +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 }}}'