ci: poll review statuses from artifacts dynamically
The live comment poller previously got its review status payloads from two sources: (1) REVIEW_STATUSES env var, frozen at comment-live job start from needs.*.outputs.review_status, and (2) a single ci-timings artifact fetched at the end. This meant status details (error messages, action_required items, etc.) only appeared in the comment after all jobs finished, even though job pass/fail was visible in real-time. Now every status-producing workflow_call uploads a small review-status artifact (review-status-<name>) as soon as it completes. The poller enumerates all review-status-* artifacts across the orchestrator run and all sub-workflow runs every cycle, downloads each, and merges them into the comment. Statuses appear as soon as each job finishes, not just at the end. Changes: - live_comment.py: replace _fetch_artifact_statuses (single artifact via gh CLI) with fetch_all_review_statuses (enumerate all review-status-* artifacts via API across all runs, download + parse each). Remove review_statuses_json parameter and --review-statuses- file CLI arg. Remove subprocess import (no longer shells out to gh). - ci.yml: remove REVIEW_STATUSES env var, inline Python merger, and --review-statuses-file arg from the comment-live step. Rename ci-timings-review-status artifact to review-status-ci-timings. - 8 workflow_call files: add a write review-status.json + upload artifact step after each review_status output is produced. - test_live_comment.py: add tests for _parse_status_file (with/without prefix, empty, invalid, nonexistent, non-list) and _merge_statuses.
This commit is contained in:
@@ -196,47 +196,10 @@ jobs:
|
|||||||
COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
|
COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
COMMIT_MESSAGE: ${{ github.event.pull_request.head.commit.message }}
|
COMMIT_MESSAGE: ${{ github.event.pull_request.head.commit.message }}
|
||||||
COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/pull/${{ github.event.pull_request.number }}/commits/${{ github.event.pull_request.head.sha }}
|
COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/pull/${{ github.event.pull_request.number }}/commits/${{ github.event.pull_request.head.sha }}
|
||||||
# Structured review statuses from workflow_call jobs.
|
|
||||||
# Each job outputs a JSON array of {source, results: [...]} objects
|
|
||||||
# that the assembler renders directly — no hardcoded job-name
|
|
||||||
# matching. We merge all available outputs into one array.
|
|
||||||
REVIEW_STATUSES: ${{ toJSON(needs.*.outputs.review_status) }}
|
|
||||||
run: |
|
run: |
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
# REVIEW_STATUSES is a JSON array of strings (some may be empty
|
|
||||||
# when a job was skipped). Parse each string and merge into one
|
|
||||||
# flat array for the assembler.
|
|
||||||
python3 - <<'PYEOF'
|
|
||||||
import json, os, sys
|
|
||||||
|
|
||||||
raw = os.environ.get("REVIEW_STATUSES", "")
|
|
||||||
merged = []
|
|
||||||
if raw:
|
|
||||||
try:
|
|
||||||
arr = json.loads(raw)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
arr = []
|
|
||||||
for item in arr:
|
|
||||||
if not item:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
statuses = json.loads(item)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
continue
|
|
||||||
if isinstance(statuses, list):
|
|
||||||
merged.extend(statuses)
|
|
||||||
|
|
||||||
# Write merged array to a temp file the poller reads.
|
|
||||||
with open("/tmp/review_statuses.json", "w") as f:
|
|
||||||
json.dump(merged, f)
|
|
||||||
print(f"Merged {len(merged)} review status entries")
|
|
||||||
PYEOF
|
|
||||||
|
|
||||||
python3 scripts/ci/live_comment.py \
|
python3 scripts/ci/live_comment.py \
|
||||||
--interval 15 \
|
--interval 15 \
|
||||||
--timeout 2100 \
|
--timeout 2100
|
||||||
--review-statuses-file /tmp/review_statuses.json
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
# Gate: runs after everything. ``if: always()`` ensures it reports a
|
# Gate: runs after everything. ``if: always()`` ensures it reports a
|
||||||
@@ -304,8 +267,9 @@ jobs:
|
|||||||
# report with a gantt chart + per-step breakdown. The report is uploaded
|
# report with a gantt chart + per-step breakdown. The report is uploaded
|
||||||
# as an artifact and a markdown summary is written to $GITHUB_STEP_SUMMARY.
|
# as an artifact and a markdown summary is written to $GITHUB_STEP_SUMMARY.
|
||||||
#
|
#
|
||||||
# The live comment poller can read the standalone review-status artifact
|
# The live comment poller dynamically fetches all review-status-* artifacts
|
||||||
# after the HTML report is uploaded, so its link points straight at that report.
|
# across the orchestrator and sub-workflow runs every cycle, so its link
|
||||||
|
# points straight at that report.
|
||||||
# ─────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
ci-timings:
|
ci-timings:
|
||||||
name: CI timing report
|
name: CI timing report
|
||||||
@@ -364,7 +328,7 @@ jobs:
|
|||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||||
with:
|
with:
|
||||||
name: ci-timings-review-status
|
name: review-status-ci-timings
|
||||||
path: review-status.json
|
path: review-status.json
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ jobs:
|
|||||||
if [ -z "$NEW_EMAILS" ]; then
|
if [ -z "$NEW_EMAILS" ]; then
|
||||||
echo "No new commits to check."
|
echo "No new commits to check."
|
||||||
echo "review_status=[]" >> "$GITHUB_OUTPUT"
|
echo "review_status=[]" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "review_status=[]" > review-status.json
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -84,8 +85,22 @@ jobs:
|
|||||||
--arg how_to_fix "$HOW_TO_FIX" \
|
--arg how_to_fix "$HOW_TO_FIX" \
|
||||||
'[{"source":"contributor attribution","results":[{"kind":"action_required","title":"Unmapped contributor email(s)","summary":"New contributor email(s) are not in AUTHOR_MAP.","detail":$detail,"how_to_fix":$how_to_fix}]}]')
|
'[{"source":"contributor attribution","results":[{"kind":"action_required","title":"Unmapped contributor email(s)","summary":"New contributor email(s) are not in AUTHOR_MAP.","detail":$detail,"how_to_fix":$how_to_fix}]}]')
|
||||||
echo "review_status=$REVIEW_STATUS" >> "$GITHUB_OUTPUT"
|
echo "review_status=$REVIEW_STATUS" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "review_status=$REVIEW_STATUS" > review-status.json
|
||||||
|
|
||||||
exit 1
|
exit 1
|
||||||
else
|
else
|
||||||
echo "✅ All contributor emails are mapped."
|
echo "✅ All contributor emails are mapped."
|
||||||
|
echo "review_status=[]" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "review_status=[]" > review-status.json
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Upload review status artifact
|
||||||
|
if: always() && steps.check-emails.outcome != 'skipped'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||||
|
with:
|
||||||
|
name: review-status-contributor-check
|
||||||
|
path: review-status.json
|
||||||
|
retention-days: 1
|
||||||
|
overwrite: true
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|||||||
@@ -170,6 +170,18 @@ jobs:
|
|||||||
cat /tmp/e2e-review-status.json
|
cat /tmp/e2e-review-status.json
|
||||||
echo '__E2E_REVIEW_STATUS__'
|
echo '__E2E_REVIEW_STATUS__'
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
cp /tmp/e2e-review-status.json review-status.json
|
||||||
|
|
||||||
|
- name: Upload review status artifact
|
||||||
|
if: always() && steps.review-status.outcome != 'skipped'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: review-status-e2e-desktop
|
||||||
|
path: apps/desktop/review-status.json
|
||||||
|
retention-days: 1
|
||||||
|
overwrite: true
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|
||||||
# The trusted workflow_run publisher consumes only this flat, bounded
|
# The trusted workflow_run publisher consumes only this flat, bounded
|
||||||
# artifact. It turns selected images into GitHub attachment URLs; it
|
# artifact. It turns selected images into GitHub attachment URLs; it
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ jobs:
|
|||||||
if ! BASE=$(git merge-base origin/main HEAD 2>/dev/null) || [ -z "$BASE" ]; then
|
if ! BASE=$(git merge-base origin/main HEAD 2>/dev/null) || [ -z "$BASE" ]; then
|
||||||
STATUS='[{"source":"unrelated histories","results":[{"kind":"action_required","title":"Unrelated histories","summary":"This PR has no common ancestor with main.","detail":"","how_to_fix":"Rebase your changes onto current main:\n```\ngit fetch origin main\ngit checkout -b fix-branch origin/main\n# re-apply your changes (cherry-pick, copy files, etc.)\ngit push -f origin fix-branch\n```\n"}]}]'
|
STATUS='[{"source":"unrelated histories","results":[{"kind":"action_required","title":"Unrelated histories","summary":"This PR has no common ancestor with main.","detail":"","how_to_fix":"Rebase your changes onto current main:\n```\ngit fetch origin main\ngit checkout -b fix-branch origin/main\n# re-apply your changes (cherry-pick, copy files, etc.)\ngit push -f origin fix-branch\n```\n"}]}]'
|
||||||
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
|
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "review_status=${STATUS}" > review-status.json
|
||||||
echo ""
|
echo ""
|
||||||
echo "::error::This PR has no common ancestor with main."
|
echo "::error::This PR has no common ancestor with main."
|
||||||
echo ""
|
echo ""
|
||||||
@@ -66,3 +67,15 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "::notice::Common ancestor with main: $BASE"
|
echo "::notice::Common ancestor with main: $BASE"
|
||||||
echo "review_status=[]" >> "$GITHUB_OUTPUT"
|
echo "review_status=[]" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "review_status=[]" > review-status.json
|
||||||
|
|
||||||
|
- name: Upload review status artifact
|
||||||
|
if: always() && steps.merge-base-check.outcome != 'skipped'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||||
|
with:
|
||||||
|
name: review-status-history-check
|
||||||
|
path: review-status.json
|
||||||
|
retention-days: 1
|
||||||
|
overwrite: true
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|||||||
@@ -79,12 +79,24 @@ jobs:
|
|||||||
|
|
||||||
if [ "$CHANGED" = "true" ]; then
|
if [ "$CHANGED" = "true" ]; then
|
||||||
CONTENT=$(cat /tmp/lockfile-diff.md | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))")
|
CONTENT=$(cat /tmp/lockfile-diff.md | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))")
|
||||||
STATUS="[{\"source\":\"lockfile-diff\",\"results\":[{\"kind\":\"action_required\",\"title\":\"package-lock.json\",\"summary\":\"Locked npm dependency versions changed.\",\"detail\":${CONTENT},\"how_to_fix\":\"Add the \`ci-reviewed\` label after verifying the version changes are expected.\"}]}"
|
STATUS="[{\"source\":\"lockfile-diff\",\"results\":[{\"kind\":\"action_required\",\"title\":\"package-lock.json\",\"summary\":\"Locked npm dependency versions changed.\",\"detail\":${CONTENT},\"how_to_fix\":\"Add the \`ci-reviewed\` label after verifying the version changes are expected.\"}]}]"
|
||||||
else
|
else
|
||||||
STATUS="[]"
|
STATUS="[]"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
|
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "review_status=${STATUS}" > review-status.json
|
||||||
|
|
||||||
|
- name: Upload review status artifact
|
||||||
|
if: always() && steps.emit-status.outcome != 'skipped'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||||
|
with:
|
||||||
|
name: review-status-lockfile-diff
|
||||||
|
path: review-status.json
|
||||||
|
retention-days: 1
|
||||||
|
overwrite: true
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|
||||||
- name: Upload diff artifact
|
- name: Upload diff artifact
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||||
|
|||||||
@@ -125,3 +125,15 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
|
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "review_status=${STATUS}" > review-status.json
|
||||||
|
|
||||||
|
- name: Upload review status artifact
|
||||||
|
if: always() && steps.emit.outcome != 'skipped'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||||
|
with:
|
||||||
|
name: review-status-osv-scanner
|
||||||
|
path: review-status.json
|
||||||
|
retention-days: 1
|
||||||
|
overwrite: true
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|||||||
@@ -98,9 +98,23 @@ jobs:
|
|||||||
if [ "$SUPPLY_CHAIN" = "true" ]; then args+=(--supply-chain); fi
|
if [ "$SUPPLY_CHAIN" = "true" ]; then args+=(--supply-chain); fi
|
||||||
if [ "$LABEL_PRESENT" = "true" ]; then args+=(--label-present); fi
|
if [ "$LABEL_PRESENT" = "true" ]; then args+=(--label-present); fi
|
||||||
|
|
||||||
|
# Write to both $GITHUB_OUTPUT and review-status.json for the
|
||||||
|
# live comment poller to pick up as an artifact.
|
||||||
python3 scripts/ci/emit_review_status.py "${args[@]}" \
|
python3 scripts/ci/emit_review_status.py "${args[@]}" \
|
||||||
--repo-url "$REPO_URL" --base-sha "$BASE_SHA" --head-sha "$HEAD_SHA" \
|
--repo-url "$REPO_URL" --base-sha "$BASE_SHA" --head-sha "$HEAD_SHA" \
|
||||||
--output "$GITHUB_OUTPUT"
|
--output "$GITHUB_OUTPUT"
|
||||||
|
grep '^review_status=' "$GITHUB_OUTPUT" > review-status.json
|
||||||
|
|
||||||
|
- name: Upload review status artifact
|
||||||
|
if: always() && steps.build-status.outcome != 'skipped'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||||
|
with:
|
||||||
|
name: review-status-review-labels
|
||||||
|
path: review-status.json
|
||||||
|
retention-days: 1
|
||||||
|
overwrite: true
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|
||||||
- name: Fail on missing label
|
- name: Fail on missing label
|
||||||
if: steps.label-check.outputs.ci_reviewed != 'true'
|
if: steps.label-check.outputs.ci_reviewed != 'true'
|
||||||
|
|||||||
@@ -290,4 +290,19 @@ jobs:
|
|||||||
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
|
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
|
||||||
f.write(f"review_status={json.dumps(merged)}\n")
|
f.write(f"review_status={json.dumps(merged)}\n")
|
||||||
f.write("critical_findings=" + os.environ.get("CRITICAL_FINDINGS", "false") + "\n")
|
f.write("critical_findings=" + os.environ.get("CRITICAL_FINDINGS", "false") + "\n")
|
||||||
|
|
||||||
|
# Write review-status.json for the live comment poller artifact.
|
||||||
|
with open("review-status.json", "w", encoding="utf-8") as f:
|
||||||
|
f.write(f"review_status={json.dumps(merged)}\n")
|
||||||
PYEOF
|
PYEOF
|
||||||
|
|
||||||
|
- name: Upload review status artifact
|
||||||
|
if: always() && steps.merge.outcome != 'skipped'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||||
|
with:
|
||||||
|
name: review-status-supply-chain
|
||||||
|
path: review-status.json
|
||||||
|
retention-days: 1
|
||||||
|
overwrite: true
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|||||||
@@ -126,7 +126,20 @@ jobs:
|
|||||||
echo "::error title=uv.lock out of sync::Run \`uv lock\` locally and commit the result. If on a PR, sync with main first."
|
echo "::error title=uv.lock out of sync::Run \`uv lock\` locally and commit the result. If on a PR, sync with main first."
|
||||||
review_status='[{"source":"uv.lock check","results":[{"kind":"action_required","title":"uv.lock out of sync","summary":"uv.lock is out of sync with pyproject.toml.","how_to_fix":"Run `uv lock` locally and commit the result. If on a PR, sync with main first:\n```\ngit fetch origin main\ngit rebase origin/main\nuv lock\ngit add uv.lock\ngit commit -m \"chore: refresh uv.lock\"\n```\n"}]}]'
|
review_status='[{"source":"uv.lock check","results":[{"kind":"action_required","title":"uv.lock out of sync","summary":"uv.lock is out of sync with pyproject.toml.","how_to_fix":"Run `uv lock` locally and commit the result. If on a PR, sync with main first:\n```\ngit fetch origin main\ngit rebase origin/main\nuv lock\ngit add uv.lock\ngit commit -m \"chore: refresh uv.lock\"\n```\n"}]}]'
|
||||||
echo "review_status=${review_status}" >> "$GITHUB_OUTPUT"
|
echo "review_status=${review_status}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "review_status=${review_status}" > review-status.json
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
review_status='[]'
|
review_status='[]'
|
||||||
echo "review_status=${review_status}" >> "$GITHUB_OUTPUT"
|
echo "review_status=${review_status}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "review_status=${review_status}" > review-status.json
|
||||||
|
|
||||||
|
- name: Upload review status artifact
|
||||||
|
if: always() && steps.verify.outcome != 'skipped'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||||
|
with:
|
||||||
|
name: review-status-uv-lockfile
|
||||||
|
path: review-status.json
|
||||||
|
retention-days: 1
|
||||||
|
overwrite: true
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|||||||
+152
-63
@@ -20,13 +20,17 @@ Architecture:
|
|||||||
|
|
||||||
- :func:`find_comment_id` / :func:`upsert_comment` — thin API wrappers.
|
- :func:`find_comment_id` / :func:`upsert_comment` — thin API wrappers.
|
||||||
|
|
||||||
- :func:`_fetch_timings_statuses` — downloads the ci-timings artifact
|
- :func:`fetch_all_review_statuses` — enumerates all
|
||||||
(if available) and parses the ``review_status=`` line from it, merging
|
``review-status-*`` artifacts across the orchestrator run and all
|
||||||
the status objects into the review statuses array.
|
sub-workflow runs, downloads each, parses the ``review_status=``
|
||||||
|
line from ``review-status.json``, and merges into one array.
|
||||||
|
Recomputed from source every poll cycle, so statuses appear as
|
||||||
|
soon as each job uploads its artifact.
|
||||||
|
|
||||||
- :func:`run` — the polling loop. Calls the API, classifies, assembles,
|
- :func:`run` — the polling loop. Calls the API, classifies,
|
||||||
upserts, sleeps, repeats. Before its final exit, it gives downstream jobs
|
fetches artifacts, assembles, upserts, sleeps, repeats. Before
|
||||||
a short grace period to appear.
|
its final exit, it gives downstream jobs a short grace period
|
||||||
|
to appear.
|
||||||
|
|
||||||
The orchestrator job names (detect, all-checks-pass, comment-live, etc.)
|
The orchestrator job names (detect, all-checks-pass, comment-live, etc.)
|
||||||
are excluded from the comment — they're infrastructure, not review signal.
|
are excluded from the comment — they're infrastructure, not review signal.
|
||||||
@@ -37,11 +41,12 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
API_BASE = "https://api.github.com"
|
API_BASE = "https://api.github.com"
|
||||||
@@ -265,39 +270,95 @@ def upsert_comment(
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Artifact fetching (ci-timings review_status)
|
# Artifact fetching (dynamic review-status artifacts)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Prefix for all review-status artifacts uploaded by status-producing jobs.
|
||||||
|
# Each job uploads a ``review-status-<name>`` artifact containing a
|
||||||
|
# ``review-status.json`` file in GITHUB_OUTPUT format:
|
||||||
|
# review_status=<json array of {source, results: [...]} objects>
|
||||||
|
_REVIEW_STATUS_ARTIFACT_PREFIX = "review-status-"
|
||||||
|
|
||||||
def _fetch_artifact_statuses(
|
|
||||||
token: str, repo: str, run_id: str, artifact_name: str,
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Download a workflow artifact and extract review_status entries.
|
|
||||||
|
|
||||||
The ci-timings job writes a ``review-status.json`` file containing
|
def _list_run_ids(token: str, repo: str, run_id: str) -> list[str]:
|
||||||
``review_status=<json>`` (GITHUB_OUTPUT format) into its artifact.
|
"""Return the orchestrator run ID + all sub-workflow run IDs.
|
||||||
This function downloads the artifact, parses the line, and returns
|
|
||||||
the parsed status array. Returns ``[]`` if the artifact doesn't exist
|
Sub-workflow runs (workflow_call) may not exist yet on the first few
|
||||||
yet or can't be parsed.
|
polls — that's fine, they just won't be in the list.
|
||||||
"""
|
"""
|
||||||
|
owner, repo_name = repo.split("/")
|
||||||
|
run_info = _api_request(
|
||||||
|
f"{API_BASE}/repos/{owner}/{repo_name}/actions/runs/{run_id}", token
|
||||||
|
)
|
||||||
|
created_at = run_info.get("created_at", "")
|
||||||
|
head_sha = run_info.get("head_sha", "")
|
||||||
|
|
||||||
|
run_ids = [run_id]
|
||||||
|
|
||||||
|
sub_runs = _api_get_paginated(
|
||||||
|
f"{API_BASE}/repos/{owner}/{repo_name}/actions/runs"
|
||||||
|
f"?head_sha={head_sha}&event=workflow_call&per_page=100",
|
||||||
|
token, list_key="workflow_runs",
|
||||||
|
)
|
||||||
|
sub_runs = [r for r in sub_runs if r.get("created_at", "") >= created_at]
|
||||||
|
run_ids.extend(str(r["id"]) for r in sub_runs)
|
||||||
|
|
||||||
|
return run_ids
|
||||||
|
|
||||||
|
|
||||||
|
def _list_artifacts(token: str, repo: str, run_id: str) -> list[dict]:
|
||||||
|
"""List artifacts for a given run (paginated)."""
|
||||||
|
owner, repo_name = repo.split("/")
|
||||||
|
return _api_get_paginated(
|
||||||
|
f"{API_BASE}/repos/{owner}/{repo_name}/actions/runs/{run_id}/artifacts",
|
||||||
|
token, list_key="artifacts",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _download_artifact(
|
||||||
|
token: str, repo: str, artifact: dict, dest_dir: Path,
|
||||||
|
) -> Path | None:
|
||||||
|
"""Download a single artifact zip via the API and extract it.
|
||||||
|
|
||||||
|
Returns the path to ``review-status.json`` inside the extracted dir,
|
||||||
|
or ``None`` if the download or extraction failed.
|
||||||
|
"""
|
||||||
|
owner, repo_name = repo.split("/")
|
||||||
|
archive_download_url = artifact.get("archive_download_url", "")
|
||||||
|
if not archive_download_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# The archive_download_url is an API URL that redirects to a S3 URL.
|
||||||
|
# Build the request with our auth headers so the redirect works.
|
||||||
|
req = urllib.request.Request(archive_download_url, headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Accept": "application/vnd.github+json",
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
"User-Agent": "ci-live-comment",
|
||||||
|
})
|
||||||
|
zip_path = dest_dir / f"{artifact['name']}.zip"
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
with urllib.request.urlopen(req) as resp:
|
||||||
["gh", "run", "download", run_id, "--repo", repo,
|
zip_path.write_bytes(resp.read())
|
||||||
"--name", artifact_name, "--dir", "/tmp/artifact-dl"],
|
|
||||||
capture_output=True, timeout=30,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return []
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return None
|
||||||
|
|
||||||
status_file = Path("/tmp/artifact-dl/review-status.json")
|
extract_dir = dest_dir / artifact["name"]
|
||||||
if not status_file.exists():
|
extract_dir.mkdir(parents=True, exist_ok=True)
|
||||||
return []
|
try:
|
||||||
|
with zipfile.ZipFile(zip_path) as zf:
|
||||||
|
zf.extractall(extract_dir)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
status_file = extract_dir / "review-status.json"
|
||||||
|
return status_file if status_file.exists() else None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_status_file(status_file: Path) -> list[dict]:
|
||||||
|
"""Parse a review-status.json file in GITHUB_OUTPUT format."""
|
||||||
try:
|
try:
|
||||||
content = status_file.read_text(encoding="utf-8").strip()
|
content = status_file.read_text(encoding="utf-8").strip()
|
||||||
# GITHUB_OUTPUT format: review_status=<json>
|
|
||||||
if content.startswith("review_status="):
|
if content.startswith("review_status="):
|
||||||
content = content[len("review_status="):]
|
content = content[len("review_status="):]
|
||||||
statuses = json.loads(content)
|
statuses = json.loads(content)
|
||||||
@@ -305,10 +366,59 @@ def _fetch_artifact_statuses(
|
|||||||
return statuses
|
return statuses
|
||||||
except (json.JSONDecodeError, OSError):
|
except (json.JSONDecodeError, OSError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_all_review_statuses(
|
||||||
|
token: str, repo: str, run_id: str,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Fetch and merge all review-status artifacts across all runs.
|
||||||
|
|
||||||
|
Enumerates artifacts with the ``review-status-`` prefix from the
|
||||||
|
orchestrator run and all sub-workflow runs (workflow_call). Downloads
|
||||||
|
each, parses the ``review-status.json`` inside, and merges into a
|
||||||
|
single flat array.
|
||||||
|
|
||||||
|
Returns the merged list of ``{source, results: [...]}`` objects.
|
||||||
|
Artifacts that don't exist yet or fail to parse are silently skipped.
|
||||||
|
"""
|
||||||
|
all_statuses: list[dict] = []
|
||||||
|
temp_base = Path("/tmp/review-status-artifacts")
|
||||||
|
|
||||||
|
try:
|
||||||
|
run_ids = _list_run_ids(token, repo, run_id)
|
||||||
|
except Exception:
|
||||||
|
return all_statuses
|
||||||
|
|
||||||
|
for rid in run_ids:
|
||||||
|
try:
|
||||||
|
artifacts = _list_artifacts(token, repo, rid)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
rs_artifacts = [
|
||||||
|
a for a in artifacts
|
||||||
|
if a.get("name", "").startswith(_REVIEW_STATUS_ARTIFACT_PREFIX)
|
||||||
|
]
|
||||||
|
if not rs_artifacts:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Clean temp dir for this run's artifacts.
|
||||||
|
run_dl_dir = temp_base / rid
|
||||||
|
if run_dl_dir.exists():
|
||||||
|
shutil.rmtree(run_dl_dir)
|
||||||
|
run_dl_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for artifact in rs_artifacts:
|
||||||
|
status_file = _download_artifact(token, repo, artifact, run_dl_dir)
|
||||||
|
if status_file is None:
|
||||||
|
continue
|
||||||
|
statuses = _parse_status_file(status_file)
|
||||||
|
all_statuses.extend(statuses)
|
||||||
|
|
||||||
|
return all_statuses
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Comment assembly
|
# Comment assembly
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -344,12 +454,9 @@ def build_comment_body(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _merge_statuses(
|
def _merge_statuses(statuses: list[dict]) -> str:
|
||||||
base_statuses: list[dict], extra_statuses: list[dict]
|
"""Merge a list of status arrays into one JSON string."""
|
||||||
) -> str:
|
return json.dumps(statuses) if statuses else ""
|
||||||
"""Merge two status arrays into one JSON string."""
|
|
||||||
merged = list(base_statuses) + list(extra_statuses)
|
|
||||||
return json.dumps(merged) if merged else ""
|
|
||||||
|
|
||||||
|
|
||||||
def _commit_info_for_state(commit_info: str, pending: list[str]) -> str:
|
def _commit_info_for_state(commit_info: str, pending: list[str]) -> str:
|
||||||
@@ -370,7 +477,6 @@ def run(
|
|||||||
run_id: str,
|
run_id: str,
|
||||||
pr_number: str,
|
pr_number: str,
|
||||||
run_url: str,
|
run_url: str,
|
||||||
review_statuses_json: str = "",
|
|
||||||
commit_info: str = "",
|
commit_info: str = "",
|
||||||
interval: int = 15,
|
interval: int = 15,
|
||||||
timeout: int = 1800,
|
timeout: int = 1800,
|
||||||
@@ -386,13 +492,7 @@ def run(
|
|||||||
quiet_grace_used = False
|
quiet_grace_used = False
|
||||||
prev_completed: dict[str, str] = {}
|
prev_completed: dict[str, str] = {}
|
||||||
prev_pending: list[str] = []
|
prev_pending: list[str] = []
|
||||||
|
prev_artifact_count = 0
|
||||||
# Parse the base statuses once (from review-labels, lockfile-diff, etc.)
|
|
||||||
try:
|
|
||||||
base_statuses = json.loads(review_statuses_json) if review_statuses_json else []
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
base_statuses = []
|
|
||||||
print(f" Loaded {len(base_statuses)} base review status entries")
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
elapsed = time.time() - start
|
elapsed = time.time() - start
|
||||||
@@ -426,14 +526,14 @@ def run(
|
|||||||
if gone_pending:
|
if gone_pending:
|
||||||
print(f" → {len(gone_pending)} job(s) disappeared from pending: {', '.join(gone_pending)}")
|
print(f" → {len(gone_pending)} job(s) disappeared from pending: {', '.join(gone_pending)}")
|
||||||
|
|
||||||
# Try to fetch ci-timings artifact statuses (may not exist yet).
|
# Dynamically fetch all review-status artifacts from every run.
|
||||||
artifact_statuses = _fetch_artifact_statuses(
|
artifact_statuses = fetch_all_review_statuses(token, repo, run_id)
|
||||||
token, repo, run_id, "ci-timings-review-status",
|
if len(artifact_statuses) != prev_artifact_count:
|
||||||
)
|
print(f" Found {len(artifact_statuses)} review status entries from artifacts "
|
||||||
if artifact_statuses:
|
f"(was {prev_artifact_count} last poll)")
|
||||||
print(f" Found ci-timings artifact with {len(artifact_statuses)} status entries")
|
prev_artifact_count = len(artifact_statuses)
|
||||||
|
|
||||||
merged_json = _merge_statuses(base_statuses, artifact_statuses)
|
merged_json = _merge_statuses(artifact_statuses)
|
||||||
current_commit_info = _commit_info_for_state(commit_info, pending)
|
current_commit_info = _commit_info_for_state(commit_info, pending)
|
||||||
|
|
||||||
body = build_comment_body(
|
body = build_comment_body(
|
||||||
@@ -450,7 +550,7 @@ def run(
|
|||||||
change_reasons.append(f"{len(new_pending)} new pending job(s)")
|
change_reasons.append(f"{len(new_pending)} new pending job(s)")
|
||||||
if gone_pending:
|
if gone_pending:
|
||||||
change_reasons.append(f"{len(gone_pending)} job(s) left pending")
|
change_reasons.append(f"{len(gone_pending)} job(s) left pending")
|
||||||
if artifact_statuses:
|
if len(artifact_statuses) != prev_artifact_count:
|
||||||
change_reasons.append("artifact statuses updated")
|
change_reasons.append("artifact statuses updated")
|
||||||
if not change_reasons:
|
if not change_reasons:
|
||||||
change_reasons.append("initial post")
|
change_reasons.append("initial post")
|
||||||
@@ -507,8 +607,6 @@ def main() -> int:
|
|||||||
help="Seconds between polls (default: 15).")
|
help="Seconds between polls (default: 15).")
|
||||||
parser.add_argument("--timeout", type=int, default=1800,
|
parser.add_argument("--timeout", type=int, default=1800,
|
||||||
help="Max seconds to poll before giving up (default: 1800).")
|
help="Max seconds to poll before giving up (default: 1800).")
|
||||||
parser.add_argument("--review-statuses-file", type=Path, default=None,
|
|
||||||
help="Path to a JSON file with merged review statuses from workflow_call jobs.")
|
|
||||||
parser.add_argument("--dry-run", action="store_true",
|
parser.add_argument("--dry-run", action="store_true",
|
||||||
help="Print comment body instead of posting to PR.")
|
help="Print comment body instead of posting to PR.")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
@@ -533,14 +631,6 @@ def main() -> int:
|
|||||||
print("PR_NUMBER is required", file=sys.stderr)
|
print("PR_NUMBER is required", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
# Read merged review statuses from file (prepared by the ci.yml step).
|
|
||||||
review_statuses_json = ""
|
|
||||||
if args.review_statuses_file:
|
|
||||||
try:
|
|
||||||
review_statuses_json = args.review_statuses_file.read_text(encoding="utf-8")
|
|
||||||
except OSError as e:
|
|
||||||
print(f"Warning: could not read review statuses file: {e}", file=sys.stderr)
|
|
||||||
|
|
||||||
# Build commit info line from env vars (set by ci.yml).
|
# Build commit info line from env vars (set by ci.yml).
|
||||||
commit_sha = os.environ.get("COMMIT_SHA", "")
|
commit_sha = os.environ.get("COMMIT_SHA", "")
|
||||||
commit_msg = os.environ.get("COMMIT_MESSAGE", "")
|
commit_msg = os.environ.get("COMMIT_MESSAGE", "")
|
||||||
@@ -566,7 +656,6 @@ def main() -> int:
|
|||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
pr_number=pr_number,
|
pr_number=pr_number,
|
||||||
run_url=run_url,
|
run_url=run_url,
|
||||||
review_statuses_json=review_statuses_json,
|
|
||||||
commit_info=commit_info,
|
commit_info=commit_info,
|
||||||
interval=args.interval,
|
interval=args.interval,
|
||||||
timeout=args.timeout,
|
timeout=args.timeout,
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
"""Tests for scripts/ci/live_comment.py — classify_jobs().
|
"""Tests for scripts/ci/live_comment.py — classify_jobs() + artifact helpers.
|
||||||
|
|
||||||
The poller's core logic is a pure function: take raw GitHub API job dicts
|
The poller's core logic is a pure function: take raw GitHub API job dicts
|
||||||
and split them into (completed, pending). The API wrapper + polling loop
|
and split them into (completed, pending). Artifact parsing helpers are also
|
||||||
are tested via E2E in CI, not here.
|
pure and tested here. The API wrapper + polling loop are tested via E2E
|
||||||
|
in CI, not here.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_PATH = Path(__file__).resolve().parents[2] / "scripts" / "ci" / "live_comment.py"
|
_PATH = Path(__file__).resolve().parents[2] / "scripts" / "ci" / "live_comment.py"
|
||||||
@@ -172,3 +175,85 @@ def test_commit_info_uses_past_tense_after_jobs_complete():
|
|||||||
assert _mod._commit_info_for_state(info, []) == (
|
assert _mod._commit_info_for_state(info, []) == (
|
||||||
"<sub>ran on [abc1234](https://commit-url) — fix: thing</sub>"
|
"<sub>ran on [abc1234](https://commit-url) — fix: thing</sub>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Artifact parsing helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_parse_status_file_with_prefix():
|
||||||
|
"""GITHUB_OUTPUT format: review_status=<json>"""
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||||
|
f.write('review_status=[{"source":"test","results":[]}]')
|
||||||
|
f.flush()
|
||||||
|
statuses = _mod._parse_status_file(Path(f.name))
|
||||||
|
assert len(statuses) == 1
|
||||||
|
assert statuses[0]["source"] == "test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_status_file_without_prefix():
|
||||||
|
"""Raw JSON (no review_status= prefix) is also accepted."""
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||||
|
f.write('[{"source":"raw","results":[]}]')
|
||||||
|
f.flush()
|
||||||
|
statuses = _mod._parse_status_file(Path(f.name))
|
||||||
|
assert len(statuses) == 1
|
||||||
|
assert statuses[0]["source"] == "raw"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_status_file_empty_array():
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||||
|
f.write('review_status=[]')
|
||||||
|
f.flush()
|
||||||
|
statuses = _mod._parse_status_file(Path(f.name))
|
||||||
|
assert statuses == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_status_file_invalid_json():
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||||
|
f.write('review_status=not json')
|
||||||
|
f.flush()
|
||||||
|
statuses = _mod._parse_status_file(Path(f.name))
|
||||||
|
assert statuses == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_status_file_nonexistent():
|
||||||
|
assert _mod._parse_status_file(Path("/nonexistent/file.json")) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_status_file_not_a_list():
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||||
|
f.write('review_status={"not":"a list"}')
|
||||||
|
f.flush()
|
||||||
|
statuses = _mod._parse_status_file(Path(f.name))
|
||||||
|
assert statuses == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_statuses_empty():
|
||||||
|
assert _mod._merge_statuses([]) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_statuses_single():
|
||||||
|
statuses = [{"source": "a", "results": []}]
|
||||||
|
result = _mod._merge_statuses(statuses)
|
||||||
|
assert json.loads(result) == statuses
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_statuses_multiple():
|
||||||
|
statuses = [
|
||||||
|
{"source": "a", "results": []},
|
||||||
|
{"source": "b", "results": [{"kind": "warning"}]},
|
||||||
|
]
|
||||||
|
result = _mod._merge_statuses(statuses)
|
||||||
|
assert json.loads(result) == statuses
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_status_artifact_prefix():
|
||||||
|
"""The prefix is used to filter artifacts from the API."""
|
||||||
|
assert _mod._REVIEW_STATUS_ARTIFACT_PREFIX == "review-status-"
|
||||||
|
# Artifacts with this prefix should be picked up
|
||||||
|
assert "review-status-ci-timings".startswith(_mod._REVIEW_STATUS_ARTIFACT_PREFIX)
|
||||||
|
assert "review-status-review-labels".startswith(_mod._REVIEW_STATUS_ARTIFACT_PREFIX)
|
||||||
|
# Artifacts without it should not
|
||||||
|
assert not "ci-timings-report".startswith(_mod._REVIEW_STATUS_ARTIFACT_PREFIX)
|
||||||
|
assert not "playwright-report".startswith(_mod._REVIEW_STATUS_ARTIFACT_PREFIX)
|
||||||
|
|||||||
Reference in New Issue
Block a user