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
23 changes: 13 additions & 10 deletions config/notion-project-map.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore mappings for the active Notion-backed repos

When an audit report includes mcp-trust, cross-provider-egress-guard, or fable-outputs, deleting their entries from notion-project-map.json makes export_notion_events() unable to resolve a localProjectId, so those Audit signals are emitted under unmapped_repos instead of being delivered to Notion. These projects are still declared in config/portfolio-catalog.yaml, so this removal regresses the existing mapping coverage rather than just adding aliases.

Useful? React with 👍 / 👎.

}
3 changes: 3 additions & 0 deletions config/project-registry-overrides.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 15 additions & 12 deletions src/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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}")
Expand Down
18 changes: 17 additions & 1 deletion src/notion_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from datetime import datetime, timezone
from pathlib import Path

from src.registry_parser import _normalize

RAW_EXCERPT_LIMIT = 2000


Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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],
Expand Down
5 changes: 4 additions & 1 deletion src/notion_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", "")
Expand Down
62 changes: 62 additions & 0 deletions src/portfolio_truth_publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the same Notion config path for the drop guard

When audit is run from outside the source checkout, load_safe_notion_project_context() still queries load_notion_project_context() with its default Path("config") relative to the working directory, but this guard checks _CONFIG_DIR/notion-config.json relative to the package repo. In that context a real config/notion-config.json with projects_data_source_id is ignored here, so a missing token/API response can produce notion_context_rows == 0 and still overwrite a previous nonzero truth snapshot because the guard returns early.

Useful? React with 👍 / 👎.

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
18 changes: 18 additions & 0 deletions src/portfolio_truth_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions tests/test_notion_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]
Expand Down
11 changes: 11 additions & 0 deletions tests/test_notion_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,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": {
Expand Down
76 changes: 75 additions & 1 deletion tests/test_portfolio_truth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -915,6 +950,45 @@ 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,
)
monkeypatch.setattr(
"src.portfolio_truth_publish._notion_project_context_configured",
lambda: True,
)

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,
Expand Down
Loading