diff --git a/.gitignore b/.gitignore index 5523c93..6074f74 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,10 @@ output/badges/ output/.badge-gist-id # Stable flock inode used to serialize security coverage receipt readers/writers. output/.github-security-coverage-latest.json.lock +# Stable flock inode used to serialize portfolio truth publishers. +output/.portfolio-truth-latest.json.lock +# Local run output written by PortfolioCommandCenter before truth publication. +output/pcc-auditor-run.log # Generated private portfolio dumps written to the repo ROOT (chmod 600). The # output/*.md rule above does not cover root-level files, so name them explicitly # to keep a stray `git add -A` from committing private portfolio data. diff --git a/src/github_repo_auditor/portfolio_decision_queue.py b/src/github_repo_auditor/portfolio_decision_queue.py index c16db3d..0bacbf8 100644 --- a/src/github_repo_auditor/portfolio_decision_queue.py +++ b/src/github_repo_auditor/portfolio_decision_queue.py @@ -15,6 +15,7 @@ import json import sys from datetime import datetime, timedelta, timezone +from functools import partial from pathlib import Path from typing import Any @@ -26,6 +27,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from github_repo_auditor.security_admission import derive_security_admission +from github_repo_auditor.timestamps import parse_utc_timestamp CONTRACT_VERSION = "decision_queue_v2" DIGEST_CONTRACT_VERSION = "portfolio_decision_digest_v2" @@ -78,17 +80,7 @@ def _sha256_identity(value: Any) -> str: return SHA256_ID_PREFIX + hashlib.sha256(_canonical_bytes(value)).hexdigest() -def _parse_datetime(value: Any) -> datetime | None: - text = _text(value) - if not text: - return None - try: - parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) - except ValueError: - return None - if parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=timezone.utc) - return parsed.astimezone(timezone.utc) +_parse_datetime = partial(parse_utc_timestamp, naive="assume_utc") def _iso(value: datetime) -> str: diff --git a/src/github_repo_auditor/security_admission.py b/src/github_repo_auditor/security_admission.py index f1a3650..960902b 100644 --- a/src/github_repo_auditor/security_admission.py +++ b/src/github_repo_auditor/security_admission.py @@ -3,9 +3,11 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import datetime from typing import Any, Mapping +from github_repo_auditor.timestamps import parse_utc_timestamp + SECURITY_ADMISSION_SCHEMA_VERSION = "SecurityAdmissionV1" SECURITY_PROVIDERS = ("dependabot", "code_scanning", "secret_scanning") @@ -31,17 +33,7 @@ def _text(value: Any) -> str: return value.strip() if isinstance(value, str) else "" -def _parse_datetime(value: Any) -> datetime | None: - text = _text(value) - if not text: - return None - try: - parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) - except ValueError: - return None - if parsed.tzinfo is None: - return None - return parsed.astimezone(timezone.utc) +_parse_datetime = parse_utc_timestamp def _reason_provider(provider: str, suffix: str) -> str: diff --git a/src/github_repo_auditor/timestamps.py b/src/github_repo_auditor/timestamps.py new file mode 100644 index 0000000..0dcc5b0 --- /dev/null +++ b/src/github_repo_auditor/timestamps.py @@ -0,0 +1,32 @@ +"""Shared lenient timestamp parsing helpers.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Literal + + +def parse_utc_timestamp( + value: object, + *, + naive: Literal["reject", "assume_utc"] = "reject", + coerce: bool = False, +) -> datetime | None: + """Parse a timestamp and normalize timezone-aware results to UTC.""" + if coerce: + text = str(value or "").strip() + elif isinstance(value, str): + text = value.strip() + else: + return None + if not text: + return None + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + if naive == "reject": + return None + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) diff --git a/src/github_repo_auditor/weekly_command_center.py b/src/github_repo_auditor/weekly_command_center.py index 1ed8f44..3296328 100644 --- a/src/github_repo_auditor/weekly_command_center.py +++ b/src/github_repo_auditor/weekly_command_center.py @@ -1,7 +1,8 @@ from __future__ import annotations import json -from datetime import datetime, timezone +from datetime import datetime +from functools import partial from pathlib import Path from typing import Any @@ -14,6 +15,7 @@ ) from github_repo_auditor.report_enrichment import build_weekly_review_pack from github_repo_auditor.security_admission import derive_security_admission +from github_repo_auditor.timestamps import parse_utc_timestamp CONTRACT_VERSION = "weekly_command_center_digest_v1" AUTHORITY_CAP = "bounded-automation" @@ -131,17 +133,11 @@ def _mapping(value: Any) -> dict[str, Any]: return {} -def _parse_datetime(value: Any) -> datetime | None: - text = _safe_text(value) - if not text: - return None - try: - parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) - except ValueError: - return None - if parsed.tzinfo is None: - return parsed.replace(tzinfo=timezone.utc) - return parsed.astimezone(timezone.utc) +_parse_datetime = partial( + parse_utc_timestamp, + naive="assume_utc", + coerce=True, +) def _source_freshness( diff --git a/tests/test_producer_preflight.py b/tests/test_producer_preflight.py index ce907ae..74e5944 100644 --- a/tests/test_producer_preflight.py +++ b/tests/test_producer_preflight.py @@ -70,6 +70,40 @@ def test_canonical_producer_fails_dirty_worktree(tmp_path: Path) -> None: assert result.checks["worktree_clean"] == "fail" +def test_publication_runtime_files_preserve_clean_producer(tmp_path: Path) -> None: + from github_repo_auditor.portfolio_truth_publish import ( + _portfolio_truth_publication_lock, + ) + + repo, _ = _repo(tmp_path) + _git(repo, "config", "core.excludesFile", "/dev/null") + (repo / ".gitignore").write_bytes( + (Path(__file__).resolve().parents[1] / ".gitignore").read_bytes() + ) + _git(repo, "add", ".gitignore") + _git(repo, "commit", "-m", "producer ignore rules") + _git(repo, "update-ref", "refs/remotes/origin/main", "HEAD") + result = inspect_canonical_producer( + repo_root=repo, + expected_repository="saagpatel/GithubRepoAuditor", + expected_ref="refs/remotes/origin/main", + checkout_role="portfolio-command-center", + ) + assert result.state == "pass" + assert result.evidence is not None + output = repo / "output" + output.mkdir() + (output / "pcc-auditor-run.log").write_text("running\n") + with _portfolio_truth_publication_lock(output / "portfolio-truth-latest.json"): + verify_evidence_still_current(repo, result.evidence) + verify_evidence_still_current(repo, result.evidence) + assert (output / ".portfolio-truth-latest.json.lock").is_file() + + (output / "unexpected-source.py").write_text("changed = True\n") + with pytest.raises(ValueError, match="worktree"): + verify_evidence_still_current(repo, result.evidence) + + def test_canonical_producer_missing_ref_is_unknown(tmp_path: Path) -> None: repo, _ = _repo(tmp_path) result = inspect_canonical_producer( diff --git a/tests/test_timestamps.py b/tests/test_timestamps.py new file mode 100644 index 0000000..3da0aaa --- /dev/null +++ b/tests/test_timestamps.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from github_repo_auditor.timestamps import parse_utc_timestamp + + +def test_z_suffix_is_treated_as_utc() -> None: + assert parse_utc_timestamp("2026-09-02T12:34:56Z") == datetime( + 2026, 9, 2, 12, 34, 56, tzinfo=timezone.utc + ) + + +def test_explicit_offset_is_converted_to_utc() -> None: + parsed = parse_utc_timestamp("2026-09-02T01:30:00+02:30") + + assert parsed == datetime( + 2026, 9, 1, 23, 0, tzinfo=timezone.utc + ) + assert parsed.tzinfo is timezone.utc + + +def test_naive_timestamp_is_rejected_by_default() -> None: + assert parse_utc_timestamp("2026-09-02T12:34:56") is None + + +def test_naive_timestamp_can_be_assumed_utc() -> None: + assert parse_utc_timestamp( + "2026-09-02T12:34:56", naive="assume_utc" + ) == datetime(2026, 9, 2, 12, 34, 56, tzinfo=timezone.utc) + + +@pytest.mark.parametrize("value", [None, "", " "]) +@pytest.mark.parametrize("coerce", [False, True]) +def test_empty_values_return_none(value: object, coerce: bool) -> None: + assert parse_utc_timestamp(value, coerce=coerce) is None + + +def test_non_string_is_rejected_without_coercion() -> None: + value = datetime(2026, 9, 2, 12, 34, 56, tzinfo=timezone.utc) + + assert parse_utc_timestamp(value) is None + + +def test_non_string_can_be_coerced_before_parsing() -> None: + value = datetime(2026, 9, 2, 12, 34, 56, tzinfo=timezone.utc) + + assert parse_utc_timestamp(value, coerce=True) == value + + +def test_invalid_string_returns_none() -> None: + assert parse_utc_timestamp("not-a-timestamp") is None