diff --git a/src/diff_risk_dashboard/cli.py b/src/diff_risk_dashboard/cli.py index 0085bf1..8db1033 100644 --- a/src/diff_risk_dashboard/cli.py +++ b/src/diff_risk_dashboard/cli.py @@ -5,37 +5,72 @@ import sys from pathlib import Path -from .core import Summary, summarize +from .core import Summary as CoreSummary +from .core import summarize_apv_json +from .report import to_markdown + +Summary = CoreSummary def _print_table(summary: Summary) -> None: - bs = summary["by_severity"] - rows = [ - ("CRITICAL", bs["CRITICAL"]), - ("HIGH", bs["HIGH"]), - ("MEDIUM", bs["MEDIUM"]), - ("LOW", bs["LOW"]), - ("INFO", bs["INFO"]), - ] - print("\n=== Diff Risk Summary ===") - print(f"Total findings: {summary['total']}") - print("Severity counts:") - w = max(len(r[0]) for r in rows) - for name, cnt in rows: - print(f" {name:<{w}} : {cnt}") - print(f"Worst severity : {summary['worst']}") - print(f"Risk level : {summary['risk_level']}\n") - - -def main(argv: list[str] | None = None) -> int: - p = argparse.ArgumentParser(description="Diff Risk Dashboard (APV JSON -> summary)") - p.add_argument("apv_json", help="Path to ai-patch-verifier JSON") - args = p.parse_args(argv) - data = json.loads(Path(args.apv_json).read_text(encoding="utf-8")) - sm = summarize(data) - _print_table(sm) - return 2 if sm["risk_level"] == "red" else (1 if sm["risk_level"] == "yellow" else 0) + by = summary["by_severity"] + print("Severity\tCount") + for sev in ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"]: + print(f"{sev}\t{by.get(sev, by.get(sev.lower(), 0))}") + print(f"TOTAL\t{summary['total']}") + + +def _exit_code(risk: str) -> int: + return {"green": 0, "yellow": 1, "red": 2}.get(risk, 0) + + +def main() -> int: + p = argparse.ArgumentParser(prog="diff-risk") + p.add_argument("input", help="Path o texto JSON de APV (ai-patch-verifier)") + p.add_argument( + "-f", + "--format", + choices=["table", "json", "md"], + default="table", + help="Formato de salida (por defecto: table)", + ) + p.add_argument( + "-o", "--output", default="-", help="Archivo de salida; '-' = stdout (por defecto)" + ) + p.add_argument( + "--no-exit-by-risk", + dest="no_exit_by_risk", + action="store_true", + help="No ajustar el exit code por nivel de riesgo", + ) + args = p.parse_args() + + summary: Summary = summarize_apv_json(args.input) # path o texto + + # compat: aceptar 'risk' o 'risk_level' + risk = summary.get("risk", summary.get("risk_level", "green")) # type: ignore[assignment] + if "risk" not in summary: + summary["risk"] = risk # type: ignore[index] + + if args.format == "json": + out = json.dumps(summary, indent=2) + elif args.format == "md": + out = to_markdown(summary) + else: + _print_table(summary) + out = "" + + if args.format in {"json", "md"}: + if args.output == "-": + print(out) + else: + Path(args.output).write_text(out, encoding="utf-8") + print(f"Wrote {args.output}", file=sys.stderr) + + if not args.no_exit_by_risk: + return _exit_code(risk) + return 0 if __name__ == "__main__": - sys.exit(main()) + raise SystemExit(main()) diff --git a/src/diff_risk_dashboard/report.py b/src/diff_risk_dashboard/report.py new file mode 100644 index 0000000..56233f4 --- /dev/null +++ b/src/diff_risk_dashboard/report.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +_SEVERITIES = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"] + + +def _get_counts(summary: Mapping[str, Any]) -> dict[str, int]: + counts: dict[str, int] = {} + by = summary.get("by_severity", {}) or {} + if isinstance(by, Mapping): + for sev in _SEVERITIES: + # Acepta claves en minúsculas o MAYÚSCULAS + counts[sev] = int(by.get(sev, by.get(sev.lower(), 0)) or 0) + return counts + + +def to_markdown(summary: Mapping[str, Any]) -> str: + counts = _get_counts(summary) + total = int(summary.get("total", 0) or 0) + worst = str(summary.get("worst", "INFO") or "INFO").upper() + risk = str(summary.get("risk", "green") or "green").lower() + + risk_emoji = {"red": "🔴", "yellow": "🟡", "green": "🟢"}.get(risk, "🟢") + lines = [] + lines.append(f"# Diff Risk Dashboard {risk_emoji} — Worst: **{worst}**") + lines.append("") + lines.append("| Severity | Count |") + lines.append("|---|---:|") + for sev in _SEVERITIES: + lines.append(f"| {sev} | {counts.get(sev, 0)} |") + lines.append(f"| **TOTAL** | **{total}** |") + lines.append("") + lines.append("> Generated by diff-risk-dashboard CLI") + return "\n".join(lines) diff --git a/src/diff_risk_dashboard/web.py b/src/diff_risk_dashboard/web.py new file mode 100644 index 0000000..a69e7d4 --- /dev/null +++ b/src/diff_risk_dashboard/web.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from typing import Any + +# Importes perezosos para no requerir fastapi/uvicorn en CI +from .core import summarize_apv_json +from .report import to_markdown + + +def create_app() -> Any: + fastapi = __import__("fastapi") + responses = __import__("fastapi.responses", fromlist=["HTMLResponse"]) + FastAPI = getattr(fastapi, "FastAPI") + HTMLResponse = getattr(responses, "HTMLResponse") + app: Any = FastAPI(title="Diff Risk Dashboard") + + @app.get("/", response_class=HTMLResponse) # type: ignore[misc] + def index() -> Any: + return HTMLResponse( + content=""" + +

Diff Risk Dashboard

+
+ + +
+ +""", + status_code=200, + ) + + @app.post("/summarize", response_class=HTMLResponse) # type: ignore[misc] + async def summarize(file: Any) -> Any: + data = await file.read() + summary = summarize_apv_json(data.decode("utf-8")) + md = to_markdown(summary).replace("&", "&").replace("<", "<").replace(">", ">") + return HTMLResponse(f"
{md}
", status_code=200) + + return app + + +def main() -> None: + uvicorn = __import__("uvicorn") + app = create_app() + uvicorn.run(app, host="127.0.0.1", port=8000) + + +if __name__ == "__main__": + main() diff --git a/tests/test_report_md.py b/tests/test_report_md.py new file mode 100644 index 0000000..69b8d57 --- /dev/null +++ b/tests/test_report_md.py @@ -0,0 +1,13 @@ +from diff_risk_dashboard.report import to_markdown + + +def test_md_contains_table_and_total(): + s = { + "total": 3, + "worst": "HIGH", + "risk": "yellow", + "by_severity": {"HIGH": 1, "MEDIUM": 1, "LOW": 1}, + } + md = to_markdown(s) + assert "# Diff Risk Dashboard" in md + assert "| **TOTAL** | **3** |" in md