feat(cli): add code-scanning command to download GitHub scan results
pkgmgr code-scanning fetches a repository's GitHub code scanning alerts (and analysis metadata) via the gh CLI and writes alerts.json, a readable summary.md digest, and analyses.json into a timestamped directory (default /tmp/<repo>/code-scanner/<YYYYMMDDHHMMSS>) for offline analysis. The repository is resolved from gh or --repo; authentication is delegated to gh; --state filters by alert state. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
from pkgmgr.actions.code_scanning import (
|
||||
CodeScanningError,
|
||||
download_code_scanning,
|
||||
)
|
||||
|
||||
_ALERTS = [
|
||||
{
|
||||
"number": 1,
|
||||
"state": "open",
|
||||
"html_url": "https://example/1",
|
||||
"rule": {
|
||||
"id": "py/sql-injection",
|
||||
"security_severity_level": "high",
|
||||
"severity": "error",
|
||||
"description": "SQL injection",
|
||||
},
|
||||
"most_recent_instance": {
|
||||
"location": {"path": "app.py", "start_line": 10},
|
||||
"message": {"text": "Possible SQL injection"},
|
||||
},
|
||||
"tool": {"name": "CodeQL"},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _cp(stdout: str = "", returncode: int = 0, stderr: str = "") -> SimpleNamespace:
|
||||
return SimpleNamespace(stdout=stdout, returncode=returncode, stderr=stderr)
|
||||
|
||||
|
||||
def _fake_gh(args):
|
||||
if args[:2] == ["repo", "view"]:
|
||||
return _cp(stdout="owner/pkgmgr-cs-test\n")
|
||||
endpoint = args[1] if len(args) > 1 else ""
|
||||
if endpoint.endswith("code-scanning/alerts"):
|
||||
return _cp(stdout=json.dumps(_ALERTS))
|
||||
if endpoint.endswith("code-scanning/analyses"):
|
||||
return _cp(stdout=json.dumps([{"id": 42, "created_at": "2026-01-01"}]))
|
||||
return _cp(returncode=1, stderr=f"unexpected: {args}")
|
||||
|
||||
|
||||
class TestDownloadCodeScanning(unittest.TestCase):
|
||||
def test_writes_alerts_summary_and_analyses(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with (
|
||||
mock.patch(
|
||||
"pkgmgr.actions.code_scanning.shutil.which",
|
||||
return_value="/usr/bin/gh",
|
||||
),
|
||||
mock.patch("pkgmgr.actions.code_scanning._gh", side_effect=_fake_gh),
|
||||
):
|
||||
result = download_code_scanning(output_dir=tmp)
|
||||
|
||||
self.assertEqual(result.repo, "owner/pkgmgr-cs-test")
|
||||
self.assertEqual(result.alert_count, 1)
|
||||
|
||||
with open(os.path.join(tmp, "alerts.json"), encoding="utf-8") as f:
|
||||
self.assertEqual(json.load(f), _ALERTS)
|
||||
with open(os.path.join(tmp, "summary.md"), encoding="utf-8") as f:
|
||||
summary = f.read()
|
||||
self.assertIn("py/sql-injection", summary)
|
||||
self.assertIn("app.py:10", summary)
|
||||
self.assertIn("high", summary)
|
||||
self.assertTrue(os.path.exists(os.path.join(tmp, "analyses.json")))
|
||||
|
||||
def test_default_output_dir_uses_repo_name(self) -> None:
|
||||
with (
|
||||
mock.patch(
|
||||
"pkgmgr.actions.code_scanning.shutil.which", return_value="/usr/bin/gh"
|
||||
),
|
||||
mock.patch("pkgmgr.actions.code_scanning._gh", side_effect=_fake_gh),
|
||||
):
|
||||
result = download_code_scanning()
|
||||
self.addCleanup(shutil.rmtree, "/tmp/pkgmgr-cs-test", ignore_errors=True)
|
||||
self.assertTrue(
|
||||
result.output_dir.startswith("/tmp/pkgmgr-cs-test/code-scanner/"),
|
||||
msg=result.output_dir,
|
||||
)
|
||||
|
||||
def test_errors_when_gh_missing(self) -> None:
|
||||
with mock.patch("pkgmgr.actions.code_scanning.shutil.which", return_value=None):
|
||||
with self.assertRaises(CodeScanningError):
|
||||
download_code_scanning(output_dir="/tmp/should-not-be-created")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user