Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/app/portfolio_truth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from src.cache import ResponseCache
from src.cli_output import print_info
from src.github_security_coverage import DEFAULT_EXPECTED_GITHUB_COHORT_COUNT
from src.portfolio_context_recovery import (
apply_context_recovery_plan,
build_context_recovery_plan,
Expand Down Expand Up @@ -62,6 +63,11 @@ def run_portfolio_truth_mode(args: Any) -> None:
max_age_hours=getattr(
args, "portfolio_truth_security_max_age_hours", 24
),
expected_cohort_count=getattr(
args,
"portfolio_truth_security_cohort_count",
DEFAULT_EXPECTED_GITHUB_COHORT_COUNT,
),
)
if loaded_security is not None:
security_alerts_by_name = loaded_security.entries_by_full_name
Expand Down
21 changes: 21 additions & 0 deletions src/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
)
from src.app.improvement_application import _run_apply_improvements_mode
from src.app.semantic_search import run_semantic_search_mode
from src.github_security_coverage import DEFAULT_EXPECTED_GITHUB_COHORT_COUNT


# Emitted at most once per process when legacy flat invocation is used.
Expand Down Expand Up @@ -459,6 +460,16 @@ def _build_report_subparser(subparsers: argparse._SubParsersAction) -> None: #
metavar="HOURS",
help="Freshness window for GitHub security observations (default: 24)",
)
p.add_argument(
"--portfolio-truth-security-cohort-count",
type=int,
default=DEFAULT_EXPECTED_GITHUB_COHORT_COUNT,
metavar="COUNT",
help=(
"Expected security cohort size; must match the receipt collection "
f"contract (default: {DEFAULT_EXPECTED_GITHUB_COHORT_COUNT})"
),
)
p.add_argument(
"--portfolio-truth-allow-empty-notion",
action="store_true",
Expand Down Expand Up @@ -723,6 +734,16 @@ def build_parser() -> argparse.ArgumentParser:
metavar="HOURS",
help="Freshness window for GitHub security observations (default: 24)",
)
parser.add_argument(
"--portfolio-truth-security-cohort-count",
type=int,
default=DEFAULT_EXPECTED_GITHUB_COHORT_COUNT,
metavar="COUNT",
help=(
"Expected security cohort size; must match the receipt collection "
f"contract (default: {DEFAULT_EXPECTED_GITHUB_COHORT_COUNT})"
),
)
parser.add_argument(
"--portfolio-truth-allow-empty-notion",
action="store_true",
Expand Down
3 changes: 3 additions & 0 deletions src/portfolio_truth_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from src.cache import ResponseCache
from src.github_client import GitHubClient
from src.github_security_coverage import (
DEFAULT_EXPECTED_GITHUB_COHORT_COUNT,
GITHUB_SECURITY_RECEIPT_FILENAME,
LoadedSecurityCoverage,
SecurityCoverageError,
Expand Down Expand Up @@ -159,6 +160,7 @@ def load_security_coverage_by_full_name(
output_dir: Path,
receipt_path: Path | None = None,
max_age_hours: int = 24,
expected_cohort_count: int = DEFAULT_EXPECTED_GITHUB_COHORT_COUNT,
now: datetime | None = None,
) -> LoadedSecurityCoverage | None:
"""Load the canonical provenance-bearing security receipt.
Expand All @@ -173,6 +175,7 @@ def load_security_coverage_by_full_name(
return load_security_coverage_receipt(
selected,
max_age_hours=max_age_hours,
expected_cohort_count=expected_cohort_count,
now=now,
)
except SecurityCoverageError as exc:
Expand Down
4 changes: 2 additions & 2 deletions tests/test_cli_subcommands.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,8 +305,8 @@ def test_triage_help_flag_count(self):
def test_report_help_flag_count(self):
text = _help_text("report")
count = _count_flags_in_help(text)
assert count <= 44, (
f"audit report --help shows {count} non-global flags (limit 44, including the explicit security receipt path, freshness, and opt-in controls).\n"
assert count <= 45, (
f"audit report --help shows {count} non-global flags (limit 45, including the explicit security receipt path, freshness, cohort-count, and opt-in controls).\n"
f"Flags found: {sorted(set(re.findall(r' (--[a-z][a-z0-9-]*)', text)))}"
)

Expand Down
18 changes: 18 additions & 0 deletions tests/test_github_security_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,24 @@ def test_receipt_loader_uses_embedded_provenance_not_newer_mtime(
)


def test_receipt_loader_honors_explicit_nondefault_cohort_count(
tmp_path: Path,
) -> None:
receipt = _collect(cohort_count=3)
canonical = tmp_path / GITHUB_SECURITY_RECEIPT_FILENAME
canonical.write_text(json.dumps(receipt))

assert load_security_coverage_by_full_name(output_dir=tmp_path, now=NOW) is None
loaded = load_security_coverage_by_full_name(
output_dir=tmp_path,
expected_cohort_count=3,
now=NOW,
)

assert loaded is not None
assert len(loaded.cohort_repositories) == 3


def test_receipt_provenance_and_provider_timestamps_fail_closed(tmp_path: Path) -> None:
receipt = _collect()
receipt["producer"]["commit"] = "short"
Expand Down
81 changes: 81 additions & 0 deletions tests/test_portfolio_truth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2413,6 +2413,87 @@ def test_report_subcommand_parses_allow_empty_notion_flag() -> None:
assert default.portfolio_truth_allow_empty_notion is False


def test_report_subcommand_parses_security_cohort_count() -> None:
from src.cli import build_subcommand_parser
from src.github_security_coverage import DEFAULT_EXPECTED_GITHUB_COHORT_COUNT

parser = build_subcommand_parser()
explicit = parser.parse_args(
[
"report",
"testuser",
"--portfolio-truth",
"--portfolio-truth-security-cohort-count",
"3",
]
)
default = parser.parse_args(["report", "testuser", "--portfolio-truth"])

assert explicit.portfolio_truth_security_cohort_count == 3
assert (
default.portfolio_truth_security_cohort_count
== DEFAULT_EXPECTED_GITHUB_COHORT_COUNT
)


def test_portfolio_truth_app_threads_security_cohort_count(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from types import SimpleNamespace

from src.app.portfolio_truth import run_portfolio_truth_mode

captured: dict[str, object] = {}

def fake_security_loader(**kwargs):
captured.update(kwargs)
return None

def fake_publish(**_kwargs):
return SimpleNamespace(
latest_path=tmp_path / "latest.json",
snapshot_path=tmp_path / "history.json",
registry_output=tmp_path / "registry.md",
portfolio_report_output=tmp_path / "report.md",
project_count=0,
registry_changed=False,
report_changed=False,
)

monkeypatch.setattr(
"src.app.portfolio_truth.load_security_coverage_by_full_name",
fake_security_loader,
)
monkeypatch.setattr("src.app.portfolio_truth.publish_portfolio_truth", fake_publish)
monkeypatch.setattr(
"src.app.portfolio_truth.load_live_repo_status_by_name", lambda **_kwargs: {}
)
monkeypatch.setenv("GHRA_REQUIRE_PRODUCER_EVIDENCE", "0")
args = SimpleNamespace(
output_dir=str(tmp_path / "output"),
workspace_root=str(tmp_path),
registry_output=str(tmp_path / "registry.md"),
portfolio_report_output=str(tmp_path / "report.md"),
registry=None,
catalog=None,
username="testuser",
token=None,
no_cache=True,
portfolio_truth_include_release_count=False,
portfolio_truth_include_security=True,
portfolio_truth_security_receipt=None,
portfolio_truth_security_max_age_hours=12,
portfolio_truth_security_cohort_count=3,
portfolio_truth_allow_empty_notion=False,
)

run_portfolio_truth_mode(args)

assert captured["expected_cohort_count"] == 3
assert captured["max_age_hours"] == 12


def test_portfolio_truth_app_passes_validated_producer_receipt_to_publisher(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
Expand Down