Skip to content

Commit ef723e2

Browse files
committed
feat(security): emit JSON sidecar from security-burndown CLI mode
The burndown dispatch wrote markdown only. Add a structured JSON sidecar (security-burndown-<user>-<date>.json from report.to_dict()) so downstream consumers like the PortfolioCommandCenter desktop shell can render the advisory-grouped fix list without parsing markdown — mirroring the per-project security overlay's JSON-first contract. Adds the first integration tests for _run_security_burndown_mode: both artifacts written, the no-ghas-file exit(1) path, and the counts-only exit(0)-without-writing path.
1 parent b725933 commit ef723e2

2 files changed

Lines changed: 89 additions & 0 deletions

File tree

src/cli.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6781,6 +6781,12 @@ def _run_security_burndown_mode(args) -> None:
67816781
out_path.write_text(markdown, encoding="utf-8")
67826782
print_info(f"Burndown written to {out_path}")
67836783

6784+
# JSON sidecar for structured consumers (e.g. PortfolioCommandCenter desktop
6785+
# shell), mirroring the per-project security overlay's JSON-first contract.
6786+
json_path = output_dir / f"security-burndown-{username}-{today}.json"
6787+
json_path.write_text(json.dumps(report.to_dict(), indent=2), encoding="utf-8")
6788+
print_info(f"Burndown JSON written to {json_path}")
6789+
67846790

67856791
# ── Main entry point ──────────────────────────────────────────────────
67866792
def main() -> None:

tests/test_security_burndown.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@
22

33
from __future__ import annotations
44

5+
import datetime
6+
import json
7+
from types import SimpleNamespace
58
from unittest.mock import MagicMock
69

10+
import pytest
711
import requests
812

13+
from src.cli import _run_security_burndown_mode
914
from src.ghas_alert_details import fetch_dependabot_details
1015
from src.security_burndown import (
1116
BurndownEntry,
@@ -739,3 +744,81 @@ def test_full_round_trip(self) -> None:
739744
assert "HIGH" in md
740745
# medium was filtered out — pip/requests still appears but as the no-ghsa entry
741746
assert "pip/requests" in md
747+
748+
749+
class TestRunSecurityBurndownMode:
750+
"""CLI dispatch (_run_security_burndown_mode) — markdown + JSON sidecar."""
751+
752+
def _write_ghas(self, output_dir, username: str) -> None:
753+
today = datetime.date.today().isoformat()
754+
ghas = {
755+
"RepoA": {
756+
"dependabot_details": [
757+
{
758+
"package": "lodash",
759+
"ecosystem": "npm",
760+
"scope": "runtime",
761+
"severity": "critical",
762+
"ghsa_id": "GHSA-aaaa-0001",
763+
"first_patched_version": "4.17.21",
764+
}
765+
]
766+
},
767+
"RepoB": {
768+
"dependabot_details": [
769+
{
770+
"package": "lodash",
771+
"ecosystem": "npm",
772+
"scope": "runtime",
773+
"severity": "critical",
774+
"ghsa_id": "GHSA-aaaa-0001",
775+
"first_patched_version": "4.17.21",
776+
}
777+
]
778+
},
779+
}
780+
(output_dir / f"ghas-alerts-{username}-{today}.json").write_text(
781+
json.dumps(ghas), encoding="utf-8"
782+
)
783+
784+
def test_writes_markdown_and_json_sidecar(self, tmp_path, capsys) -> None:
785+
self._write_ghas(tmp_path, "octocat")
786+
args = SimpleNamespace(output_dir=str(tmp_path), username="octocat")
787+
788+
_run_security_burndown_mode(args)
789+
790+
today = datetime.date.today().isoformat()
791+
md_path = tmp_path / f"security-burndown-octocat-{today}.md"
792+
json_path = tmp_path / f"security-burndown-octocat-{today}.json"
793+
assert md_path.exists(), "markdown artifact should be written"
794+
assert json_path.exists(), "JSON sidecar should be written"
795+
796+
payload = json.loads(json_path.read_text(encoding="utf-8"))
797+
# one advisory (GHSA-aaaa-0001) spanning two repos
798+
assert payload["distinct_advisories"] == 1
799+
assert payload["repos_touched"] == 2
800+
assert payload["total_repo_instances"] == 2
801+
entry = payload["entries"][0]
802+
assert entry["package"] == "lodash"
803+
assert entry["severity"] == "critical"
804+
assert entry["affected_repo_count"] == 2
805+
assert sorted(entry["affected_repos"]) == ["RepoA", "RepoB"]
806+
807+
def test_no_ghas_file_exits_nonzero(self, tmp_path) -> None:
808+
args = SimpleNamespace(output_dir=str(tmp_path), username="nobody")
809+
with pytest.raises(SystemExit) as exc:
810+
_run_security_burndown_mode(args)
811+
assert exc.value.code == 1
812+
813+
def test_counts_only_ghas_exits_without_writing(self, tmp_path) -> None:
814+
"""A counts-only ghas file (no dependabot_details) exits 0, writes nothing."""
815+
today = datetime.date.today().isoformat()
816+
ghas = {"RepoA": {"dependabot": {"critical": 1, "available": True}}}
817+
(tmp_path / f"ghas-alerts-octocat-{today}.json").write_text(
818+
json.dumps(ghas), encoding="utf-8"
819+
)
820+
args = SimpleNamespace(output_dir=str(tmp_path), username="octocat")
821+
with pytest.raises(SystemExit) as exc:
822+
_run_security_burndown_mode(args)
823+
assert exc.value.code == 0
824+
assert not list(tmp_path.glob("security-burndown-*"))

0 commit comments

Comments
 (0)