Skip to content

Commit b4e26d4

Browse files
authored
[codex] fix portfolio truth Notion context mapping (#68)
* Fix portfolio truth Notion context mapping * Fix CI-safe Notion context tests
1 parent 53e6f42 commit b4e26d4

11 files changed

Lines changed: 249 additions & 25 deletions

config/notion-project-map.json

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,19 @@
2121
"localProjectId": "378c21f1-caf0-819a-86c3-c0c41d12eef3"
2222
},
2323
"GithubRepoAuditor": {
24-
"localProjectId": "377c21f1-caf0-817d-9d57-e59a673dd0b7"
24+
"localProjectId": "332c21f1-caf0-8125-b553-f75377da8fed"
25+
},
26+
"GitHub Repo Auditor": {
27+
"localProjectId": "332c21f1-caf0-8125-b553-f75377da8fed"
28+
},
29+
"MCPAudit": {
30+
"localProjectId": "332c21f1-caf0-8150-a80f-d23b2b9e601d"
31+
},
32+
"MCP Audit": {
33+
"localProjectId": "332c21f1-caf0-8150-a80f-d23b2b9e601d"
34+
},
35+
"Notion": {
36+
"localProjectId": "332c21f1-caf0-81c5-85fb-d7451163ab6b"
2537
},
2638
"Grotto": {
2739
"localProjectId": "377c21f1-caf0-81d3-add8-f9f31584d5ae"
@@ -217,14 +229,5 @@
217229
},
218230
"GhostRoutes": {
219231
"localProjectId": "32dc21f1-caf0-813a-9ca9-ef7062c11bf8"
220-
},
221-
"mcp-trust": {
222-
"localProjectId": "37ec21f1-caf0-8110-bcd5-c1f4219623d9"
223-
},
224-
"cross-provider-egress-guard": {
225-
"localProjectId": "37ec21f1-caf0-8110-8081-d87ffe80e1c3"
226-
},
227-
"fable-outputs": {
228-
"localProjectId": "37ec21f1-caf0-81b8-b365-e894672184ac"
229232
}
230233
}

config/project-registry-overrides.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,11 @@
4040
"DesktopPEt-ready": "DesktopPEt",
4141
"EarthPulse-readiness": "EarthPulse",
4242
"FreelanceInvoice": "FreeLanceInvoice",
43+
"GitHub Repo Auditor": "GithubRepoAuditor",
4344
"GithubRepoAuditor-public": "GithubRepoAuditor",
4445
"KBFreshness": "KBFreshnessDetector",
46+
"MCP Audit": "MCPAudit",
47+
"Notion": "Notion",
4548
"Notion Operating System": "Notion",
4649
"OrbitMechanics": "OrbitMechanic",
4750
"OrbitForge (staging)": "OrbitForge",

src/cli.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5351,7 +5351,7 @@ def _warn_if_warehouse_report_stale(output_dir: Path, username: str) -> None:
53515351

53525352

53535353
def _run_portfolio_truth_mode(args) -> None:
5354-
from src.portfolio_truth_publish import publish_portfolio_truth
5354+
from src.portfolio_truth_publish import PortfolioTruthPublishError, publish_portfolio_truth
53555355

53565356
output_dir = Path(args.output_dir)
53575357
workspace_root = Path(args.workspace_root)
@@ -5381,17 +5381,20 @@ def _run_portfolio_truth_mode(args) -> None:
53815381
username=args.username,
53825382
)
53835383

