From 6c95a618b4757a81bb009b3a34be859a4eb53874 Mon Sep 17 00:00:00 2001 From: Nick Vigilante Date: Wed, 17 Jun 2026 12:19:08 -0400 Subject: [PATCH] fix: replace stale docs() paths with redirect destinations (DOCS-253) (#25740) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Replaces 9 stale docs paths and 2 stale doc anchors in `site/src/`, and adds a TS/TSX audit script (`site/scripts/audit-docs-paths.mjs`) plus unit tests that scan the codebase for paths that resolve via `coder.com/redirects.json`. ### Redirect-target updates (9) Each of these paths' `/docs/...` source matches a Next.js redirect rule, so requests today produce a 302 on `coder.com`. The audit script identifies them by cross-referencing against `redirects.json`. - Five update product-code references to `/ai-coder/ai-bridge` (renamed to `/ai-coder/ai-gateway` in v2.33). - One updates a commented-out reference to `/templates#template-filtering` in `TemplatesFilter.tsx`. - Three update notification-template mock data in `testHelpers/entities.ts` that pointed at the renamed `/docs/templates/schedule`. ### Anchor-only updates (2) These paths are still live on `coder.com` (no redirect), but their `#fragment` no longer matches a heading on the destination page. Fragments are evaluated client-side and never sent to the server, so the audit script does not catch them. Found and verified manually against the current docs. - `AuditFilter.tsx`: `/admin/security/audit-logs#filtering-logs` → `/admin/security/audit-logs#how-to-filter-audit-logs`. The current heading is `## How to Filter Audit Logs` in [`docs/admin/security/audit-logs.md`](https://github.com/coder/coder/blob/main/docs/admin/security/audit-logs.md). - `UserAuthSettingsPageView.tsx`: drops the stale `#openid-connect` anchor; the path itself (`/admin/users/oidc-auth`) is unchanged. The page H1 is now `# OpenID Connect`, so the bare path lands at the same place the anchor used to. None are user-visible label changes; only the doc target URLs change. ## Audit script `site/scripts/audit-docs-paths.mjs` cross-references TS/TSX docs-URL references against `coder.com/redirects.json` and reports anything that resolves via a redirect (which means stale source). It catches four forms: - `docs("/...")` / `docs('/...')` / `` docs(`/...`) `` - `` docs(`/.../${expr}/...`) `` (literal prefix, flagged as dynamic) - `"https://coder.com/docs/..."` and other quoted forms - `](https://coder.com/docs/...)` and `](/docs/...)` markdown-link forms The full audit (26 findings: 9 in `coder/coder/site/`, 17 in `coder/coder.com/src/`) lives in [DOCS-253 on Linear](https://linear.app/codercom/issue/DOCS-253) rather than being committed to the repo. Re-run locally with: ```bash node site/scripts/audit-docs-paths.mjs \ --redirects=/path/to/coder.com/redirects.json \ --roots=/path/to/coder/site/src,/path/to/coder.com/src ``` Default output goes to `docs/.audit/redirects-audit-YYYY-MM-DD.md`, which is gitignored. ## Tests `site/scripts/audit-docs-paths.test.mjs` has 59 cases covering the four regexes, `matchRedirect` (exact, `:path*`, `:slug(.*)`, miss), `findMatchingRedirect`, `stripQueryAndFragment`, `literalPrefix`, `extractReferences` end-to-end with line numbers and multi-line `docs()` calls, `buildReport` (empty input, repo grouping and sort order, fragment annotation, unclassified section), `walk` against a real temp filesystem (recursion, extension filter, `SKIP_DIRS` pruning, missing/file inputs, seeded results), and `runCli` (missing-root warning, real-but-empty root). Run with `pnpm exec vitest run scripts/audit-docs-paths.test.mjs --project=unit` from `site/`. ## Notes - User-visible "AI Bridge" label text is not changed here. Renaming the product surface from "AI Bridge" to "AI Gateway" is tracked separately by the AI Governance team in AIGOV-233. - A vitest assertion that no literal path in `site/src/` resolves via a redirect will land in a follow-up PR (DOCS-257), so future drift fails fast in CI. - A generated `DocsPath` type for the `docs()` helper is planned in DOCS-254. - The `/docs/templates/schedule` drift in `entities.ts` also appears in `coderd/notifications/testdata/*.golden` test fixtures and in historical SQL migrations under `coderd/database/migrations/`. Those are tracked under DOCS-256 (A2: non-TS audit) and not in scope here. ## Related work - Linear: DOCS-253 (this PR), parent DOCS-209. - Companion redirect rule on the coder.com side: coder/coder.com#826. - Companion fixes for the 17 coder.com findings: coder/coder.com#876 (supersedes the closed coder/coder.com#827, which was made redundant by coder/coder.com#832).
Implementation plan (Linear DOCS-209) | Phase | Scope | Linear | Status | |---|---|---|---| | D | Versioned redirect for `/docs/@v2.33.x/ai-coder/ai-bridge` in `coder.com/redirects.json` | DOCS-255 | coder/coder.com#826 open | | A1 | TS/TSX audit + autofix in `coder/coder/site/` | DOCS-253 | This PR | | A1 follow-up | Same autofix in `coder/coder.com/src/` (3 remaining findings after coder/coder.com#832) | DOCS-281 | coder/coder.com#876 open | | A2 | Non-TS audit in `coder/coder` (Go, comments, markdown) | DOCS-256 | Backlog | | A3 | code-server audit | DOCS-252 | Backlog | | B | vitest assertion against `redirects.json` | DOCS-257 | Blocked by A1 | | C | Generated `DocsPath` type | DOCS-254 | Blocked by B |
--- Generated by Coder Agent on behalf of @nickvigilante. --------- Co-authored-by: Coder --- .gitignore | 5 + site/scripts/audit-docs-paths.mjs | 453 +++++++++++ site/scripts/audit-docs-paths.test.mjs | 715 ++++++++++++++++++ .../Paywall/PaywallAIGovernance.tsx | 2 +- .../AIBridgePage/AIBridgeSessionsLayout.tsx | 2 +- .../pages/AIBridgePage/AIBridgeSetupAlert.tsx | 2 +- .../SessionTimeline/SessionTimeline.tsx | 2 +- site/src/pages/AuditPage/AuditFilter.tsx | 4 +- .../UserAuthSettingsPageView.tsx | 4 +- .../pages/TemplatesPage/TemplatesFilter.tsx | 2 +- site/src/testHelpers/entities.ts | 4 +- 11 files changed, 1184 insertions(+), 11 deletions(-) create mode 100644 site/scripts/audit-docs-paths.mjs create mode 100644 site/scripts/audit-docs-paths.test.mjs diff --git a/.gitignore b/.gitignore index 21da30a370..4263ff63dd 100644 --- a/.gitignore +++ b/.gitignore @@ -115,4 +115,9 @@ license.txt # Agent planning documents (local working files). docs/plans/ + +# Local audit reports (e.g. site/scripts/audit-docs-paths.mjs output). +# The file is for local inspection only. +docs/.audit/ + /release-action diff --git a/site/scripts/audit-docs-paths.mjs b/site/scripts/audit-docs-paths.mjs new file mode 100644 index 0000000000..fade7cdc2a --- /dev/null +++ b/site/scripts/audit-docs-paths.mjs @@ -0,0 +1,453 @@ +// Audit script for docs-URL drift. +// +// Cross-references docs(...) and string-literal docs-URL references in TS/TSX +// files against the source side of every /docs/* rule in +// coder.com/redirects.json. Anything that matches a redirect source is stale +// and needs to be updated to the redirect's destination. +// +// Usage: +// node site/scripts/audit-docs-paths.mjs \ +// --redirects=/path/to/coder.com/redirects.json \ +// --roots=/path/to/coder/site,/path/to/coder.com/src \ +// --out=docs/.audit/redirects-audit-YYYY-MM-DD.md +// +// All flags are optional. Defaults assume a standard Coder dev layout under +// /home/coder/. The script never modifies source files; it only emits the +// report. The output file defaults to today's date so each run produces a +// dated snapshot. +// +// docs/.audit/ is gitignored; the report file is a local working artifact. + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; + +// --------------------------------------------------------------------------- +// Redirect indexing. + +export function loadRedirects(p) { + return JSON.parse(fs.readFileSync(p, "utf-8")); +} + +// Filter all entries to the /docs/* subset. Order matters because Next.js +// picks the first matching rule. +export function docsRedirects(all) { + if (!Array.isArray(all)) { + throw new TypeError( + `docsRedirects: expected an array of redirect rules, got ${typeof all}`, + ); + } + return all.filter( + (r) => typeof r.source === "string" && r.source.startsWith("/docs/"), + ); +} + +// Match a path against a single redirect source. Returns the redirect's +// destination with path params substituted, or null if no match. +export function matchRedirect(refPath, redirect) { + const src = redirect.source; + const dst = redirect.destination; + + if (src === refPath) return dst; + + // Trailing /:path* wildcard (Next.js's "match anything below this prefix"). + if (src.endsWith("/:path*")) { + const prefix = src.slice(0, -"/:path*".length); + if (refPath === prefix) { + return dst.endsWith("/:path*") ? dst.slice(0, -"/:path*".length) : dst; + } + if (refPath.startsWith(prefix + "/")) { + const tail = refPath.slice(prefix.length); // includes leading slash + if (dst.endsWith("/:path*")) { + return dst.slice(0, -"/:path*".length) + tail; + } + return dst; + } + } + + // :slug(.*) at the end: same idea but params named "slug". + if (src.endsWith(":slug(.*)")) { + const prefix = src.slice(0, -":slug(.*)".length); + if (refPath.startsWith(prefix)) { + const tail = refPath.slice(prefix.length); + if (dst.endsWith(":slug")) { + return dst.slice(0, -":slug".length) + tail; + } + return dst; + } + } + + // :version capture groups appear in the new versioned redirects and are + // rare elsewhere. The audit only cares whether a literal source path is + // stale, so paths containing @version segments would never appear as + // literals in TS/TSX. Skip. + return null; +} + +export function findMatchingRedirect(refPath, redirects) { + for (const r of redirects) { + const dst = matchRedirect(refPath, r); + if (dst !== null) return { redirect: r, suggestedDestination: dst }; + } + return null; +} + +// --------------------------------------------------------------------------- +// Reference extraction. + +// docs("/path") | docs('/path') | docs(`/path`) with NO ${expr}. +export const DOCS_LITERAL_RE = /\bdocs\(\s*(['"`])([^'"`)$]+)\1/g; + +// docs(`/path/${expr}/more`). Captures the whole literal segment between the +// backticks so we can flag the literal prefix. +export const DOCS_TEMPLATE_RE = /\bdocs\(\s*`([^`]*\$\{[^`]*\}[^`]*)`/g; + +// "https://coder.com/docs/..." wrapped in any string-literal delimiter. +// The backreference (\1) requires the closing delimiter to match the opening +// one, mirroring DOCS_LITERAL_RE. +export const HARDCODED_URL_RE = + /(['"`])https?:\/\/(?:[a-z0-9-]+\.)?coder\.com(\/docs\/[^'"`)\s]+)\1/g; + +// Markdown-link form: [text](https://coder.com/docs/...) or [text](/docs/...). +// Used inside notification bodies, doc strings, and other prose. The URL is +// bounded by ( and ), not by string delimiters. +export const MARKDOWN_LINK_RE = + /\]\(\s*(?:https?:\/\/(?:[a-z0-9-]+\.)?coder\.com)?(\/docs\/[^\s)]+)\)/g; + +export function stripQueryAndFragment(p) { + // Remove hash fragment and query string before redirect matching. + let out = p; + const hash = out.indexOf("#"); + if (hash !== -1) out = out.slice(0, hash); + const query = out.indexOf("?"); + if (query !== -1) out = out.slice(0, query); + return out; +} + +export function literalPrefix(tmpl) { + // Return the literal portion before the first ${...} so we can do a + // partial redirect match on the static prefix. + const idx = tmpl.indexOf("${"); + return idx === -1 ? tmpl : tmpl.slice(0, idx); +} + +// Map a regex match's 0-based string index to a 1-based line number using +// a precomputed array of line-start offsets. +function buildLineIndex(content) { + const lineStarts = [0]; + for (let i = 0; i < content.length; i++) { + if (content[i] === "\n") lineStarts.push(i + 1); + } + return lineStarts; +} + +function indexToLine(idx, lineStarts) { + // Binary search for the largest lineStart <= idx. + let lo = 0; + let hi = lineStarts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >>> 1; + if (lineStarts[mid] <= idx) lo = mid; + else hi = mid - 1; + } + return lo + 1; // 1-based +} + +export function extractReferences(filePath, content) { + const refs = []; + const lineStarts = buildLineIndex(content); + + const push = (m, kind, rawArg, docsPath, dynamic) => { + refs.push({ + file: filePath, + lineNo: indexToLine(m.index, lineStarts), + kind, + rawArg, + docsPath, + dynamic, + }); + }; + + for (const m of content.matchAll(DOCS_LITERAL_RE)) { + push(m, "docs-literal", m[2], "/docs" + stripQueryAndFragment(m[2]), false); + } + + for (const m of content.matchAll(DOCS_TEMPLATE_RE)) { + push( + m, + "docs-template", + m[1], + "/docs" + stripQueryAndFragment(literalPrefix(m[1])), + true, + ); + } + + for (const m of content.matchAll(HARDCODED_URL_RE)) { + push(m, "hardcoded-url", m[2], stripQueryAndFragment(m[2]), false); + } + + for (const m of content.matchAll(MARKDOWN_LINK_RE)) { + push(m, "markdown-link", m[1], stripQueryAndFragment(m[1]), false); + } + + return refs; +} + +// --------------------------------------------------------------------------- +// File discovery. + +const SKIP_DIRS = new Set([ + "node_modules", + "dist", + "build", + ".next", + ".cache", + "out", + ".audit", + ".style", + "storybook-static", + "__generated__", +]); + +export function walk(dir, exts, results = []) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (e) { + if (e.code === "ENOENT" || e.code === "ENOTDIR") return results; + throw e; + } + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) { + if (SKIP_DIRS.has(e.name)) continue; + walk(full, exts, results); + } else if (e.isFile() && exts.some((ext) => e.name.endsWith(ext))) { + results.push(full); + } + } + return results; +} + +// --------------------------------------------------------------------------- +// Report rendering. + +export function repoForFile(file) { + if (file.includes("/coder/site/")) return "coder/coder/site"; + if (file.includes("/coder.com/src/")) return "coder/coder.com/src"; + return "unknown"; +} + +export function relForFile(file) { + // Strip absolute prefix so the report is portable. + return file + .replace(/^.*\/coder\/site\//, "site/") + .replace(/^.*\/coder\.com\/src\//, "src/"); +} + +export function escapeMd(s) { + return s.replace(/\|/g, "\\|"); +} + +export function suggestedFixForKind(kind, suggestedDestination) { + // Reverse the /docs prefix transformation for docs() callsites so the + // suggested fix is the literal string the developer should paste in. + if (kind === "hardcoded-url") { + return "https://coder.com" + suggestedDestination; + } + if (kind === "markdown-link") { + return suggestedDestination; + } + // docs-literal / docs-template: helper prepends /docs, so strip it. + return suggestedDestination.startsWith("/docs") + ? suggestedDestination.slice("/docs".length) + : suggestedDestination; +} + +export function buildReport({ findings, redirectsPath, roots, startedAt }) { + const byRepo = Map.groupBy(findings, (f) => repoForFile(f.file)); + + // Stable sort: dynamic last; otherwise by file then line. + for (const list of byRepo.values()) { + list.sort((a, b) => { + if (a.dynamic !== b.dynamic) return a.dynamic ? 1 : -1; + if (a.file !== b.file) return a.file < b.file ? -1 : 1; + return a.lineNo - b.lineNo; + }); + } + + const sectionRows = (list) => + list + .map((f) => { + const rel = relForFile(f.file); + const fix = suggestedFixForKind(f.kind, f.match.suggestedDestination); + const fragmentNote = f.rawArg.includes("#") + ? " (preserve `#anchor` from current value)" + : ""; + return `| \`${rel}:${f.lineNo}\` | \`${escapeMd(f.rawArg)}\` | \`${escapeMd(f.match.redirect.source)}\` -> \`${escapeMd(f.match.redirect.destination)}\` | \`${escapeMd(fix)}\`${fragmentNote} | ${f.dynamic ? "Yes" : "No"} |`; + }) + .join("\n"); + + const totalDynamic = findings.filter((f) => f.dynamic).length; + const totalStatic = findings.length - totalDynamic; + + const out = [ + "# Redirects audit: TS/TSX docs-URL references", + "", + `Generated: ${startedAt.toISOString()}`, + "", + "## Method", + "", + 'This audit cross-references every static `docs("...")` call, every `docs(`...`)` template literal with a literal prefix, every hardcoded `coder.com/docs/...` URL, and every `](/docs/...)`-style Markdown link in TS/TSX files against the source side of every `/docs/*` rule in `coder/coder.com/redirects.json`. Anything that matches a redirect source is stale and needs to be updated to the destination.', + "", + `Source of truth for the redirect set: \`${redirectsPath}\` at audit time.`, + "", + "Scanned roots:", + "", + ...roots.map((r) => `* \`${r}\``), + "", + "Pattern matchers:", + "", + '* `docs("/...")` and `docs(\'/...\')` and `docs(`/...`)` (no `${}`).', + "* `docs(`/.../${expr}/...`)` (literal prefix only; flagged as dynamic for manual review).", + "* Any string literal containing `https://coder.com/docs/...` or `https://*.coder.com/docs/...`.", + "* Markdown-link form `](https://coder.com/docs/...)` or `](/docs/...)` inside prose, notifications, and mock data.", + "", + "Hash fragments (`#anchor`) and query strings (`?foo`) are stripped before redirect matching.", + "", + "## Summary", + "", + "| Total findings | Auto-fixable (literal) | Manual review (dynamic) |", + "|---|---|---|", + `| ${findings.length} | ${totalStatic} | ${totalDynamic} |`, + "", + ]; + + const repoOrder = ["coder/coder/site", "coder/coder.com/src"]; + for (const repo of repoOrder) { + const list = byRepo.get(repo) ?? []; + out.push(`## ${repo}`); + out.push(""); + if (list.length === 0) { + out.push("No findings."); + out.push(""); + continue; + } + out.push(`${list.length} ${list.length === 1 ? "finding" : "findings"}.`); + out.push(""); + out.push( + "| File:Line | Current path | Redirect rule | Suggested fix | Dynamic? |", + ); + out.push("|---|---|---|---|---|"); + out.push(sectionRows(list)); + out.push(""); + } + + const unknown = byRepo.get("unknown") ?? []; + if (unknown.length > 0) { + out.push("## Unclassified findings"); + out.push(""); + out.push( + "These came from a path that did not match the known repo prefixes. Investigate.", + ); + out.push(""); + out.push( + "| File:Line | Current path | Redirect rule | Suggested fix | Dynamic? |", + ); + out.push("|---|---|---|---|---|"); + out.push(sectionRows(unknown)); + out.push(""); + } + + out.push("## Notes"); + out.push(""); + out.push( + "* Dynamic findings have a `${...}` expression somewhere in the path. The suggested fix shows what the literal prefix should become; the developer must keep the dynamic suffix intact.", + ); + out.push( + "* Findings under `docs/.audit/` are excluded by file discovery (`.audit` is in SKIP_DIRS) to avoid feedback loops on the audit itself.", + ); + out.push( + "* Re-run with `node site/scripts/audit-docs-paths.mjs` from the repo root.", + ); + out.push(""); + + return out.join("\n"); +} + +// --------------------------------------------------------------------------- +// CLI entry point. + +function defaultOutForToday() { + const today = new Date().toISOString().slice(0, 10); + return `/home/coder/coder/docs/.audit/redirects-audit-${today}.md`; +} + +export function runCli(argv) { + const { values: args } = parseArgs({ + args: argv, + options: { + redirects: { type: "string" }, + roots: { type: "string" }, + out: { type: "string" }, + }, + }); + const redirectsPath = args.redirects ?? "/home/coder/coder.com/redirects.json"; + const roots = ( + args.roots ?? "/home/coder/coder/site/src,/home/coder/coder.com/src" + ) + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + const outPath = args.out ?? defaultOutForToday(); + + const startedAt = new Date(); + console.error(`Loading redirects from ${redirectsPath}`); + const redirects = docsRedirects(loadRedirects(redirectsPath)); + console.error(` ${redirects.length} /docs/* redirect rules indexed`); + + const exts = [".ts", ".tsx"]; + const allFiles = []; + for (const root of roots) { + if (!fs.existsSync(root)) { + console.error(` WARNING: ${root} does not exist; skipping`); + continue; + } + console.error(`Scanning ${root}`); + const found = walk(root, exts); + console.error(` ${found.length} files`); + allFiles.push(...found); + } + + const findings = []; + for (const file of allFiles) { + const content = fs.readFileSync(file, "utf-8"); + const refs = extractReferences(file, content); + for (const ref of refs) { + const match = findMatchingRedirect(ref.docsPath, redirects); + if (match) findings.push({ ...ref, match }); + } + } + + console.error(`Total findings: ${findings.length}`); + + const report = buildReport({ + findings, + redirectsPath, + roots, + startedAt, + }); + + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, report); + const totalDynamic = findings.filter((f) => f.dynamic).length; + const totalStatic = findings.length - totalDynamic; + console.error(`Wrote ${outPath}`); + console.error(` Static: ${totalStatic}`); + console.error(` Dynamic: ${totalDynamic}`); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + runCli(process.argv.slice(2)); +} diff --git a/site/scripts/audit-docs-paths.test.mjs b/site/scripts/audit-docs-paths.test.mjs new file mode 100644 index 0000000000..13f9721dc2 --- /dev/null +++ b/site/scripts/audit-docs-paths.test.mjs @@ -0,0 +1,715 @@ +import { describe, expect, it, vi } from "vitest"; +import { + DOCS_LITERAL_RE, + DOCS_TEMPLATE_RE, + HARDCODED_URL_RE, + MARKDOWN_LINK_RE, + buildReport, + docsRedirects, + escapeMd, + extractReferences, + findMatchingRedirect, + literalPrefix, + matchRedirect, + relForFile, + repoForFile, + runCli, + stripQueryAndFragment, + suggestedFixForKind, + walk, +} from "./audit-docs-paths.mjs"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const rule = (source, destination) => ({ source, destination, permanent: true }); + +describe("docsRedirects", () => { + it("keeps only /docs/* sources", () => { + const all = [ + rule("/docs/a", "/docs/b"), + rule("/api/old", "/api/new"), + rule("/docs/c/:path*", "/docs/d/:path*"), + { source: 42, destination: "/" }, // malformed; must be skipped + ]; + expect(docsRedirects(all)).toEqual([ + rule("/docs/a", "/docs/b"), + rule("/docs/c/:path*", "/docs/d/:path*"), + ]); + }); + + it("throws a TypeError when given a non-array (e.g. object)", () => { + expect(() => docsRedirects({})).toThrowError(TypeError); + expect(() => docsRedirects({})).toThrowError(/got object/); + }); + + it("throws a TypeError when given a non-array (e.g. string)", () => { + expect(() => docsRedirects("hello")).toThrowError(TypeError); + expect(() => docsRedirects("hello")).toThrowError(/got string/); + }); + + it("throws a TypeError when given null", () => { + expect(() => docsRedirects(null)).toThrowError(TypeError); + }); +}); + +describe("matchRedirect", () => { + it("returns destination on an exact match", () => { + expect( + matchRedirect("/docs/admin/rbac", rule("/docs/admin/rbac", "/docs/x")), + ).toBe("/docs/x"); + }); + + it("returns null when nothing matches", () => { + expect(matchRedirect("/docs/foo", rule("/docs/bar", "/docs/baz"))).toBe(null); + }); + + it("matches /:path* with a real subpath and substitutes the tail", () => { + expect( + matchRedirect( + "/docs/old/sub/page", + rule("/docs/old/:path*", "/docs/new/:path*"), + ), + ).toBe("/docs/new/sub/page"); + }); + + it("matches /:path* with an empty path (bare prefix)", () => { + expect( + matchRedirect("/docs/old", rule("/docs/old/:path*", "/docs/new/:path*")), + ).toBe("/docs/new"); + }); + + it("matches /:path* when the destination drops the wildcard", () => { + expect( + matchRedirect("/docs/old/x/y", rule("/docs/old/:path*", "/docs/new")), + ).toBe("/docs/new"); + }); + + it("matches :slug(.*) and substitutes the tail", () => { + expect( + matchRedirect( + "/docs/platforms/kubernetes", + rule("/docs/platforms/:slug(.*)", "/docs/install/:slug"), + ), + ).toBe("/docs/install/kubernetes"); + }); + + it("matches :slug(.*) when destination drops the slug", () => { + expect( + matchRedirect( + "/docs/platforms/aws", + rule("/docs/platforms/:slug(.*)", "/docs/install/cloud"), + ), + ).toBe("/docs/install/cloud"); + }); + + it("does not partial-match a path that overshoots the prefix", () => { + expect( + matchRedirect( + "/docs/oldsibling", + rule("/docs/old/:path*", "/docs/new/:path*"), + ), + ).toBe(null); + }); +}); + +describe("findMatchingRedirect", () => { + const redirects = [ + rule("/docs/admin/rbac", "/docs/admin/templates/template-permissions"), + rule("/docs/old/:path*", "/docs/new/:path*"), + ]; + + it("returns the first matching rule and its destination", () => { + const got = findMatchingRedirect("/docs/admin/rbac", redirects); + expect(got).not.toBeNull(); + expect(got.redirect).toBe(redirects[0]); + expect(got.suggestedDestination).toBe( + "/docs/admin/templates/template-permissions", + ); + }); + + it("returns null when no rule matches", () => { + expect(findMatchingRedirect("/docs/never", redirects)).toBeNull(); + }); +}); + +describe("stripQueryAndFragment", () => { + it("removes a hash fragment", () => { + expect(stripQueryAndFragment("/docs/a#section")).toBe("/docs/a"); + }); + + it("removes a query string", () => { + expect(stripQueryAndFragment("/docs/a?x=1")).toBe("/docs/a"); + }); + + it("removes both when query precedes hash", () => { + expect(stripQueryAndFragment("/docs/a?x=1#section")).toBe("/docs/a"); + }); + + it("removes both when hash precedes query", () => { + // Slices at the first hash; query suffix after the hash is dropped too. + expect(stripQueryAndFragment("/docs/a#section?x=1")).toBe("/docs/a"); + }); + + it("is a no-op when neither is present", () => { + expect(stripQueryAndFragment("/docs/a/b")).toBe("/docs/a/b"); + }); +}); + +describe("literalPrefix", () => { + it("returns the whole string when there is no interpolation", () => { + expect(literalPrefix("/docs/a/b")).toBe("/docs/a/b"); + }); + + it("returns the prefix up to the first ${...}", () => { + expect(literalPrefix("/docs/a/${slug}/b")).toBe("/docs/a/"); + }); + + it("handles interpolation at position zero", () => { + expect(literalPrefix("${root}/docs/a")).toBe(""); + }); +}); + +describe("regex patterns", () => { + const exec = (re, content) => [...content.matchAll(re)].map((m) => [...m]); + + it("DOCS_LITERAL_RE matches docs(\"...\"), docs('...'), docs(`...`)", () => { + const src = `docs("/a/b") docs('/c/d') docs(\`/e/f\`)`; + const m = exec(DOCS_LITERAL_RE, src); + expect(m.map((row) => row[2])).toEqual(["/a/b", "/c/d", "/e/f"]); + }); + + it("DOCS_LITERAL_RE skips template literals with interpolation", () => { + const src = "docs(`/a/${x}/b`)"; + expect(exec(DOCS_LITERAL_RE, src)).toHaveLength(0); + }); + + it("DOCS_TEMPLATE_RE matches docs(`/.../${expr}/...`)", () => { + const src = "docs(`/a/${x}/b`)"; + const m = exec(DOCS_TEMPLATE_RE, src); + expect(m).toHaveLength(1); + expect(m[0][1]).toBe("/a/${x}/b"); + }); + + it("HARDCODED_URL_RE matches https://coder.com URLs in any quote", () => { + const src = `a = "https://coder.com/docs/foo"; b = 'https://coder.com/docs/bar';`; + const m = exec(HARDCODED_URL_RE, src); + expect(m.map((row) => row[2])).toEqual(["/docs/foo", "/docs/bar"]); + }); + + it("HARDCODED_URL_RE matches *.coder.com subdomains", () => { + const src = `"https://dev.coder.com/docs/foo"`; + const m = exec(HARDCODED_URL_RE, src); + expect(m).toHaveLength(1); + expect(m[0][2]).toBe("/docs/foo"); + }); + + it("HARDCODED_URL_RE does NOT match markdown-link form", () => { + // The URL is bounded by ( and ), not by quotes. This pattern misses + // the URL; MARKDOWN_LINK_RE catches it. + const src = `[label](https://coder.com/docs/foo)`; + expect(exec(HARDCODED_URL_RE, src)).toHaveLength(0); + }); + + it("HARDCODED_URL_RE rejects mismatched opening and closing delimiters", () => { + // Backreference ensures "...' or '...\" cannot match. + const src = `a = "https://coder.com/docs/foo'; b = 'https://coder.com/docs/bar";`; + expect(exec(HARDCODED_URL_RE, src)).toHaveLength(0); + }); + + it("MARKDOWN_LINK_RE matches full URLs in markdown links", () => { + const src = `[label](https://coder.com/docs/foo) and [x](https://dev.coder.com/docs/bar)`; + const m = exec(MARKDOWN_LINK_RE, src); + expect(m.map((row) => row[1])).toEqual(["/docs/foo", "/docs/bar"]); + }); + + it("MARKDOWN_LINK_RE matches relative /docs/... links too", () => { + const src = `See [the docs](/docs/admin/rbac) for details.`; + const m = exec(MARKDOWN_LINK_RE, src); + expect(m.map((row) => row[1])).toEqual(["/docs/admin/rbac"]); + }); + + it("MARKDOWN_LINK_RE ignores trailing punctuation outside the parentheses", () => { + const src = `See [the docs](/docs/x).`; + const m = exec(MARKDOWN_LINK_RE, src); + expect(m[0][1]).toBe("/docs/x"); + }); +}); + +describe("extractReferences", () => { + it("captures all four kinds and sets line numbers (1-based)", () => { + const content = [ + 'docs("/admin/rbac");', // line 1 + "const x = `${base}/docs/admin/groups`;", // line 2: no match + 'const y = "https://coder.com/docs/admin/quotas";', // line 3 + "// see [the docs](/docs/admin/audit-logs)", // line 4 + "docs(`/templates/${slug}/edit`);", // line 5 + ].join("\n"); + const refs = extractReferences("/tmp/foo.ts", content); + const byKind = (k) => refs.filter((r) => r.kind === k); + + expect(byKind("docs-literal")).toEqual([ + expect.objectContaining({ + lineNo: 1, + rawArg: "/admin/rbac", + docsPath: "/docs/admin/rbac", + dynamic: false, + }), + ]); + + expect(byKind("hardcoded-url")).toEqual([ + expect.objectContaining({ + lineNo: 3, + rawArg: "/docs/admin/quotas", + docsPath: "/docs/admin/quotas", + dynamic: false, + }), + ]); + + expect(byKind("markdown-link")).toEqual([ + expect.objectContaining({ + lineNo: 4, + rawArg: "/docs/admin/audit-logs", + docsPath: "/docs/admin/audit-logs", + dynamic: false, + }), + ]); + + expect(byKind("docs-template")).toEqual([ + expect.objectContaining({ + lineNo: 5, + docsPath: "/docs/templates/", + dynamic: true, + }), + ]); + }); + + it("strips fragments from the redirect-matching docsPath but preserves rawArg", () => { + const content = `docs("/admin/rbac#perms");`; + const refs = extractReferences("/tmp/foo.ts", content); + expect(refs[0].rawArg).toBe("/admin/rbac#perms"); + expect(refs[0].docsPath).toBe("/docs/admin/rbac"); + }); + + it("returns an empty array when content has no docs refs", () => { + expect(extractReferences("/tmp/foo.ts", "const x = 1;")).toEqual([]); + }); + + it("handles a multi-line docs() invocation", () => { + const content = [ + "link={docs(", + ' "/admin/rbac",', + ")}", + ].join("\n"); + const refs = extractReferences("/tmp/foo.tsx", content); + expect(refs).toHaveLength(1); + expect(refs[0].kind).toBe("docs-literal"); + expect(refs[0].docsPath).toBe("/docs/admin/rbac"); + // The match starts on the line with "docs(", which is line 1. + expect(refs[0].lineNo).toBe(1); + }); +}); + +describe("repoForFile / relForFile", () => { + it("classifies coder/coder/site/ files", () => { + expect(repoForFile("/home/coder/coder/site/src/app.tsx")).toBe( + "coder/coder/site", + ); + }); + + it("classifies coder/coder.com/src/ files", () => { + expect(repoForFile("/home/coder/coder.com/src/foo.ts")).toBe( + "coder/coder.com/src", + ); + }); + + it("returns 'unknown' for paths that do not match a known repo", () => { + expect(repoForFile("/var/tmp/foo.ts")).toBe("unknown"); + }); + + it("rewrites coder/coder/site paths to a site/ relative form", () => { + expect(relForFile("/home/coder/coder/site/src/app.tsx")).toBe( + "site/src/app.tsx", + ); + }); + + it("rewrites coder.com paths to an src/ relative form", () => { + expect(relForFile("/home/coder/coder.com/src/data/x.ts")).toBe( + "src/data/x.ts", + ); + }); +}); + +describe("escapeMd", () => { + it("escapes pipes so table rows do not break", () => { + expect(escapeMd("a|b|c")).toBe("a\\|b\\|c"); + }); + + it("leaves other characters alone", () => { + expect(escapeMd("/docs/a-b.c")).toBe("/docs/a-b.c"); + }); +}); + +describe("suggestedFixForKind", () => { + it("strips /docs from docs-literal suggestions", () => { + expect(suggestedFixForKind("docs-literal", "/docs/admin/x")).toBe( + "/admin/x", + ); + }); + + it("strips /docs from docs-template suggestions", () => { + expect(suggestedFixForKind("docs-template", "/docs/admin/x")).toBe( + "/admin/x", + ); + }); + + it("prefixes hardcoded-url suggestions with https://coder.com", () => { + expect(suggestedFixForKind("hardcoded-url", "/docs/admin/x")).toBe( + "https://coder.com/docs/admin/x", + ); + }); + + it("returns markdown-link suggestions verbatim", () => { + expect(suggestedFixForKind("markdown-link", "/docs/admin/x")).toBe( + "/docs/admin/x", + ); + }); +}); + +describe("buildReport", () => { + const finding = (file, lineNo, overrides = {}) => ({ + file, + lineNo, + kind: "docs-literal", + rawArg: "/admin/rbac", + docsPath: "/docs/admin/rbac", + dynamic: false, + match: { + redirect: rule( + "/docs/admin/rbac", + "/docs/admin/templates/template-permissions", + ), + suggestedDestination: "/docs/admin/templates/template-permissions", + }, + ...overrides, + }); + + const startedAt = new Date("2026-05-27T12:00:00.000Z"); + + it("renders an empty report with zero findings", () => { + const report = buildReport({ + findings: [], + redirectsPath: "/redirects.json", + roots: ["/home/coder/coder/site/src"], + startedAt, + }); + expect(report).toContain("# Redirects audit"); + expect(report).toContain("| 0 | 0 | 0 |"); + expect(report).toContain("## coder/coder/site"); + expect(report).toContain("No findings."); + expect(report).toContain("/redirects.json"); + expect(report).toContain("`/home/coder/coder/site/src`"); + }); + + it("groups findings by repo, counts dynamic/static, and sorts within sections", () => { + const findings = [ + // Dynamic finding under site (sorted last in section). + finding("/home/coder/coder/site/src/b.tsx", 5, { + kind: "docs-template", + rawArg: "/templates/${slug}/edit", + docsPath: "/docs/templates/", + dynamic: true, + }), + // Static finding under site, file b, later line. + finding("/home/coder/coder/site/src/b.tsx", 20), + // Static finding under site, file a (sorts before file b). + finding("/home/coder/coder/site/src/a.tsx", 10), + // Static finding under coder.com/src. + finding("/home/coder/coder.com/src/page.ts", 3), + ]; + const report = buildReport({ + findings, + redirectsPath: "/redirects.json", + roots: ["/home/coder/coder/site/src", "/home/coder/coder.com/src"], + startedAt, + }); + + // Summary: 4 total, 3 static, 1 dynamic. + expect(report).toContain("| 4 | 3 | 1 |"); + + // Both repo sections present, each with finding counts. + expect(report).toMatch(/## coder\/coder\/site\n\n3 findings\./); + expect(report).toMatch(/## coder\/coder\.com\/src\n\n1 finding\./); + + // Within the site section, file a should appear before file b, and the + // dynamic finding should appear after the static ones from the same file. + const siteIdx = report.indexOf("## coder/coder/site"); + const comIdx = report.indexOf("## coder/coder.com/src"); + const siteSection = report.slice(siteIdx, comIdx); + const aIdx = siteSection.indexOf("site/src/a.tsx:10"); + const bStaticIdx = siteSection.indexOf("site/src/b.tsx:20"); + const bDynamicIdx = siteSection.indexOf("site/src/b.tsx:5"); + expect(aIdx).toBeGreaterThan(-1); + expect(bStaticIdx).toBeGreaterThan(-1); + expect(bDynamicIdx).toBeGreaterThan(-1); + expect(aIdx).toBeLessThan(bStaticIdx); + expect(bStaticIdx).toBeLessThan(bDynamicIdx); + + // Dynamic? column reflects the dynamic flag. + expect(siteSection).toMatch(/site\/src\/a\.tsx:10\b[^\n]*\| No \|/); + expect(siteSection).toMatch(/site\/src\/b\.tsx:5\b[^\n]*\| Yes \|/); + }); + + it("annotates findings whose rawArg contains a # fragment", () => { + const report = buildReport({ + findings: [ + finding("/home/coder/coder/site/src/a.tsx", 1, { + rawArg: "/admin/rbac#perms", + }), + ], + redirectsPath: "/redirects.json", + roots: ["/home/coder/coder/site/src"], + startedAt, + }); + expect(report).toContain("(preserve `#anchor` from current value)"); + }); + + it("emits an Unclassified findings section for paths outside known repos", () => { + const report = buildReport({ + findings: [finding("/var/tmp/foo.ts", 1)], + redirectsPath: "/redirects.json", + roots: ["/var/tmp"], + startedAt, + }); + expect(report).toContain("## Unclassified findings"); + expect(report).toContain("/var/tmp/foo.ts:1"); + }); +}); + +describe("walk", () => { + // Builds a temp tree of the shape: + // /a.ts + // /b.tsx + // /c.js + // /nested/d.ts + // /nested/e.md + // /node_modules/skipme.ts + // /dist/also-skipped.ts + // /.audit/audit-report.ts + const buildTree = (root) => { + fs.writeFileSync(path.join(root, "a.ts"), "export const a = 1;"); + fs.writeFileSync(path.join(root, "b.tsx"), "export const B = () => null;"); + fs.writeFileSync(path.join(root, "c.js"), "module.exports = {};"); + fs.mkdirSync(path.join(root, "nested")); + fs.writeFileSync(path.join(root, "nested", "d.ts"), "export const d = 1;"); + fs.writeFileSync(path.join(root, "nested", "e.md"), "# notes"); + fs.mkdirSync(path.join(root, "node_modules")); + fs.writeFileSync( + path.join(root, "node_modules", "skipme.ts"), + "export const x = 1;", + ); + fs.mkdirSync(path.join(root, "dist")); + fs.writeFileSync( + path.join(root, "dist", "also-skipped.ts"), + "export const x = 1;", + ); + fs.mkdirSync(path.join(root, ".audit")); + fs.writeFileSync( + path.join(root, ".audit", "audit-report.ts"), + "export const x = 1;", + ); + }; + + it("returns matching files, recurses, and filters by extension", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "audit-docs-walk-")); + try { + buildTree(tmpDir); + const found = walk(tmpDir, [".ts", ".tsx"]).sort(); + const rel = found.map((f) => path.relative(tmpDir, f)).sort(); + expect(rel).toEqual([ + "a.ts", + "b.tsx", + path.join("nested", "d.ts"), + ]); + // c.js excluded by extension filter; e.md excluded by extension filter; + // node_modules/dist/.audit excluded by SKIP_DIRS. + expect(rel).not.toContain("c.js"); + expect(rel).not.toContain(path.join("nested", "e.md")); + expect(rel).not.toContain(path.join("node_modules", "skipme.ts")); + expect(rel).not.toContain(path.join("dist", "also-skipped.ts")); + expect(rel).not.toContain(path.join(".audit", "audit-report.ts")); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("honors the extension filter (.tsx only)", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "audit-docs-walk-")); + try { + buildTree(tmpDir); + const found = walk(tmpDir, [".tsx"]); + const rel = found.map((f) => path.relative(tmpDir, f)); + expect(rel).toEqual(["b.tsx"]); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("returns an empty array when the directory does not exist", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "audit-docs-walk-")); + try { + const missing = path.join(tmpDir, "does-not-exist"); + expect(walk(missing, [".ts"])).toEqual([]); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("returns an empty array when handed a file instead of a directory", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "audit-docs-walk-")); + try { + const filePath = path.join(tmpDir, "a.ts"); + fs.writeFileSync(filePath, "export const a = 1;"); + expect(walk(filePath, [".ts"])).toEqual([]); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("appends to an existing results array instead of replacing it", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "audit-docs-walk-")); + try { + fs.writeFileSync(path.join(tmpDir, "a.ts"), "export const a = 1;"); + const seed = ["/seed/value.ts"]; + const out = walk(tmpDir, [".ts"], seed); + expect(out).toBe(seed); + expect(out).toContain("/seed/value.ts"); + expect(out.some((f) => f.endsWith("a.ts"))).toBe(true); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + +describe("runCli", () => { + // Minimal redirects file written to a tmp dir so the CLI can load it. + const writeTmpRedirects = (tmpDir) => { + const redirectsPath = path.join(tmpDir, "redirects.json"); + fs.writeFileSync( + redirectsPath, + JSON.stringify([ + rule("/docs/admin/rbac", "/docs/admin/templates/template-permissions"), + ]), + ); + return redirectsPath; + }; + + it("warns and continues when a --roots directory does not exist", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "audit-docs-cli-")); + try { + const redirectsPath = writeTmpRedirects(tmpDir); + const missingRoot = path.join(tmpDir, "does-not-exist"); + const outPath = path.join(tmpDir, "out.md"); + + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + let stderr; + try { + runCli([ + `--redirects=${redirectsPath}`, + `--roots=${missingRoot}`, + `--out=${outPath}`, + ]); + stderr = errSpy.mock.calls.map((c) => c.join(" ")).join("\n"); + } finally { + errSpy.mockRestore(); + } + + expect(stderr).toContain(missingRoot); + expect(stderr).toContain("does not exist"); + + // Report was still written with zero findings. + expect(fs.existsSync(outPath)).toBe(true); + const report = fs.readFileSync(outPath, "utf-8"); + expect(report).toContain("| 0 | 0 | 0 |"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("writes a report when --roots contains a real directory with no matches", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "audit-docs-cli-")); + try { + const redirectsPath = writeTmpRedirects(tmpDir); + const emptyRoot = path.join(tmpDir, "empty"); + fs.mkdirSync(emptyRoot); + const outPath = path.join(tmpDir, "out.md"); + + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + runCli([ + `--redirects=${redirectsPath}`, + `--roots=${emptyRoot}`, + `--out=${outPath}`, + ]); + } finally { + errSpy.mockRestore(); + } + + expect(fs.existsSync(outPath)).toBe(true); + const report = fs.readFileSync(outPath, "utf-8"); + expect(report).toContain("| 0 | 0 | 0 |"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("wires the full pipeline end-to-end and reports a real finding", () => { + // Exercises walk -> readFile -> extractReferences -> + // findMatchingRedirect -> buildReport -> writeFile against a seeded + // .ts file. The path layout mimics coder/coder/site so repoForFile + // classifies the finding into the expected report section. + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "audit-docs-cli-")); + try { + const redirectsPath = writeTmpRedirects(tmpDir); + const siteRoot = path.join(tmpDir, "coder", "site", "src"); + const nested = path.join(siteRoot, "pages"); + fs.mkdirSync(nested, { recursive: true }); + const sourceFile = path.join(nested, "Example.tsx"); + fs.writeFileSync( + sourceFile, + 'import { docs } from "./docs";\nconst href = docs("/admin/rbac");\n', + ); + const outPath = path.join(tmpDir, "out.md"); + + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + runCli([ + `--redirects=${redirectsPath}`, + `--roots=${siteRoot}`, + `--out=${outPath}`, + ]); + } finally { + errSpy.mockRestore(); + } + + expect(fs.existsSync(outPath)).toBe(true); + const report = fs.readFileSync(outPath, "utf-8"); + + // Summary row reflects exactly one auto-fixable finding. + expect(report).toContain("| 1 | 1 | 0 |"); + // Repo section header for coder/coder/site. + expect(report).toContain("## coder/coder/site"); + expect(report).toContain("1 finding."); + // Table row includes the relative file path, the raw arg, the + // matched redirect, and the suggested fix. + expect(report).toContain("site/src/pages/Example.tsx:2"); + expect(report).toContain("`/admin/rbac`"); + expect(report).toContain( + "`/docs/admin/rbac` -> `/docs/admin/templates/template-permissions`", + ); + expect(report).toContain("`/admin/templates/template-permissions`"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/site/src/components/Paywall/PaywallAIGovernance.tsx b/site/src/components/Paywall/PaywallAIGovernance.tsx index 9aff0257f4..64d916687b 100644 --- a/site/src/components/Paywall/PaywallAIGovernance.tsx +++ b/site/src/components/Paywall/PaywallAIGovernance.tsx @@ -44,7 +44,7 @@ const PaywallAIGovernance = () => { Visit{" "} = () => { Review and audit AI activity, token usage, and prompt history across sessions.{" "} diff --git a/site/src/pages/AIBridgePage/AIBridgeSetupAlert.tsx b/site/src/pages/AIBridgePage/AIBridgeSetupAlert.tsx index 30715e3ff7..5378103309 100644 --- a/site/src/pages/AIBridgePage/AIBridgeSetupAlert.tsx +++ b/site/src/pages/AIBridgePage/AIBridgeSetupAlert.tsx @@ -10,7 +10,7 @@ export const AIBridgeSetupAlert: FC = () => { AI Gateway is included in your license, but not set up yet. - You have access to AI Governance, but it still needs to be setup. Check + You have access to AI Governance, but it still needs to be set up. Check out the{" "} AI Gateway diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx index fb64cddd4c..00fda27fea 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx @@ -319,7 +319,7 @@ const ThreadItem: FC = ({ thread, initiator }) => {

= ({ filter, error, menus }) => { const width = menus.organization ? DEFAULT_USER_FILTER_WIDTH : undefined; return ( + } > diff --git a/site/src/pages/TemplatesPage/TemplatesFilter.tsx b/site/src/pages/TemplatesPage/TemplatesFilter.tsx index 0ba45245de..68e9ffed74 100644 --- a/site/src/pages/TemplatesPage/TemplatesFilter.tsx +++ b/site/src/pages/TemplatesPage/TemplatesFilter.tsx @@ -60,7 +60,7 @@ export const TemplatesFilter: FC = ({ { query: "deprecated:true", name: "Deprecated templates" }, ]} // TODO: Add docs for this - // learnMoreLink={docs("/templates#template-filtering")} + // learnMoreLink={docs("/admin/templates#template-filtering")} isLoading={false} filter={filter} error={error} diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 310cc1d1ef..6eb84004b6 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -4955,7 +4955,7 @@ export const MockSystemNotificationTemplates: TypesGen.NotificationTemplate[] = name: "Workspace Marked as Dormant", title_template: 'Workspace "{{.Labels.name}}" marked as dormant', body_template: - "Hi {{.UserName}}\n\nYour workspace **{{.Labels.name}}** has been marked as [**dormant**](https://coder.com/docs/templates/schedule#dormancy-threshold-enterprise) because of {{.Labels.reason}}.\nDormant workspaces are [automatically deleted](https://coder.com/docs/templates/schedule#dormancy-auto-deletion-enterprise) after {{.Labels.timeTilDormant}} of inactivity.\nTo prevent deletion, use your workspace with the link below.", + "Hi {{.UserName}}\n\nYour workspace **{{.Labels.name}}** has been marked as [**dormant**](https://coder.com/docs/admin/templates/managing-templates/schedule#dormancy-threshold-enterprise) because of {{.Labels.reason}}.\nDormant workspaces are [automatically deleted](https://coder.com/docs/admin/templates/managing-templates/schedule#dormancy-auto-deletion-enterprise) after {{.Labels.timeTilDormant}} of inactivity.\nTo prevent deletion, use your workspace with the link below.", actions: '[{"url": "{{ base_url }}/@{{.UserUsername}}/{{.Labels.name}}", "label": "View workspace"}]', group: "Workspace Events", @@ -4981,7 +4981,7 @@ export const MockSystemNotificationTemplates: TypesGen.NotificationTemplate[] = name: "Workspace Marked for Deletion", title_template: 'Workspace "{{.Labels.name}}" marked for deletion', body_template: - "Hi {{.UserName}}\n\nYour workspace **{{.Labels.name}}** has been marked for **deletion** after {{.Labels.timeTilDormant}} of [dormancy](https://coder.com/docs/templates/schedule#dormancy-auto-deletion-enterprise) because of {{.Labels.reason}}.\nTo prevent deletion, use your workspace with the link below.", + "Hi {{.UserName}}\n\nYour workspace **{{.Labels.name}}** has been marked for **deletion** after {{.Labels.timeTilDormant}} of [dormancy](https://coder.com/docs/admin/templates/managing-templates/schedule#dormancy-auto-deletion-enterprise) because of {{.Labels.reason}}.\nTo prevent deletion, use your workspace with the link below.", actions: '[{"url": "{{ base_url }}/@{{.UserUsername}}/{{.Labels.name}}", "label": "View workspace"}]', group: "Workspace Events",