From d603e9684fc4c6bf9c613894dff11521435b3f4a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 22 May 2026 13:59:40 +0000 Subject: [PATCH] fix(install-tracker): fall back to GitHub code search for monorepos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rigid candidate list missed any monorepo whose app lived in a subdirectory (e.g. profullstack/sh1pt has the Next.js layout at sites/sh1pt.com/app/layout.tsx). The error was clear but unactionable. Three changes: 1. installTracker() takes an optional rootPath, which prefixes the canonical candidate paths. Caller (the API route or future UI) can pin a monorepo's app directory explicitly. 2. When canonical probing still misses, fall back to GitHub Code Search for any file containing "" in the repo. Handles monorepos and non-standard layouts (SvelteKit src/app.html, Remix app/root.tsx, etc.) without us having to enumerate them. Hits inside rootPath get priority when set. 3. New searchRepoCode() helper in lib/github/repos.ts. Quoted-substring query scoped to repo:owner/name via the /search/code endpoint. The fallback's index lag (minutes) is fine — we instrument existing sites, not freshly-pushed ones. Error message now also tells the user to set a root path or open an issue with their layout. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/github/install-tracker.ts | 95 +++++++++++++++++++++++++++++++---- lib/github/repos.ts | 49 ++++++++++++++++++ 2 files changed, 135 insertions(+), 9 deletions(-) diff --git a/lib/github/install-tracker.ts b/lib/github/install-tracker.ts index b9a34012..91769e5e 100644 --- a/lib/github/install-tracker.ts +++ b/lib/github/install-tracker.ts @@ -11,6 +11,7 @@ import { getRepo, openPullRequest, putFile, + searchRepoCode, } from "./repos"; interface InstallInput { @@ -18,6 +19,10 @@ interface InstallInput { owner: string; repo: string; projectId: string; + /** Optional subdirectory inside a monorepo, e.g. "apps/web" or + * "sites/sh1pt.com". Canonical candidate paths get this prefix + * before being probed. Leave undefined for single-app repos. */ + rootPath?: string; } export interface InstallResult { @@ -78,6 +83,12 @@ function injectBeforeBodyClose( return `${prefix}${indent} ${snippet}\n${indent}${content.slice(idx)}`; } +/** Strip leading/trailing slashes so we can confidently join with "/". */ +function normalizeRoot(p: string | undefined): string { + if (!p) return ""; + return p.replace(/^\/+/, "").replace(/\/+$/, ""); +} + export async function installTracker(input: InstallInput): Promise { const repoMeta = await getRepo({ token: input.token, @@ -88,9 +99,18 @@ export async function installTracker(input: InstallInput): Promise. - let target: { path: string; sha: string; content: string } | null = null; - for (const path of CANDIDATES) { + const root = normalizeRoot(input.rootPath); + const candidates = root + ? CANDIDATES.map((p) => `${root}/${p}`) + : CANDIDATES; + + // Probe a path: returns the file if it has AND doesn't already + // have our snippet; signals "already installed" if it does. + type ProbeResult = + | { kind: "hit"; file: { path: string; sha: string; content: string } } + | { kind: "already"; path: string } + | { kind: "miss" }; + const probe = async (path: string): Promise => { const file = await getFileContent({ token: input.token, owner: input.owner, @@ -98,23 +118,80 @@ export async function installTracker(input: InstallInput): Promise/i.test(file.content)) { + return { kind: "hit", file }; + } + return { kind: "miss" }; + }; + + // 1. Try the canonical candidate list (optionally prefixed by rootPath). + let target: { path: string; sha: string; content: string } | null = null; + for (const path of candidates) { + const r = await probe(path); + if (r.kind === "already") { return { status: "noop", - path: file.path, - detail: `Tracker already installed at ${file.path}.`, + path: r.path, + detail: `Tracker already installed at ${r.path}.`, }; } - if (/<\/body>/i.test(file.content)) { - target = file; + if (r.kind === "hit") { + target = r.file; break; } } + // 2. Fallback: ask GitHub's code search for any file containing + // in this repo. Handles monorepos (e.g. apps/web/app/layout.tsx, + // sites/foo/app/layout.tsx) and non-standard frameworks + // (SvelteKit src/app.html, Remix app/root.tsx, ...). + if (!target) { + try { + const hits = await searchRepoCode({ + token: input.token, + owner: input.owner, + repo: input.repo, + query: "", + }); + // If a root path is configured, prefer hits inside it. + const ordered = root + ? [ + ...hits.filter((h) => h.path.startsWith(root + "/")), + ...hits.filter((h) => !h.path.startsWith(root + "/")), + ] + : hits; + for (const hit of ordered) { + const r = await probe(hit.path); + if (r.kind === "already") { + return { + status: "noop", + path: r.path, + detail: `Tracker already installed at ${r.path}.`, + }; + } + if (r.kind === "hit") { + target = r.file; + break; + } + } + } catch (err) { + // Code search failure is non-fatal — fall through to the error + // path below with a clearer message. + console.warn("[install-tracker] code search failed:", err); + } + } + if (!target) { + const probed = candidates.join(", "); + const hint = root + ? `Looked under root "${root}". Try a different root path or open an issue with your repo layout.` + : "Monorepo? Set a root path on the project's Repos tab to point at the app directory (e.g. apps/web)."; throw new Error( - `No template file with found. Looked in: ${CANDIDATES.join(", ")}.`, + `No template file with found in ${input.owner}/${input.repo}. Probed canonical paths: ${probed}. ${hint}`, ); } diff --git a/lib/github/repos.ts b/lib/github/repos.ts index fcff1e55..d312f5aa 100644 --- a/lib/github/repos.ts +++ b/lib/github/repos.ts @@ -176,6 +176,55 @@ export async function putFile(input: { return (await res.json()) as PutFileResponse; } +interface CodeSearchHit { + name: string; + path: string; + repository?: { full_name: string }; +} + +interface CodeSearchResponse { + total_count: number; + items: CodeSearchHit[]; +} + +/** + * Find files in a repo whose contents match a literal substring. Uses + * the GitHub Code Search API so we can locate template files in + * monorepos / non-standard layouts where the canonical paths miss. + * + * Note: the search index lags writes by minutes, so a freshly-pushed + * file may not appear immediately. For our use case (instrumenting an + * existing site) that's fine. + */ +export async function searchRepoCode(input: { + token: string; + owner: string; + repo: string; + query: string; + /** Optional path qualifier, e.g. "*.tsx" or "app/". */ + extension?: string; + perPage?: number; +}): Promise { + // The search API needs the substring inside quotes when it contains + // special characters. Build q="" repo:owner/name + const q = [ + `"${input.query}"`, + `repo:${input.owner}/${input.repo}`, + input.extension ? `extension:${input.extension}` : "", + ] + .filter(Boolean) + .join(" "); + const res = await gh( + `/search/code?q=${encodeURIComponent(q)}&per_page=${input.perPage ?? 30}`, + { token: input.token }, + ); + if (!res.ok) { + throw new Error(`searchRepoCode ${res.status}: ${await res.text()}`); + } + const body = (await res.json()) as CodeSearchResponse; + return body.items ?? []; +} + interface PullRequest { number: number; html_url: string;