Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .github/scripts/github_cli_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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}'")
126 changes: 126 additions & 0 deletions .github/scripts/pr_reflect_checks.py
Original file line number Diff line number Diff line change
@@ -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()
77 changes: 77 additions & 0 deletions .github/workflows/pr-reflect-checks-pull.yml
Original file line number Diff line number Diff line change
@@ -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
62 changes: 62 additions & 0 deletions .github/workflows/pr-reflect-checks-push.yml
Original file line number Diff line number Diff line change
@@ -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}"
53 changes: 53 additions & 0 deletions .github/workflows/subrepo/pr-push-checks.yml
Original file line number Diff line number Diff line change
@@ -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/<num>/<subtree>)
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 \
Comment thread
jayhawk-commits marked this conversation as resolved.
--method POST \
--field event_type="reflect-checks-push" \
--field client_payload='{"subrepo": "'"${{ github.repository }}"'", "monorepo_pr": ${{ steps.extract.outputs.monorepo_pr }}}'