hermes-agent/scripts/ci/assemble_review_comment.py
ethernet b9f82ed39f ci: live-updating PR review comment with structured job statuses
Replace the static comment-pending + comment-results two-job pattern
with a live-updating comment system that polls the GitHub Actions API
every 15s, re-assembles the review comment from whatever results are
available, and upserts it via the <!-- hermes-ci-review-bot --> marker.
The comment updates in real time as each job finishes — no waiting for
the full pipeline.

Every CI job that wants to appear in the review comment emits a
review_status output — a JSON array of objects, each with a source
and a results array:

    [
      {
        "source": "review-label-gate",
        "results": [
          {"kind": "action_required", "title": "...", "summary": "...",
           "how_to_fix": "..."},
          {"kind": "info", "title": "...", "summary": "..."}
        ]
      },
      {
        "source": "ci timing",
        "results": [
          {"kind": "warning", "title": "CI timings", "summary": "...",
           "detail": "...", "link": "..."}
        ]
      }
    ]

One job can emit multiple results of different kinds. The source field
is used to exclude the corresponding job from the synthesized error
list (case-insensitive, hyphen-normalized matching against GitHub
Actions job display names).

| job                        | source                   | kind (on failure)         | section              |
|----------------------------|--------------------------|---------------------------|----------------------|
| review-labels              | review label gate        | action_required / info    | Action required      |
| lockfile-diff              | lockfile-diff            | action_required           | Action required      |
| ci-timings                 | ci timing                | warning / info            | Warnings             |
| supply-chain scan          | supply chain             | error / (none)            | Job failures         |
| supply-chain dep-bounds    | supply chain             | action_required / (none)  | Action required      |
| osv-scanner                | osv scan                 | warning / (none)          | Warnings             |
| uv-lockfile-check          | uv.lock check            | action_required / (none)  | Action required      |
| history-check              | unrelated histories      | action_required           | Action required      |
| contributor-check          | contributor attribution  | action_required           | Action required      |

Jobs that find nothing emit [] (empty array) — no noise info items.

A single comment-live job polls the GitHub Actions API every 15s,
classifies jobs into (completed, pending), assembles the comment, and
upserts it. Merges review_status outputs from all needs jobs via
toJSON(needs.*.outputs.review_status), and downloads the ci-timings
artifact when it becomes available. Shows commit SHA + message below
the header.

The assembler has ZERO job-specific knowledge. It just:
1. collect_from_statuses() — flattens all nested status objects into ReviewItems
2. collect_failed_jobs() — synthesizes errors for failed jobs with no declared status
3. _attach_job_urls() — fills in per-job log links for ALL items
4. render_comment() — groups by severity, renders with group headers

Each item shows links inline next to the title: View report (job-emitted
URL) and View job (auto-attached logs link). Each info item is its own
collapsible <details> block.

    # ૮ >ﻌ< ა ci review

    running on abc1234 — commit message first line

    ##  Job failures
    ### {title} · [View job](url)
    {summary}

    ## ⚠️ Action required
    ### {title} · [View job](url)
    {summary}
    **How to fix:**
    {how_to_fix}

    ## ⚠️ Warnings
    ### {title} · [View report](url) · [View job](url)
    {summary}
    {detail}

    <details><summary>{title}</summary>
    {content}
    </details>

    Still running 3 jobs: ci-timings, docker

- test_assemble_review_comment.py (48 tests): collect_from_statuses,
  collect_failed_jobs with exclude_sources, _attach_job_urls,
  render_comment (group headers, inline links, commit info, per-item
  details, pending footer), assemble integration
- test_live_comment.py (16 tests): classify_jobs pure function
- test_timings_report.py (10 tests): generate_review_status nested format
- test_lockfile_diff.py (6 tests)
- test_classify_changes.py (32 tests, pre-existing)
2026-07-20 16:48:25 -04:00

