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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 3 additions & 11 deletions src/github_repo_auditor/portfolio_decision_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"
Expand Down Expand Up @@ -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:
Expand Down
16 changes: 4 additions & 12 deletions src/github_repo_auditor/security_admission.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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:
Expand Down
32 changes: 32 additions & 0 deletions src/github_repo_auditor/timestamps.py
Original file line number Diff line number Diff line change
@@ -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)
20 changes: 8 additions & 12 deletions src/github_repo_auditor/weekly_command_center.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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"
Expand Down Expand Up @@ -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(
Expand Down
34 changes: 34 additions & 0 deletions tests/test_producer_preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
54 changes: 54 additions & 0 deletions tests/test_timestamps.py
Original file line number Diff line number Diff line change
@@ -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