From a89634489666692b221ff443ecee315d3c5183e1 Mon Sep 17 00:00:00 2001 From: Oseltamivir <58582368+Oseltamivir@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:38:11 +0800 Subject: [PATCH] feat(ingest): comment app ingest reports on the merged PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ingest-results-callback.yml, which receives ingest-results-completed from InferenceX-app, resolves the PR merged by the Merge Ingest run's commit and posts the per-run ingest counts as one marker comment per merge run, updated in place on reruns and failures. 中文:在已合并的 PR 上评论 InferenceX-app 的 ingest 报告。新增 ingest-results-callback.yml,接收 InferenceX-app 发送的 ingest-results-completed,根据 Merge Ingest 运行的提交解析已合并的 PR,并以每个合并运行一条带标记的评论发布本次 ingest 的统计;重跑或失败时原地更新。 --- .github/workflows/README.md | 7 + .github/workflows/ingest-results-callback.yml | 33 +++ .../workflows/test_ingest_results_callback.py | 118 ++++++++++ .../infx/workflows/ingest_results_callback.py | 218 ++++++++++++++++++ 4 files changed, 376 insertions(+) create mode 100644 .github/workflows/ingest-results-callback.yml create mode 100644 inferencex-e2e/infx/tests/workflows/test_ingest_results_callback.py create mode 100644 inferencex-e2e/infx/workflows/ingest_results_callback.py diff --git a/.github/workflows/README.md b/.github/workflows/README.md index cfe8867fe3..2888402746 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -308,6 +308,13 @@ normal ingestion code skips failed benchmark rows. Benchmark rows and public links retain source-run provenance. Source coverage is authoritative, so later matrix/eval policy changes do not invalidate reuse. +When the app's ingest finishes, on success or failure, it sends +`ingest-results-completed` back with per-run counts (benchmark, eval, trace and +power rows: new, duplicate, skipped, unmapped, failed). `ingest-results-callback.yml` +checks that `merge-run-id` is a Merge Ingest push to `main`, resolves the PR merged +by that commit, and posts one comment per merge run, updated in place on reruns +(marker ``). + Reuse fails closed when authorized but ineligible or invalid. Pushes to `main` never run a sweep: `run-sweep.yml` is PR-only, and without reuse authorization the Merge Ingest run fails and nothing is benchmarked or ingested. diff --git a/.github/workflows/ingest-results-callback.yml b/.github/workflows/ingest-results-callback.yml new file mode 100644 index 0000000000..7e347edb44 --- /dev/null +++ b/.github/workflows/ingest-results-callback.yml @@ -0,0 +1,33 @@ +name: Report Ingest Result + +run-name: Report ingest of merge run ${{ github.event.client_payload.merge-run-id }} + +on: + repository_dispatch: + types: [ingest-results-completed] + +permissions: {} + +concurrency: + group: ingest-results-callback-${{ github.event.client_payload.merge-run-id }} + cancel-in-progress: false + +jobs: + comment: + name: Comment on merged pull request + runs-on: ubuntu-latest + permissions: + actions: read # Resolve the Merge Ingest run to its commit + contents: read # Read trusted callback implementation and commit PRs + pull-requests: write # Post the ingest report as github-actions + steps: + - name: Checkout trusted callback code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Validate callback and comment + working-directory: inferencex-e2e + env: + GH_TOKEN: ${{ github.token }} + run: python3 -m infx.workflows.ingest_results_callback diff --git a/inferencex-e2e/infx/tests/workflows/test_ingest_results_callback.py b/inferencex-e2e/infx/tests/workflows/test_ingest_results_callback.py new file mode 100644 index 0000000000..1b6edd3884 --- /dev/null +++ b/inferencex-e2e/infx/tests/workflows/test_ingest_results_callback.py @@ -0,0 +1,118 @@ +import copy + +import pytest + +from infx import github +from infx.workflows import ingest_results_callback as callback + + +def counts(**overrides): + rows = {c: dict.fromkeys(callback.OUTCOMES, 0) for c in callback.CATEGORIES} + for category, values in overrides.items(): + rows[category].update(values) + return rows + + +@pytest.fixture +def case(monkeypatch): + state = { + "event": { + "action": "ingest-results-completed", + "client_payload": { + "source-repository": "SemiAnalysisAI/InferenceX-app", + "merge-run-id": "40178629864", + "source-run-id": "40141873883", + "app-run-id": "40178653878", + "app-run-attempt": "1", + "database-target": "production", + "ingest-succeeded": "true", + "report": { + "counts": counts(benchmark={"new": 1}), + "detail": {"benchmarkFiles": 2, "changelogEntriesWritten": 1}, + "unmapped": {"models": [], "hardware": ["mi999x"]}, + }, + }, + }, + "responses": { + "/actions/runs/40178629864": { + "path": ".github/workflows/merge-ingest.yml", "event": "push", + "head_branch": "main", "head_sha": "abc", + }, + "/commits/abc/pulls": [ + {"number": 4456, "merged_at": "2026-10-24T00:00:00Z", "merge_commit_sha": "abc"}, + ], + "/issues/4456/comments": [], + }, + "writes": [], + } + + def api(repo, path, token=None, params=None, *, method="GET", data=None, paginate=False): + if method != "GET": + state["writes"].append((method, path, data["body"])) + return {"id": 1} + response = state["responses"][path] + return copy.deepcopy([response] if paginate else response) + + monkeypatch.setattr(github, "api", api) + return state + + +def run(state): + callback.report("SemiAnalysisAI/InferenceX", state["event"], "t", "https://github.com") + + +def test_posts_counts_on_the_merged_pr(case): + run(case) + [(method, path, body)] = case["writes"] + assert (method, path) == ("POST", "/issues/4456/comments") + assert body.startswith("") + assert "| benchmark | 1 | 0 | 0 | 0 | 0 |" in body + assert "benchmarkFiles 2, changelogEntriesWritten 1" in body + assert "Unmapped hardware: `mi999x`" in body + assert "中文" in body + + +def test_updates_existing_comment_in_place_on_failure(case): + case["responses"]["/issues/4456/comments"] = [ + {"id": 9, "user": {"login": "someone"}, + "body": ""}, + {"id": 10, "user": {"login": "github-actions[bot]"}, + "body": "\nold"}, + ] + case["event"]["client_payload"]["ingest-succeeded"] = "false" + case["event"]["client_payload"]["report"] = None + run(case) + [(method, path, body)] = case["writes"] + assert (method, path) == ("PATCH", "/issues/comments/10") + assert "failed" in body + assert "No ingest report was produced" in body + + +def test_no_merged_pr_posts_nothing(case): + case["responses"]["/commits/abc/pulls"] = [{"number": 1, "merged_at": None}] + run(case) + assert case["writes"] == [] + + +@pytest.mark.parametrize( + ("key", "value"), + [ + ("source-repository", "someone/fork"), + ("merge-run-id", "1; rm"), + ("ingest-succeeded", "yes"), + ("database-target", "prod`uction"), + ("report", {"counts": counts(eval={"new": -1})}), + ("report", {"counts": counts(), "unmapped": {"models": ["[x](http://e)"]}}), + ], +) +def test_rejects_malformed_payload(case, key, value): + case["event"]["client_payload"][key] = value + with pytest.raises(RuntimeError): + run(case) + assert case["writes"] == [] + + +def test_rejects_runs_other_than_merge_ingest(case): + case["responses"]["/actions/runs/40178629864"]["path"] = ".github/workflows/run-sweep.yml" + with pytest.raises(RuntimeError): + run(case) diff --git a/inferencex-e2e/infx/workflows/ingest_results_callback.py b/inferencex-e2e/infx/workflows/ingest_results_callback.py new file mode 100644 index 0000000000..514cfeb4b6 --- /dev/null +++ b/inferencex-e2e/infx/workflows/ingest_results_callback.py @@ -0,0 +1,218 @@ +"""Report an InferenceX-app ingest outcome on the PR whose changelog entry it published. + +InferenceX-app sends ``ingest-results-completed`` after every dispatched merge +ingest, on success and failure. The PR is resolved here from the Merge Ingest +run's commit, never taken from the payload, and one comment per merge run is +updated in place. +""" + +from __future__ import annotations + +import json +import os +import re +import sys +from pathlib import Path +from typing import Any + +from infx import github + +APP_REPOSITORY = "SemiAnalysisAI/InferenceX-app" +MERGE_WORKFLOW = ".github/workflows/merge-ingest.yml" +BOT_LOGIN = "github-actions[bot]" +CATEGORIES = ("benchmark", "eval", "trace", "power") +OUTCOMES = ("new", "duplicate", "skipped", "unmapped", "failed") +DETAIL_KEYS = ( + "benchmarkFiles", + "rawBenchmarkRows", + "coveredByPerJobFiles", + "purgedPoints", + "failedRunRows", + "runStatsNew", + "runStatsDuplicate", + "evalSamplesNew", + "gpuMetricSamplesNew", + "powerPublicationPoints", + "changelogEntriesWritten", +) +NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/+-]{0,79}") +MAX_NAMES = 20 + + +def _count(value: Any) -> int: + """Return ``value`` as a non-negative int count; raise RuntimeError otherwise.""" + if type(value) is not int or value < 0: + raise RuntimeError("Report counts must be non-negative integers") + return value + + +def render_report(report: Any) -> list[str]: + """Return markdown lines for the payload's count report, validating every value. + + Raises RuntimeError on a malformed report. A missing report renders a note. + """ + if report is None: + return ["No ingest report was produced; the ingest stopped before counting rows."] + if not isinstance(report, dict) or not isinstance(report.get("counts"), dict): + raise RuntimeError("Callback report must be an object with counts") + counts = report["counts"] + lines = [ + f"| Category | {' | '.join(OUTCOMES)} |", + f"| --- | {' | '.join('---:' for _ in OUTCOMES)} |", + ] + for category in CATEGORIES: + row = counts.get(category) + if not isinstance(row, dict): + raise RuntimeError(f"Callback report is missing {category} counts") + lines.append(f"| {category} | {' | '.join(str(_count(row.get(o))) for o in OUTCOMES)} |") + detail = report.get("detail") or {} + if not isinstance(detail, dict): + raise RuntimeError("Callback report detail must be an object") + extras = [f"{key} {_count(detail[key])}" for key in DETAIL_KEYS if key in detail] + if extras: + lines += ["", f"{', '.join(extras)}"] + unmapped = report.get("unmapped") or {} + if not isinstance(unmapped, dict): + raise RuntimeError("Callback report unmapped must be an object") + for kind in ("models", "hardware", "precisions", "datasets"): + names = unmapped.get(kind) or [] + if not isinstance(names, list): + raise RuntimeError("Unmapped names must be lists") + shown = [f"`{n}`" for n in names[:MAX_NAMES] if isinstance(n, str) and NAME.fullmatch(n)] + if len(shown) != min(len(names), MAX_NAMES): + raise RuntimeError("Unmapped names must be plain identifiers") + if shown: + more = f" (+{len(names) - MAX_NAMES} more)" if len(names) > MAX_NAMES else "" + lines.append(f"\n⚠️ Unmapped {kind}: {', '.join(shown)}{more}") + return lines + + +def merged_pr(repo: str, merge_run_id: str, token: str) -> int | None: + """Return the PR merged by the Merge Ingest run ``merge_run_id``, or None if none matches. + + Raises RuntimeError when the run is not a Merge Ingest push to main. + """ + run = github.api(repo, f"/actions/runs/{merge_run_id}", token) + if ( + str(run.get("path", "")).split("@")[0] != MERGE_WORKFLOW + or run.get("event") != "push" + or run.get("head_branch") != "main" + ): + raise RuntimeError("Callback merge run is not a Merge Ingest push to main") + sha = str(run.get("head_sha", "")) + pulls = github.api(repo, f"/commits/{sha}/pulls", token) + merged = [p for p in pulls if p.get("merged_at") and p.get("merge_commit_sha") == sha] + return int(merged[0]["number"]) if merged else None + + +def report(repo: str, event: dict[str, Any], token: str, server_url: str) -> None: + """Post or update the ingest comment for one ``ingest-results-completed`` event. + + Raises RuntimeError if the payload is malformed or from an unexpected source. + """ + if event.get("action") != "ingest-results-completed": + raise RuntimeError("Unsupported ingest callback type") + payload = event["client_payload"] + if payload.get("source-repository") != APP_REPOSITORY: + raise RuntimeError("Unsupported ingest callback source") + merge_run_id, source_run_id, app_run_id, app_attempt = ( + str(payload.get(key, "")) + for key in ("merge-run-id", "source-run-id", "app-run-id", "app-run-attempt") + ) + if not all( + re.fullmatch(r"[0-9]+", v) for v in (merge_run_id, source_run_id, app_run_id, app_attempt) + ): + raise RuntimeError("Callback run identifiers must be numeric") + succeeded = payload.get("ingest-succeeded") + if succeeded not in {"true", "false"}: + raise RuntimeError("Callback ingest-succeeded value must be true or false") + target = str(payload.get("database-target", "")) + if not re.fullmatch(r"[a-z-]+", target): + raise RuntimeError("Callback database target must be a plain name") + table = render_report(payload.get("report")) + + pr_number = merged_pr(repo, merge_run_id, token) + if pr_number is None: + print(f"No merged PR found for merge run {merge_run_id}; nothing to report.") + return + + app_url = f"{server_url}/{APP_REPOSITORY}/actions/runs/{app_run_id}/attempts/{app_attempt}" + merge_url = f"{server_url}/{repo}/actions/runs/{merge_run_id}" + source_url = f"{server_url}/{repo}/actions/runs/{source_run_id}" + marker = f"" + if succeeded == "true": + status_en = f"✅ Results ingested into the `{target}` database." + status_zh = f"✅ 结果已写入 `{target}` 数据库。" + else: + status_en = ( + f"❌ Ingest into the `{target}` database failed. " + f"[Inspect the ingest workflow]({app_url})." + ) + status_zh = ( + f"❌ 写入 `{target}` 数据库的 ingest 失败,请[查看 ingest workflow]({app_url})。" + ) + links = ( + f"[Ingest workflow]({app_url}) · [Merge Ingest run]({merge_url}) · " + f"[Benchmark source run]({source_url})" + ) + body = "\n".join( + [ + marker, + f"**Ingest report** — {status_en}", + "", + links, + "", + *table, + "", + ( + "Counts cover only this run. Rows: benchmark_results, eval_results, agentic rows " + "linked to a trace, gpu_metrics series. This comment updates if the ingest is rerun." + ), + "", + "
", + "中文", + "", + f"**Ingest 报告** — {status_zh}", + "", + ( + "上表仅统计本次运行写入的数据,各行单位依次为 benchmark_results 行、eval_results 行、" + "已关联 trace 的 agentic 行和 gpu_metrics 序列。重新运行 ingest 时本评论会原地更新。" + ), + "", + "
", + ] + ) + comments = github.paginate(repo, f"/issues/{pr_number}/comments", token, "") + existing = next( + ( + c + for c in comments + if c.get("user", {}).get("login") == BOT_LOGIN and marker in str(c.get("body")) + ), + None, + ) + if existing: + github.api( + repo, f"/issues/comments/{existing['id']}", token, method="PATCH", data={"body": body} + ) + else: + github.api(repo, f"/issues/{pr_number}/comments", token, method="POST", data={"body": body}) + + +def main() -> None: + """Report the ingest callback from the GitHub Actions environment.""" + event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text(encoding="utf-8")) + report( + os.environ["GITHUB_REPOSITORY"], + event, + os.environ["GH_TOKEN"], + os.environ["GITHUB_SERVER_URL"], + ) + + +if __name__ == "__main__": + try: + main() + except RuntimeError as exc: + print(f"error: {exc}", file=sys.stderr) + sys.exit(1)