mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-25 17:18:11 +00:00
* ci: surface E2E screenshots in review comment * ci: mark completed review commits in past tense * ci: surface approved sensitive-file reviews * ci: link sensitive files to reviewed changes * ci: stage desktop E2E visual evidence Track screenshots newly introduced against main and package visual diffs for a trusted publisher. * fix(ci): pass E2E evidence output paths Supply the manifest and staging-directory arguments required by the screenshot status helper. * fix(ci): download the OSV SARIF artifact Match the artifact name and result filename emitted by the pinned upstream reusable workflow.
683 lines
25 KiB
Python
683 lines
25 KiB
Python
"""Tests for scripts/ci/assemble_review_comment.py.
|
||
|
||
The assembler collects status from every CI sub-workflow into ReviewItems
|
||
classified by severity (error / action_required / warning / info / debug), then
|
||
renders them into a single PR comment body.
|
||
|
||
Status data comes from two sources:
|
||
1. --review-statuses-json: JSON array of {source, results: [...]} objects
|
||
from workflow_call jobs. Each result has kind/title/summary/detail/
|
||
how_to_fix/link. The assembler flattens all results into ReviewItems.
|
||
2. --needs-json: {job_name: result} from all-checks-pass. Failed jobs not
|
||
claimed by any status become synthesized ❌ Error items.
|
||
|
||
Layout rules tested here:
|
||
- group headers: ## ❌ Job failures, ## ⚠️ Action required, ## ⚠️ Warnings
|
||
- each item is a ### section under its group header
|
||
- errors + action_required always visible
|
||
- warnings shown only when present
|
||
- info above the fold; debug in a collapsible <details> block
|
||
- sections separated by ---
|
||
- how_to_fix rendered at bottom of action_required items
|
||
- empty → clean banner
|
||
- jobs with declared statuses excluded from failed-jobs list
|
||
- per-job URLs used for failed job links when available
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import importlib.util
|
||
import json
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
_PATH = Path(__file__).resolve().parents[2] / "scripts" / "ci" / "assemble_review_comment.py"
|
||
_spec = importlib.util.spec_from_file_location("assemble_review_comment", _PATH)
|
||
if _spec is None or _spec.loader is None:
|
||
raise ImportError("Failed to load assemble_review_comment.py")
|
||
_mod = importlib.util.module_from_spec(_spec)
|
||
sys.modules["assemble_review_comment"] = _mod
|
||
_spec.loader.exec_module(_mod)
|
||
|
||
MARKER = _mod.MARKER
|
||
ReviewItem = _mod.ReviewItem
|
||
|
||
|
||
def _status(source: str, results: list[dict]) -> str:
|
||
"""Helper: build a review_statuses JSON string with one source entry."""
|
||
return json.dumps([{"source": source, "results": results}])
|
||
|
||
|
||
# ─── collect_from_statuses ──────────────────────────────────────────
|
||
|
||
|
||
def test_statuses_empty_json():
|
||
items, sources = _mod.collect_from_statuses("")
|
||
assert items == []
|
||
assert sources == set()
|
||
|
||
|
||
def test_statuses_bad_json():
|
||
items, sources = _mod.collect_from_statuses("not json")
|
||
assert items == []
|
||
assert sources == set()
|
||
|
||
|
||
def test_statuses_action_required():
|
||
statuses = _status("review-label-gate", [{
|
||
"kind": "action_required",
|
||
"title": "CI-sensitive file review",
|
||
"summary": "Changes detected.",
|
||
"how_to_fix": "Add the label.",
|
||
}])
|
||
items, sources = _mod.collect_from_statuses(statuses)
|
||
assert len(items) == 1
|
||
assert items[0].severity == "action_required"
|
||
assert items[0].title == "CI-sensitive file review"
|
||
assert items[0].how_to_fix == "Add the label."
|
||
assert items[0].source == "review-label-gate"
|
||
assert sources == {"review-label-gate"}
|
||
|
||
|
||
def test_statuses_info():
|
||
statuses = _status("review-label-gate", [{
|
||
"kind": "info",
|
||
"title": "CI-sensitive file review",
|
||
"summary": "Label present.",
|
||
}])
|
||
items, sources = _mod.collect_from_statuses(statuses)
|
||
assert len(items) == 1
|
||
assert items[0].severity == "info"
|
||
assert sources == {"review-label-gate"}
|
||
|
||
|
||
def test_statuses_debug():
|
||
statuses = _status("ci-timings", [{
|
||
"kind": "debug",
|
||
"title": "CI timings",
|
||
"summary": "No regression.",
|
||
}])
|
||
items, _ = _mod.collect_from_statuses(statuses)
|
||
assert items[0].severity == "debug"
|
||
|
||
|
||
def test_statuses_multiple_results_same_source():
|
||
"""One source can emit multiple results of different kinds."""
|
||
statuses = _status("review-label-gate", [
|
||
{"kind": "action_required", "title": "CI review", "summary": "Missing label."},
|
||
{"kind": "action_required", "title": "MCP review", "summary": "Missing label."},
|
||
])
|
||
items, sources = _mod.collect_from_statuses(statuses)
|
||
assert len(items) == 2
|
||
assert sources == {"review-label-gate"}
|
||
|
||
|
||
def test_statuses_mixed_kinds_same_source():
|
||
"""One source can emit both a warning and an info."""
|
||
statuses = _status("ci-timings", [
|
||
{"kind": "warning", "title": "CI timings", "summary": "Slower."},
|
||
{"kind": "info", "title": "Baseline", "summary": "OK."},
|
||
])
|
||
items, sources = _mod.collect_from_statuses(statuses)
|
||
assert len(items) == 2
|
||
assert items[0].severity == "warning"
|
||
assert items[1].severity == "info"
|
||
assert sources == {"ci-timings"}
|
||
|
||
|
||
def test_statuses_multiple_sources():
|
||
statuses = json.dumps([
|
||
{"source": "review-label-gate", "results": [
|
||
{"kind": "action_required", "title": "CI review", "summary": "Missing."},
|
||
]},
|
||
{"source": "lockfile-diff", "results": [
|
||
{"kind": "info", "title": "package-lock.json", "summary": "No changes."},
|
||
]},
|
||
])
|
||
items, sources = _mod.collect_from_statuses(statuses)
|
||
assert len(items) == 2
|
||
assert sources == {"review-label-gate", "lockfile-diff"}
|
||
|
||
|
||
def test_statuses_unknown_kind_becomes_info():
|
||
statuses = _status("some-job", [{
|
||
"kind": "bogus",
|
||
"title": "X",
|
||
"summary": "Y",
|
||
}])
|
||
items, _ = _mod.collect_from_statuses(statuses)
|
||
assert items[0].severity == "info"
|
||
|
||
|
||
def test_statuses_no_source():
|
||
"""Status without a source — still rendered, just not excluded from errors."""
|
||
statuses = json.dumps([{
|
||
"results": [{"kind": "info", "title": "X", "summary": "Y"}],
|
||
}])
|
||
items, sources = _mod.collect_from_statuses(statuses)
|
||
assert len(items) == 1
|
||
assert sources == set()
|
||
|
||
|
||
def test_statuses_passes_through_optional_fields():
|
||
statuses = _status("ci-timings", [{
|
||
"kind": "warning",
|
||
"title": "CI timings",
|
||
"summary": "Slower.",
|
||
"detail": "- job: +5s",
|
||
"link": "https://report",
|
||
"link_label": "View report",
|
||
"how_to_fix": "Optimize.",
|
||
}])
|
||
items, _ = _mod.collect_from_statuses(statuses)
|
||
assert items[0].detail == "- job: +5s"
|
||
assert items[0].link == "https://report"
|
||
assert items[0].link_label == "View report"
|
||
assert items[0].how_to_fix == "Optimize."
|
||
|
||
|
||
# ─── collect_failed_jobs ─────────────────────────────────────────────
|
||
|
||
|
||
def test_failed_jobs_empty_needs():
|
||
assert _mod.collect_failed_jobs("", "https://run") == []
|
||
|
||
|
||
def test_failed_jobs_no_failures():
|
||
needs = json.dumps({"tests": "success", "lint": "skipped"})
|
||
assert _mod.collect_failed_jobs(needs, "https://run") == []
|
||
|
||
|
||
def test_failed_jobs_collects_only_failures():
|
||
needs = json.dumps({"tests": "success", "lint": "failure", "js-tests": "failure"})
|
||
items = _mod.collect_failed_jobs(needs, "https://run/123")
|
||
assert len(items) == 2
|
||
assert all(i.severity == "error" for i in items)
|
||
# sorted by name
|
||
names = [i.title for i in items]
|
||
assert names == ["js-tests", "lint"]
|
||
assert all(i.job_url == "https://run/123" for i in items)
|
||
|
||
|
||
def test_failed_jobs_bad_json():
|
||
assert _mod.collect_failed_jobs("not json", "https://run") == []
|
||
|
||
|
||
def test_failed_jobs_excluded_by_source():
|
||
"""Jobs whose name contains a declared source are excluded."""
|
||
needs = json.dumps({
|
||
"Review label gate / Review label gate": "failure",
|
||
"tests": "failure",
|
||
})
|
||
items = _mod.collect_failed_jobs(needs, "https://run", exclude_sources={"review-label-gate"})
|
||
assert len(items) == 1
|
||
assert items[0].title == "tests"
|
||
|
||
|
||
def test_failed_jobs_no_exclusion_without_sources():
|
||
"""Without exclude_sources, all failures are shown."""
|
||
needs = json.dumps({"review-label-gate": "failure", "tests": "failure"})
|
||
items = _mod.collect_failed_jobs(needs, "https://run")
|
||
assert len(items) == 2
|
||
|
||
|
||
def test_failed_jobs_per_job_url():
|
||
"""When job_urls is provided, the link points to the specific job."""
|
||
needs = json.dumps({"tests": "failure", "lint": "failure"})
|
||
job_urls = {"tests": "https://run/1/job/2", "lint": "https://run/1/job/3"}
|
||
items = _mod.collect_failed_jobs(needs, "https://fallback", job_urls=job_urls)
|
||
assert len(items) == 2
|
||
urls = {i.title: i.job_url for i in items}
|
||
assert urls["tests"] == "https://run/1/job/2"
|
||
assert urls["lint"] == "https://run/1/job/3"
|
||
|
||
|
||
def test_failed_jobs_fallback_to_run_url():
|
||
"""Jobs not in job_urls fall back to run_url."""
|
||
needs = json.dumps({"tests": "failure", "lint": "failure"})
|
||
job_urls = {"tests": "https://run/1/job/2"}
|
||
items = _mod.collect_failed_jobs(needs, "https://fallback", job_urls=job_urls)
|
||
urls = {i.title: i.job_url for i in items}
|
||
assert urls["tests"] == "https://run/1/job/2"
|
||
assert urls["lint"] == "https://fallback"
|
||
|
||
|
||
# ─── render_comment ───────────────────────────────────────────────────
|
||
|
||
|
||
def test_render_empty_shows_clean_banner():
|
||
"""Completely clean — dog kaomoji + 'all good' banner, no sections."""
|
||
body = _mod.render_comment([])
|
||
assert body.startswith(MARKER)
|
||
assert "૮ >ﻌ< ა" in body
|
||
assert "all good!" in body
|
||
assert "##" not in body # no section headers
|
||
|
||
|
||
def test_render_info_only_is_visible_above_the_fold():
|
||
"""Info items are visible rather than hidden in debug details."""
|
||
items = [
|
||
ReviewItem(severity="info", title="lockfile", summary="No changes."),
|
||
ReviewItem(severity="info", title="timings", summary="OK."),
|
||
]
|
||
body = _mod.render_comment(items)
|
||
assert "૮ >ﻌ< ა" in body
|
||
assert "## ℹ️ Info" in body
|
||
assert "<details>" not in body
|
||
assert "No changes." in body
|
||
assert "OK." in body
|
||
# No blocking sections
|
||
assert "## ❌" not in body
|
||
assert "## ⚠️" not in body
|
||
|
||
|
||
def test_render_info_only_with_pending_shows_info_plus_footer():
|
||
items = [ReviewItem(severity="info", title="lockfile", summary="No changes.")]
|
||
body = _mod.render_comment(items, pending_jobs=["ci-timings"])
|
||
assert "૮ >ﻌ< ა" in body
|
||
assert "Still running" in body
|
||
assert "## ℹ️ Info" in body
|
||
assert "Still running" in body
|
||
assert "`ci-timings`" in body
|
||
|
||
|
||
def test_render_group_header_for_errors():
|
||
"""Errors appear under a '## ❌ Job failures' group header."""
|
||
items = [
|
||
ReviewItem(severity="error", title="tests", summary="Job **tests** failed.", link="https://run"),
|
||
ReviewItem(severity="error", title="lint", summary="Job **lint** failed.", link="https://run"),
|
||
]
|
||
body = _mod.render_comment(items)
|
||
assert "## ❌ Job failures" in body
|
||
assert "### tests" in body
|
||
assert "### lint" in body
|
||
assert body.index("## ❌ Job failures") < body.index("### tests")
|
||
|
||
|
||
def test_render_group_header_for_action_required():
|
||
items = [
|
||
ReviewItem(severity="action_required", title="CI review", summary="Need label."),
|
||
]
|
||
body = _mod.render_comment(items)
|
||
assert "## ⚠️ Action required" in body
|
||
assert "### CI review" in body
|
||
|
||
|
||
def test_render_group_header_for_warnings():
|
||
items = [
|
||
ReviewItem(severity="warning", title="CI timings", summary="Slower."),
|
||
]
|
||
body = _mod.render_comment(items)
|
||
assert "## ⚠️ Warnings" in body
|
||
assert "### CI timings" in body
|
||
assert "<details>" not in body
|
||
|
||
items2 = [ReviewItem(severity="info", title="x", summary="y")]
|
||
body2 = _mod.render_comment(items2)
|
||
assert "## ⚠️ Warnings" not in body2
|
||
|
||
|
||
def test_render_no_duplicated_severity_in_item_body():
|
||
"""Items don't repeat the severity label — the group header carries it."""
|
||
items = [ReviewItem(severity="error", title="tests", summary="Job failed.", link="https://run")]
|
||
body = _mod.render_comment(items)
|
||
assert "### tests" in body
|
||
assert "Job failed." in body
|
||
assert "**❌ Error**" not in body
|
||
|
||
|
||
def test_render_how_to_fix_at_bottom():
|
||
items = [
|
||
ReviewItem(severity="action_required", title="CI review", summary="Need label.",
|
||
how_to_fix="Add the `ci-reviewed` label."),
|
||
]
|
||
body = _mod.render_comment(items)
|
||
assert "**How to fix:**" in body
|
||
assert "Add the `ci-reviewed` label." in body
|
||
assert body.index("Need label.") < body.index("How to fix")
|
||
|
||
|
||
def test_render_sections_separated_by_hr():
|
||
items = [
|
||
ReviewItem(severity="error", title="tests", summary="failed."),
|
||
ReviewItem(severity="action_required", title="CI review", summary="need label."),
|
||
]
|
||
body = _mod.render_comment(items)
|
||
assert "\n\n---\n\n" in body
|
||
|
||
|
||
def test_render_errors_always_visible():
|
||
items = [
|
||
ReviewItem(severity="error", title="tests", summary="Job **tests** failed.", job_url="https://run"),
|
||
ReviewItem(severity="info", title="lockfile", summary="No changes."),
|
||
]
|
||
body = _mod.render_comment(items)
|
||
assert "## ❌ Job failures" in body
|
||
assert "### tests" in body
|
||
assert "Job **tests** failed." in body
|
||
assert "[View job](https://run)" in body
|
||
assert "## ℹ️ Info" in body
|
||
assert "No changes." in body
|
||
|
||
|
||
def test_render_debug_in_collapsible_details():
|
||
"""Debug items have a small label and each has its own <details> block."""
|
||
items = [
|
||
ReviewItem(severity="debug", title="lockfile", summary="No changes."),
|
||
ReviewItem(severity="debug", title="timings", summary="OK."),
|
||
]
|
||
body = _mod.render_comment(items)
|
||
assert "### debug info" in body
|
||
assert body.index("### debug info") < body.index("<details>")
|
||
assert body.count("<details>") == 2
|
||
assert body.count("</details>") == 2
|
||
assert "<summary>lockfile</summary>" in body
|
||
assert "<summary>timings</summary>" in body
|
||
assert "No changes." in body
|
||
assert "OK." in body
|
||
|
||
|
||
def test_render_order_errors_then_action_then_warn_then_info_then_debug():
|
||
items = [
|
||
ReviewItem(severity="info", title="i", summary="info"),
|
||
ReviewItem(severity="debug", title="d", summary="debug"),
|
||
ReviewItem(severity="warning", title="w", summary="warn"),
|
||
ReviewItem(severity="action_required", title="a", summary="action"),
|
||
ReviewItem(severity="error", title="e", summary="error"),
|
||
]
|
||
body = _mod.render_comment(items)
|
||
error_pos = body.index("## ❌ Job failures")
|
||
action_pos = body.index("## ⚠️ Action required")
|
||
warn_pos = body.index("## ⚠️ Warnings")
|
||
info_pos = body.index("## ℹ️ Info")
|
||
debug_pos = body.index("<details>")
|
||
assert error_pos < action_pos < warn_pos < info_pos < debug_pos
|
||
|
||
|
||
# ─── render_comment (pending jobs) ────────────────────────────────────
|
||
|
||
|
||
def test_render_pending_only_shows_header_with_clock():
|
||
"""Pending jobs only — header has 'still waiting', footer lists jobs, no sections."""
|
||
body = _mod.render_comment([], pending_jobs=["ci-timings"])
|
||
assert body.startswith(MARKER)
|
||
assert "૮ >ﻌ< ა" in body
|
||
assert "Still running" in body
|
||
assert "`ci-timings`" in body
|
||
assert "##" not in body
|
||
|
||
|
||
def test_render_pending_notif():
|
||
items = [ReviewItem(severity="info", title="lockfile", summary="No changes.")]
|
||
body = _mod.render_comment(items, pending_jobs=["ci-timings"])
|
||
assert "૮ >ﻌ< ა" in body
|
||
assert "<sub>Still running 1 job: `ci-timings`</sub>" in body
|
||
|
||
|
||
def test_render_pending_multiple_jobs_sorted():
|
||
body = _mod.render_comment([], pending_jobs=["docker", "ci-timings"])
|
||
assert "`ci-timings`" in body
|
||
assert "`docker`" in body
|
||
assert body.index("`ci-timings`") < body.index("`docker`")
|
||
|
||
|
||
def test_render_no_pending_no_footer():
|
||
items = [ReviewItem(severity="info", title="x", summary="y")]
|
||
body = _mod.render_comment(items)
|
||
assert "Still running" not in body
|
||
|
||
|
||
# ─── assemble (integration) ──────────────────────────────────────────
|
||
|
||
|
||
def test_assemble_all_skipped_clean_banner():
|
||
body = _mod.assemble()
|
||
assert body.startswith(MARKER)
|
||
assert "૮ >ﻌ< ა" in body
|
||
assert "all good!" in body
|
||
assert "##" not in body
|
||
|
||
|
||
def test_assemble_failed_job_shown():
|
||
needs = json.dumps({"tests": "failure", "lint": "success"})
|
||
body = _mod.assemble(needs_json=needs, run_url="https://run/1")
|
||
assert "## ❌ Job failures" in body
|
||
assert "### tests" in body
|
||
assert "[View job](https://run/1)" in body
|
||
|
||
|
||
def test_assemble_with_review_statuses():
|
||
"""Statuses from review-labels render directly + exclude gate from errors."""
|
||
statuses = _status("review-label-gate", [{
|
||
"kind": "action_required",
|
||
"title": "CI-sensitive file review",
|
||
"summary": "Changes detected.",
|
||
"how_to_fix": "Add the label.",
|
||
}])
|
||
needs = json.dumps({
|
||
"Review label gate / Review label gate": "failure",
|
||
"tests": "success",
|
||
})
|
||
body = _mod.assemble(
|
||
needs_json=needs,
|
||
run_url="https://run",
|
||
review_statuses_json=statuses,
|
||
)
|
||
assert "## ⚠️ Action required" in body
|
||
assert "### CI-sensitive file review" in body
|
||
assert "Add the label." in body
|
||
assert "## ❌ Job failures" not in body
|
||
|
||
|
||
def test_assemble_review_status_detail_renders_sensitive_file_links():
|
||
statuses = _status("review-label-gate", [{
|
||
"kind": "action_required",
|
||
"title": "CI-sensitive file review",
|
||
"summary": "Changes detected.",
|
||
"detail": "**Sensitive files:**\n- [`ci.yml`](https://example.test/ci.yml)",
|
||
}])
|
||
body = _mod.assemble(review_statuses_json=statuses)
|
||
assert "**Sensitive files:**" in body
|
||
assert "[`ci.yml`](https://example.test/ci.yml)" in body
|
||
|
||
|
||
def test_assemble_info_keeps_screenshot_details_visible_below_its_summary():
|
||
statuses = _status("playwright e2e", [{
|
||
"kind": "info",
|
||
"title": "Desktop E2E screenshots",
|
||
"summary": "1 screenshot captured; 0 visual diffs.",
|
||
"detail": "<details>\n<summary>1 captured screenshot</summary>\n\n- [`proof.png`](https://example.test/artifact)\n\n</details>",
|
||
}])
|
||
body = _mod.assemble(review_statuses_json=statuses)
|
||
assert "## ℹ️ Info" in body
|
||
assert "1 screenshot captured; 0 visual diffs." in body
|
||
assert "<summary>1 captured screenshot</summary>" in body
|
||
assert "[`proof.png`](https://example.test/artifact)" in body
|
||
|
||
|
||
def test_assemble_pending_jobs():
|
||
body = _mod.assemble(pending_jobs=["ci-timings"])
|
||
assert "Still running" in body
|
||
assert "`ci-timings`" in body
|
||
|
||
|
||
def test_assemble_with_items_and_pending():
|
||
needs = json.dumps({"tests": "failure"})
|
||
body = _mod.assemble(needs_json=needs, run_url="https://run", pending_jobs=["ci-timings"])
|
||
assert "## ❌ Job failures" in body
|
||
assert "### tests" in body
|
||
assert "Still running" in body
|
||
assert "`ci-timings`" in body
|
||
|
||
|
||
def test_assemble_with_timings_status():
|
||
"""Timings status from the nested format renders as debug or warning."""
|
||
statuses = _status("ci-timings", [{
|
||
"kind": "debug",
|
||
"title": "CI timings",
|
||
"summary": "Wall time 3m (no baseline yet).",
|
||
"detail": "",
|
||
"link": "https://report",
|
||
}])
|
||
body = _mod.assemble(review_statuses_json=statuses)
|
||
assert "<details>" in body
|
||
assert "### CI timings" in body
|
||
assert "Wall time 3m" in body
|
||
assert "## ❌" not in body
|
||
assert "## ⚠️" not in body
|
||
|
||
|
||
def test_assemble_with_lockfile_status():
|
||
"""Lockfile no-changes status renders as visible info."""
|
||
statuses = _status("lockfile-diff", [{
|
||
"kind": "info",
|
||
"title": "package-lock.json",
|
||
"summary": "No lockfile changes — locked versions match the target branch.",
|
||
}])
|
||
body = _mod.assemble(review_statuses_json=statuses)
|
||
assert "## ℹ️ Info" in body
|
||
assert "### package-lock.json" in body
|
||
assert "No lockfile changes" in body
|
||
|
||
|
||
def test_assemble_with_lockfile_changed_status():
|
||
"""Lockfile changed status renders as action_required with ci-reviewed how_to_fix."""
|
||
statuses = _status("lockfile-diff", [{
|
||
"kind": "action_required",
|
||
"title": "package-lock.json",
|
||
"summary": "Locked npm dependency versions changed.",
|
||
"detail": "#### `package-lock.json`\n\n| col | | |",
|
||
"how_to_fix": "Add the `ci-reviewed` label after verifying the version changes are expected.",
|
||
}])
|
||
body = _mod.assemble(review_statuses_json=statuses)
|
||
assert "## ⚠️ Action required" in body
|
||
assert "### package-lock.json" in body
|
||
assert "Locked npm dependency versions changed." in body
|
||
assert "Add the `ci-reviewed` label" in body
|
||
assert "<details>" not in body # action_required is not in the collapsible block
|
||
|
||
|
||
# ─── _attach_job_urls ────────────────────────────────────────────────
|
||
|
||
|
||
def test_attach_job_urls_fills_missing_links():
|
||
"""Items without a link get one from job_urls via source matching."""
|
||
items = [
|
||
ReviewItem(severity="info", title="Supply chain scan",
|
||
summary="No risks.", source="supply chain"),
|
||
ReviewItem(severity="warning", title="CI timings",
|
||
summary="Slower.", source="ci timings",
|
||
link="https://report"), # already has a link
|
||
]
|
||
job_urls = {
|
||
"Supply Chain Audit / Scan PR for critical supply chain risks": "https://run/1/job/2",
|
||
}
|
||
_mod._attach_job_urls(items, job_urls, "https://fallback")
|
||
# First item gets the per-job URL as job_url (link untouched)
|
||
assert items[0].job_url == "https://run/1/job/2"
|
||
assert items[0].link == "" # no emitted link
|
||
# Second item keeps its existing link, job_url is set separately
|
||
assert items[1].link == "https://report"
|
||
assert items[1].job_url == "https://fallback" # fell back to run_url
|
||
|
||
|
||
def test_attach_job_urls_fallback_to_run_url():
|
||
"""Items with a source but no matching job URL fall back to run_url."""
|
||
items = [
|
||
ReviewItem(severity="info", title="X", summary="Y", source="some-job"),
|
||
]
|
||
_mod._attach_job_urls(items, {}, "https://fallback")
|
||
assert items[0].job_url == "https://fallback"
|
||
|
||
|
||
def test_attach_job_urls_no_source_no_link():
|
||
"""Items without a source don't get a link."""
|
||
items = [
|
||
ReviewItem(severity="info", title="X", summary="Y"), # no source
|
||
]
|
||
_mod._attach_job_urls(items, {"job": "https://run/1"}, "https://fallback")
|
||
assert items[0].job_url == ""
|
||
|
||
|
||
def test_assemble_attaches_links_to_all_items():
|
||
"""Integration: assemble() attaches job URLs to status items, not just errors."""
|
||
statuses = _status("supply chain", [{
|
||
"kind": "info",
|
||
"title": "Supply chain scan",
|
||
"summary": "No risks.",
|
||
}])
|
||
job_urls = {
|
||
"Supply Chain Audit / Scan PR for critical supply chain risks": "https://run/1/job/2",
|
||
}
|
||
body = _mod.assemble(
|
||
review_statuses_json=statuses,
|
||
job_urls=job_urls,
|
||
run_url="https://fallback",
|
||
)
|
||
assert "[View job](https://run/1/job/2)" in body
|
||
|
||
|
||
def test_render_commit_info_below_header():
|
||
"""Commit info is rendered below the header, above the content."""
|
||
body = _mod.render_comment(
|
||
[ReviewItem(severity="error", title="tests", summary="failed.")],
|
||
commit_info="<sub>running on [abc1234](https://commit-url) — fix: thing</sub>",
|
||
)
|
||
assert "# ૮ >ﻌ< ა ci review" in body
|
||
assert "running on [abc1234](https://commit-url)" in body
|
||
assert "fix: thing" in body
|
||
# Commit info appears before the content
|
||
assert body.index("abc1234") < body.index("## ❌")
|
||
|
||
|
||
def test_render_no_commit_info_when_empty():
|
||
"""No commit_info → no extra line below header."""
|
||
body = _mod.render_comment([])
|
||
assert "running on" not in body
|
||
|
||
|
||
def test_assemble_passes_commit_info():
|
||
"""assemble() passes commit_info through to render_comment."""
|
||
body = _mod.assemble(commit_info="<sub>running on abc1234</sub>")
|
||
assert "running on abc1234" in body
|
||
assert "all good!" in body
|
||
|
||
|
||
def test_render_both_emitted_link_and_job_url():
|
||
"""An item with both an emitted link and a job_url shows both."""
|
||
item = ReviewItem(
|
||
severity="warning",
|
||
title="CI timings",
|
||
summary="Slower.",
|
||
link="https://artifact/report.html",
|
||
link_label="View report",
|
||
source="ci timings",
|
||
job_url="https://github.com/run/1/job/5",
|
||
)
|
||
body = _mod.render_comment([item])
|
||
assert "[View report](https://artifact/report.html)" in body
|
||
assert "[View job](https://github.com/run/1/job/5)" in body
|
||
# Both links on the same line, separated by ·
|
||
assert " · " in body
|
||
|
||
|
||
def test_assemble_both_links_for_ci_timings():
|
||
"""Integration: ci-timings has a report URL (link) AND gets a job_url."""
|
||
statuses = _status("ci timings", [{
|
||
"kind": "warning",
|
||
"title": "CI timings",
|
||
"summary": "Wall time 5m vs 3m (+66%).",
|
||
"detail": "- tests: +120s",
|
||
"link": "https://artifact/report.html",
|
||
"link_label": "View report",
|
||
}])
|
||
job_urls = {"CI timings": "https://github.com/run/1/job/5"}
|
||
body = _mod.assemble(
|
||
review_statuses_json=statuses,
|
||
job_urls=job_urls,
|
||
run_url="https://fallback",
|
||
)
|
||
assert "[View report](https://artifact/report.html)" in body
|
||
assert "[View job](https://github.com/run/1/job/5)" in body
|