mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
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)
174 lines
6.5 KiB
Python
174 lines
6.5 KiB
Python
#!/usr/bin/env python3
|
||
"""Semantic diff of npm ``package-lock.json`` files for PR comments.
|
||
|
||
``git diff`` on a lockfile is unreadable: npm reorders entries, rewrites
|
||
integrity hashes, and moves packages between nesting levels, so a one-line
|
||
``package.json`` bump can produce a thousand-line textual diff. This script
|
||
ignores the text entirely — it parses the ``packages`` map out of both
|
||
versions of each lockfile (lockfileVersion 2/3), reduces each to
|
||
``{install path: version}``, and set-diffs the two dicts. Reordering and
|
||
hash churn vanish; what's left is the actual dependency change.
|
||
|
||
Usage (from a checkout that still has the base ref available):
|
||
|
||
python scripts/ci/lockfile_diff.py --base <ref> --head <ref> \
|
||
--output diff.md [--repo-root .]
|
||
|
||
Reads every ``package-lock.json`` tracked at either ref (top-level and
|
||
nested — the repo has several), diffs each, and writes a Markdown fragment
|
||
to ``--output``. Exits 0 always; an empty output file means "no version
|
||
changes" (the caller uses that to decide whether to include the section).
|
||
The fragment is consumed by ``scripts/ci/assemble_review_comment.py``,
|
||
which wraps it in a section with a header and action note.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import subprocess
|
||
import sys
|
||
|
||
|
||
def parse_lockfile(text: str) -> dict[str, str]:
|
||
"""Reduce lockfile JSON to ``{install path: version}``.
|
||
|
||
Keys are the ``packages`` map's keys (e.g. ``node_modules/react`` or
|
||
``node_modules/foo/node_modules/react``), so the same package deduped
|
||
at two versions shows up as two distinct entries. The root entry
|
||
(``""``, the workspace itself) is skipped, as are versionless link
|
||
entries.
|
||
"""
|
||
data = json.loads(text)
|
||
out: dict[str, str] = {}
|
||
for path, meta in data.get("packages", {}).items():
|
||
if not path:
|
||
continue # root project entry, not a dependency
|
||
version = meta.get("version")
|
||
if version:
|
||
out[path] = version
|
||
return out
|
||
|
||
|
||
def diff_locks(base: dict[str, str], head: dict[str, str]) -> dict[str, list]:
|
||
"""Set-diff two ``{path: version}`` maps.
|
||
|
||
Returns ``added`` / ``removed`` as ``[(path, version)]`` and
|
||
``updated`` as ``[(path, base_version, head_version)]``, each sorted
|
||
by path.
|
||
"""
|
||
added = sorted((p, v) for p, v in head.items() if p not in base)
|
||
removed = sorted((p, v) for p, v in base.items() if p not in head)
|
||
updated = sorted(
|
||
(p, base[p], head[p]) for p in base.keys() & head.keys() if base[p] != head[p]
|
||
)
|
||
return {"added": added, "removed": removed, "updated": updated}
|
||
|
||
|
||
def _display_name(path: str) -> str:
|
||
"""``node_modules/foo/node_modules/@scope/bar`` → ``@scope/bar (nested under foo)``."""
|
||
parts = path.split("node_modules/")
|
||
name = parts[-1].rstrip("/")
|
||
if len(parts) > 2:
|
||
parents = " → ".join(p.rstrip("/") for p in parts[1:-1])
|
||
return f"{name} *(nested under {parents})*"
|
||
return name
|
||
|
||
|
||
def render_markdown(diffs: dict[str, dict[str, list]]) -> str:
|
||
"""Render per-lockfile diffs as a Markdown fragment.
|
||
|
||
``diffs`` maps lockfile repo-path → the output of :func:`diff_locks`.
|
||
Lockfiles with no version changes are omitted. Returns ``""`` when
|
||
nothing changed anywhere (caller skips the section entirely).
|
||
|
||
The output is a fragment — per-lockfile ``####`` subsections with
|
||
tables — not a standalone comment. The ``assemble_review_comment``
|
||
script wraps this in a section with its own header and action note,
|
||
so no top-level header or comment marker is emitted here.
|
||
"""
|
||
sections = []
|
||
for lockfile, d in sorted(diffs.items()):
|
||
added, removed, updated = d["added"], d["removed"], d["updated"]
|
||
n = len(added) + len(removed) + len(updated)
|
||
if n == 0:
|
||
continue
|
||
lines = [f"#### `{lockfile}`", ""]
|
||
lines.append("| Package | Before | After |")
|
||
lines.append("| --- | --- | --- |")
|
||
for path, old, new in updated:
|
||
lines.append(f"| {_display_name(path)} | `{old}` | `{new}` |")
|
||
for path, version in added:
|
||
lines.append(f"| ➕ {_display_name(path)} | — | `{version}` |")
|
||
for path, version in removed:
|
||
lines.append(f"| ➖ {_display_name(path)} | `{version}` | — |")
|
||
sections.append("\n".join(lines))
|
||
|
||
if not sections:
|
||
return ""
|
||
|
||
return "\n\n".join(sections) + "\n"
|
||
|
||
|
||
def _git_show(ref: str, path: str, repo_root: str) -> str | None:
|
||
"""Contents of ``path`` at ``ref``, or None if it doesn't exist there."""
|
||
proc = subprocess.run(
|
||
["git", "show", f"{ref}:{path}"],
|
||
capture_output=True,
|
||
text=True,
|
||
cwd=repo_root,
|
||
)
|
||
return proc.stdout if proc.returncode == 0 else None
|
||
|
||
|
||
def _tracked_lockfiles(ref: str, repo_root: str) -> set[str]:
|
||
proc = subprocess.run(
|
||
["git", "ls-tree", "-r", "--name-only", ref],
|
||
capture_output=True,
|
||
text=True,
|
||
cwd=repo_root,
|
||
check=True,
|
||
)
|
||
return {
|
||
line
|
||
for line in proc.stdout.splitlines()
|
||
if line.split("/")[-1] == "package-lock.json"
|
||
}
|
||
|
||
|
||
def diff_refs(base: str, head: str, repo_root: str = ".") -> dict[str, dict[str, list]]:
|
||
"""Diff every package-lock.json tracked at either ref."""
|
||
lockfiles = _tracked_lockfiles(base, repo_root) | _tracked_lockfiles(head, repo_root)
|
||
diffs = {}
|
||
for path in sorted(lockfiles):
|
||
base_text = _git_show(base, path, repo_root)
|
||
head_text = _git_show(head, path, repo_root)
|
||
base_map = parse_lockfile(base_text) if base_text else {}
|
||
head_map = parse_lockfile(head_text) if head_text else {}
|
||
diffs[path] = diff_locks(base_map, head_map)
|
||
return diffs
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser(description=__doc__)
|
||
ap.add_argument("--base", required=True, help="base git ref (merge base)")
|
||
ap.add_argument("--head", required=True, help="head git ref")
|
||
ap.add_argument("--output", required=True, help="markdown output path")
|
||
ap.add_argument("--repo-root", default=".", help="repository root")
|
||
args = ap.parse_args()
|
||
|
||
diffs = diff_refs(args.base, args.head, args.repo_root)
|
||
markdown = render_markdown(diffs)
|
||
with open(args.output, "w", encoding="utf-8") as fh:
|
||
fh.write(markdown)
|
||
|
||
if markdown:
|
||
changed = sum(len(v) for d in diffs.values() for v in d.values())
|
||
print(f"{changed} package version change(s) — report written to {args.output}")
|
||
else:
|
||
print("No package version changes.")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|