From b22f16f0cb3664040955eae50b2b7eb467fae2bd Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:20:56 +0800 Subject: [PATCH] feat: report stale consumer QPK pin PRs Co-Authored-By: Codex --- .../workflows/open-downstream-qpk-pin-prs.yml | 9 ++ scripts/report_consumer_qpk_pin_prs.py | 108 ++++++++++++++++++ tests/test_report_consumer_qpk_pin_prs.py | 47 ++++++++ 3 files changed, 164 insertions(+) create mode 100644 scripts/report_consumer_qpk_pin_prs.py create mode 100644 tests/test_report_consumer_qpk_pin_prs.py diff --git a/.github/workflows/open-downstream-qpk-pin-prs.yml b/.github/workflows/open-downstream-qpk-pin-prs.yml index e28900b..ed9a2d3 100644 --- a/.github/workflows/open-downstream-qpk-pin-prs.yml +++ b/.github/workflows/open-downstream-qpk-pin-prs.yml @@ -81,6 +81,15 @@ jobs: qpk_sha=$(tr -d '[:space:]' < QPK_PIN) python3 scripts/merge_verified_strategy_qpk_pin_prs.py --qpk-sha "$qpk_sha" --close-superseded + - name: Report consumer QPK pin PR hygiene + if: steps.sync.outputs.missing_token != 'true' + env: + QSL_REPO_SYNC_TOKEN: ${{ secrets.QSL_REPO_SYNC_TOKEN }} + run: | + set -euo pipefail + qpk_sha=$(tr -d '[:space:]' < QPK_PIN) + python3 scripts/report_consumer_qpk_pin_prs.py --qpk-sha "$qpk_sha" >> "$GITHUB_STEP_SUMMARY" + - name: Summarize missing token if: steps.sync.outputs.missing_token == 'true' run: | diff --git a/scripts/report_consumer_qpk_pin_prs.py b/scripts/report_consumer_qpk_pin_prs.py new file mode 100644 index 0000000..46eb7b0 --- /dev/null +++ b/scripts/report_consumer_qpk_pin_prs.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Report stale generated QPK pin PRs in runtime-consumer repositories. + +Consumer repositories may own deployment or broker workflows, so this command +is deliberately read-only. It exposes recognizable older generated PRs in +the QPK workflow summary without closing, merging, labelling, or changing any +consumer repository state. +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +from collections.abc import Iterable +from typing import Any + +try: # Package import for tests and module execution. + from scripts.merge_verified_strategy_qpk_pin_prs import ( + expected_branch, + superseded_pr_reason, + ) + from scripts.open_downstream_qpk_pin_prs import CONSUMER_REPOS, RepoSpec +except ModuleNotFoundError: # Direct `python scripts/.py` execution in Actions. + from merge_verified_strategy_qpk_pin_prs import expected_branch, superseded_pr_reason + from open_downstream_qpk_pin_prs import CONSUMER_REPOS, RepoSpec + + +def run(command: list[str], *, env: dict[str, str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(command, text=True, capture_output=True, check=True, env=env) + + +def generated_prs(repo: RepoSpec, *, env: dict[str, str]) -> list[dict[str, Any]]: + result = run( + [ + "gh", + "pr", + "list", + "--repo", + f"QuantStrategyLab/{repo.name}", + "--state", + "open", + "--limit", + "100", + "--json", + "author,baseRefName,headRefName,isCrossRepository,isDraft,number,title,updatedAt,url", + ], + env=env, + ) + return json.loads(result.stdout) + + +def classify_generated_prs( + prs: Iterable[dict[str, Any]], + *, + current_branch: str, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """Return current and recognizable stale generated PRs, ignoring all others.""" + + current: list[dict[str, Any]] = [] + stale: list[dict[str, Any]] = [] + for pr in prs: + reason = superseded_pr_reason(pr=pr, current_branch=current_branch) + if reason == "current_branch": + current.append(pr) + continue + if reason is None: + stale.append(pr) + return current, stale + + +def render_row(repo: RepoSpec, current: list[dict[str, Any]], stale: list[dict[str, Any]]) -> str: + current_refs = ", ".join(f"[#{item['number']}]({item['url']})" for item in current) or "—" + stale_refs = ", ".join(f"[#{item['number']}]({item['url']})" for item in stale) or "—" + return f"| {repo.name} | {current_refs} | {stale_refs} |" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--qpk-sha", required=True) + parser.add_argument("--token-env", default="QSL_REPO_SYNC_TOKEN") + args = parser.parse_args() + qpk_sha = args.qpk_sha.strip() + if len(qpk_sha) != 40 or any(char not in "0123456789abcdef" for char in qpk_sha): + raise SystemExit("qpk_sha must be a full lowercase SHA") + token = os.environ.get(args.token_env, "").strip() + if not token: + raise SystemExit(f"missing required token env: {args.token_env}") + + env = {**os.environ, "GH_TOKEN": token} + print("## Consumer QPK pin PR hygiene") + print() + print("Consumer repositories are report-only: they are never auto-merged or auto-closed.") + print() + print("| Repository | Current generated PR | Recognizable stale generated PRs |") + print("| --- | --- | --- |") + for repo in CONSUMER_REPOS: + current, stale = classify_generated_prs( + generated_prs(repo, env=env), + current_branch=expected_branch(repo, qpk_sha), + ) + print(render_row(repo, current, stale)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_report_consumer_qpk_pin_prs.py b/tests/test_report_consumer_qpk_pin_prs.py new file mode 100644 index 0000000..9e79abf --- /dev/null +++ b/tests/test_report_consumer_qpk_pin_prs.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from scripts.report_consumer_qpk_pin_prs import classify_generated_prs, render_row +from scripts.open_downstream_qpk_pin_prs import RepoSpec + + +TARGET = "8378e939d9324ea63a0f45c9f21ba0e2eeb1cfff" + + +def _pr(*, branch: str, number: int = 1) -> dict[str, object]: + return { + "author": {"login": "Pigbibi"}, + "baseRefName": "main", + "headRefName": branch, + "isCrossRepository": False, + "isDraft": False, + "number": number, + "title": "chore(deps): align QPK pin to 8378e939d932", + "url": f"https://example.test/pr/{number}", + } + + +def test_classify_generated_consumer_prs_keeps_current_and_reports_only_recognized_stale() -> None: + current_branch = "auto/qpk-pin-sync-8378e939d932-longbridgeplatform" + current, stale = classify_generated_prs( + [ + _pr(branch=current_branch, number=10), + _pr(branch="auto/qpk-pin-sync-37c81901160c-longbridgeplatform", number=9), + _pr(branch="codex/manual-update", number=8), + ], + current_branch=current_branch, + ) + + assert [item["number"] for item in current] == [10] + assert [item["number"] for item in stale] == [9] + + +def test_render_row_includes_links_without_mutation_instruction() -> None: + row = render_row( + RepoSpec("LongBridgePlatform"), + [_pr(branch="auto/qpk-pin-sync-8378e939d932-longbridgeplatform", number=10)], + [_pr(branch="auto/qpk-pin-sync-37c81901160c-longbridgeplatform", number=9)], + ) + + assert "LongBridgePlatform" in row + assert "[#10](https://example.test/pr/10)" in row + assert "[#9](https://example.test/pr/9)" in row