5384-
result = publish_portfolio_truth(
5385-
workspace_root=workspace_root,
5386-
output_dir=output_dir,
5387-
registry_output=registry_output,
5388-
portfolio_report_output=portfolio_report_output,
5389-
catalog_path=Path(args.catalog) if args.catalog else None,
5390-
legacy_registry_path=legacy_registry_path,
5391-
include_notion=True,
5392-
release_count_by_name=release_count_by_name,
5393-
security_alerts_by_name=security_alerts_by_name,
5394-
)
5384+
try:
5385+
result = publish_portfolio_truth(
5386+
workspace_root=workspace_root,
5387+
output_dir=output_dir,
5388+
registry_output=registry_output,
5389+
portfolio_report_output=portfolio_report_output,
5390+
catalog_path=Path(args.catalog) if args.catalog else None,
5391+
legacy_registry_path=legacy_registry_path,
5392+
include_notion=True,
5393+
release_count_by_name=release_count_by_name,
5394+
security_alerts_by_name=security_alerts_by_name,
5395+
)
5396+
except PortfolioTruthPublishError as exc:
5397+
raise SystemExit(str(exc)) from exc
53955398
print_info(f"Portfolio truth snapshot: {result.latest_path}")
53965399
print_info(f"Portfolio truth history snapshot: {result.snapshot_path}")
53975400
print_info(f"Project registry compatibility output: {result.registry_output}")

src/notion_export.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from datetime import datetime, timezone
1010
from pathlib import Path
1111

12+
from src.registry_parser import _normalize
13+
1214
RAW_EXCERPT_LIMIT = 2000
1315

1416

