From 5b95c9303ea3fa0a2bfc3da11908d524b690d43c Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 14 Jun 2026 02:25:18 -0700 Subject: [PATCH 1/2] Fix portfolio truth Notion context mapping --- config/notion-project-map.json | 23 ++++---- config/project-registry-overrides.json | 3 ++ src/cli.py | 27 +++++----- src/notion_export.py | 18 ++++++- src/notion_registry.py | 5 +- src/portfolio_truth_publish.py | 62 ++++++++++++++++++++++ src/portfolio_truth_sources.py | 18 +++++++ tests/test_notion_export.py | 14 +++++ tests/test_notion_registry.py | 19 +++++++ tests/test_portfolio_truth.py | 72 +++++++++++++++++++++++++- tests/test_project_registry.py | 17 ++++++ 11 files changed, 253 insertions(+), 25 deletions(-) diff --git a/config/notion-project-map.json b/config/notion-project-map.json index 905f3ac..7036639 100644 --- a/config/notion-project-map.json +++ b/config/notion-project-map.json @@ -21,7 +21,19 @@ "localProjectId": "378c21f1-caf0-819a-86c3-c0c41d12eef3" }, "GithubRepoAuditor": { - "localProjectId": "377c21f1-caf0-817d-9d57-e59a673dd0b7" + "localProjectId": "332c21f1-caf0-8125-b553-f75377da8fed" + }, + "GitHub Repo Auditor": { + "localProjectId": "332c21f1-caf0-8125-b553-f75377da8fed" + }, + "MCPAudit": { + "localProjectId": "332c21f1-caf0-8150-a80f-d23b2b9e601d" + }, + "MCP Audit": { + "localProjectId": "332c21f1-caf0-8150-a80f-d23b2b9e601d" + }, + "Notion": { + "localProjectId": "332c21f1-caf0-81c5-85fb-d7451163ab6b" }, "Grotto": { "localProjectId": "377c21f1-caf0-81d3-add8-f9f31584d5ae" @@ -217,14 +229,5 @@ }, "GhostRoutes": { "localProjectId": "32dc21f1-caf0-813a-9ca9-ef7062c11bf8" - }, - "mcp-trust": { - "localProjectId": "37ec21f1-caf0-8110-bcd5-c1f4219623d9" - }, - "cross-provider-egress-guard": { - "localProjectId": "37ec21f1-caf0-8110-8081-d87ffe80e1c3" - }, - "fable-outputs": { - "localProjectId": "37ec21f1-caf0-81b8-b365-e894672184ac" } } diff --git a/config/project-registry-overrides.json b/config/project-registry-overrides.json index 821a572..615188d 100644 --- a/config/project-registry-overrides.json +++ b/config/project-registry-overrides.json @@ -40,8 +40,11 @@ "DesktopPEt-ready": "DesktopPEt", "EarthPulse-readiness": "EarthPulse", "FreelanceInvoice": "FreeLanceInvoice", + "GitHub Repo Auditor": "GithubRepoAuditor", "GithubRepoAuditor-public": "GithubRepoAuditor", "KBFreshness": "KBFreshnessDetector", + "MCP Audit": "MCPAudit", + "Notion": "Notion", "Notion Operating System": "Notion", "OrbitMechanics": "OrbitMechanic", "OrbitForge (staging)": "OrbitForge", diff --git a/src/cli.py b/src/cli.py index cc845f4..434bf83 100644 --- a/src/cli.py +++ b/src/cli.py @@ -5351,7 +5351,7 @@ def _warn_if_warehouse_report_stale(output_dir: Path, username: str) -> None: def _run_portfolio_truth_mode(args) -> None: - from src.portfolio_truth_publish import publish_portfolio_truth + from src.portfolio_truth_publish import PortfolioTruthPublishError, publish_portfolio_truth output_dir = Path(args.output_dir) workspace_root = Path(args.workspace_root) @@ -5381,17 +5381,20 @@ def _run_portfolio_truth_mode(args) -> None: username=args.username, ) - result = publish_portfolio_truth( - workspace_root=workspace_root, - output_dir=output_dir, - registry_output=registry_output, - portfolio_report_output=portfolio_report_output, - catalog_path=Path(args.catalog) if args.catalog else None, - legacy_registry_path=legacy_registry_path, - include_notion=True, - release_count_by_name=release_count_by_name, - security_alerts_by_name=security_alerts_by_name, - ) + try: + result = publish_portfolio_truth( + workspace_root=workspace_root, + output_dir=output_dir, + registry_output=registry_output, + portfolio_report_output=portfolio_report_output, + catalog_path=Path(args.catalog) if args.catalog else None, + legacy_registry_path=legacy_registry_path, + include_notion=True, + release_count_by_name=release_count_by_name, + security_alerts_by_name=security_alerts_by_name, + ) + except PortfolioTruthPublishError as exc: + raise SystemExit(str(exc)) from exc print_info(f"Portfolio truth snapshot: {result.latest_path}") print_info(f"Portfolio truth history snapshot: {result.snapshot_path}") print_info(f"Project registry compatibility output: {result.registry_output}") diff --git a/src/notion_export.py b/src/notion_export.py index 8bd72ec..27275fc 100644 --- a/src/notion_export.py +++ b/src/notion_export.py @@ -9,6 +9,8 @@ from datetime import datetime, timezone from pathlib import Path +from src.registry_parser import _normalize + RAW_EXCERPT_LIMIT = 2000 @@ -50,7 +52,7 @@ def _normalize_audit_event( if not name: return None - project = mapping.get(name) + project = _lookup_project_mapping(name, mapping) if not project: return None @@ -100,6 +102,20 @@ def _normalize_audit_event( } +def _lookup_project_mapping(name: str, mapping: dict[str, dict]) -> dict | None: + """Resolve exact and safe normalized project-name aliases in the page map.""" + project = mapping.get(name) + if project: + return project + normalized = _normalize(name) + if not normalized: + return None + for mapped_name, mapped_project in mapping.items(): + if _normalize(mapped_name) == normalized: + return mapped_project + return None + + def _build_raw_excerpt( dim_scores: dict[str, float], badges: list[str], diff --git a/src/notion_registry.py b/src/notion_registry.py index 592445e..b399a3f 100644 --- a/src/notion_registry.py +++ b/src/notion_registry.py @@ -76,9 +76,12 @@ def _extract_title(page: dict) -> str: def _extract_select(page: dict, prop_name: str) -> str: - """Extract a select property value from a Notion page.""" + """Extract a select/status property value from a Notion page.""" props = page.get("properties", {}) prop = props.get(prop_name, {}) + status = prop.get("status") + if status: + return status.get("name", "") sel = prop.get("select") if sel: return sel.get("name", "") diff --git a/src/portfolio_truth_publish.py b/src/portfolio_truth_publish.py index 0a87a33..2a6f473 100644 --- a/src/portfolio_truth_publish.py +++ b/src/portfolio_truth_publish.py @@ -29,6 +29,10 @@ class PortfolioTruthPublishResult: project_registry_path: Path | None = None +class PortfolioTruthPublishError(RuntimeError): + """Raised when publishing would corrupt or misrepresent portfolio truth.""" + + _REPO_ROOT = Path(__file__).resolve().parents[1] _CONFIG_DIR = _REPO_ROOT / "config" @@ -100,6 +104,11 @@ def publish_portfolio_truth( snapshot_stamp = build_result.snapshot.generated_at.strftime("%Y-%m-%dT%H%M%SZ") snapshot_path = output_dir / f"portfolio-truth-{snapshot_stamp}.json" latest_path = truth_latest_path(output_dir) + _guard_against_notion_context_drop( + build_result.snapshot.source_summary, + latest_path=latest_path, + include_notion=include_notion, + ) latest_name = latest_path.name snapshot_json = json.dumps(build_result.snapshot.to_dict(), indent=2) + "\n" project_registry_path = output_dir / "project-registry.json" @@ -184,3 +193,56 @@ def _content_changed(path: Path, content: str) -> bool: if not path.exists(): return True return path.read_text() != content + + +def _guard_against_notion_context_drop( + source_summary: dict[str, object], + *, + latest_path: Path, + include_notion: bool, +) -> None: + """Avoid overwriting local truth when Notion bootstrap silently disappears.""" + if not include_notion or not _notion_project_context_configured(): + return + current_rows = _int_value(source_summary.get("notion_context_rows")) + if current_rows != 0: + return + previous_rows = _previous_notion_context_rows(latest_path) + if previous_rows is None or previous_rows <= 0: + return + raise PortfolioTruthPublishError( + "Refusing to publish portfolio truth with 0 Notion context rows because " + f"{latest_path} currently has {previous_rows}. Load NOTION_TOKEN or run " + "with an explicit no-Notion path before replacing local portfolio truth." + ) + + +def _notion_project_context_configured() -> bool: + path = _CONFIG_DIR / "notion-config.json" + try: + data = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + return False + return bool(str(data.get("projects_data_source_id", "")).strip()) + + +def _previous_notion_context_rows(latest_path: Path) -> int | None: + try: + data = json.loads(latest_path.read_text()) + except (OSError, json.JSONDecodeError): + return None + source_summary = data.get("source_summary", {}) + if not isinstance(source_summary, dict): + return None + return _int_value(source_summary.get("notion_context_rows")) + + +def _int_value(value: object) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + try: + return int(str(value)) + except (TypeError, ValueError): + return None diff --git a/src/portfolio_truth_sources.py b/src/portfolio_truth_sources.py index a221edc..25371a2 100644 --- a/src/portfolio_truth_sources.py +++ b/src/portfolio_truth_sources.py @@ -259,9 +259,27 @@ def load_safe_notion_project_context( "momentum": str(context.get("momentum", "") or "").strip(), "current_state": str(context.get("current_state", "") or "").strip(), } + for raw_alias, target in _load_notion_title_aliases(config_dir).items(): + alias_context = sanitized.get(_normalize(raw_alias)) + if alias_context: + sanitized.setdefault(_normalize(target), alias_context) return sanitized +def _load_notion_title_aliases(config_dir: Path) -> dict[str, str]: + path = config_dir / "project-registry-overrides.json" + if not path.is_file(): + return {} + try: + data = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + return {} + aliases = data.get("notion_title_aliases", {}) + if not isinstance(aliases, dict): + return {} + return {str(raw): str(target) for raw, target in aliases.items() if raw and target} + + def _inspect_project_dir( project_path: Path, workspace_root: Path, diff --git a/tests/test_notion_export.py b/tests/test_notion_export.py index 7ddd3c0..b9e05c0 100644 --- a/tests/test_notion_export.py +++ b/tests/test_notion_export.py @@ -5,6 +5,7 @@ from src.notion_export import ( _build_event_key, _find_biggest_drag, + _lookup_project_mapping, _normalize_audit_event, _severity_from_grade, export_notion_events, @@ -127,6 +128,19 @@ def test_unmapped_repo_returns_none(self): assert event is None +class TestProjectMappingLookup: + def test_exact_match_wins(self): + mapping = { + "GitHub Repo Auditor": {"localProjectId": "spaced-id"}, + "GithubRepoAuditor": {"localProjectId": "exact-id"}, + } + assert _lookup_project_mapping("GithubRepoAuditor", mapping)["localProjectId"] == "exact-id" + + def test_normalized_alias_match_resolves_spacing_and_case(self): + mapping = {"MCP Audit": {"localProjectId": "mcp-id"}} + assert _lookup_project_mapping("MCPAudit", mapping)["localProjectId"] == "mcp-id" + + class TestBiggestDrag: def test_finds_lowest(self): audit = _make_report()["audits"][0] diff --git a/tests/test_notion_registry.py b/tests/test_notion_registry.py index 4249e45..acdb68a 100644 --- a/tests/test_notion_registry.py +++ b/tests/test_notion_registry.py @@ -1,5 +1,8 @@ from __future__ import annotations +import json +from pathlib import Path + from src.notion_registry import ( _extract_first_select, _extract_select, @@ -38,6 +41,17 @@ def test_extracts_select(self): } assert _extract_select(page, "Current State") == "Active" + def test_extracts_status_property(self): + page = { + "properties": { + "Pipeline Stage": { + "type": "status", + "status": {"name": "Post-Build Review Done"}, + }, + }, + } + assert _extract_select(page, "Pipeline Stage") == "Post-Build Review Done" + def test_null_select(self): page = { "properties": { @@ -87,3 +101,8 @@ def test_archived_states(self): def test_unknown_defaults_active(self): assert _normalize_status("Something New") == "active" + + +def test_live_notion_config_uses_operational_local_portfolio_projects(): + config = json.loads(Path("config/notion-config.json").read_text()) + assert config["projects_data_source_id"] == "7858b551-4ce9-4bc3-ad1d-07b187d7117b" diff --git a/tests/test_portfolio_truth.py b/tests/test_portfolio_truth.py index c74cf8b..48803d5 100644 --- a/tests/test_portfolio_truth.py +++ b/tests/test_portfolio_truth.py @@ -20,7 +20,11 @@ render_portfolio_report_markdown, render_registry_markdown, ) -from src.portfolio_truth_sources import _classify_context_quality, _extract_github_full_name +from src.portfolio_truth_sources import ( + _classify_context_quality, + _extract_github_full_name, + load_safe_notion_project_context, +) from src.portfolio_truth_validate import validate_portfolio_report_markdown from src.registry_parser import parse_registry @@ -85,6 +89,37 @@ def test_extract_github_full_name_uses_exact_github_host() -> None: assert _extract_github_full_name("https://evil.example/github.com/octo/repo.git") == "" +def test_notion_context_uses_configured_title_aliases( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + (config_dir / "project-registry-overrides.json").write_text( + json.dumps( + { + "notion_title_aliases": { + "Notion Operating System": "Notion", + } + } + ) + ) + + monkeypatch.setattr( + "src.portfolio_truth_sources.load_notion_project_context", + lambda _config_dir: { + "Notion Operating System": { + "portfolio_call": "Build Now", + "momentum": "Post-Build Review Done", + "current_state": "Shipped", + } + }, + ) + + context = load_safe_notion_project_context(config_dir) + + assert context["notion"]["current_state"] == "Shipped" + + @pytest.fixture def portfolio_workspace(tmp_path: Path) -> Path: workspace = tmp_path / "workspace" @@ -915,6 +950,41 @@ def _boom(_snapshot, _latest_json_path): assert not list(output_dir.glob("*.tmp")) +def test_publish_refuses_to_drop_existing_notion_context( + portfolio_workspace: Path, + portfolio_catalog: Path, + legacy_registry: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + output_dir = tmp_path / "output" + output_dir.mkdir() + latest_path = output_dir / "portfolio-truth-latest.json" + latest_path.write_text( + json.dumps({"source_summary": {"notion_context_rows": 137}}) + "\n" + ) + registry_output = portfolio_workspace / "project-registry.md" + report_output = portfolio_workspace / "PORTFOLIO-AUDIT-REPORT.md" + + monkeypatch.setattr( + "src.portfolio_truth_sources.load_notion_project_context", + lambda _config_dir: None, + ) + + with pytest.raises(RuntimeError, match="0 Notion context rows"): + publish_portfolio_truth( + workspace_root=portfolio_workspace, + output_dir=output_dir, + registry_output=registry_output, + portfolio_report_output=report_output, + catalog_path=portfolio_catalog, + legacy_registry_path=legacy_registry, + include_notion=True, + ) + + assert json.loads(latest_path.read_text())["source_summary"]["notion_context_rows"] == 137 + + def test_context_recovery_plan_freezes_and_filters_targets( portfolio_workspace: Path, portfolio_catalog: Path, diff --git a/tests/test_project_registry.py b/tests/test_project_registry.py index cddf10a..2f5e092 100644 --- a/tests/test_project_registry.py +++ b/tests/test_project_registry.py @@ -85,6 +85,23 @@ def test_resolve_joins_spelling_variants(): assert result["canonical_key"] == "MCPAudit", spelling +def test_configured_notion_title_aliases_cover_operating_spellings(): + snapshot = _snapshot( + _ident("GithubRepoAuditor", "GithubRepoAuditor", "saagpatel/GithubRepoAuditor"), + _ident("MCPAudit", "MCPAudit", "saagpatel/MCPAudit"), + _ident("Notion", "Notion", "saagpatel/notion-operating-system"), + ) + registry = build_project_registry( + snapshot, + notion_snapshot_path=None, + overrides_config_path=Path("config/project-registry-overrides.json"), + ) + index = build_index(registry) + assert resolve("GitHub Repo Auditor", index)["canonical_key"] == "GithubRepoAuditor" + assert resolve("MCP Audit", index)["canonical_key"] == "MCPAudit" + assert resolve("Notion Operating System", index)["canonical_key"] == "Notion" + + def test_resolve_hard_normalization_failures_via_override(): registry = build_project_registry(SNAPSHOT, overrides_config_path=None) index = build_index(registry) From 7294001ba84f336e2cfe6051b9333f4da9672c1b Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 14 Jun 2026 02:31:55 -0700 Subject: [PATCH 2/2] Fix CI-safe Notion context tests --- tests/test_notion_registry.py | 8 -------- tests/test_portfolio_truth.py | 4 ++++ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/test_notion_registry.py b/tests/test_notion_registry.py index acdb68a..c5b2744 100644 --- a/tests/test_notion_registry.py +++ b/tests/test_notion_registry.py @@ -1,8 +1,5 @@ from __future__ import annotations -import json -from pathlib import Path - from src.notion_registry import ( _extract_first_select, _extract_select, @@ -101,8 +98,3 @@ def test_archived_states(self): def test_unknown_defaults_active(self): assert _normalize_status("Something New") == "active" - - -def test_live_notion_config_uses_operational_local_portfolio_projects(): - config = json.loads(Path("config/notion-config.json").read_text()) - assert config["projects_data_source_id"] == "7858b551-4ce9-4bc3-ad1d-07b187d7117b" diff --git a/tests/test_portfolio_truth.py b/tests/test_portfolio_truth.py index 48803d5..0428106 100644 --- a/tests/test_portfolio_truth.py +++ b/tests/test_portfolio_truth.py @@ -970,6 +970,10 @@ def test_publish_refuses_to_drop_existing_notion_context( "src.portfolio_truth_sources.load_notion_project_context", lambda _config_dir: None, ) + monkeypatch.setattr( + "src.portfolio_truth_publish._notion_project_context_configured", + lambda: True, + ) with pytest.raises(RuntimeError, match="0 Notion context rows"): publish_portfolio_truth(