From 91bb2f42576983e6527bf6e24c77ea1f124b4cf2 Mon Sep 17 00:00:00 2001 From: Rachael Rose Renk <91027132+rachaelrenk@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:54:31 -0600 Subject: [PATCH 1/3] feat: automate valid_paths.json refresh on Settings UI changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the automation gap described in the validate_ui_refs skill: the valid_paths.json snapshot now refreshes automatically whenever app/src/settings_view/ changes in warp-internal, rather than requiring manual intervention. Changes: - Add .github/workflows/refresh-ui-paths.yml — triggered by repository_dispatch (settings-ui-changed) from warp-internal or workflow_dispatch (manual fallback). Checks out both repos, runs --refresh-valid-paths, and if the snapshot changed runs --all --fix --slack-notify and opens a PR with the updated snapshot + any doc fixes. - Sync valid_paths.json from gitbook to docs (was Feb 2026, now Apr 2026, including the Agents/Code/Cloud platform umbrella migration). - Update SKILL.md: remove 'not yet implemented' caveat, replace the Cloud Agent / Scheduling section with documentation of the new workflow and the two required secrets (WARP_INTERNAL_READ_PAT, SLACK_BOT_TOKEN). Requires two new secrets (see SKILL.md for details): - WARP_INTERNAL_READ_PAT in docs (Contents read on warp-internal) - SLACK_BOT_TOKEN in docs (chat:write scope) - DOCS_DISPATCH_PAT in warp-internal (PR opened separately) Co-Authored-By: Oz --- .agents/skills/validate_ui_refs/SKILL.md | 61 +++--- .../skills/validate_ui_refs/valid_paths.json | 185 +++++++++++++++--- .github/workflows/refresh-ui-paths.yml | 128 ++++++++++++ 3 files changed, 317 insertions(+), 57 deletions(-) create mode 100644 .github/workflows/refresh-ui-paths.yml diff --git a/.agents/skills/validate_ui_refs/SKILL.md b/.agents/skills/validate_ui_refs/SKILL.md index 2006f1da7..d55010680 100644 --- a/.agents/skills/validate_ui_refs/SKILL.md +++ b/.agents/skills/validate_ui_refs/SKILL.md @@ -127,51 +127,52 @@ Fixes that require manual review (e.g. renamed sections, removed features) are r ## Slack Notifications -Slack notifications are designed for scheduled/automated runs, not ad-hoc usage. When running the skill manually (e.g., during a PR review or docs update), you can review results directly in the terminal output. +Slack notifications are designed for automated runs, not ad-hoc usage. When running the skill manually, review results directly in the terminal output. -### Current behavior +The `--slack-notify` flag posts a summary to `#growth-docs` when unfixed issues remain after a run. If the scan is clean (0 issues), no notification is sent. -The `--slack-notify` flag posts a summary to the configured Slack channel when unfixed issues remain after a run. If the scan is clean (0 issues), no notification is sent. - -### Intended behavior for scheduled runs - -When this skill is configured as a scheduled cloud agent, Slack notifications should alert the team in two cases: +### Setup (one-time) -1. **Auto-fixes applied** — the script found and corrected issues, and created a PR. The notification should include the PR link so the team can review and merge. -2. **Unfixed issues remain** — some issues could not be auto-corrected (e.g., a renamed or removed section) and require manual attention. The notification should list these for triage. +Add `SLACK_BOT_TOKEN` as a repository secret in `warpdotdev/docs` (**Settings** > **Secrets and variables** > **Actions**). The token needs `chat:write` scope. -If a scheduled run finds no issues at all, the notification should be skipped (no noise). +### Usage -> **Note:** This two-condition notification logic is not yet implemented. The current `--slack-notify` flag only covers condition 2 (unfixed issues). When we set up scheduled runs, the script should be updated to also notify on condition 1 (auto-fixes with PR link). +```bash +python3 .agents/skills/validate_ui_refs/validate_ui_refs.py --all --slack-notify +``` -### Setup (one-time) +## Automated refresh -Create a Warp team secret for the Slack bot token: +`valid_paths.json` is refreshed automatically via the `refresh-ui-paths` GitHub Actions workflow (`.github/workflows/refresh-ui-paths.yml`). -```bash -oz secret create SLACK_BOT_TOKEN --team --description "Slack bot token for UI ref validation reports" -``` +### How it works -The token needs `chat:write` scope. +1. A push to `master` in `warpdotdev/warp-internal` that touches `app/src/settings_view/**` sends a `repository_dispatch` event (`settings-ui-changed`) to `warpdotdev/docs`. +2. The `refresh-ui-paths` workflow fires, checks out both repos, and runs `--refresh-valid-paths`. +3. If the snapshot changed, it runs `--all --fix --slack-notify`, commits all changes (updated snapshot + any doc fixes), and opens a PR. +4. If unfixed issues remain, a notification is posted to `#growth-docs`. +5. If the snapshot is unchanged, the workflow exits with no-op. -### Usage +### Secrets required -```bash -python3 .warp/skills/validate_ui_refs/validate_ui_refs.py --all --slack-notify -``` +| Secret | Repo | Purpose | +|---|---|---| +| `DOCS_DISPATCH_PAT` | `warp-internal` | Fine-grained PAT — Actions write + Contents read on `warpdotdev/docs` | +| `WARP_INTERNAL_READ_PAT` | `docs` | Fine-grained PAT — Contents read on `warpdotdev/warp-internal` | +| `SLACK_BOT_TOKEN` | `docs` | Slack bot token with `chat:write` scope | -## Cloud Agent / Scheduling +### Manual trigger -For scheduled cloud agent runs: +To trigger the workflow manually (e.g., if the PAT expired or a migration was missed): -1. Configure the environment with the docs repo -2. Keep `valid_paths.json` up-to-date by running `--refresh-valid-paths` as a pre-step (requires `warp-internal` in the environment) -3. Set the `SLACK_BOT_TOKEN` secret in the environment -4. Run: `python3 .warp/skills/validate_ui_refs/validate_ui_refs.py --all --fix --create-pr --slack-notify` +1. In the `warpdotdev/docs` repo, go to **Actions** > **Refresh UI paths snapshot** > **Run workflow**. +2. Or run locally: -A typical scheduled agent would: -1. Run `--refresh-valid-paths` to update the snapshot -2. Run `--all --fix --create-pr --slack-notify` to check, fix, and report +```bash +python3 .agents/skills/validate_ui_refs/validate_ui_refs.py \ + --refresh-valid-paths \ + --warp-internal-path /path/to/warp-internal +``` ## Dependencies diff --git a/.agents/skills/validate_ui_refs/valid_paths.json b/.agents/skills/validate_ui_refs/valid_paths.json index fd0166044..3d4fb714f 100644 --- a/.agents/skills/validate_ui_refs/valid_paths.json +++ b/.agents/skills/validate_ui_refs/valid_paths.json @@ -1,4 +1,76 @@ { + "umbrellas": { + "Agents": { + "subpages": [ + "Oz", + "Profiles", + "MCP servers", + "Knowledge", + "Third party CLI agents" + ], + "source_file": "app/src/settings_view/mod.rs" + }, + "Code": { + "subpages": [ + "Indexing and projects", + "Editor and Code Review" + ], + "source_file": "app/src/settings_view/mod.rs" + }, + "Cloud platform": { + "subpages": [ + "Environments", + "Oz Cloud API Keys" + ], + "source_file": "app/src/settings_view/mod.rs" + } + }, + "deprecated_sections": { + "AI": { + "umbrella": "Agents", + "default_subpage": "Oz", + "subsection_to_subpage": { + "Active AI": "Oz", + "Input": "Oz", + "Voice": "Oz", + "Other": "Oz", + "Experimental": "Oz", + "Usage": "Profiles", + "Agents": "Profiles", + "Profiles": "Profiles", + "Permissions": "Profiles", + "Command allowlist": "Profiles", + "Command denylist": "Profiles", + "MCP Servers": "MCP servers", + "MCP servers": "MCP servers", + "Knowledge": "Knowledge", + "CLI Agents": "Third party CLI agents", + "Coding Agents": "Third party CLI agents", + "Third party CLI agents": "Third party CLI agents" + }, + "subsection_aliases": [ + "Agents", + "Profiles", + "MCP Servers", + "MCP servers", + "Knowledge", + "CLI Agents", + "Third party CLI agents" + ] + }, + "MCP Servers": { + "umbrella": "Agents", + "default_subpage": "MCP servers" + }, + "Environments": { + "umbrella": "Cloud platform", + "default_subpage": "Environments" + }, + "Platform": { + "umbrella": "Cloud platform", + "default_subpage": "Oz Cloud API Keys" + } + }, "settings_sections": { "About": { "display_name": "About", @@ -10,38 +82,85 @@ "sub_sections": [], "source_file": "app/src/settings_view/mod.rs" }, - "AI": { - "display_name": "AI", + "Oz": { + "display_name": "Oz", + "umbrella": "Agents", "sub_sections": [ - "Usage", "Active AI", - "Agents", "Input", - "MCP Servers", - "Knowledge", "Voice", - "CLI Agents", - "Experimental", - "API Keys", - "AWS Bedrock" + "Other", + "Experimental" ], "source_file": "app/src/settings_view/ai_page.rs" }, - "Environments": { - "display_name": "Environments", + "Profiles": { + "display_name": "Profiles", + "umbrella": "Agents", + "sub_sections": [ + "Usage", + "Profiles", + "Agents", + "Permissions", + "Command allowlist", + "Command denylist", + "MCP permissions" + ], + "source_file": "app/src/settings_view/ai_page.rs" + }, + "MCP servers": { + "display_name": "MCP servers", + "umbrella": "Agents", "sub_sections": [], - "source_file": "app/src/settings_view/mod.rs" + "source_file": "app/src/settings_view/mcp_servers_page.rs" }, - "MCP Servers": { - "display_name": "MCP Servers", + "Knowledge": { + "display_name": "Knowledge", + "umbrella": "Agents", "sub_sections": [], - "source_file": "app/src/settings_view/mod.rs" + "source_file": "app/src/settings_view/ai_page.rs" + }, + "Third party CLI agents": { + "display_name": "Third party CLI agents", + "umbrella": "Agents", + "sub_sections": [], + "source_file": "app/src/settings_view/ai_page.rs" }, "Billing and usage": { "display_name": "Billing and usage", "sub_sections": [], "source_file": "app/src/settings_view/billing_and_usage_page.rs" }, + "Indexing and projects": { + "display_name": "Indexing and projects", + "umbrella": "Code", + "sub_sections": [ + "Codebase Indexing" + ], + "source_file": "app/src/settings_view/code_page.rs" + }, + "Editor and Code Review": { + "display_name": "Editor and Code Review", + "umbrella": "Code", + "sub_sections": [ + "Code Editor and Review" + ], + "source_file": "app/src/settings_view/code_page.rs" + }, + "Environments": { + "display_name": "Environments", + "umbrella": "Cloud platform", + "sub_sections": [], + "source_file": "app/src/settings_view/environments_page.rs" + }, + "Oz Cloud API Keys": { + "display_name": "Oz Cloud API Keys", + "umbrella": "Cloud platform", + "sub_sections": [ + "API Keys" + ], + "source_file": "app/src/settings_view/platform_page.rs" + }, "Appearance": { "display_name": "Appearance", "sub_sections": [ @@ -58,11 +177,6 @@ ], "source_file": "app/src/settings_view/appearance_page.rs" }, - "Code": { - "display_name": "Code", - "sub_sections": [], - "source_file": "app/src/settings_view/code_page.rs" - }, "Features": { "display_name": "Features", "sub_sections": [ @@ -83,11 +197,6 @@ "sub_sections": [], "source_file": "app/src/settings_view/mod.rs" }, - "Platform": { - "display_name": "Platform", - "sub_sections": [], - "source_file": "app/src/settings_view/platform_page.rs" - }, "Privacy": { "display_name": "Privacy", "sub_sections": [], @@ -108,6 +217,11 @@ "sub_sections": [], "source_file": "app/src/settings_view/teams_page.rs" }, + "Warp Drive": { + "display_name": "Warp Drive", + "sub_sections": [], + "source_file": "app/src/settings_view/warp_drive_page.rs" + }, "Warpify": { "display_name": "Warpify", "sub_sections": [ @@ -117,6 +231,23 @@ "source_file": "app/src/settings_view/warpify_page.rs" } }, + "top_level_sidebar": [ + "Account", + "Agents", + "Billing and usage", + "Code", + "Cloud platform", + "Teams", + "Appearance", + "Features", + "Keyboard shortcuts", + "Warpify", + "Referrals", + "Shared blocks", + "Warp Drive", + "Privacy", + "About" + ], "macos_menu_bar": { "File": [ "New Tab", @@ -313,5 +444,5 @@ {"name": "input:toggle_vim_keybindings", "description": "Enable Editing Commands With Vim Keybindings"}, {"name": "workspace:share_pane", "description": "Share Pane"} ], - "generated_at": "2026-02-19T20:37:00Z" + "generated_at": "2026-04-23T23:55:00Z" } diff --git a/.github/workflows/refresh-ui-paths.yml b/.github/workflows/refresh-ui-paths.yml new file mode 100644 index 000000000..727631443 --- /dev/null +++ b/.github/workflows/refresh-ui-paths.yml @@ -0,0 +1,128 @@ +name: Refresh UI paths snapshot + +# Triggered automatically when warp-internal's Settings UI files change +# (via repository_dispatch from warpdotdev/warp-internal) or manually +# via workflow_dispatch as a fallback. +on: + repository_dispatch: + types: + - settings-ui-changed + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: refresh-ui-paths + cancel-in-progress: true + +jobs: + refresh: + name: Refresh snapshot and validate docs + runs-on: ubuntu-latest + steps: + - name: Checkout docs + uses: actions/checkout@v4 + + - name: Checkout warp-internal + uses: actions/checkout@v4 + with: + repository: warpdotdev/warp-internal + token: ${{ secrets.WARP_INTERNAL_READ_PAT }} + path: warp-internal + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Python dependencies + run: pip install requests + + - name: Refresh valid_paths.json + run: | + python3 .agents/skills/validate_ui_refs/validate_ui_refs.py \ + --refresh-valid-paths \ + --warp-internal-path ./warp-internal + + - name: Check if snapshot changed + id: snapshot + run: | + if git diff --quiet .agents/skills/validate_ui_refs/valid_paths.json; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "Snapshot unchanged — Settings structure matches the existing snapshot. Nothing to do." + else + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "Snapshot changed — running validation and auto-fix." + git diff --stat .agents/skills/validate_ui_refs/valid_paths.json + fi + + - name: Run validation and auto-fix + if: steps.snapshot.outputs.changed == 'true' + id: validate + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + run: | + python3 .agents/skills/validate_ui_refs/validate_ui_refs.py \ + --all --fix --slack-notify \ + --output /tmp/ui-ref-report.json + + # Surface fix counts for the commit message + python3 - <<'PY' + import json, os + try: + d = json.load(open('/tmp/ui-ref-report.json')) + fixes = len(d.get('fixes_applied', [])) + paths = len(d.get('path_issues', [])) + cmds = len(d.get('command_issues', [])) + summary = f"{fixes} fix(es) applied; {paths} path issue(s) and {cmds} command issue(s) remaining" + except Exception: + summary = "snapshot refreshed" + with open(os.environ['GITHUB_OUTPUT'], 'a') as f: + f.write(f"summary={summary}\n") + PY + + - name: Create PR + if: steps.snapshot.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BRANCH="fix/ui-ref-refresh-$(date -u +%Y%m%d-%H%M%S)" + git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "github-actions[bot]" + git checkout -b "$BRANCH" + git add -A + + # Only create a PR if there are actual changes to commit + if git diff --cached --quiet; then + echo "No changes to commit after validation — exiting." + exit 0 + fi + + SUMMARY="${{ steps.validate.outputs.summary }}" + git commit -m "docs: refresh UI paths snapshot and fix reference issues + + Automatically triggered by a Settings UI change in warp-internal. + ${SUMMARY} + + Co-Authored-By: Oz " + + git push -u origin "$BRANCH" + + gh pr create \ + --title "docs: refresh UI paths snapshot" \ + --body "Automatically generated by the \`refresh-ui-paths\` workflow after a change to \`app/src/settings_view/\` in \`warp-internal\`. + + ## What changed + \`valid_paths.json\` was refreshed to reflect the latest Settings structure from \`warp-internal\` source. Any auto-fixable doc issues (case mismatches, deprecated section paths, non-canonical formatting) were also corrected. + + ## ${SUMMARY} + + If unfixed issues remain, a notification was posted to \`#growth-docs\` with details. + + --- + *To run this workflow manually: **Actions** > **Refresh UI paths snapshot** > **Run workflow**.* + + Co-Authored-By: Oz " From a122c4991032f6510f77b1648a0720506cd3b72c Mon Sep 17 00:00:00 2001 From: Rachael Rose Renk <91027132+rachaelrenk@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:55:46 -0600 Subject: [PATCH 2/3] fix: address oz-review blockers in refresh-ui-paths workflow Five issues flagged by oz-for-oss[bot] review: 1. validate_ui_refs.py was the Feb 2026 version (pre-umbrella migration), missing umbrella-aware refresh_valid_paths(), _extract_umbrellas(), deprecated_section fix type, and _WARP_SETTINGS_ROOT_SECTIONS guard. Replaced with the April 2026 gitbook version, updated for Astro: - DEFAULT_DOCS_DIR -> src/content/docs/ (not docs/) - scan_docs() now finds *.md and *.mdx 2. generated_at is rewritten on every --refresh-valid-paths run, causing git diff to always report changed=true. Now saves normalized JSON (without generated_at) before and after refresh and diffs those. 3. --slack-notify called without a channel ID. DEFAULT_SLACK_CHANNEL in the docs script reads GROWTH_DOCS_SLACK_CHANNEL_ID env var (defaults to empty). Added explicit --slack-channel C09BVK0PL3Y to the command. 4. validate_ui_refs.py --all --fix exits 1 when unfixed issues remain, aborting the step before the summary output or PR creation. Added || true so the step always continues. 5. warp-internal was checked out at ./warp-internal inside the docs working directory; git add -A would stage it as a nested submodule. Moved checkout to ${{ runner.tem Moved checkout to ${{ runner.tem Moved checkout to ${{ runner Co-Authored-By: Oz --- .../validate_ui_refs/validate_ui_refs.py | 618 +++++++++++++++++- .github/workflows/refresh-ui-paths.yml | 40 +- 2 files changed, 634 insertions(+), 24 deletions(-) diff --git a/.agents/skills/validate_ui_refs/validate_ui_refs.py b/.agents/skills/validate_ui_refs/validate_ui_refs.py index 972324ff9..dd6078d0c 100644 --- a/.agents/skills/validate_ui_refs/validate_ui_refs.py +++ b/.agents/skills/validate_ui_refs/validate_ui_refs.py @@ -32,8 +32,8 @@ SCRIPT_DIR = Path(__file__).resolve().parent DEFAULT_VALID_PATHS_FILE = SCRIPT_DIR / "valid_paths.json" -DEFAULT_DOCS_DIR = SCRIPT_DIR.parents[2] / "docs" -DEFAULT_SLACK_CHANNEL = os.environ.get("GROWTH_DOCS_SLACK_CHANNEL_ID", "") +DEFAULT_DOCS_DIR = SCRIPT_DIR.parents[2] / "src" / "content" / "docs" +DEFAULT_SLACK_CHANNEL = "C09BVK0PL3Y" # #growth-docs # Known Warp UI roots — paths starting with these are Warp UI paths WARP_UI_ROOTS = {"Settings", "File", "View", "Warp", "Warp Drive", "Personal"} @@ -129,15 +129,43 @@ def _to_canonical_format(normalized: str) -> str: return " > ".join(f"**{seg}**" for seg in segments) +# Warp Settings sections that unambiguously identify a path as Warp's UI +# (not an external product's settings), even when the surrounding line mentions +# external products like GitHub / Slack / Linear. Matched against segments[1] +# after stripping formatting. +_WARP_SETTINGS_ROOT_SECTIONS = { + "About", "Account", "Agents", "Billing and usage", "Code", + "Cloud platform", "Teams", "Appearance", "Features", + "Keyboard shortcuts", "Warpify", "Referrals", "Shared blocks", + "Warp Drive", "Privacy", + # Deprecated-but-unambiguously-Warp top-level labels the validator still + # recognizes in order to auto-migrate them: + "AI", "MCP Servers", "Environments", "Platform", "Keybindings", +} + + def _is_external_path(path: str, line: str) -> bool: """Check if a path belongs to an external product, not Warp.""" - root = path.split(">")[0].strip() + segments = [s.strip() for s in path.split(">")] + root = segments[0] if root in EXTERNAL_ROOTS: return True # Check against known external Settings paths for ext_path in EXTERNAL_SETTINGS_PATHS: if path.startswith(ext_path): return True + # If the Settings path's second segment is a recognized Warp section/umbrella + # (or a known deprecated one), the external-product-mention bail-out below + # should not fire — the path is unambiguously Warp's UI. This keeps sentences + # like "Navigate to **Settings** > **MCP Servers** to get started. Some + # integrations (like Linear, GitHub, and Sentry) are available..." from being + # silently skipped. + if ( + root == "Settings" + and len(segments) >= 2 + and segments[1] in _WARP_SETTINGS_ROOT_SECTIONS + ): + return False # Check surrounding line for external product keywords line_lower = line.lower() for kw in EXTERNAL_CONTEXT_KEYWORDS: @@ -364,6 +392,79 @@ def _best_fuzzy_match(needle: str, haystack: List[str]) -> Tuple[Optional[str], return best_match, best_score +def _suggest_migration_for_deprecated_section( + segments: List[str], + deprecated: Dict[str, Any], + umbrellas: Dict[str, Any], +) -> Optional[Dict[str, Any]]: + """If segments[1] is a deprecated section, return an auto-fix suggestion. + + Handles patterns like: + Settings > AI > Input -> Settings > Agents > Oz > Input + Settings > AI > Knowledge -> Settings > Agents > Knowledge + Settings > Platform -> Settings > Cloud platform > Oz Cloud API Keys + Settings > Environments -> Settings > Cloud platform > Environments + Settings > MCP Servers -> Settings > Agents > MCP servers + """ + if len(segments) < 2: + return None + old_section = segments[1] + info = deprecated.get(old_section) + if not info: + return None + umbrella = info["umbrella"] + default_subpage = info["default_subpage"] + subsection_map = info.get("subsection_to_subpage", {}) + + if len(segments) == 2: + # Settings > Platform -> Settings > Cloud platform > Oz Cloud API Keys + new_path = ["Settings", umbrella, default_subpage] + return { + "valid": False, + "issue": ( + f"\"{old_section}\" has moved under the \"{umbrella}\" umbrella; " + f"use \"{default_subpage}\"" + ), + "suggestion": " > ".join(new_path), + "confidence": 0.95, + "fix_type": "deprecated_section", + } + + # len(segments) >= 3 — check if segments[2] routes to a specific subpage + old_sub = segments[2] + remaining = segments[3:] + mapped_subpage = subsection_map.get(old_sub, default_subpage) + aliases = info.get("subsection_aliases", []) + + # If old_sub is an alias for a subpage name (e.g. "AI > Knowledge" where + # "Knowledge" is now a subpage under Agents, or "AI > Agents" where "Agents" + # was the old label for the "Profiles" subpage), drop it. Otherwise treat + # old_sub as a sub-section header that should remain at its deeper level. + if old_sub in aliases or old_sub == mapped_subpage: + # Also strip any leading alias segments from `remaining` — they come from + # paths like `AI > Agents > Profiles` where both `Agents` and `Profiles` + # redundantly refer to the new `Profiles` subpage; drop them so we get + # `Agents > Profiles` instead of `Agents > Profiles > Profiles`. + while remaining and ( + remaining[0] in aliases + or remaining[0] == mapped_subpage + ): + remaining = remaining[1:] + new_path = ["Settings", umbrella, mapped_subpage] + remaining + else: + new_path = ["Settings", umbrella, mapped_subpage, old_sub] + remaining + + return { + "valid": False, + "issue": ( + f"\"Settings > {old_section}\" has moved under the \"{umbrella}\" umbrella" + ), + "suggestion": " > ".join(new_path), + "confidence": 0.95, + "fix_type": "deprecated_section", + } + + def validate_ui_path(path: str, valid_paths: Dict[str, Any]) -> Dict[str, Any]: """Validate a single UI path against valid_paths data. @@ -373,6 +474,8 @@ def validate_ui_path(path: str, valid_paths: Dict[str, Any]) -> Dict[str, Any]: root = segments[0] settings = valid_paths.get("settings_sections", {}) + umbrellas = valid_paths.get("umbrellas", {}) + deprecated = valid_paths.get("deprecated_sections", {}) menu_bar = valid_paths.get("macos_menu_bar", {}) warp_drive = valid_paths.get("warp_drive", {}) @@ -381,6 +484,124 @@ def validate_ui_path(path: str, valid_paths: Dict[str, Any]) -> Dict[str, Any]: section = segments[1] section_names = list(settings.keys()) + # Flag deprecated top-level section names BEFORE fuzzy matching + # so we suggest the umbrella-based replacement. + if section in deprecated: + migration = _suggest_migration_for_deprecated_section( + segments, deprecated, umbrellas + ) + if migration: + return migration + + # --- Umbrella paths (Settings > Agents > Oz > Input, etc.) --- + if section in umbrellas: + umbrella_data = umbrellas[section] + subpages = umbrella_data.get("subpages", []) + if len(segments) < 3: + # Settings > Agents alone is ambiguous — flag but don't fail hard. + return { + "valid": False, + "issue": ( + f"\"Settings > {section}\" is an umbrella; pick a subpage" + ), + "suggestion": ( + f"Valid subpages: {', '.join(subpages)}" + ), + "confidence": 0.5, + "fix_type": None, + } + subpage = segments[2] + if subpage not in subpages: + ci_match = next( + (s for s in subpages if s.lower() == subpage.lower()), None + ) + if ci_match: + return { + "valid": False, + "issue": ( + f"Case mismatch: \"{subpage}\" should be \"{ci_match}\"" + ), + "suggestion": " > ".join( + ["Settings", section, ci_match] + segments[3:] + ), + "confidence": 0.95, + "fix_type": "case_mismatch", + } + best, score = _best_fuzzy_match(subpage, subpages) + if score >= FUZZY_MATCH_THRESHOLD: + return { + "valid": False, + "issue": ( + f"\"{subpage}\" is not a known subpage of " + f"\"{section}\"" + ), + "suggestion": ( + f"Did you mean \"{best}\"? (score: {score:.2f})" + ), + "confidence": score, + "fix_type": "fuzzy" if score >= AUTO_FIX_THRESHOLD else None, + } + return { + "valid": False, + "issue": ( + f"\"{subpage}\" is not a known subpage of " + f"\"{section}\"" + ), + "suggestion": ( + f"Valid subpages: {', '.join(subpages)}" + ), + "confidence": 0.0, + "fix_type": None, + } + + # Valid umbrella > subpage. Now check optional sub-section (segments[3:]). + if len(segments) >= 4: + subpage_data = settings.get(subpage, {}) + sub_sections = subpage_data.get("sub_sections", []) + sub = segments[3] + if sub in sub_sections: + return { + "valid": True, + "issue": None, + "suggestion": None, + "confidence": 1.0, + "fix_type": None, + } + if sub_sections: + ci_match = next( + (s for s in sub_sections if s.lower() == sub.lower()), + None, + ) + if ci_match: + return { + "valid": False, + "issue": ( + f"Case mismatch: \"{sub}\" should be " + f"\"{ci_match}\"" + ), + "suggestion": " > ".join( + ["Settings", section, subpage, ci_match] + + segments[4:] + ), + "confidence": 0.95, + "fix_type": "case_mismatch", + } + # Unknown sub-section — likely a toggle/setting name; allow. + return { + "valid": True, + "issue": None, + "suggestion": None, + "confidence": 0.8, + "fix_type": None, + } + return { + "valid": True, + "issue": None, + "suggestion": None, + "confidence": 1.0, + "fix_type": None, + } + # Check section (case-insensitive) exact = section in section_names if not exact: @@ -725,9 +946,13 @@ def scan_docs( docs_dir: Path, include_changelog: bool = False, ) -> List[Path]: - """Collect all .md files under docs_dir, excluding skip dirs.""" + """Collect all .md and .mdx files under docs_dir, excluding skip dirs.""" files = [] - for md_file in sorted(docs_dir.rglob("*.md")): + patterns = ["*.md", "*.mdx"] + candidates = sorted( + f for p in patterns for f in docs_dir.rglob(p) + ) + for md_file in candidates: # Skip directories parts = set(md_file.relative_to(docs_dir).parts) if parts & SKIP_DIRS: @@ -742,6 +967,33 @@ def scan_docs( # Auto-fix # --------------------------------------------------------------------------- +# Fix types eligible for auto-fix (case mismatches + deprecated-section migrations). +_AUTO_FIXABLE_TYPES = {"case_mismatch", "deprecated_section"} + + +def _format_suggestion_like_original(suggestion_path: str, original_format: str) -> str: + """Wrap a plain 'Settings > Foo > Bar' suggestion in the same format as the original. + + Always returns canonical bold for non-formatted (bare) originals so that the + auto-fix also upgrades the formatting while migrating the path. + """ + segments = [s.strip() for s in suggestion_path.split(">")] + if original_format == "canonical": + return " > ".join(f"**{s}**" for s in segments) + if original_format == "bold": + # Original was **Whole Path** — produce canonical bold in the replacement + # so the result matches the style guide. + return " > ".join(f"**{s}**" for s in segments) + if original_format == "seg_backtick": + return " > ".join(f"`{s}`" for s in segments) + if original_format == "backtick": + return " > ".join(f"**{s}**" for s in segments) + if original_format == "italic": + return " > ".join(f"**{s}**" for s in segments) + # bare or unknown + return " > ".join(f"**{s}**" for s in segments) + + def apply_fixes(issues: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Apply auto-fixes for high-confidence issues. Returns list of applied fixes.""" fixes_by_file: Dict[str, List[Dict[str, Any]]] = {} @@ -752,7 +1004,11 @@ def apply_fixes(issues: List[Dict[str, Any]]) -> List[Dict[str, Any]]: confidence = issue.get("validation", {}).get("confidence", 0) suggestion = issue.get("validation", {}).get("suggestion") - if fix_type == "case_mismatch" and confidence >= AUTO_FIX_THRESHOLD and suggestion: + if ( + fix_type in _AUTO_FIXABLE_TYPES + and confidence >= AUTO_FIX_THRESHOLD + and suggestion + ): file_path = issue["file"] fixes_by_file.setdefault(file_path, []).append(issue) @@ -760,8 +1016,11 @@ def apply_fixes(issues: List[Dict[str, Any]]) -> List[Dict[str, Any]]: try: text = Path(file_path).read_text(encoding="utf-8") for issue in file_issues: - old = issue["raw"] - new = issue["validation"]["suggestion"] + # Use match_text (full match including wrappers) so the replace + # removes the bold/backtick wrappers and they don't become dangling. + old = issue.get("match_text", issue["raw"]) + suggestion = issue["validation"]["suggestion"] + new = _format_suggestion_like_original(suggestion, issue.get("format", "bare")) if old in text: text = text.replace(old, new, 1) applied.append({ @@ -797,7 +1056,7 @@ def create_pr(fixes: List[Dict[str, Any]], repo_root: Path) -> Optional[str]: f"docs: fix {len(fixes)} UI reference issue(s)\n\n" f"Auto-fixed by validate_ui_refs skill.\n" f"Files changed: {', '.join(sorted(str(Path(f).relative_to(repo_root)) for f in files_changed))}\n\n" - f"Co-Authored-By: Oz " + f"Co-Authored-By: Warp " ) subprocess.run(["git", "commit", "-m", commit_msg], cwd=repo_root, check=True) subprocess.run(["git", "push", "-u", "origin", branch], cwd=repo_root, check=True) @@ -809,7 +1068,7 @@ def create_pr(fixes: List[Dict[str, Any]], repo_root: Path) -> Optional[str]: f"## Changes\n" + "\n".join(f"- `{Path(f['file']).relative_to(repo_root)}` line {f['line']}: " f"`{f['old']}` → `{f['new']}`" for f in fixes) - + f"\n\nCo-Authored-By: Oz " + + f"\n\nCo-Authored-By: Warp " ) with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as tmp: tmp.write(pr_body) @@ -925,19 +1184,55 @@ def notify_slack( # --------------------------------------------------------------------------- def refresh_valid_paths(warp_internal_path: Path, output_path: Path) -> None: - """Re-extract valid paths from warp-internal Rust sources and save to JSON.""" + """Re-extract valid paths from warp-internal Rust sources and save to JSON. + + Preserves hand-maintained lists (macos_menu_bar, warp_drive, umbrellas, + deprecated_sections, top_level_sidebar) from the existing snapshot. + Auto-detected umbrellas from `SettingsUmbrella::new(...)` calls in mod.rs + are merged in; if a new umbrella is detected that's not in the existing + snapshot, it's added. Existing umbrella entries take precedence on + conflict so the hand-authored `subpages` lists aren't lost. + """ print(f"Refreshing valid_paths.json from {warp_internal_path}...") settings_sections = _extract_settings_sections(warp_internal_path) command_palette = _extract_command_palette_commands(warp_internal_path) - # Load existing for menu bar and warp drive (manual lists) - existing = {} + # Load existing for menu bar, warp drive, umbrellas, deprecated_sections, + # and top_level_sidebar (all manually maintained lists). + existing: Dict[str, Any] = {} if output_path.exists(): existing = load_valid_paths(output_path) + existing_umbrellas: Dict[str, Any] = existing.get("umbrellas", {}) or {} + + # Best-effort: pull umbrellas from `SettingsUmbrella::new(...)` calls in mod.rs + # and merge into the existing snapshot (existing entries win on conflict). + try: + extracted_umbrellas = _extract_umbrellas(warp_internal_path) + except Exception as e: # pragma: no cover - defensive, parser errors + print(f" Warning: umbrella extraction failed: {e}", file=sys.stderr) + extracted_umbrellas = {} + + umbrellas: Dict[str, Any] = dict(extracted_umbrellas) + for name, payload in existing_umbrellas.items(): + umbrellas[name] = payload # existing entries take precedence + + # Stamp `umbrella:` on settings_sections entries whose variant belongs to + # an umbrella, so the snapshot stays self-describing. + subpage_to_umbrella: Dict[str, str] = {} + for umbrella_name, payload in umbrellas.items(): + for subpage in (payload or {}).get("subpages", []) or []: + subpage_to_umbrella[subpage] = umbrella_name + for display_name, entry in settings_sections.items(): + if display_name in subpage_to_umbrella: + entry["umbrella"] = subpage_to_umbrella[display_name] + data = { + "umbrellas": umbrellas, + "deprecated_sections": existing.get("deprecated_sections", {}), "settings_sections": settings_sections, + "top_level_sidebar": existing.get("top_level_sidebar", []), "macos_menu_bar": existing.get("macos_menu_bar", {}), "warp_drive": existing.get("warp_drive", {}), "command_palette_commands": command_palette, @@ -946,7 +1241,80 @@ def refresh_valid_paths(warp_internal_path: Path, output_path: Path) -> None: with open(output_path, "w") as f: json.dump(data, f, indent=2) - print(f"Wrote {output_path} ({len(settings_sections)} sections, {len(command_palette)} commands)") + print( + f"Wrote {output_path} (" + f"{len(settings_sections)} sections, " + f"{len(umbrellas)} umbrellas, " + f"{len(data['deprecated_sections'])} deprecated, " + f"{len(command_palette)} commands)" + ) + + +def _extract_umbrellas(warp_internal: Path) -> Dict[str, Any]: + """Parse SettingsUmbrella::new("Label", vec![...]) calls from mod.rs. + + Maps each umbrella label to its ordered list of subpage **display names** + (resolved via the `Display for SettingsSection` impl). Returns a dict + shaped like the `umbrellas` field in valid_paths.json. + """ + mod_rs = warp_internal / "app" / "src" / "settings_view" / "mod.rs" + umbrellas: Dict[str, Any] = {} + try: + mod_text = mod_rs.read_text(encoding="utf-8") + except OSError: + return umbrellas + + # Build variant -> display_name map from the Display impl. + display_map: Dict[str, str] = {} + display_impl = re.search( + r"impl Display for SettingsSection\s*\{.*?fn fmt.*?\{(.*?)\}\s*\}", + mod_text, + re.DOTALL, + ) + if display_impl: + for m in re.finditer( + r'SettingsSection::(\w+)\s*=>\s*write!\(f,\s*"([^"]+)"\)', + display_impl.group(1), + ): + display_map[m.group(1)] = m.group(2) + + def _display(variant: str) -> str: + return display_map.get(variant, variant) + + # Parse `SettingsUmbrella::new("Label", )`. + # We match the label then capture until the *closing* paren of the new call. + # The subpages_expr is usually `vec![...]` with SettingsSection::Variant items, + # or a call like `SettingsSection::ai_subpages().to_vec()`. + for m in re.finditer( + r'SettingsUmbrella::new\(\s*"([^"]+)",\s*(.*?)\)\)', + mod_text, + re.DOTALL, + ): + label = m.group(1) + body = m.group(2) + variants = re.findall(r"SettingsSection::(\w+)", body) + # Also handle `SettingsSection::ai_subpages()` helpers by looking for the + # referenced list function and parsing its body. + helper_match = re.search(r"SettingsSection::(\w+_subpages)\(\)", body) + if helper_match: + helper_name = helper_match.group(1) + helper_body_match = re.search( + rf"pub fn {re.escape(helper_name)}\(\)[^{{]*\{{(.*?)\}}", + mod_text, + re.DOTALL, + ) + if helper_body_match: + helper_variants = re.findall( + r"Self::(\w+)", helper_body_match.group(1) + ) + variants = helper_variants or variants + subpages = [_display(v) for v in variants] + if subpages: + umbrellas[label] = { + "subpages": subpages, + "source_file": "app/src/settings_view/mod.rs", + } + return umbrellas def _extract_settings_sections(warp_internal: Path) -> Dict[str, Any]: @@ -987,15 +1355,38 @@ def _extract_settings_sections(warp_internal: Path) -> Dict[str, Any]: ): display_map[m.group(1)] = m.group(2) - # Map of source files for sub-sections + # Map of source files for sub-sections. Includes both the legacy + # backing-page variants (`AI`, `Platform`, `Code`) and the new umbrella + # subpage variants (`Oz`, `AgentProfiles`, `AgentMCPServers`, `Knowledge`, + # `ThirdPartyCLIAgents`, `CodeIndexing`, `EditorAndCodeReview`, + # `CloudEnvironments`, `OzCloudAPIKeys`). Variants not listed here fall + # through to `mod.rs` and get empty sub_sections, which is fine for simple + # top-level pages without their own widget file. page_files = { + # Legacy backing pages (still exist as internal enum variants). "AI": "ai_page.rs", + "Platform": "platform_page.rs", + "Code": "code_page.rs", + # Agents umbrella subpages (all render widgets defined in ai_page.rs). + "Oz": "ai_page.rs", + "AgentProfiles": "ai_page.rs", + "Knowledge": "ai_page.rs", + "ThirdPartyCLIAgents": "ai_page.rs", + # The standalone MCP servers page is shared between the umbrella + # subpage and the legacy top-level MCPServers entry. + "AgentMCPServers": "mcp_servers_page.rs", + "MCPServers": "mcp_servers_page.rs", + # Code umbrella subpages. + "CodeIndexing": "code_page.rs", + "EditorAndCodeReview": "code_page.rs", + # Cloud platform umbrella subpages. + "CloudEnvironments": "environments_page.rs", + "OzCloudAPIKeys": "platform_page.rs", + # Regular top-level pages. "Appearance": "appearance_page.rs", "Features": "features_page.rs", "Warpify": "warpify_page.rs", - "Code": "code_page.rs", "Privacy": "privacy_page.rs", - "Platform": "platform_page.rs", } settings_dir = warp_internal / "app" / "src" / "settings_view" @@ -1147,6 +1538,194 @@ def generate_report( return "\n".join(lines) +# --------------------------------------------------------------------------- +# Self-test +# --------------------------------------------------------------------------- + +_SYNTHETIC_MOD_RS = """ +pub enum SettingsSection { + About, + Account, + MCPServers, + BillingAndUsage, + Appearance, + Features, + Keybindings, + Privacy, + Referrals, + SharedBlocks, + Teams, + WarpDrive, + Warpify, + AI, + Oz, + AgentProfiles, + AgentMCPServers, + Knowledge, + ThirdPartyCLIAgents, + Code, + CodeIndexing, + EditorAndCodeReview, + CloudEnvironments, + OzCloudAPIKeys, +} + +impl Display for SettingsSection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SettingsSection::BillingAndUsage => write!(f, "Billing and usage"), + SettingsSection::Keybindings => write!(f, "Keyboard shortcuts"), + SettingsSection::SharedBlocks => write!(f, "Shared blocks"), + SettingsSection::MCPServers => write!(f, "MCP Servers"), + SettingsSection::WarpDrive => write!(f, "Warp Drive"), + SettingsSection::Oz => write!(f, "Oz"), + SettingsSection::AgentProfiles => write!(f, "Profiles"), + SettingsSection::AgentMCPServers => write!(f, "MCP servers"), + SettingsSection::Knowledge => write!(f, "Knowledge"), + SettingsSection::ThirdPartyCLIAgents => write!(f, "Third party CLI agents"), + SettingsSection::CodeIndexing => write!(f, "Indexing and projects"), + SettingsSection::EditorAndCodeReview => write!(f, "Editor and Code Review"), + SettingsSection::CloudEnvironments => write!(f, "Environments"), + SettingsSection::OzCloudAPIKeys => write!(f, "Oz Cloud API Keys"), + _ => write!(f, "{self:?}"), + } + } +} + +let mut nav_items = vec![ + SettingsNavItem::Page(SettingsSection::Account), + SettingsNavItem::Umbrella(SettingsUmbrella::new( + "Agents", + vec![ + SettingsSection::Oz, + SettingsSection::AgentProfiles, + SettingsSection::AgentMCPServers, + SettingsSection::Knowledge, + SettingsSection::ThirdPartyCLIAgents, + ], + )), + SettingsNavItem::Umbrella(SettingsUmbrella::new( + "Code", + vec![ + SettingsSection::CodeIndexing, + SettingsSection::EditorAndCodeReview, + ], + )), + SettingsNavItem::Umbrella(SettingsUmbrella::new( + "Cloud platform", + vec![ + SettingsSection::CloudEnvironments, + SettingsSection::OzCloudAPIKeys, + ], + )), +]; +""" + + +def _run_self_test(valid_paths_path: Path) -> int: + """Lightweight sanity check for the validator. Returns 0 on success, 1 on failure. + + Covers: + 1. The committed `valid_paths.json` has non-empty `umbrellas` + + `deprecated_sections` (would regress silently otherwise). + 2. `_is_external_path()` no longer suppresses `Settings > MCP Servers` in a + sentence containing GitHub / Linear mentions (previous bug). + 3. `refresh_valid_paths()` preserves umbrellas + deprecated_sections when + run against a synthetic warp-internal with the new enum, and populates + the new subpage entries. + """ + import textwrap + + failures = [] + + # --- 1. Snapshot invariants + try: + data = load_valid_paths(valid_paths_path) + except Exception as e: + print(f"FAIL: could not load {valid_paths_path}: {e}") + return 1 + + if not data.get("umbrellas"): + failures.append("valid_paths.json has empty or missing `umbrellas`") + if not data.get("deprecated_sections"): + failures.append("valid_paths.json has empty or missing `deprecated_sections`") + + # --- 2. _is_external_path regression + legit_line = ( + "Navigate to **Settings** > **MCP Servers** to get started. " + "Some integrations (like Linear, GitHub, and Sentry) are available." + ) + if _is_external_path("Settings > MCP Servers", legit_line): + failures.append( + "_is_external_path() false-positive: " + "`Settings > MCP Servers` was suppressed in a GitHub/Linear sentence" + ) + # Also confirm the positive case still works: a GitHub org 'Settings' path + # mentioning github on the same line is still suppressed. + gh_line = "In GitHub, go to Settings > Secrets and variables to add your token." + if not _is_external_path("Settings > Secrets and variables", gh_line): + failures.append( + "_is_external_path() regression: " + "`Settings > Secrets and variables` should still be suppressed" + ) + + # --- 3. refresh_valid_paths preservation + extraction + with tempfile.TemporaryDirectory() as td: + wi_root = Path(td) / "warp-internal" + mod_rs = wi_root / "app" / "src" / "settings_view" / "mod.rs" + mod_rs.parent.mkdir(parents=True) + mod_rs.write_text(textwrap.dedent(_SYNTHETIC_MOD_RS)) + + # Copy the committed snapshot to a tmp path so we don't clobber it. + snap_path = Path(td) / "valid_paths.json" + snap_path.write_text(valid_paths_path.read_text()) + + refresh_valid_paths(wi_root, snap_path) + + refreshed = load_valid_paths(snap_path) + + if not refreshed.get("umbrellas"): + failures.append("refresh dropped `umbrellas`") + if not refreshed.get("deprecated_sections"): + failures.append("refresh dropped `deprecated_sections`") + if "Agents" not in refreshed.get("umbrellas", {}): + failures.append("refresh lost the Agents umbrella") + + # The extractor should have picked up the synthetic umbrellas too. + extracted = _extract_umbrellas(wi_root) + for expected in ("Agents", "Code", "Cloud platform"): + if expected not in extracted: + failures.append( + f"_extract_umbrellas() did not detect `{expected}` umbrella" + ) + + # New subpage entries should be present in settings_sections. + for expected_subpage in ( + "Oz", + "Profiles", + "MCP servers", + "Knowledge", + "Third party CLI agents", + "Indexing and projects", + "Editor and Code Review", + "Environments", + "Oz Cloud API Keys", + ): + if expected_subpage not in refreshed.get("settings_sections", {}): + failures.append( + f"settings_sections missing subpage `{expected_subpage}` after refresh" + ) + + if failures: + print("SELF-TEST FAILED:") + for f in failures: + print(f" - {f}") + return 1 + + print("SELF-TEST PASSED ✅") + return 0 + + # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- @@ -1173,9 +1752,14 @@ def main() -> int: parser.add_argument("--valid-paths", default=str(DEFAULT_VALID_PATHS_FILE), help="Path to valid_paths.json") parser.add_argument("--docs-dir", default=str(DEFAULT_DOCS_DIR), help="Path to docs directory") parser.add_argument("--output", help="Save results to JSON file") + parser.add_argument("--self-test", action="store_true", help="Run internal self-test and exit") args = parser.parse_args() + # Self-test short-circuits everything else. + if args.self_test: + return _run_self_test(Path(args.valid_paths)) + # Default to --all if no check flags specified if not args.check_paths and not args.check_commands and not args.check_format and not args.refresh_valid_paths: args.all = True diff --git a/.github/workflows/refresh-ui-paths.yml b/.github/workflows/refresh-ui-paths.yml index 727631443..ce3aaacf2 100644 --- a/.github/workflows/refresh-ui-paths.yml +++ b/.github/workflows/refresh-ui-paths.yml @@ -30,7 +30,9 @@ jobs: with: repository: warpdotdev/warp-internal token: ${{ secrets.WARP_INTERNAL_READ_PAT }} - path: warp-internal + # Check out outside the docs working directory so 'git add -A' below + # does not accidentally stage it as a nested repository/submodule. + path: ${{ runner.temp }}/warp-internal - name: Set up Python 3.12 uses: actions/setup-python@v5 @@ -40,22 +42,41 @@ jobs: - name: Install Python dependencies run: pip install requests + - name: Snapshot valid_paths.json before refresh + run: | + # Strip generated_at before saving so the change-detection step below + # only fires when Settings structure actually changed, not just the timestamp. + python3 -c " + import json, pathlib + d = json.loads(pathlib.Path('.agents/skills/validate_ui_refs/valid_paths.json').read_text()) + d.pop('generated_at', None) + pathlib.Path('/tmp/valid_paths_before.json').write_text(json.dumps(d, sort_keys=True)) + " + - name: Refresh valid_paths.json run: | python3 .agents/skills/validate_ui_refs/validate_ui_refs.py \ --refresh-valid-paths \ - --warp-internal-path ./warp-internal + --warp-internal-path ${{ runner.temp }}/warp-internal - name: Check if snapshot changed id: snapshot run: | - if git diff --quiet .agents/skills/validate_ui_refs/valid_paths.json; then + # Compare normalized JSON (without generated_at) to avoid false positives + # from the timestamp being rewritten on every run. + python3 -c " + import json, pathlib + d = json.loads(pathlib.Path('.agents/skills/validate_ui_refs/valid_paths.json').read_text()) + d.pop('generated_at', None) + pathlib.Path('/tmp/valid_paths_after.json').write_text(json.dumps(d, sort_keys=True)) + " + if diff -q /tmp/valid_paths_before.json /tmp/valid_paths_after.json > /dev/null 2>&1; then echo "changed=false" >> "$GITHUB_OUTPUT" - echo "Snapshot unchanged — Settings structure matches the existing snapshot. Nothing to do." + echo "Snapshot unchanged (Settings structure identical) — nothing to do." else echo "changed=true" >> "$GITHUB_OUTPUT" echo "Snapshot changed — running validation and auto-fix." - git diff --stat .agents/skills/validate_ui_refs/valid_paths.json + diff /tmp/valid_paths_before.json /tmp/valid_paths_after.json | head -40 || true fi - name: Run validation and auto-fix @@ -65,9 +86,14 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} run: | + # Capture exit code explicitly: the script returns 1 when unfixed issues + # remain, which would abort the step before we write the summary output + # or reach the PR creation step — exactly the case we most need a PR for. python3 .agents/skills/validate_ui_refs/validate_ui_refs.py \ - --all --fix --slack-notify \ - --output /tmp/ui-ref-report.json + --all --fix \ + --slack-channel C09BVK0PL3Y \ + --slack-notify \ + --output /tmp/ui-ref-report.json || true # Surface fix counts for the commit message python3 - <<'PY' From 529c116c61b7b81d33d6a265d20652fd6415a09e Mon Sep 17 00:00:00 2001 From: Rachael Rose Renk <91027132+rachaelrenk@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:04:22 -0600 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20review=20cleanup=20=E2=80=94=20rogue?= =?UTF-8?q?=20paths,=20concurrency,=20ci=20self-test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SKILL.md: fix 4 rogue .warp/skills/... paths → .agents/skills/...; update --slack-notify flag description (channel is hardcoded, not an env var); correct DOCS_DISPATCH_PAT permission — only Actions: write needed on docs, not Contents read; add --self-test to options list - refresh-ui-paths.yml: cancel-in-progress: false (prevent cancelling a run mid-commit); add --base main to gh pr create - ci.yml: add --self-test step to catch script/snapshot breakage on every PR without needing warp-internal checked out Co-Authored-By: Oz --- .agents/skills/validate_ui_refs/SKILL.md | 15 ++++++++------- .github/workflows/ci.yml | 5 +++++ .github/workflows/refresh-ui-paths.yml | 6 +++++- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/.agents/skills/validate_ui_refs/SKILL.md b/.agents/skills/validate_ui_refs/SKILL.md index d55010680..52e99b2d8 100644 --- a/.agents/skills/validate_ui_refs/SKILL.md +++ b/.agents/skills/validate_ui_refs/SKILL.md @@ -12,7 +12,7 @@ This skill scans Warp's Astro Starlight documentation for references to UI paths From the docs repo root: ```bash -python3 .warp/skills/validate_ui_refs/validate_ui_refs.py --all +python3 .agents/skills/validate_ui_refs/validate_ui_refs.py --all ``` ### Options @@ -23,8 +23,9 @@ python3 .warp/skills/validate_ui_refs/validate_ui_refs.py --all - `--all`: Run all checks (default) - `--fix`: Auto-fix high-confidence issues (e.g. case mismatches) - `--create-pr`: Create a branch and PR with auto-fixes (requires `gh` CLI) -- `--slack-notify`: Post results to Slack (only sends when issues are found; requires `SLACK_BOT_TOKEN` and `GROWTH_DOCS_SLACK_CHANNEL_ID` env vars) -- `--slack-channel ID`: Override default Slack channel +- `--slack-notify`: Post results to `#growth-docs` Slack channel when unfixed issues remain (requires `SLACK_BOT_TOKEN` env var; channel is hardcoded in the script) +- `--slack-channel ID`: Override the default Slack channel (`C09BVK0PL3Y`) +- `--self-test`: Run internal sanity checks against the current snapshot and exit (no `warp-internal` needed) - `--include-changelog`: Include `changelog/` in the scan (excluded by default since it's a historical record) - `--refresh-valid-paths`: Re-extract valid paths from `warp-internal` and update `valid_paths.json` - `--warp-internal-path PATH`: Path to the `warp-internal` repo (default: `../warp-internal` relative to docs root, or `WARP_INTERNAL_PATH` env var) @@ -33,13 +34,13 @@ python3 .warp/skills/validate_ui_refs/validate_ui_refs.py --all ### Quick path-only check: ```bash -python3 .warp/skills/validate_ui_refs/validate_ui_refs.py --check-paths +python3 .agents/skills/validate_ui_refs/validate_ui_refs.py --check-paths ``` ### Full check with auto-fix and PR: ```bash -python3 .warp/skills/validate_ui_refs/validate_ui_refs.py --all --fix --create-pr +python3 .agents/skills/validate_ui_refs/validate_ui_refs.py --all --fix --create-pr ``` ## Output Format @@ -74,7 +75,7 @@ Files scanned: 174 The `valid_paths.json` file is a static snapshot of valid UI paths. To update it from the latest `warp-internal` source: ```bash -python3 .warp/skills/validate_ui_refs/validate_ui_refs.py --refresh-valid-paths --warp-internal-path /path/to/warp-internal +python3 .agents/skills/validate_ui_refs/validate_ui_refs.py --refresh-valid-paths --warp-internal-path /path/to/warp-internal ``` This parses: @@ -157,7 +158,7 @@ python3 .agents/skills/validate_ui_refs/validate_ui_refs.py --all --slack-notify | Secret | Repo | Purpose | |---|---|---| -| `DOCS_DISPATCH_PAT` | `warp-internal` | Fine-grained PAT — Actions write + Contents read on `warpdotdev/docs` | +| `DOCS_DISPATCH_PAT` | `warp-internal` | Fine-grained PAT — **Actions: write** on `warpdotdev/docs` (to trigger `repository_dispatch`; no Contents access needed) | | `WARP_INTERNAL_READ_PAT` | `docs` | Fine-grained PAT — Contents read on `warpdotdev/warp-internal` | | `SLACK_BOT_TOKEN` | `docs` | Slack bot token with `chat:write` scope | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1bf8c5950..32daedc6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,11 @@ jobs: python3 .agents/skills/missing_docs/scripts/test_suggest_reviewers.py python3 .agents/skills/missing_docs/scripts/test_audit_docs.py + # Validate the validate_ui_refs snapshot and script invariants. Uses + # a synthetic warp-internal fixture internally — no checkout required. + - name: Self-test validate_ui_refs skill + run: python3 .agents/skills/validate_ui_refs/validate_ui_refs.py --self-test + # Production-only audit; gate on high+ so dev-only deprecation chatter # doesn't break PRs. The `|| true` keeps it informational; tighten this # to a hard fail once we're confident the noise is gone. diff --git a/.github/workflows/refresh-ui-paths.yml b/.github/workflows/refresh-ui-paths.yml index ce3aaacf2..16d3a19d5 100644 --- a/.github/workflows/refresh-ui-paths.yml +++ b/.github/workflows/refresh-ui-paths.yml @@ -15,7 +15,10 @@ permissions: concurrency: group: refresh-ui-paths - cancel-in-progress: true + # Do not cancel in-progress runs: a run may be mid-commit when the next + # dispatch arrives, leaving a branch with no PR. Each run is fast and + # idempotent — if two overlap, the second will simply find no diff. + cancel-in-progress: false jobs: refresh: @@ -138,6 +141,7 @@ jobs: git push -u origin "$BRANCH" gh pr create \ + --base main \ --title "docs: refresh UI paths snapshot" \ --body "Automatically generated by the \`refresh-ui-paths\` workflow after a change to \`app/src/settings_view/\` in \`warp-internal\`.