@@ -50,7 +52,7 @@ def _normalize_audit_event(
5052
if not name:
5153
return None
5254

53-
project = mapping.get(name)
55+
project = _lookup_project_mapping(name, mapping)
5456
if not project:
5557
return None
5658

@@ -100,6 +102,20 @@ def _normalize_audit_event(
100102
}
101103

102104

105+
def _lookup_project_mapping(name: str, mapping: dict[str, dict]) -> dict | None:
106+
"""Resolve exact and safe normalized project-name aliases in the page map."""
107+
project = mapping.get(name)
108+
if project:
109+
return project
110+
normalized = _normalize(name)
111+
if not normalized:
112+
return None
113+
for mapped_name, mapped_project in mapping.items():
114+
if _normalize(mapped_name) == normalized:
115+
return mapped_project
116+
return None
117+
118+
103119
def _build_raw_excerpt(
104120
dim_scores: dict[str, float],
105121
badges: list[str],

src/notion_registry.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,12 @@ def _extract_title(page: dict) -> str:
7676

7777

7878
def _extract_select(page: dict, prop_name: str) -> str:
79-
"""Extract a select property value from a Notion page."""
79+
"""Extract a select/status property value from a Notion page."""
8080
props = page.get("properties", {})
8181
prop = props.get(prop_name, {})
82+
status = prop.get("status")
83+
if status:
84+
return status.get("name", "")
8285
sel = prop.get("select")
8386
if sel:
8487
return sel.get("name", "")

src/portfolio_truth_publish.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ class PortfolioTruthPublishResult:
2929
project_registry_path: Path | None = None
3030

3131

32+
class PortfolioTruthPublishError(RuntimeError):
33+
"""Raised when publishing would corrupt or misrepresent portfolio truth."""
34+
35+
3236
_REPO_ROOT = Path(__file__).resolve().parents[1]
3337
_CONFIG_DIR = _REPO_ROOT / "config"
3438

@@ -100,6 +104,11 @@ def publish_portfolio_truth(
100104
snapshot_stamp = build_result.snapshot.generated_at.strftime("%Y-%m-%dT%H%M%SZ")
101105
snapshot_path = output_dir / f"portfolio-truth-{snapshot_stamp}.json"
102106
latest_path = truth_latest_path(output_dir)
107+
_guard_against_notion_context_drop(
108+
build_result.snapshot.source_summary,
109+
latest_path=latest_path,
110+
include_notion=include_notion,
111+
)
103112
latest_name = latest_path.name
104113
snapshot_json = json.dumps(build_result.snapshot.to_dict(), indent=2) + "\n"
105114
project_registry_path = output_dir / "project-registry.json"
@@ -184,3 +193,56 @@ def _content_changed(path: Path, content: str) -> bool:
184193
if not path.exists():
185194
return True
186195
return path.read_text() != content
196+
197+
198+
def _guard_against_notion_context_drop(
199+
source_summary: dict[str, object],
200+
*,
201+
latest_path: Path,
202+
include_notion: bool,
203+
) -> None:
204+
"""Avoid overwriting local truth when Notion bootstrap silently disappears."""
205+
if not include_notion or not _notion_project_context_configured():
206+
return
207+
current_rows = _int_value(source_summary.get("notion_context_rows"))
208+
if current_rows != 0:
209+
return
210+
previous_rows = _previous_notion_context_rows(latest_path)
211+
if previous_rows is None or previous_rows <= 0:
212+
return
213+
raise PortfolioTruthPublishError(
214+
"Refusing to publish portfolio truth with 0 Notion context rows because "
215+
f"{latest_path} currently has {previous_rows}. Load NOTION_TOKEN or run "
216+
"with an explicit no-Notion path before replacing local portfolio truth."
217+
)
218+
219+
220+
def _notion_project_context_configured() -> bool:
221+
path = _CONFIG_DIR / "notion-config.json"
222+
try:
223+
data = json.loads(path.read_text())
224+
except (OSError, json.JSONDecodeError):
225+
return False
226+
return bool(str(data.get("projects_data_source_id", "")).strip())
227+
228+
229+
def _previous_notion_context_rows(latest_path: Path) -> int | None:
230+
try:
231+
data = json.loads(latest_path.read_text())
232+
except (OSError, json.JSONDecodeError):
233+
return None
234+
source_summary = data.get("source_summary", {})
235+
if not isinstance(source_summary, dict):
236+
return None
237+
return _int_value(source_summary.get("notion_context_rows"))
238+
239+
240+
def _int_value(value: object) -> int | None:
241+
if isinstance(value, bool):
242+
return None
243+
if isinstance(value, int):
244+
return value
245+
try:
246+
return int(str(value))
247+
except (TypeError, ValueError):
248+
return None

src/portfolio_truth_sources.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,9 +259,27 @@ def load_safe_notion_project_context(
259259
"momentum": str(context.get("momentum", "") or "").strip(),
260260
"current_state": str(context.get("current_state", "") or "").strip(),
261261
}
262+
for raw_alias, target in _load_notion_title_aliases(config_dir).items():
263+
alias_context = sanitized.get(_normalize(raw_alias))
264+
if alias_context:
265+
sanitized.setdefault(_normalize(target), alias_context)
262266
return sanitized
263267

264268

269+
def _load_notion_title_aliases(config_dir: Path) -> dict[str, str]:
270+
path = config_dir / "project-registry-overrides.json"
271+
if not path.is_file():
272+
return {}
273+
try:
274+
data = json.loads(path.read_text())
275+
except (OSError, json.JSONDecodeError):
276+
return {}
277+
aliases = data.get("notion_title_aliases", {})
278+
if not isinstance(aliases, dict):
279+
return {}
280+
return {str(raw): str(target) for raw, target in aliases.items() if raw and target}
281+
282+
265283
def _inspect_project_dir(
266284
project_path: Path,
267285
workspace_root: Path,

tests/test_notion_export.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from src.notion_export import (
66
_build_event_key,
77
_find_biggest_drag,
8+
_lookup_project_mapping,
89
_normalize_audit_event,
910
_severity_from_grade,
1011
export_notion_events,
@@ -127,6 +128,19 @@ def test_unmapped_repo_returns_none(self):
127128
assert event is None
128129

129130

131+
class TestProjectMappingLookup:
132+
def test_exact_match_wins(self):
133+
mapping = {
134+
"GitHub Repo Auditor": {"localProjectId": "spaced-id"},
135+
"GithubRepoAuditor": {"localProjectId": "exact-id"},
136+
}
137+
assert _lookup_project_mapping("GithubRepoAuditor", mapping)["localProjectId"] == "exact-id"
138+
139+
def test_normalized_alias_match_resolves_spacing_and_case(self):
140+
mapping = {"MCP Audit": {"localProjectId": "mcp-id"}}
141+
assert _lookup_project_mapping("MCPAudit", mapping)["localProjectId"] == "mcp-id"
142+
143+
130144
class TestBiggestDrag:
131145
def test_finds_lowest(self):
132146
audit = _make_report()["audits"][0]

tests/test_notion_registry.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,17 @@ def test_extracts_select(self):
3838
}
3939
assert _extract_select(page, "Current State") == "Active"
4040

41+
def test_extracts_status_property(self):
42+
page = {
43+
"properties": {
44+
"Pipeline Stage": {
45+
"type": "status",
46+
"status": {"name": "Post-Build Review Done"},
47+
},
48+
},
49+
}
50+
assert _extract_select(page, "Pipeline Stage") == "Post-Build Review Done"
51+
4152
def test_null_select(self):
4253
page = {
4354
"properties": {

tests/test_portfolio_truth.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@
2020
render_portfolio_report_markdown,
2121
render_registry_markdown,
2222
)
23-
from src.portfolio_truth_sources import _classify_context_quality, _extract_github_full_name
23+
from src.portfolio_truth_sources import (
24+
_classify_context_quality,
25+
_extract_github_full_name,
26+
load_safe_notion_project_context,
27+
)
2428
from src.portfolio_truth_validate import validate_portfolio_report_markdown
2529
from src.registry_parser import parse_registry
2630

@@ -85,6 +89,37 @@ def test_extract_github_full_name_uses_exact_github_host() -> None:
8589
assert _extract_github_full_name("https://evil.example/github.com/octo/repo.git") == ""
8690

8791

92+
def test_notion_context_uses_configured_title_aliases(
93+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
94+
) -> None:
95+
config_dir = tmp_path / "config"
96+
config_dir.mkdir()
97+
(config_dir / "project-registry-overrides.json").write_text(
98+
json.dumps(
99+
{
100+
"notion_title_aliases": {
101+
"Notion Operating System": "Notion",
102+
}
103+
}
104+
)
105+
)
106+
107+
monkeypatch.setattr(
108+
"src.portfolio_truth_sources.load_notion_project_context",
109+
lambda _config_dir: {
110+
"Notion Operating System": {
111+
"portfolio_call": "Build Now",
112+
"momentum": "Post-Build Review Done",
113+
"current_state": "Shipped",
114+
}
115+
},
116+
)
117+
118+
context = load_safe_notion_project_context(config_dir)
119+
120+
assert context["notion"]["current_state"] == "Shipped"
121+
122+
88123
@pytest.fixture
89124
def portfolio_workspace(tmp_path: Path) -> Path:
90125
workspace = tmp_path / "workspace"
@@ -915,6 +950,45 @@ def _boom(_snapshot, _latest_json_path):
915950
assert not list(output_dir.glob("*.tmp"))
916951

917952

953+
def test_publish_refuses_to_drop_existing_notion_context(
954+
portfolio_workspace: Path,
955+
portfolio_catalog: Path,
956+
legacy_registry: Path,
957+
tmp_path: Path,
958+
monkeypatch: pytest.MonkeyPatch,
959+
) -> None:
960+
output_dir = tmp_path / "output"
961+
output_dir.mkdir()
962+
latest_path = output_dir / "portfolio-truth-latest.json"
963+
latest_path.write_text(
964+
json.dumps({"source_summary": {"notion_context_rows": 137}}) + "\n"
965+
)
966+
registry_output = portfolio_workspace / "project-registry.md"
967+
report_output = portfolio_workspace / "PORTFOLIO-AUDIT-REPORT.md"
968+
969+
monkeypatch.setattr(
970+
"src.portfolio_truth_sources.load_notion_project_context",
971+
lambda _config_dir: None,
972+
)
973+
monkeypatch.setattr(
974+
"src.portfolio_truth_publish._notion_project_context_configured",
975+
lambda: True,
976+
)
977+
978+
with pytest.raises(RuntimeError, match="0 Notion context rows"):
979+
publish_portfolio_truth(
980+
workspace_root=portfolio_workspace,
981+
output_dir=output_dir,
982+
registry_output=registry_output,
983+
portfolio_report_output=report_output,
984+
catalog_path=portfolio_catalog,
985+
legacy_registry_path=legacy_registry,
986+
include_notion=True,
987+
)
988+
989+
assert json.loads(latest_path.read_text())["source_summary"]["notion_context_rows"] == 137
990+
991+
918992
def test_context_recovery_plan_freezes_and_filters_targets(
919993
portfolio_workspace: Path,
920994
portfolio_catalog: Path,

0 commit comments

Comments
 (0)