Compare commits

...
Author SHA1 Message Date
ethernet 7144eb4900 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.
2026-07-24 18:10:38 -04:00
ethernet 18481742ee ci: add detailed logging to live comment poller
The poller now logs transitions between polls: newly completed jobs
(with results), newly appeared jobs, and jobs that disappeared from
pending. The comment update line includes the reason for the change
(e.g. '1 new completion(s); artifact statuses updated'). When no
change occurs, it lists the jobs still being waited on. The status
line also shows raw API job count and how many were filtered as infra.
2026-07-24 17:47:24 -04:00
11 changed files with 392 additions and 112 deletions
+5 -41
View File
@@ -196,47 +196,10 @@ jobs:
COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
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 }}
# 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: |
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 \
--interval 15 \
--timeout 2100 \
--review-statuses-file /tmp/review_statuses.json
--timeout 2100
# ─────────────────────────────────────────────────────────────────────
# 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
# as an artifact and a markdown summary is written to $GITHUB_STEP_SUMMARY.
#
# The live comment poller can read the standalone review-status artifact
# after the HTML report is uploaded, so its link points straight at that report.
# The live comment poller dynamically fetches all review-status-* artifacts
# across the orchestrator and sub-workflow runs every cycle, so its link
# points straight at that report.
# ─────────────────────────────────────────────────────────────────────
ci-timings:
name: CI timing report
@@ -364,7 +328,7 @@ jobs:
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ci-timings-review-status
name: review-status-ci-timings
path: review-status.json
retention-days: 14
+15
View File
@@ -33,6 +33,7 @@ jobs:
if [ -z "$NEW_EMAILS" ]; then
echo "No new commits to check."
echo "review_status=[]" >> "$GITHUB_OUTPUT"
echo "review_status=[]" > review-status.json
exit 0
fi
@@ -84,8 +85,22 @@ jobs:
--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}]}]')
echo "review_status=$REVIEW_STATUS" >> "$GITHUB_OUTPUT"
echo "review_status=$REVIEW_STATUS" > review-status.json
exit 1
else
echo "✅ All contributor emails are mapped."
echo "review_status=[]" >> "$GITHUB_OUTPUT"
echo "review_status=[]" > review-status.json
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
+12
View File
@@ -170,6 +170,18 @@ jobs:
cat /tmp/e2e-review-status.json
echo '__E2E_REVIEW_STATUS__'
} >> "$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
# artifact. It turns selected images into GitHub attachment URLs; it
+13
View File
@@ -44,6 +44,7 @@ jobs:
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"}]}]'
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
echo "review_status=${STATUS}" > review-status.json
echo ""
echo "::error::This PR has no common ancestor with main."
echo ""
@@ -66,3 +67,15 @@ jobs:
fi
echo "::notice::Common ancestor with main: $BASE"
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
+13 -1
View File
@@ -79,12 +79,24 @@ jobs:
if [ "$CHANGED" = "true" ]; then
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
STATUS="[]"
fi
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
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+12
View File
@@ -125,3 +125,15 @@ jobs:
fi
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
+14
View File
@@ -98,9 +98,23 @@ jobs:
if [ "$SUPPLY_CHAIN" = "true" ]; then args+=(--supply-chain); 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[@]}" \
--repo-url "$REPO_URL" --base-sha "$BASE_SHA" --head-sha "$HEAD_SHA" \
--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
if: steps.label-check.outputs.ci_reviewed != 'true'
+15
View File
@@ -290,4 +290,19 @@ jobs:
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
f.write(f"review_status={json.dumps(merged)}\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
- 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
+13
View File
@@ -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."
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}" > review-status.json
exit 1
fi
review_status='[]'
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
+192 -67
View File
@@ -20,13 +20,17 @@ Architecture:
- :func:`find_comment_id` / :func:`upsert_comment` — thin API wrappers.
- :func:`_fetch_timings_statuses` — downloads the ci-timings artifact
(if available) and parses the ``review_status=`` line from it, merging
the status objects into the review statuses array.
- :func:`fetch_all_review_statuses` — enumerates all
``review-status-*`` artifacts across the orchestrator run and all
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,
upserts, sleeps, repeats. Before its final exit, it gives downstream jobs
a short grace period to appear.
- :func:`run` — the polling loop. Calls the API, classifies,
fetches artifacts, assembles, upserts, sleeps, repeats. Before
its final exit, it gives downstream jobs a short grace period
to appear.
The orchestrator job names (detect, all-checks-pass, comment-live, etc.)
are excluded from the comment — they're infrastructure, not review signal.
@@ -37,11 +41,12 @@ from __future__ import annotations
import argparse
import json
import os
import subprocess
import shutil
import sys
import time
import urllib.error
import urllib.request
import zipfile
from pathlib import Path
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
``review_status=<json>`` (GITHUB_OUTPUT format) into its artifact.
This function downloads the artifact, parses the line, and returns
the parsed status array. Returns ``[]`` if the artifact doesn't exist
yet or can't be parsed.
def _list_run_ids(token: str, repo: str, run_id: str) -> list[str]:
"""Return the orchestrator run ID + all sub-workflow run IDs.
Sub-workflow runs (workflow_call) may not exist yet on the first few
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:
result = subprocess.run(
["gh", "run", "download", run_id, "--repo", repo,
"--name", artifact_name, "--dir", "/tmp/artifact-dl"],
capture_output=True, timeout=30,
)
if result.returncode != 0:
return []
with urllib.request.urlopen(req) as resp:
zip_path.write_bytes(resp.read())
except Exception:
return []
return None
status_file = Path("/tmp/artifact-dl/review-status.json")
if not status_file.exists():
return []
extract_dir = dest_dir / artifact["name"]
extract_dir.mkdir(parents=True, exist_ok=True)
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:
content = status_file.read_text(encoding="utf-8").strip()
# GITHUB_OUTPUT format: review_status=<json>
if content.startswith("review_status="):
content = content[len("review_status="):]
statuses = json.loads(content)
@@ -305,10 +366,59 @@ def _fetch_artifact_statuses(
return statuses
except (json.JSONDecodeError, OSError):
pass
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
# ---------------------------------------------------------------------------
@@ -344,12 +454,9 @@ def build_comment_body(
)
def _merge_statuses(
base_statuses: list[dict], extra_statuses: list[dict]
) -> str:
"""Merge two status arrays into one JSON string."""
merged = list(base_statuses) + list(extra_statuses)
return json.dumps(merged) if merged else ""
def _merge_statuses(statuses: list[dict]) -> str:
"""Merge a list of status arrays into one JSON string."""
return json.dumps(statuses) if statuses else ""
def _commit_info_for_state(commit_info: str, pending: list[str]) -> str:
@@ -370,7 +477,6 @@ def run(
run_id: str,
pr_number: str,
run_url: str,
review_statuses_json: str = "",
commit_info: str = "",
interval: int = 15,
timeout: int = 1800,
@@ -384,13 +490,9 @@ def run(
start = time.time()
last_body = ""
quiet_grace_used = False
# 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")
prev_completed: dict[str, str] = {}
prev_pending: list[str] = []
prev_artifact_count = 0
while True:
elapsed = time.time() - start
@@ -407,17 +509,31 @@ def run(
completed, pending, job_urls = classify_jobs(jobs)
total = len(completed) + len(pending)
print(f" [{elapsed:.0f}s] {len(completed)} completed, {len(pending)} pending "
f"({total} total jobs)")
infra_count = len(jobs) - total
print(f" [{elapsed:.0f}s] fetched {len(jobs)} jobs from API "
f"({infra_count} infra filtered) → {len(completed)} completed, "
f"{len(pending)} pending ({total} review jobs)")
# Try to fetch ci-timings artifact statuses (may not exist yet).
artifact_statuses = _fetch_artifact_statuses(
token, repo, run_id, "ci-timings-review-status",
)
if artifact_statuses:
print(f" Found ci-timings artifact with {len(artifact_statuses)} status entries")
# Log transitions since last poll.
new_completed = {k: v for k, v in completed.items() if k not in prev_completed}
new_pending = [j for j in pending if j not in prev_pending]
gone_pending = [j for j in prev_pending if j not in pending and j not in completed]
if new_completed:
parts = [f"{name}={result}" for name, result in new_completed.items()]
print(f" → {len(new_completed)} job(s) newly completed: {', '.join(parts)}")
if new_pending:
print(f" → {len(new_pending)} job(s) newly appeared: {', '.join(new_pending)}")
if gone_pending:
print(f" → {len(gone_pending)} job(s) disappeared from pending: {', '.join(gone_pending)}")
merged_json = _merge_statuses(base_statuses, artifact_statuses)
# Dynamically fetch all review-status artifacts from every run.
artifact_statuses = fetch_all_review_statuses(token, repo, run_id)
if len(artifact_statuses) != prev_artifact_count:
print(f" Found {len(artifact_statuses)} review status entries from artifacts "
f"(was {prev_artifact_count} last poll)")
prev_artifact_count = len(artifact_statuses)
merged_json = _merge_statuses(artifact_statuses)
current_commit_info = _commit_info_for_state(commit_info, pending)
body = build_comment_body(
@@ -427,19 +543,39 @@ def run(
)
if body != last_body:
change_reasons = []
if new_completed:
change_reasons.append(f"{len(new_completed)} new completion(s)")
if new_pending:
change_reasons.append(f"{len(new_pending)} new pending job(s)")
if gone_pending:
change_reasons.append(f"{len(gone_pending)} job(s) left pending")
if len(artifact_statuses) != prev_artifact_count:
change_reasons.append("artifact statuses updated")
if not change_reasons:
change_reasons.append("initial post")
reason = "; ".join(change_reasons)
if dry_run:
print(f" Comment body changed ({reason}) — DRY RUN:")
print("--- DRY RUN — comment body ---")
print(body)
print("--- END ---")
else:
cid = upsert_comment(token, repo, pr_number, body)
if cid:
print(f" Updated comment {cid}")
print(f" Updated comment {cid} ({reason})")
else:
print(" Failed to update comment (will retry)", file=sys.stderr)
print(f" Failed to update comment ({reason}, will retry)", file=sys.stderr)
last_body = body
else:
print(" No change since last poll.")
if pending:
print(f" No change since last poll. Still waiting on: {', '.join(pending)}")
else:
print(" No change since last poll.")
prev_completed = completed
prev_pending = pending
if not pending and not quiet_grace_used:
quiet_grace_used = True
@@ -471,8 +607,6 @@ def main() -> int:
help="Seconds between polls (default: 15).")
parser.add_argument("--timeout", type=int, 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",
help="Print comment body instead of posting to PR.")
args = parser.parse_args()
@@ -497,14 +631,6 @@ def main() -> int:
print("PR_NUMBER is required", file=sys.stderr)
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).
commit_sha = os.environ.get("COMMIT_SHA", "")
commit_msg = os.environ.get("COMMIT_MESSAGE", "")
@@ -530,7 +656,6 @@ def main() -> int:
run_id=run_id,
pr_number=pr_number,
run_url=run_url,
review_statuses_json=review_statuses_json,
commit_info=commit_info,
interval=args.interval,
timeout=args.timeout,
+88 -3
View File
@@ -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
and split them into (completed, pending). The API wrapper + polling loop
are tested via E2E in CI, not here.
and split them into (completed, pending). Artifact parsing helpers are also
pure and tested here. The API wrapper + polling loop are tested via E2E
in CI, not here.
"""
from __future__ import annotations
import importlib.util
import json
import sys
import tempfile
from pathlib import Path
_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, []) == (
"<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)