424 lines
14 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Assemble the unified CI review comment for a pull request.
Every CI job that wants to appear in the review comment emits a
``review_status`` output: a JSON array of objects, each with a ``source``
(the workflow name, used for dedup) and a ``results`` array of typed
result objects::
[
{
"source": "review-label-gate",
"results": [
{"kind": "action_required", "title": "...", "summary": "...",
"how_to_fix": "..."},
{"kind": "info", "title": "...", "summary": "..."}
]
},
{
"source": "ci-timings",
"results": [
{"kind": "warning", "title": "CI timings", "summary": "...",
"detail": "...", "link": "..."}
]
}
]
Each result object has:
kind: "error" | "action_required" | "warning" | "info"
title: section heading
summary: one-line description
detail: markdown detail (optional)
how_to_fix: markdown checklist (optional)
link: URL (optional)
link_label: label for the link (optional, default "View logs")
The assembler flattens all results into a flat list of ReviewItems,
grouped by severity in the comment. Jobs that failed (from the
``needs`` context) but didn't emit any status get synthesized ❌ Error
items. Jobs that DID emit a status are excluded from the synthesized
error list — their own output is the authority for their classification.
Exits 0 always — comment posting is best-effort (fork PRs are read-only).
"""
from __future__ import annotations
import argparse
import json
import sys
from dataclasses import dataclass
from pathlib import Path
# Hidden marker the comment system uses to find-and-edit its
# previous comment instead of stacking new ones on each run.
MARKER = "<!-- hermes-ci-review-bot -->"
# Severity ordering for display.
_SEVERITY_ORDER = ["error", "action_required", "warning", "info"]
# Severities that trigger the "blocking issues" layout (vs. the
# "looks good!" banner).
_BLOCKING_SEVERITIES = ("error", "action_required", "warning")
_SEVERITY_GROUP_HEADER = {
"error": "## ❌ Job failures",
"action_required": "## ⚠️ Action required",
"warning": "## ⚠️ Warnings",
"info": "## Details",
}
@dataclass
class ReviewItem:
"""A single piece of review information with a severity tag."""
severity: str # "error" | "action_required" | "warning" | "info"
title: str # short section title, e.g. "package-lock.json"
summary: str # one-line summary
detail: str = "" # optional markdown detail (tables, bullet lists, etc.)
link: str = "" # optional URL emitted by the job (e.g. report URL)
link_label: str = "View report" # label for the emitted link
how_to_fix: str = "" # optional markdown checklist for action_required items
source: str = "" # workflow that declared this status (for dedup)
job_url: str = "" # auto-attached per-job log link (from the live poller)
# ---------------------------------------------------------------------------
# Collectors — each returns a list of ReviewItems (possibly empty)
# ---------------------------------------------------------------------------
def collect_from_statuses(review_statuses_json: str) -> tuple[list[ReviewItem], set[str]]:
"""Parse the nested review_status JSON into flat ReviewItems.
The input is a JSON array of ``{source, results: [...]}`` objects.
Each entry in ``results`` becomes one ReviewItem, tagged with the
parent's ``source``.
Returns ``(items, sources)`` where ``sources`` is the set of source
values — used by :func:`collect_failed_jobs` to exclude jobs that
already declared their own status (so a failing job that emitted an
``action_required`` status doesn't also show as a synthesized ❌ Error).
"""
if not review_statuses_json:
return [], set()
try:
data = json.loads(review_statuses_json)
except (json.JSONDecodeError, TypeError):
return [], set()
if not isinstance(data, list):
return [], set()
items: list[ReviewItem] = []
sources: set[str] = set()
for entry in data:
if not isinstance(entry, dict):
continue
source = entry.get("source", "")
if source:
sources.add(source)
for r in entry.get("results", []):
if not isinstance(r, dict):
continue
kind = r.get("kind", "info")
if kind not in _SEVERITY_ORDER:
kind = "info"
items.append(ReviewItem(
severity=kind,
title=r.get("title", "Unknown"),
summary=r.get("summary", ""),
detail=r.get("detail", ""),
link=r.get("link", ""),
link_label=r.get("link_label", "View logs"),
how_to_fix=r.get("how_to_fix", ""),
source=source,
))
return items, sources
def collect_failed_jobs(
needs_json: str,
run_url: str,
exclude_sources: set[str] | None = None,
job_urls: dict[str, str] | None = None,
) -> list[ReviewItem]:
"""Build error items for failed CI jobs from the ``needs`` context.
``needs_json`` is the JSON string emitted by ``all-checks-pass`` — a
``{job_name: result}`` dict where result is ``success`` / ``failure``
/ ``skipped``. Only ``failure`` entries become error items.
``exclude_sources`` is a set of ``source`` values from status objects
declared by workflow_call jobs. Job names containing any of these
source strings are excluded — their failure is already covered by their
own status output.
``job_urls`` is an optional ``{job_name: html_url}`` dict from the
live poller. When a job's name is in this dict, the ❌ Error link
points directly to that job's logs page instead of the whole run.
Falls back to ``run_url`` when no per-job URL is available.
"""
if not needs_json:
return []
try:
needs = json.loads(needs_json)
except (json.JSONDecodeError, TypeError):
return []
# Pre-normalize exclude sources once: lowercase + hyphens→spaces, so
# "review-label-gate" matches "Review label gate / Review label gate".
norm_sources = {
src.lower().replace("-", " ") for src in (exclude_sources or set())
}
items: list[ReviewItem] = []
for name, result in sorted(needs.items()):
if result != "failure":
continue
if norm_sources:
norm = name.lower().replace("-", " ")
if any(src in norm for src in norm_sources):
continue
job_url = (job_urls or {}).get(name, run_url)
items.append(ReviewItem(
severity="error",
title=name,
summary=f"Job **{name}** failed.",
job_url=job_url,
))
return items
# ---------------------------------------------------------------------------
# Rendering
# ---------------------------------------------------------------------------
def _render_item(item: ReviewItem) -> str:
"""Render a single ReviewItem as a markdown block.
The group header (``## ❌ Job failures`` etc.) carries the severity
emoji, so items don't repeat it. Links are shown inline next to the
title. Layout per item::
### {title} · [View report](url) · [View job](url)
{summary}
{detail}
**How to fix:**
{how_to_fix}
"""
title = f"### {item.title}"
# Build inline links next to the title.
links: list[str] = []
if item.link:
links.append(f"[{item.link_label}]({item.link})")
if item.job_url:
links.append(f"[View job]({item.job_url})")
if links:
title += " · " + " · ".join(links)
parts = [title, "", item.summary]
if item.detail:
parts += ["", item.detail]
if item.how_to_fix:
parts += ["", "**How to fix:**", "", item.how_to_fix]
return "\n".join(parts)
def _render_group(header: str, items: list[ReviewItem]) -> str:
"""Render a severity group: ``##`` header + items separated by ``---``."""
blocks = [_render_item(i) for i in items]
return f"{header}\n\n" + "\n\n---\n\n".join(blocks)
def _render_info_details(items: list[ReviewItem]) -> str:
"""Render each info item as its own collapsible ``<details>`` block."""
blocks = []
for item in items:
inner = _render_item(item)
blocks.append(
f"<details>\n<summary>{item.title}</summary>\n\n{inner}\n\n</details>"
)
return "\n\n".join(blocks)
def _render_pending_items(pending_jobs: list[str]) -> str:
"""Render the dimmed ``<sub>`` items for jobs still running."""
job_list = ", ".join(f"`{j}`" for j in sorted(pending_jobs))
return f"\n\n---\n\n<sub>Still running {len(pending_jobs)} job{'s' if len(pending_jobs) != 1 else ''}: {job_list}</sub>\n"
def render_comment(items: list[ReviewItem], pending_jobs: list[str] | None = None, commit_info: str = "") -> str:
"""Render the full comment body from a list of review items.
Items are grouped by severity under ``##`` group headers, separated
by ``---``. Errors and action_required items are always visible.
Warnings are shown only when present. Info items are in a collapsible
``<details>`` block. If ``pending_jobs`` is non-empty, a dimmed
``<sub>`` footer is appended listing jobs still running.
When there are no errors, action_required, or warnings (only info
items, or nothing at all), a "looks good!" banner is shown at the top,
and info items (if any) follow in a collapsible ``<details>`` block.
"""
pending = pending_jobs or []
# Group by severity
by_severity: dict[str, list[ReviewItem]] = {s: [] for s in _SEVERITY_ORDER}
for item in items:
by_severity.setdefault(item.severity, []).append(item)
info = by_severity.get("info", [])
has_blocking = any(by_severity.get(s) for s in _BLOCKING_SEVERITIES)
body = f"{MARKER}\n# ૮ >ﻌ< ა ci review\n\n"
if commit_info:
body += f"{commit_info}\n\n"
if not items and not pending:
return f"{body}looks good to me!"
sections: list[str] = []
for sev in _BLOCKING_SEVERITIES:
group = by_severity.get(sev, [])
if group:
sections.append(_render_group(_SEVERITY_GROUP_HEADER[sev], group))
# Info: collapsible <details>
if info:
sections.append(_render_info_details(info))
if pending:
body += _render_pending_items(pending)
if sections:
body += "\n\n---\n\n".join(sections)
return body
# ---------------------------------------------------------------------------
# Assembly
# ---------------------------------------------------------------------------
def _attach_job_urls(items: list[ReviewItem], job_urls: dict[str, str], run_url: str) -> None:
"""Fill in per-job log links for all items.
Uses the same case-insensitive, hyphen-normalized matching as
:func:`collect_failed_jobs`: the item's ``source`` is matched against
job names in ``job_urls``. Sets ``job_url`` on the item — this is
separate from ``link`` (the job-emitted URL, e.g. a report artifact),
so both can appear in the rendered comment.
"""
if not job_urls and not run_url:
return
# Pre-normalize job_url keys once.
norm_urls: dict[str, str] = {}
for name, url in job_urls.items():
norm_urls[name.lower().replace("-", " ")] = url
for item in items:
if item.job_url:
continue
src = item.source.lower().replace("-", " ")
# Try exact match first, then substring match.
if src in norm_urls:
item.job_url = norm_urls[src]
continue
for norm_name, url in norm_urls.items():
if src and src in norm_name:
item.job_url = url
break
# If no per-job URL found, fall back to run_url for items with a source.
if not item.job_url and item.source and run_url:
item.job_url = run_url
def assemble(
needs_json: str = "",
run_url: str = "",
job_urls: dict[str, str] | None = None,
review_statuses_json: str = "",
pending_jobs: list[str] | None = None,
commit_info: str = "",
) -> str:
"""Assemble the full comment body from all available inputs."""
items: list[ReviewItem] = []
# 1. Structured statuses from workflow_call jobs (review-labels, etc.)
status_items, sources = collect_from_statuses(review_statuses_json)
items.extend(status_items)
# 2. Synthesized error items for failed jobs not covered by statuses
items.extend(collect_failed_jobs(needs_json, run_url, exclude_sources=sources, job_urls=job_urls))
# 3. Attach per-job log links to all items (not just synthesized errors)
_attach_job_urls(items, job_urls or {}, run_url)
return render_comment(items, pending_jobs, commit_info)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--needs-json",
default="",
help="JSON string of {job_name: result} from the all-checks-pass job.",
)
parser.add_argument(
"--run-url",
default="",
help="URL to the CI run summary page (for failed job links).",
)
parser.add_argument(
"--review-statuses-json",
default="",
help="JSON array of {source, results: [...]} objects from workflow_call jobs.",
)
parser.add_argument(
"--pending-jobs",
default="",
help="Comma-separated list of job names still running (shown in a dimmed footer).",
)
parser.add_argument(
"--output",
type=Path,
required=True,
help="Output file for the assembled comment body.",
)
args = parser.parse_args()
pending = [j.strip() for j in args.pending_jobs.split(",") if j.strip()] if args.pending_jobs else None
body = assemble(
needs_json=args.needs_json,
run_url=args.run_url,
review_statuses_json=args.review_statuses_json,
pending_jobs=pending,
)
args.output.write_text(body)
print(f"Wrote {len(body)} chars to {args.output}")
return 0
if __name__ == "__main__":
sys.exit(main())