diff --git a/tests/tools/seer_nav/__init__.py b/tests/tools/seer_nav/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/tools/seer_nav/test_generate_snapshot.py b/tests/tools/seer_nav/test_generate_snapshot.py new file mode 100644 index 000000000000..5525e4faa991 --- /dev/null +++ b/tests/tools/seer_nav/test_generate_snapshot.py @@ -0,0 +1,75 @@ +from pathlib import Path +from unittest import mock + +import pytest + +from sentry.utils import json +from tools.seer_nav import generate_snapshot +from tools.seer_nav.entries import NavEntry +from tools.seer_nav.generate_snapshot import refresh_snapshot + +LOGS = NavEntry(crumbs="Explore → Logs", path="/explore/logs/") +TRACES = NavEntry(crumbs="Explore → Traces", path="/explore/traces/") + + +def test_unchanged_entries_leave_the_file_untouched(tmp_path: Path) -> None: + path = tmp_path / "snapshot.json" + assert refresh_snapshot([LOGS], path) == [ + '+ {"crumbs": "Explore → Logs", "path": "/explore/logs/"}' + ] + written = path.read_text() + + assert refresh_snapshot([LOGS], path) == [] + assert path.read_text() == written + + +def test_changed_entries_rewrite_the_snapshot(tmp_path: Path) -> None: + path = tmp_path / "snapshot.json" + refresh_snapshot([LOGS], path) + + assert refresh_snapshot([TRACES], path) == [ + '+ {"crumbs": "Explore → Traces", "path": "/explore/traces/"}', + '- {"crumbs": "Explore → Logs", "path": "/explore/logs/"}', + ] + assert json.loads(path.read_text()) == { + "version": 1, + "entries": [{"crumbs": "Explore → Traces", "path": "/explore/traces/"}], + } + + +def test_check_reports_a_stale_snapshot_with_the_update_command( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + path = tmp_path / "snapshot.json" + refresh_snapshot([LOGS], path) + + with ( + mock.patch.object(generate_snapshot, "SNAPSHOT_PATH", path), + mock.patch.object(generate_snapshot, "parse_checkout", return_value=[LOGS, TRACES]), + ): + assert generate_snapshot.main(["--check"]) == 1 + out = capsys.readouterr().out + assert '+ {"crumbs": "Explore → Traces", "path": "/explore/traces/"}' in out + assert "python -m tools.seer_nav.generate_snapshot" in out + assert json.loads(path.read_text())["entries"] == [LOGS.to_dict()] + + with ( + mock.patch.object(generate_snapshot, "SNAPSHOT_PATH", path), + mock.patch.object(generate_snapshot, "parse_checkout", return_value=[LOGS]), + ): + assert generate_snapshot.main(["--check"]) == 0 + + +def test_entry_defaults_are_left_out_of_the_snapshot() -> None: + page = NavEntry(crumbs="Explore → Logs → New", path="/explore/logs/new/", sub_page=True) + assert page.to_dict() == { + "crumbs": "Explore → Logs → New", + "path": "/explore/logs/new/", + "sub_page": True, + } + assert LOGS.to_dict() == {"crumbs": "Explore → Logs", "path": "/explore/logs/"} + + +def test_parses_this_checkout() -> None: + sections = {e.crumbs.split(" → ")[0] for e in generate_snapshot.parse_checkout()} + assert {"Explore", "Issues", "Settings"} <= sections diff --git a/tests/tools/seer_nav/test_parser.py b/tests/tools/seer_nav/test_parser.py new file mode 100644 index 000000000000..cfd4e83d1095 --- /dev/null +++ b/tests/tools/seer_nav/test_parser.py @@ -0,0 +1,252 @@ +from pathlib import Path +from unittest import mock + +import pytest + +from tools.seer_nav import parser +from tools.seer_nav.entries import Condition +from tools.seer_nav.parser import ( + _js_condition, + _parse_route_tree, + check_alias_sources, + parse_nav_entries, +) + +EXPLORE_NAV = """ +const hasNewErrors = organization.features.includes('explore-errors'); +export function ExploreSecondaryNavigation() { + const baseUrl = `/organizations/${organization.slug}/explore`; + return ( + + {t('Explore')} + + + {t('Logs')} + + {/* Used to be wrapped in before GA */} + {t('Traces')} + {!hasNewErrors && ( + {t('Errors')} + )} + {hasBeta && ( + + {t('Beta pages')} + {t('Beta')} + + )} + {t('Releases')} + {hasNewHome ? ( + {t('Home')} + ) : ( + {t('Classic')} + )} + {hasPreview ? ( + {t('Preview')} + ) : null} + {t('Saved')} + {organization.openMembership && ( + + {t('Investigations')} + + )} + ); +} +""" + +ORG_SETTINGS = """ +const organizationSettingsPathPrefix = '/settings'; +export function getUserOrgNavigationConfiguration() { + return [ + { + name: t('Organization'), + items: [ + {path: `${organizationSettingsPathPrefix}/`, title: settingsV3 ? t('General') : t('General Settings')}, + {path: `${organizationSettingsPathPrefix}/teams/`, title: t('Teams')}, + {path: `${organizationSettingsPathPrefix}/early-features/`, title: t('Early Features'), show: ({isSelfHosted}) => isSelfHosted || false}, + {path: `${organizationSettingsPathPrefix}/old/`, title: t('Old'), show: false, // deprecated + }, + { + path: `${organizationSettingsPathPrefix}/seer/repos/`, + title: t('Code Review'), + show: ({organization}) => + !!organization && + (organization.features.includes('a') || organization.features.includes('b')), + }, + ], + }, + ]; +} +""" + + +BILLING_SETTINGS = """ +function buildBillingNavigationConfig(organization, membersCanViewSubscriptionInfo) { + const prefix = '/settings/:orgId'; + + const items = [ + { + path: `${prefix}/billing/`, + title: t('Subscription'), + show: ({access}: NavProps) => access?.has('org:billing') || membersCanViewSubscriptionInfo, + }, + { + path: `${prefix}/subscription/spend-allocations/`, + title: t('Spend Allocations'), + show: () => organization.features.includes('spend-allocations'), + }, + {path: `${prefix}/legal/`, title: t('Legal & Compliance')}, + ]; + + return {name: t('Usage & Billing'), items}; +} +""" + + +def _write(root: Path, rel: str, content: str) -> None: + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + + +@pytest.fixture +def frontend_dir(tmp_path: Path) -> str: + _write( + tmp_path, + "views/navigation/secondary/sections/explore/exploreSecondaryNavigation.tsx", + EXPLORE_NAV, + ) + _write(tmp_path, "views/settings/organization/userOrgNavigationConfiguration.tsx", ORG_SETTINGS) + return str(tmp_path) + + +def test_secondary_nav_records_flag_negation_and_org_conditions(frontend_dir: str) -> None: + entries = {e.crumbs: (e.path, e.when) for e in parse_nav_entries(frontend_dir)} + assert entries["Explore → Logs"] == ("/explore/logs/", {"flag": "ourlogs-enabled"}) + assert entries["Explore → Errors"] == ("/explore/errors/", {"not": {"flag": "explore-errors"}}) + assert entries["Explore → Investigations"] == ( + "/explore/investigations/", + {"all": [{"flag": "investigations"}, {"org": "openMembership"}]}, + ) + + +def test_commented_out_gates_are_ignored(frontend_dir: str) -> None: + entries = {e.crumbs: e.when for e in parse_nav_entries(frontend_dir)} + assert entries["Explore → Traces"] is None + + +def test_ternary_branches_get_the_condition_and_its_negation(frontend_dir: str) -> None: + entries = {e.crumbs: e.when for e in parse_nav_entries(frontend_dir)} + assert entries["Explore → Home"] == {"unknown": "hasNewHome"} + assert entries["Explore → Classic"] == {"unknown": "!hasNewHome"} + assert entries["Explore → Preview"] == {"unknown": "hasPreview"} + assert entries["Explore → Saved"] is None + + +def test_label_inside_a_condition_block_does_not_end_it(frontend_dir: str) -> None: + entries = {e.crumbs: e.when for e in parse_nav_entries(frontend_dir)} + assert entries["Explore → Beta"] == {"unknown": "hasBeta"} + assert entries["Explore → Releases"] is None + + +def test_settings_show_expressions_become_conditions(frontend_dir: str) -> None: + entries = {e.crumbs: e.when for e in parse_nav_entries(frontend_dir)} + assert entries["Settings → Organization → Teams"] is None + assert entries["Settings → Organization → Early Features"] == {"deployment": "self_hosted"} + assert entries["Settings → Organization → Code Review"] == { + "any": [{"flag": "a"}, {"flag": "b"}] + } + assert "Settings → Organization → Old" not in entries + + +def test_flag_dependent_titles_become_one_entry_per_label(frontend_dir: str) -> None: + entries = {e.crumbs: (e.path, e.when) for e in parse_nav_entries(frontend_dir)} + assert entries["Settings → Organization → General"] == ("/settings/", {"flag": "settings-v3"}) + assert entries["Settings → Organization → General Settings"] == ( + "/settings/", + {"not": {"flag": "settings-v3"}}, + ) + + +@pytest.mark.parametrize( + ("expr", "expected"), + [ + ("!!organization?.features?.includes('x')", {"flag": "x"}), + ("!isSelfHosted", {"not": {"deployment": "self_hosted"}}), + ("!hasTempestAccess(organization)", {"unknown": "!hasTempestAccess(organization)"}), + ("true", None), + ("access?.has('org:billing')", {"scope": "org:billing"}), + ], +) +def test_js_condition(expr: str, expected: Condition | None) -> None: + assert _js_condition(expr) == expected + + +def test_gsapp_billing_settings_join_settings_nav(frontend_dir: str, tmp_path: Path) -> None: + gsapp_dir = tmp_path / "gsApp" + _write(gsapp_dir, "overrides/useBillingNavigationConfig.tsx", BILLING_SETTINGS) + entries = {e.crumbs: (e.path, e.when) for e in parse_nav_entries(frontend_dir, str(gsapp_dir))} + assert entries["Settings → Usage & Billing → Subscription"] == ( + "/settings/billing/", + {"any": [{"scope": "org:billing"}, {"unknown": "membersCanViewSubscriptionInfo"}]}, + ) + assert entries["Settings → Usage & Billing → Spend Allocations"] == ( + "/settings/subscription/spend-allocations/", + {"flag": "spend-allocations"}, + ) + assert entries["Settings → Usage & Billing → Legal & Compliance"] == ("/settings/legal/", None) + + +def test_billing_settings_need_the_gsapp_tree(frontend_dir: str) -> None: + assert not any("Usage & Billing" in e.crumbs for e in parse_nav_entries(frontend_dir)) + + +def test_show_new_seer_becomes_its_flag_checks() -> None: + assert _js_condition("!showNewSeer(organization)") == { + "not": { + "any": [ + {"flag": "seat-based-seer-enabled"}, + {"flag": "token-billed-seer-enabled"}, + { + "all": [ + {"not": {"flag": "seer-added"}}, + {"not": {"flag": "code-review-beta"}}, + {"flag": "seer-user-billing-launch"}, + ] + }, + ] + } + } + + +def test_alias_source_guard(tmp_path: Path) -> None: + source = "export function showNewSeer(organization) {\n return true;\n}\n" + _write(tmp_path, "utils/seer/showNewSeer.ts", source) + hashes = {"utils/seer/showNewSeer.ts": parser._code_hash(source)} + with mock.patch.object(parser, "_ALIAS_SOURCES", hashes): + check_alias_sources(str(tmp_path)) + + # Comment and whitespace edits don't trip it; code edits and a moved file do. + _write(tmp_path, "utils/seer/showNewSeer.ts", "// note\n" + source.replace(" ", " ")) + check_alias_sources(str(tmp_path)) + _write(tmp_path, "utils/seer/showNewSeer.ts", source.replace("true", "false")) + with pytest.raises(SystemExit, match=r"showNewSeer\.ts changed"): + check_alias_sources(str(tmp_path)) + (tmp_path / "utils/seer/showNewSeer.ts").unlink() + with pytest.raises(SystemExit, match=r"showNewSeer\.ts changed"): + check_alias_sources(str(tmp_path)) + + +def test_routes_under_a_computed_path_are_skipped() -> None: + routes = """ +const insightsRoutes = { + path: '/insights/', + children: [ + {path: 'overview/', name: t('Overview')}, + { + path: `${MODULE_BASE_URLS[ModuleName.HTTP]}/`, + children: [{path: 'domains/', name: t('Domains')}], + }, + ], +}; +""" + assert _parse_route_tree(routes) == [("/insights/", None), ("/insights/overview/", "Overview")] diff --git a/tools/seer_nav/__init__.py b/tools/seer_nav/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tools/seer_nav/entries.py b/tools/seer_nav/entries.py new file mode 100644 index 000000000000..e5ee9c75b594 --- /dev/null +++ b/tools/seer_nav/entries.py @@ -0,0 +1,35 @@ +"""The nav snapshot's entry shape, shared by the parser and the generator. + +Conditions are JSON: `{"flag": name}`, `{"org": attribute}`, `{"deployment": kind}`, +`{"scope": permission}`, `{"unknown": source_expression}`, combined with `{"all": [...]}`, +`{"any": [...]}` and `{"not": cond}`. A missing condition means always shown. +""" + +from __future__ import annotations + +import dataclasses +from typing import Any + +PROJECT_PLACEHOLDER = "" +TEAM_PLACEHOLDER = "" +CRUMB_SEPARATOR = " → " + +Condition = dict[str, Any] + + +@dataclasses.dataclass(frozen=True) +class NavEntry: + crumbs: str + path: str + when: Condition | None = None + # Create forms and detail tabs reached from a nav page rather than the sidebar itself. + sub_page: bool = False + + def to_dict(self) -> dict[str, Any]: + """The entry as stored in the snapshot, leaving out fields at their defaults.""" + data: dict[str, Any] = {"crumbs": self.crumbs, "path": self.path} + if self.when is not None: + data["when"] = self.when + if self.sub_page: + data["sub_page"] = True + return data diff --git a/tools/seer_nav/generate_snapshot.py b/tools/seer_nav/generate_snapshot.py new file mode 100644 index 000000000000..2387c0408f69 --- /dev/null +++ b/tools/seer_nav/generate_snapshot.py @@ -0,0 +1,102 @@ +"""Regenerate Seer's nav snapshot from this checkout's frontend source. + + python -m tools.seer_nav.generate_snapshot [--check] + +Seer links users to the Sentry pages listed in the snapshot, so it must change whenever a +nav page is added, renamed, moved or gated differently. Without `--check` it rewrites the +snapshot when its pages changed; with `--check` it writes nothing and exits 1 if stale. +""" + +from __future__ import annotations + +import argparse +import json +from collections.abc import Sequence +from pathlib import Path + +from tools.seer_nav.entries import NavEntry +from tools.seer_nav.parser import check_alias_sources, parse_nav_entries + +REPO_ROOT = Path(__file__).resolve().parents[2] +SNAPSHOT_PATH = REPO_ROOT / "src/sentry/seer/nav_snapshot.generated.json" +SNAPSHOT_VERSION = 1 + +UPDATE_COMMANDS = ( + " python -m tools.seer_nav.generate_snapshot\n" + " git add src/sentry/seer/nav_snapshot.generated.json" +) +STALE_MESSAGE = ( + "Seer nav snapshot is out of date: this change touches pages Seer links to.\n" + f"Update it with:\n{UPDATE_COMMANDS}" +) + + +def _entry_key(entry: NavEntry) -> str: + return json.dumps(entry.to_dict(), sort_keys=True, ensure_ascii=False) + + +def _snapshot_keys(path: Path) -> set[str]: + if not path.exists(): + return set() + return { + json.dumps(e, sort_keys=True, ensure_ascii=False) + for e in json.loads(path.read_text())["entries"] + } + + +def _entry_changes(old: set[str], new: set[str]) -> list[str]: + return [f"+ {key}" for key in sorted(new - old)] + [f"- {key}" for key in sorted(old - new)] + + +def _write_snapshot(entries: Sequence[NavEntry], path: Path) -> None: + payload = {"version": SNAPSHOT_VERSION, "entries": [e.to_dict() for e in entries]} + path.write_text(json.dumps(payload, indent=1, ensure_ascii=False) + "\n") + + +def refresh_snapshot(entries: Sequence[NavEntry], path: Path = SNAPSHOT_PATH) -> list[str]: + """Rewrite the snapshot if its entries changed; return the `+`/`-` changed entries.""" + changes = _entry_changes(_snapshot_keys(path), {_entry_key(e) for e in entries}) + if changes: + _write_snapshot(entries, path) + return changes + + +def parse_checkout(repo_root: Path = REPO_ROOT) -> list[NavEntry]: + app_dir = str(repo_root / "static/app") + check_alias_sources(app_dir) + entries = parse_nav_entries(app_dir, str(repo_root / "static/gsApp")) + if not entries: + raise SystemExit("Parsed no nav entries; the frontend layout may have changed.") + return entries + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument( + "--check", + action="store_true", + help="Write nothing; exit 1 if the snapshot no longer matches the frontend source.", + ) + args = parser.parse_args(argv) + + entries = parse_checkout() + if args.check: + changes = _entry_changes(_snapshot_keys(SNAPSHOT_PATH), {_entry_key(e) for e in entries}) + if changes: + print("\n".join(changes)) + print(f"\n{STALE_MESSAGE}") + return 1 + print("Seer nav snapshot is up to date.") + return 0 + + changes = refresh_snapshot(entries) + if not changes: + print("Seer nav snapshot is up to date.") + return 0 + print("\n".join(changes)) + print(f"\nWrote {len(entries)} entries to {SNAPSHOT_PATH.relative_to(REPO_ROOT)}.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/seer_nav/parser.py b/tools/seer_nav/parser.py new file mode 100644 index 000000000000..d9cec85711c3 --- /dev/null +++ b/tools/seer_nav/parser.py @@ -0,0 +1,667 @@ +"""Parse Sentry's frontend source into the entries of Seer's nav snapshot. + +Only runs when regenerating the snapshot (see generate_snapshot), never per request. +Each entry keeps the conditions the frontend renders it under, as data, so the +request-time filter can evaluate them per org instead of baking in one org's view. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +from pathlib import Path + +from tools.seer_nav.entries import ( + CRUMB_SEPARATOR, + PROJECT_PLACEHOLDER, + TEAM_PLACEHOLDER, + Condition, + NavEntry, +) + +_SECONDARY_NAV_TOKEN_RE = re.compile( + r".*?)" + r"|[^>]*)>" + r"|(?P)" + r"|[^>]*)>" + r"|(?P)" + r"|\{(?P[^{}\n]*?)\s*(?P&&|\?)\s*\(", + re.DOTALL, +) +_LINK_TO_RE = re.compile(r"to=\{`([^`]*)`\}|pathname: `([^`]*)`") +_FEATURES_ATTR_RE = re.compile(r"""features=(\{\[[^\]]*\]\}|"[^"]*"|'[^']*')""") +_LINK_LABEL_RE = re.compile(r"\{t\('([^']+)'\)\}") +_TITLE_ATTR_RE = re.compile(r"title=\{t\('([^']+)'\)\}") +_HEADER_RE = re.compile( + r"\s*(?:\s*)?\{t\('([^']+)'\)\}" +) +_BASE_URL_RE = re.compile(r"const baseUrl = `([^`]*)`") +_FLAG_VAR_RE = re.compile(r"const (\w+) = organization\.features\.includes\(\s*'([^']+)'\s*\)") +_QUOTED_RE = re.compile(r"""['"]([^'"]+)['"]""") +_TEMPLATE_REF_RE = re.compile(r"\$\{(\w+)\}") +_IMPORT_RE = re.compile(r"import \{([^}]*)\} from '(sentry/[^']+)'") +_STRING_CONST_RE = re.compile(r"""^(?:export )?const (\w+) = ['"]([^'"]*)['"]""", re.MULTILINE) +_SETTINGS_SECTION_NAME_RE = re.compile(r"name: t\('([^']+)'\)") +_SETTINGS_ITEM_RE = re.compile(r"path: `([^`]*)`") +_SETTINGS_TITLE_RE = re.compile(r"title: t\('([^']+)'\)") +_SETTINGS_TERNARY_TITLE_RE = re.compile(r"title: ([\w.?!]+) \? t\('([^']+)'\) : t\('([^']+)'\)") +# Hook results and helpers the nav branches on, with the check each one performs. +_KNOWN_ALIASES = { + # views/settings/useSettingsV3.ts + "settingsV3": "organization.features.includes('settings-v3')", + # utils/seer/showNewSeer.ts, with orgHasNewSeerPlan.ts inlined + "showNewSeer(organization)": ( + "organization.features.includes('seat-based-seer-enabled') || " + "organization.features.includes('token-billed-seer-enabled') || (" + "!organization.features.includes('seer-added') && " + "!organization.features.includes('code-review-beta') && " + "organization.features.includes('seer-user-billing-launch'))" + ), +} +# Source files the hand-copied aliases above were written from, with a hash of their code +# (comments and whitespace ignored). A changed file means the alias may be stale. +_ALIAS_SOURCES = { + "utils/seer/showNewSeer.ts": "96a95fd07e5ce7da", + "utils/seer/orgHasNewSeerPlan.ts": "ec7683ec625ced39", +} +_SETTINGS_FUNCTION_SPLIT_RE = re.compile(r"\n(?:export )?function ") +_SETTINGS_SHOW_RE = re.compile(r"\bshow:\s*") +_TAXONOMY_ENTRY_RE = re.compile(r"\[IssueTaxonomy\.\w+\]: \{(.*?)\n \},", re.DOTALL) +_TAXONOMY_LABEL_RE = re.compile(r"label: t\('([^']+)'\)") +_TAXONOMY_KEY_RE = re.compile(r"key: '([^']+)'") +_TAXONOMY_FLAGS_RE = re.compile(r"featureFlags: (\w+)") + +_ARROW_PARAMS_RE = re.compile(r"^\([^)]*\)\s*=>\s*") +_FEATURE_CHECK_RE = re.compile( + r"""(?:organization\??\.)?features\??\.includes\(\s*['"]([^'"]+)['"]\s*\)""" +) +_ORG_ATTR_RE = re.compile(r"organization\??\.(\w+)") +_SCOPE_CHECK_RE = re.compile(r"""access\??\.has\(\s*['"]([^'"]+)['"]\s*\)""") +_DEPLOYMENT_VARS = { + "isSelfHosted": "self_hosted", + "isSelfHostedErrorsOnly": "self_hosted_errors_only", +} + +# (file, breadcrumb prefix) for settings nav configs, which are data objects rather +# than JSX: sections carry a `name` header and items a `path` and `title`. +_SETTINGS_NAV_CONFIGS = ( + ("views/settings/organization/userOrgNavigationConfiguration.tsx", ["Settings"]), + ( + "views/settings/project/navigationConfiguration.tsx", + ["Settings", "Organization", "Projects", PROJECT_PLACEHOLDER], + ), +) +# getsentry's settings sections live in static/gsApp, a separate tree from static/app; it +# appends them after the configs above (Usage & Billing). +_GSAPP_SETTINGS_NAV_CONFIGS = (("overrides/useBillingNavigationConfig.tsx", ["Settings"]),) +# Settings configs also declare path prefixes inside the builder function. +_LOCAL_STRING_CONST_RE = re.compile(r"""^\s*const (\w+) = ['"]([^'"]*)['"]""", re.MULTILINE) + +_NEVER: Condition = {"any": []} + + +def _flag(name: str) -> Condition: + return {"flag": name.removeprefix("organizations:")} + + +def _all_of(conds: list[Condition | None]) -> Condition | None: + unique: dict[str, Condition] = {} + for c in conds: + for part in c["all"] if c is not None and "all" in c else [c]: + if part is not None: + unique.setdefault(json.dumps(part, sort_keys=True), part) + parts = list(unique.values()) + if _NEVER in parts: + return _NEVER + return None if not parts else parts[0] if len(parts) == 1 else {"all": parts} + + +def _any_of(conds: list[Condition | None]) -> Condition | None: + if any(c is None for c in conds): + return None + flat = [ + p for c in conds if c is not None for p in (c["any"] if "any" in c and c != _NEVER else [c]) + ] + parts = [c for c in flat if c != _NEVER] + return _NEVER if not parts else parts[0] if len(parts) == 1 else {"any": parts} + + +def _negate(cond: Condition | None) -> Condition | None: + if cond is None: + return _NEVER + if cond == _NEVER: + return None + return {"unknown": f"!{cond['unknown']}"} if "unknown" in cond else {"not": cond} + + +# --- Minimal JS scanning: skip strings and comments, match brackets, split expressions. + + +def _skip_string(text: str, i: int) -> int: + quote = text[i] + i += 1 + while i < len(text): + c = text[i] + if c == "\\": + i += 2 + continue + if quote == "`" and text.startswith("${", i): + i = _match_bracket(text, i + 1) + continue + if c == quote: + return i + 1 + i += 1 + return i + + +def _match_bracket(text: str, i: int) -> int: + """Index just past the bracket matching text[i], skipping strings and comments.""" + pairs = {"{": "}", "[": "]", "(": ")"} + stack = [pairs[text[i]]] + i += 1 + while i < len(text) and stack: + c = text[i] + if c in "'\"`": + i = _skip_string(text, i) + continue + if text.startswith("//", i): + i = text.find("\n", i) if text.find("\n", i) != -1 else len(text) + continue + if text.startswith("/*", i): + end = text.find("*/", i) + i = len(text) if end == -1 else end + 2 + continue + if c in pairs: + stack.append(pairs[c]) + elif stack and c == stack[-1]: + stack.pop() + i += 1 + return i + + +def _strip_comments(text: str) -> str: + """Blank out comments, keeping offsets and newlines, so commented-out JSX isn't parsed.""" + out: list[str] = [] + i = 0 + while i < len(text): + c = text[i] + if c in "'\"`": + end = _skip_string(text, i) + out.append(text[i:end]) + i = end + continue + if text.startswith("//", i): + end = text.find("\n", i) + end = len(text) if end == -1 else end + elif text.startswith("/*", i): + end = text.find("*/", i) + end = len(text) if end == -1 else end + 2 + else: + out.append(c) + i += 1 + continue + out.append(re.sub(r"[^\n]", " ", text[i:end])) + i = end + return "".join(out) + + +def _read_source(root: str, rel: str) -> str | None: + path = Path(root, rel) + return _strip_comments(path.read_text()) if path.is_file() else None + + +def _split_top_level(body: str, separator: str = ",") -> list[str]: + """Split on `separator` wherever it is outside brackets and strings.""" + parts: list[str] = [] + start = i = 0 + while i < len(body): + c = body[i] + if c in "'\"`": + i = _skip_string(body, i) + continue + if c in "{[(": + i = _match_bracket(body, i) + continue + if body.startswith(separator, i): + parts.append(body[start:i]) + i += len(separator) + start = i + continue + i += 1 + parts.append(body[start:]) + return [p.strip() for p in parts if p.strip()] + + +def _expression_at(text: str, i: int) -> str: + """The JS expression starting at text[i], up to its top-level `,` or closing bracket.""" + start = i + while i < len(text): + c = text[i] + if c in "'\"`": + i = _skip_string(text, i) + continue + if text.startswith("//", i): + break + if c in "{[(": + i = _match_bracket(text, i) + continue + if c in ",}])": + break + i += 1 + return text[start:i].strip() + + +def _js_condition(expr: str) -> Condition | None: + """Translate a frontend visibility expression into a snapshot condition.""" + expr = expr.strip() + alternatives = _split_top_level(expr, "||") + if len(alternatives) > 1: + return _any_of([_js_condition(a) for a in alternatives]) + terms = _split_top_level(expr, "&&") + if len(terms) > 1: + return _all_of([_js_condition(t) for t in terms]) + if expr.startswith("(") and _match_bracket(expr, 0) == len(expr): + return _js_condition(expr[1:-1]) + if expr.startswith("!!"): + return _js_condition(expr[2:]) + if expr.startswith("!"): + return _negate(_js_condition(expr[1:])) + if expr in ("true", "organization"): + return None + if expr == "false": + return _NEVER + if expr in _DEPLOYMENT_VARS: + return {"deployment": _DEPLOYMENT_VARS[expr]} + if m := _FEATURE_CHECK_RE.fullmatch(expr): + return _flag(m.group(1)) + if m := _SCOPE_CHECK_RE.fullmatch(expr): + return {"scope": m.group(1)} + if m := _ORG_ATTR_RE.fullmatch(expr): + return {"org": m.group(1)} + if expr in _KNOWN_ALIASES: + return _js_condition(_KNOWN_ALIASES[expr]) + return {"unknown": expr} + + +def _imported_string_consts(frontend_dir: str, content: str) -> dict[str, str]: + """Resolve string constants a file imports (e.g. a route sub-path) from their source.""" + consts: dict[str, str] = {} + for names, module in _IMPORT_RE.findall(content): + wanted = {n.strip() for n in names.split(",") if n.strip()} + rel = module.removeprefix("sentry/") + for ext in (".ts", ".tsx"): + source = _read_source(frontend_dir, rel + ext) + if source is not None: + consts.update( + (name, value) + for name, value in _STRING_CONST_RE.findall(source) + if name in wanted + ) + break + return consts + + +def _org_relative_path(path: str) -> str | None: + """Turn a frontend route path into the path under the org's customer domain.""" + path = path.replace("/organizations/${organization.slug}", "").replace("/:orgId", "") + path = path.replace(":projectId", "{project_slug}") + if "${" in path or re.search(r"/:\w", path): + return None + return path + + +def _secondary_nav_entries(frontend_dir: str) -> list[NavEntry]: + entries: list[NavEntry] = [] + sections_root = os.path.join(frontend_dir, "views/navigation/secondary/sections") + if not os.path.isdir(sections_root): + return entries + for section_dir in sorted(os.listdir(sections_root)): + rel = f"views/navigation/secondary/sections/{section_dir}/{section_dir}SecondaryNavigation.tsx" + content = _read_source(frontend_dir, rel) + if content is None: + continue + header_match = _HEADER_RE.search(content) + base_match = _BASE_URL_RE.search(content) + if not header_match or not base_match: + continue + header = header_match.group(1) + consts = {"baseUrl": base_match.group(1), **_imported_string_consts(frontend_dir, content)} + flag_vars = { + var: f"organization.features.includes('{flag}')" + for var, flag in _FLAG_VAR_RE.findall(content) + } + + feature_stack: list[Condition | None] = [] + # (start, end, condition) of each `{cond && (...)}` block and each branch of a + # `{cond ? (...) : ...}`. Ends come from bracket matching, since a bare `)}` also ends + # every `{t('...')}` label. + cond_blocks: list[tuple[int, int, Condition | None]] = [] + section_stack: list[str | None] = [] + for m in _SECONDARY_NAV_TOKEN_RE.finditer(content): + if m.group("feature") is not None: + attr = _FEATURES_ATTR_RE.search(m.group("feature")) + flags = _QUOTED_RE.findall(attr.group(1)) if attr else [] + feature_stack.append(_all_of([_flag(f) for f in flags])) + elif m.group("feature_close") is not None: + if feature_stack: + feature_stack.pop() + elif m.group("cond") is not None: + expr = m.group("cond").strip() + for var, check in flag_vars.items(): + expr = re.sub(rf"\b{var}\b", check, expr) + cond = _js_condition(expr) + then_end = _match_bracket(content, m.end() - 1) + cond_blocks.append((m.start(), then_end, cond)) + if m.group("cond_op") == "?": + # The else branch runs from the first branch to the `}` closing the expression. + else_end = _match_bracket(content, m.start()) + cond_blocks.append((then_end, else_end, _negate(cond))) + elif m.group("section") is not None: + title = _TITLE_ATTR_RE.search(m.group("section")) + section_stack.append(title.group(1) if title else None) + elif m.group("section_close") is not None: + if section_stack: + section_stack.pop() + elif m.group("link") is not None: + body = m.group("link") + to_match = _LINK_TO_RE.search(body) + labels = _LINK_LABEL_RE.findall(body) + if not to_match or not labels: + continue + path = _TEMPLATE_REF_RE.sub( + lambda r: consts.get(r.group(1), r.group(0)), + to_match.group(1) or to_match.group(2), + ) + org_path = _org_relative_path(path) + if org_path is None: + continue + crumbs = [header, *(s for s in section_stack if s), labels[-1]] + entries.append( + NavEntry( + crumbs=CRUMB_SEPARATOR.join(crumbs), + path=org_path, + when=_all_of( + [ + *feature_stack, + *(c for start, end, c in cond_blocks if start < m.start() < end), + ] + ), + ) + ) + return entries + + +def _issue_taxonomy_entries(frontend_dir: str) -> list[NavEntry]: + """Issue type nav items are rendered from ISSUE_TAXONOMY_CONFIG, not literal JSX links.""" + content = _read_source(frontend_dir, "views/issueList/taxonomies.tsx") + if content is None: + return [] + entries: list[NavEntry] = [] + for body in _TAXONOMY_ENTRY_RE.findall(content): + label = _TAXONOMY_LABEL_RE.search(body) + key = _TAXONOMY_KEY_RE.search(body) + if not label or not key: + continue + when = None + flags_ref = _TAXONOMY_FLAGS_RE.search(body) + if flags_ref: + array = re.search(rf"const {flags_ref.group(1)}[^=]*= \[(.*?)\]", content, re.DOTALL) + flags = _QUOTED_RE.findall(array.group(1)) if array else [] + # A taxonomy shows when any of its flags is on. + when = _any_of([_flag(f) for f in flags]) if flags else None + entries.append( + NavEntry( + crumbs=f"Issues{CRUMB_SEPARATOR}{label.group(1)}", + path=f"/issues/{key.group(1)}/", + when=when, + ) + ) + return entries + + +def _settings_item_condition(segment: str) -> Condition | None: + show = _SETTINGS_SHOW_RE.search(segment) + if not show: + return None + expr = _ARROW_PARAMS_RE.sub("", _expression_at(segment, show.end())) + return _js_condition(expr) + + +def _settings_nav_entries(frontend_dir: str, gsapp_dir: str | None = None) -> list[NavEntry]: + entries: list[NavEntry] = [] + configs = [(frontend_dir, rel, prefix) for rel, prefix in _SETTINGS_NAV_CONFIGS] + if gsapp_dir: + configs += [(gsapp_dir, rel, prefix) for rel, prefix in _GSAPP_SETTINGS_NAV_CONFIGS] + for root, rel, prefix in configs: + content = _read_source(root, rel) + if content is None: + continue + file_consts = dict(_STRING_CONST_RE.findall(content)) + for function_body in _SETTINGS_FUNCTION_SPLIT_RE.split(content)[1:]: + consts = {**file_consts, **dict(_LOCAL_STRING_CONST_RE.findall(function_body))} + names = [ + (m.start(), m.group(1)) for m in _SETTINGS_SECTION_NAME_RE.finditer(function_body) + ] + if not names: + continue + items = list(_SETTINGS_ITEM_RE.finditer(function_body)) + for i, item in enumerate(items): + end = items[i + 1].start() if i + 1 < len(items) else len(function_body) + segment = function_body[item.start() : end] + # A title can depend on a flag (`settingsV3 ? t('General') : t('...')`); + # emit one entry per label, each under its branch's condition. + titles: list[tuple[str, Condition | None]] + if ternary := _SETTINGS_TERNARY_TITLE_RE.search(segment): + branch = _js_condition(ternary.group(1)) + titles = [(ternary.group(2), branch), (ternary.group(3), _negate(branch))] + elif title := _SETTINGS_TITLE_RE.search(segment): + titles = [(title.group(1), None)] + else: + continue + when = _settings_item_condition(segment) + if when == _NEVER: + continue + # Items belong to the nearest section header before them; a section that + # builds its items array first (Developer Settings) has its header after. + before = [n for pos, n in names if pos < item.start()] + section = before[-1] if before else names[0][1] + path = _TEMPLATE_REF_RE.sub( + lambda r: consts.get(r.group(1), r.group(0)), item.group(1) + ) + org_path = _org_relative_path(path) + if org_path is None: + continue + for label, label_when in titles: + entries.append( + NavEntry( + crumbs=CRUMB_SEPARATOR.join([*prefix, section, label]), + path=org_path, + when=_all_of([when, label_when]), + ) + ) + return entries + + +# --- Sub-pages (create forms, detail tabs) aren't nav items, but every one has a route in +# router/routes.tsx, usually with a `name` label. Parse the route tree and emit each nav +# page's direct children. + +_ROUTE_CONST_RE = re.compile(r"\bconst (\w+)(?:\s*:\s*[^=;]+?)?\s*=\s*(?=[\[{])") +# Route params the URL can be filled in for: (route param, URL placeholder, breadcrumb label). +_ROUTE_PARAMS = ( + (":projectId", "{project_slug}", PROJECT_PLACEHOLDER), + (":teamId", "{team_slug}", TEAM_PLACEHOLDER), +) +_PARAM_PLACEHOLDER_LABELS = {placeholder: label for _, placeholder, label in _ROUTE_PARAMS} + + +def _parse_route_tree(content: str) -> list[tuple[str, str | None]]: + """Return (full route path, route name) for every route reachable from an absolute path.""" + consts: dict[str, str] = {} + for m in _ROUTE_CONST_RE.finditer(content): + start = m.end() + consts.setdefault(m.group(1), content[start : _match_bracket(content, start)]) + + def entries(value: str, depth: int) -> list[str]: + value = value.strip().removesuffix("!") + if depth > 40: + return [] + if value.startswith("["): + out: list[str] = [] + for item in _split_top_level(value[1:-1]): + out.extend(entries(item.removeprefix("..."), depth + 1)) + return out + if value.startswith("{"): + return [value] + ref = value.split(".")[0] + if ref in consts: + target = consts[ref] + if value.endswith(".children") and target.startswith("{"): + children = _object_field(target, "children") + return entries(children, depth + 1) if children else [] + return entries(target, depth + 1) + return [] + + results: list[tuple[str, str | None]] = [] + + def walk(obj: str, prefix: str, depth: int) -> None: + if depth > 40: + return + path = _object_string_field(obj, "path") + # A computed path's URL is unknown; skip its subtree rather than guess a parent's URL. + if _object_field(obj, "redirectTo") is not None or ( + path is None and _object_field(obj, "path") is not None + ): + return + full = prefix + if path is not None: + full = path if path.startswith("/") else prefix.rstrip("/") + "/" + path + name_value = _object_field(obj, "name") + name = _LINK_LABEL_RE.search("{" + name_value + "}") if name_value else None + results.append((full, name.group(1) if name else None)) + children = _object_field(obj, "children") + if children: + for child in entries(children, depth + 1): + walk(child, full, depth + 1) + + for obj_text in consts.values(): + for obj in entries(obj_text, 0): + if (_object_string_field(obj, "path") or "").startswith("/"): + walk(obj, "", 0) + return results + + +def _object_field(obj: str, key: str) -> str | None: + for part in _split_top_level(obj.strip()[1:-1]): + if part.startswith(f"{key}:"): + return part[len(key) + 1 :].strip() + return None + + +def _object_string_field(obj: str, key: str) -> str | None: + value = _object_field(obj, key) + if value and value[0] in "'\"`" and "${" not in value: + return value[1:-1] + return None + + +def _routes(frontend_dir: str) -> list[tuple[str, str | None]]: + content = _read_source(frontend_dir, "router/routes.tsx") + if content is None: + return [] + # Feature route trees (e.g. detectorRoutes) live in their own `*/routes` modules. + for _names, module in _IMPORT_RE.findall(content): + if module.endswith("/routes"): + rel = module.removeprefix("sentry/") + for ext in (".tsx", ".ts"): + extra = _read_source(frontend_dir, rel + ext) + if extra is not None: + content += "\n" + extra + break + return _parse_route_tree(content) + + +def _humanize_segment(segment: str) -> str: + words = segment.replace("-", " ").split() + if words and words[0] == "new": + return "Create New" + ("".join(f" {w.capitalize()}" for w in words[1:])) + return " ".join(w.capitalize() for w in words) + + +def _sub_page_entries(frontend_dir: str, pages: list[NavEntry]) -> list[NavEntry]: + """Direct child routes of each nav page; they inherit the page's visibility.""" + entries: list[NavEntry] = [] + nav_paths = {p.path for p in pages} + seen_paths: set[str] = set() + routes = _routes(frontend_dir) + for page in pages: + # Other nav pages live under a section root (/settings/, /issues/), so its child + # routes are mostly sibling or legacy pages; keep only its create forms. + is_section_root = any(p != page.path and p.startswith(page.path) for p in nav_paths) + for route_path, name in routes: + rel = route_path.replace("/organizations/:orgId", "").replace("/:orgId", "") + for param, placeholder, _ in _ROUTE_PARAMS: + rel = rel.replace(param, placeholder) + rel = rel if rel.endswith("/") else rel + "/" + if not rel.startswith(page.path) or rel in nav_paths or rel in seen_paths: + continue + if is_section_root and not rel[len(page.path) :].startswith("new"): + continue + segments = rel[len(page.path) :].strip("/").split("/") + extra_crumbs: list[str] = [] + url_segments: list[str] = [] + if len(segments) == 2 and segments[0] in _PARAM_PLACEHOLDER_LABELS: + extra_crumbs.append(_PARAM_PLACEHOLDER_LABELS[segments[0]]) + url_segments.append(segments[0]) + segments = segments[1:] + if len(segments) != 1 or segments[0].startswith((":", "{")): + continue + seen_paths.add(rel) + url_segments.append(segments[0]) + leaf = name or _humanize_segment(segments[0]) + entries.append( + NavEntry( + crumbs=CRUMB_SEPARATOR.join([page.crumbs, *extra_crumbs, leaf]), + path=page.path + "/".join(url_segments) + "/", + when=page.when, + sub_page=True, + ) + ) + return entries + + +def _code_hash(source: str) -> str: + return hashlib.sha256(" ".join(_strip_comments(source).split()).encode()).hexdigest()[:16] + + +def check_alias_sources(frontend_dir: str) -> None: + """Fail if a file an alias in `_KNOWN_ALIASES` was copied from has changed or moved.""" + for rel, expected in _ALIAS_SOURCES.items(): + path = os.path.join(frontend_dir, rel) + if not os.path.isfile(path) or _code_hash(Path(path).read_text()) != expected: + raise SystemExit( + f"{rel} changed or moved since its nav alias was written. Update the alias in " + "_KNOWN_ALIASES to match it, then the hash in _ALIAS_SOURCES." + ) + + +def parse_nav_entries(frontend_dir: str, gsapp_dir: str | None = None) -> list[NavEntry]: + """Every nav destination in the frontend source, with its visibility conditions. + + `gsapp_dir` is getsentry's `static/gsApp` tree, which adds SaaS-only settings pages. + """ + pages = [ + *_secondary_nav_entries(frontend_dir), + *_issue_taxonomy_entries(frontend_dir), + *_settings_nav_entries(frontend_dir, gsapp_dir), + ] + seen: set[str] = set() + unique: list[NavEntry] = [] + for entry in [*pages, *_sub_page_entries(frontend_dir, pages)]: + key = json.dumps(entry.to_dict(), sort_keys=True) + if key not in seen: + seen.add(key) + unique.append(entry) + return unique