From 147ee497a908f31a6be091588cbee07e99a65443 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 22 May 2026 14:30:18 +0000 Subject: [PATCH] feat(install-tracker): pick path, preview diff, confirm before PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User report: on profullstack/sh1pt the tracker installer picked boilerplates/next-supabase/app/layout.tsx — a template, not the live site at sites/sh1pt.com/app/layout.tsx. PRs were auto-submitted without review. Fix: four-step modal — pick repo → pick path → preview → submit. The user always sees and approves the target file before any PR opens. Backend (one endpoint, three modes): - mode=candidates: scan canonical paths + code search, return ranked candidate file paths. Ranking penalizes boilerplates/, examples/, templates/, samples/, fixtures/, tests/, stories/, docs/. Boosts apps/*, sites/*, web/, anything containing the repo name, root-level files, and Next.js app/layout.tsx specifically. No PR opened. - mode=preview: given a target_path, return the before/after content and the exact snippet that would be inserted. No PR opened. Detects "already installed" (data-site marker present) and "not a template" (no in file) up front. - mode=submit: requires target_path, runs installTracker with targetPath set so discovery is skipped. Opens the PR. project_pr_runs row recorded only in this mode (the scan / preview steps are free reads — no logging needed). installTracker now accepts an explicit targetPath that short-circuits discovery entirely. UI uses this after the user confirms. UI flow (replaces single-shot "click repo → PR opens"): 1. Pick repo from the list (filter, bound vs all). 2. Optional "Web app subdirectory" input (visible, not buried). 3. Click "Use this repo" → backend scans → shows ranked candidates + a manual path field for explicit override. 4. Click "Use this file" or type a path → backend previews → shows the snippet that will be inserted (and notes the next/script import if it's a Next.js layout). 5. Click "Open PR" → backend submits. PR URL shown on success. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../projects/[id]/stats/auto-install.tsx | 499 +++++++++++++----- .../[id]/github/install-tracker/route.ts | 57 +- lib/github/install-tracker.ts | 198 ++++++- 3 files changed, 614 insertions(+), 140 deletions(-) diff --git a/app/(app)/projects/[id]/stats/auto-install.tsx b/app/(app)/projects/[id]/stats/auto-install.tsx index 8ac5719e..81bf0847 100644 --- a/app/(app)/projects/[id]/stats/auto-install.tsx +++ b/app/(app)/projects/[id]/stats/auto-install.tsx @@ -15,20 +15,49 @@ interface Repo { interface AutoInstallProps { projectId: string; installations: Installation[]; - /** All repos across all installations the user has connected. */ repos: Repo[]; - /** Repos already bound to this project (subset of repos by full_name). */ boundRepos: Repo[]; - /** True when no GH App is configured at all on this deployment. */ notConfigured: boolean; } -interface InstallResult { +interface Candidate { + path: string; + score: number; + sizeBytes?: number; +} + +interface PreviewReady { + status: "ready"; + path: string; + snippet: string; + before: string; + after: string; + addsImport: boolean; +} +interface PreviewAlready { + status: "already_installed"; + path: string; +} +interface PreviewBad { + status: "not_a_template"; + path: string; + reason: string; +} +type Preview = PreviewReady | PreviewAlready | PreviewBad; + +interface SubmitOk { status: "opened" | "noop"; prUrl?: string; detail: string; + path?: string; } +type Step = + | { kind: "pick-repo" } + | { kind: "pick-path"; repo: Repo; candidates: Candidate[]; loading: boolean } + | { kind: "preview"; repo: Repo; preview: Preview; path: string } + | { kind: "done"; result: SubmitOk; repo: Repo }; + export function AutoInstall({ projectId, installations, @@ -37,16 +66,26 @@ export function AutoInstall({ notConfigured, }: AutoInstallProps) { const [open, setOpen] = useState(false); + const [step, setStep] = useState({ kind: "pick-repo" }); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); const [q, setQ] = useState(""); const [rootPath, setRootPath] = useState(""); - const [submitting, setSubmitting] = useState(null); - const [result, setResult] = useState<{ repo: string; result: InstallResult } | null>(null); - const [error, setError] = useState(null); - // When repos are bound to the project, start narrow; let users opt - // into the global list by toggling. + const [manualPath, setManualPath] = useState(""); const [showAll, setShowAll] = useState(boundRepos.length === 0); - // ESC closes the modal. + // Reset state when the modal opens/closes. + useEffect(() => { + if (open) { + setStep({ kind: "pick-repo" }); + setError(null); + setQ(""); + setRootPath(""); + setManualPath(""); + } + }, [open]); + + // ESC closes. useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { @@ -63,52 +102,89 @@ export function AutoInstall({ return source.filter((r) => r.full_name.toLowerCase().includes(needle)); }, [q, source]); - async function run(repo: Repo) { - setError(null); - setSubmitting(repo.full_name); - setResult(null); + async function apiCall(repo: Repo, body: Record) { const [owner, name] = repo.full_name.split("/"); + const res = await fetch( + `/api/projects/${projectId}/github/install-tracker`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + owner, + repo: name, + installation_id: repo.installation_id, + ...body, + }), + }, + ); + const json = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`); + return json.data; + } + + async function startScan(repo: Repo) { + setError(null); + setBusy(true); + setStep({ kind: "pick-path", repo, candidates: [], loading: true }); + try { + const data = await apiCall(repo, { + mode: "candidates", + root_path: rootPath.trim() || undefined, + }); + setStep({ + kind: "pick-path", + repo, + candidates: data.candidates ?? [], + loading: false, + }); + } catch (e) { + setError(e instanceof Error ? e.message : "Scan failed"); + setStep({ kind: "pick-repo" }); + } finally { + setBusy(false); + } + } + + async function previewPath(repo: Repo, path: string) { + setError(null); + setBusy(true); try { - const res = await fetch( - `/api/projects/${projectId}/github/install-tracker`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - owner, - repo: name, - installation_id: repo.installation_id, - root_path: rootPath.trim() || undefined, - }), - }, - ); - const json = await res.json(); - if (!res.ok) { - setError(json.error || "Failed to open PR"); - return; - } - setResult({ repo: repo.full_name, result: json.data }); + const preview: Preview = await apiCall(repo, { + mode: "preview", + target_path: path, + }); + setStep({ kind: "preview", repo, preview, path }); } catch (e) { - setError(e instanceof Error ? e.message : "Network error"); + setError(e instanceof Error ? e.message : "Preview failed"); } finally { - setSubmitting(null); + setBusy(false); } } - if (notConfigured) { - return null; + async function submitInstall(repo: Repo, path: string) { + setError(null); + setBusy(true); + try { + const result: SubmitOk = await apiCall(repo, { + mode: "submit", + target_path: path, + }); + setStep({ kind: "done", result, repo }); + } catch (e) { + setError(e instanceof Error ? e.message : "Submit failed"); + } finally { + setBusy(false); + } } + if (notConfigured) return null; + return ( <> @@ -119,7 +195,7 @@ export function AutoInstall({ onClick={() => setOpen(false)} >
e.stopPropagation()} >
@@ -135,118 +211,269 @@ export function AutoInstall({
- {installations.length === 0 ? ( -

- You haven't connected a GitHub installation yet.{" "} - - Connect on the GitHub settings page - - . -

- ) : ( + {/* STEP 1: pick repo */} + {step.kind === "pick-repo" && ( + <> + {installations.length === 0 ? ( +

+ You haven't connected a GitHub installation yet.{" "} + + Connect on the GitHub settings page + + . +

+ ) : ( + <> +

+ Pick the repo for this site. You'll review the + target file before any PR is opened. +

+ +
+ setQ(e.target.value)} + placeholder={`Filter ${source.length} repos…`} + className="w-full max-w-xs rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm" + /> + {boundRepos.length > 0 && ( + + )} +
+ +
    + {filtered.slice(0, 50).map((r) => ( +
  • + {r.full_name} + +
  • + ))} +
+ +
+ + setRootPath(e.target.value)} + placeholder="apps/web — leave blank to scan whole repo" + className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm" + /> +
+ + )} + + )} + + {/* STEP 2: pick path */} + {step.kind === "pick-path" && ( <>

- Pick the repo for this site. We'll find the right - template file, inject the script tag, and open a PR. + Repo: {step.repo.full_name} + {rootPath && <> · root {rootPath}}

-
- setQ(e.target.value)} - placeholder={`Filter ${source.length} repo${source.length === 1 ? "" : "s"}…`} - className="w-full max-w-xs rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm" - /> - {boundRepos.length > 0 && ( + {step.loading ? ( +

+ Scanning repo for layout files… +

+ ) : step.candidates.length === 0 ? ( +

+ No candidate files found. Enter a file path manually + below, or close and try a different repo. +

+ ) : ( + <> +

+ Candidates (best match first): +

+
    + {step.candidates.map((c) => ( +
  • +
    +

    {c.path}

    +

    + score {c.score.toFixed(0)} + {c.sizeBytes != null && ` · ${c.sizeBytes}b`} +

    +
    + +
  • + ))} +
+ + )} + +
+ +
+ setManualPath(e.target.value)} + placeholder="apps/web/app/layout.tsx" + className="flex-1 rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm font-mono" + /> - )} +
-
- - Subdirectory (optional, for monorepos) - - setRootPath(e.target.value)} - placeholder="apps/web" - className="mt-2 w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm" - /> -

- Leave blank to auto-detect. Set if your site lives in a - monorepo subdir like apps/web. -

-
- -
    - {filtered.length === 0 ? ( -
  • - No repos match. -
  • - ) : ( - filtered.slice(0, 50).map((r) => ( -
  • - {r.full_name} - -
  • - )) - )} -
- {filtered.length > 50 && ( -

- Showing the first 50 of {filtered.length} matches — refine - the filter to narrow further. -

- )} +
+ +
+ + )} - {error && ( -
- {error} + {/* STEP 3: preview */} + {step.kind === "preview" && ( + <> +

+ Repo: {step.repo.full_name} · path{" "} + {step.path} +

+ + {step.preview.status === "already_installed" && ( +
+ Tracker already installed at{" "} + {step.preview.path}. Nothing to do.
)} - {result && ( -
-

{result.repo}

-

- {result.result.detail} + {step.preview.status === "not_a_template" && ( +

+ {step.preview.reason} Pick a different file or close and + try a different repo. +
+ )} + {step.preview.status === "ready" && ( + <> +

+ The PR will add this line before </body>:

- {result.result.prUrl && ( - - Open PR → - +
+                      {step.preview.snippet}
+                    
+ {step.preview.addsImport && ( +

+ Also adds{" "} + import Script from "next/script";{" "} + to the top of the file. +

)} + + )} + + {error && ( +
+ {error}
)} + +
+ + {step.preview.status === "ready" && ( + + )} +
)} + + {/* STEP 4: done */} + {step.kind === "done" && ( +
+
+

+ {step.result.status === "opened" + ? "PR opened" + : "Already installed"} +

+

+ {step.result.detail} +

+ {step.result.prUrl && ( + + Open PR → + + )} +
+ +
+ )} + + {error && step.kind !== "preview" && ( +
+ {error} +
+ )}
)} diff --git a/app/api/projects/[id]/github/install-tracker/route.ts b/app/api/projects/[id]/github/install-tracker/route.ts index 6302077d..82cb02d4 100644 --- a/app/api/projects/[id]/github/install-tracker/route.ts +++ b/app/api/projects/[id]/github/install-tracker/route.ts @@ -7,7 +7,11 @@ import { z } from "zod"; import { createClient } from "@/lib/supabase/server"; import { serviceClient } from "@/lib/supabase/service"; import { getOrMintInstallationToken } from "@/lib/github/installations"; -import { installTracker } from "@/lib/github/install-tracker"; +import { + installTracker, + findInstallCandidates, + previewInstallAtPath, +} from "@/lib/github/install-tracker"; export const runtime = "nodejs"; @@ -18,6 +22,12 @@ const bodySchema = z.object({ /** Optional subdirectory inside the repo where the app lives, * e.g. "apps/web" or "sites/sh1pt.com". */ root_path: z.string().max(500).optional(), + /** When set: skip discovery, install at this exact path. */ + target_path: z.string().max(500).optional(), + /** "candidates": return ranked candidate paths (no PR). + * "preview": return the diff that would be applied at target_path. + * "submit": open the PR (default). target_path required when set. */ + mode: z.enum(["candidates", "preview", "submit"]).optional(), }); export async function POST( @@ -66,7 +76,49 @@ export async function POST( const svc = serviceClient(); - // Record the run row up front so we have something to update on failure. + // mode=candidates: just list candidate files; no PR, no run row. + const mode = body.mode ?? "submit"; + if (mode === "candidates") { + try { + const token = await getOrMintInstallationToken(body.installation_id); + const candidates = await findInstallCandidates({ + token, + owner: body.owner, + repo: body.repo, + rootPath: body.root_path, + }); + return NextResponse.json({ data: { candidates } }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error: msg }, { status: 500 }); + } + } + + // mode=preview: show what the install at target_path would look like. + if (mode === "preview") { + if (!body.target_path) { + return NextResponse.json( + { error: "target_path is required for preview" }, + { status: 400 }, + ); + } + try { + const token = await getOrMintInstallationToken(body.installation_id); + const preview = await previewInstallAtPath({ + token, + owner: body.owner, + repo: body.repo, + path: body.target_path, + projectId, + }); + return NextResponse.json({ data: preview }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error: msg }, { status: 500 }); + } + } + + // mode=submit: actually open the PR. Record the run row. const { data: run } = await (svc as any) .from("project_pr_runs") .insert({ @@ -98,6 +150,7 @@ export async function POST( repo: body.repo, projectId, rootPath: body.root_path, + targetPath: body.target_path, }); await finalize({ status: result.status, diff --git a/lib/github/install-tracker.ts b/lib/github/install-tracker.ts index 0f177a68..0553d10a 100644 --- a/lib/github/install-tracker.ts +++ b/lib/github/install-tracker.ts @@ -23,6 +23,10 @@ interface InstallInput { * "sites/sh1pt.com". Canonical candidate paths get this prefix * before being probed. Leave undefined for single-app repos. */ rootPath?: string; + /** Explicit target file (e.g. "apps/web/app/layout.tsx"). When set, + * discovery is skipped — we install at exactly this path. Used by + * the UI's confirmation step so the user picks the file. */ + targetPath?: string; } export interface InstallResult { @@ -136,6 +140,175 @@ function normalizeRoot(p: string | undefined): string { return p.replace(/^\/+/, "").replace(/\/+$/, ""); } +// Ranks a candidate path. Higher is better. The picker uses this to +// surface the right file by default instead of (say) the boilerplate +// under examples/. Tuned for monorepos where the production app lives +// at apps/* or sites/*. +function rankCandidatePath(path: string, repoName: string): number { + let score = 0; + // Strong negatives — almost never the live site's template. + if (/(^|\/)(boilerplates?|examples?|templates?|samples?|fixtures?|__tests__|tests?|spec|stories|playground|sandbox|demo|node_modules)(\/|$)/i.test(path)) { + score -= 100; + } + // Penalize markdown / docs paths just in case. + if (/(^|\/)(docs?|documentation)(\/|$)/i.test(path)) score -= 20; + // Likely a real app dir. + if (/(^|\/)apps\//i.test(path)) score += 30; + if (/(^|\/)sites\//i.test(path)) score += 30; + if (/(^|\/)web(\/|$)/i.test(path)) score += 20; + // Repo-name match boosts (e.g. sites/sh1pt.com beats sites/foo when + // repo is "sh1pt"). + const lower = path.toLowerCase(); + const nameLower = repoName.toLowerCase(); + if (lower.includes(nameLower)) score += 25; + // Root-level template files always come first when present. + if (!path.includes("/")) score += 50; + // Next.js App Router is more common than Pages — slight nudge. + if (/\/app\/layout\.(tsx|jsx)$/.test(path)) score += 10; + // Shorter paths slightly preferred (closer to root = more canonical). + score -= path.length * 0.05; + return score; +} + +export interface InstallCandidate { + path: string; + /** Score from rankCandidatePath — debug / explainer. */ + score: number; + /** Bytes of the file; helps the UI hint at "is this a real app file?". */ + sizeBytes?: number; +} + +/** + * Probe + rank all the candidate template files in a repo. Returns the + * list sorted best-first so the UI can show the top pick with the rest + * as alternatives. + */ +export async function findInstallCandidates(input: { + token: string; + owner: string; + repo: string; + rootPath?: string; +}): Promise { + const repoMeta = await getRepo({ + token: input.token, + owner: input.owner, + repo: input.repo, + }); + const ref = repoMeta.default_branch; + const root = normalizeRoot(input.rootPath); + const canonical = root ? CANDIDATES.map((p) => `${root}/${p}`) : CANDIDATES; + + const found = new Map(); + + // Probe the canonical list — cheap, no rate limit on contents API. + for (const path of canonical) { + const file = await getFileContent({ + token: input.token, + owner: input.owner, + repo: input.repo, + path, + ref, + }); + if (file && /<\/body>/i.test(file.content)) { + found.set(file.path, { sizeBytes: file.content.length }); + } + } + + // Expand with code search hits — handles monorepos / unusual layouts. + try { + const hits = await searchRepoCode({ + token: input.token, + owner: input.owner, + repo: input.repo, + query: "", + }); + for (const h of hits) { + if (!found.has(h.path)) found.set(h.path, {}); + } + } catch { + // Search is a fallback; tolerate failure. + } + + const ranked: InstallCandidate[] = [...found.entries()].map(([path, meta]) => ({ + path, + score: rankCandidatePath(path, input.repo), + sizeBytes: meta.sizeBytes, + })); + ranked.sort((a, b) => b.score - a.score); + return ranked; +} + +/** + * Generate the would-be diff preview for installing the tracker at a + * specific path. Used by the UI to confirm before opening the PR. + */ +export async function previewInstallAtPath(input: { + token: string; + owner: string; + repo: string; + path: string; + projectId: string; +}): Promise< + | { status: "already_installed"; path: string } + | { + status: "ready"; + path: string; + snippet: string; + before: string; + after: string; + addsImport: boolean; + } + | { status: "not_a_template"; path: string; reason: string } +> { + const repoMeta = await getRepo({ + token: input.token, + owner: input.owner, + repo: input.repo, + }); + const file = await getFileContent({ + token: input.token, + owner: input.owner, + repo: input.repo, + path: input.path, + ref: repoMeta.default_branch, + }); + if (!file) { + return { status: "not_a_template", path: input.path, reason: "File not found." }; + } + const projectIdMarker = `data-site="${input.projectId}"`; + if (file.content.includes(projectIdMarker)) { + return { status: "already_installed", path: file.path }; + } + if (!/<\/body>/i.test(file.content)) { + return { + status: "not_a_template", + path: file.path, + reason: "No tag in this file.", + }; + } + const snippet = snippetForPath(input.projectId, file.path); + const after = injectBeforeBodyClose(file.content, snippet, file.path); + if (!after) { + return { + status: "not_a_template", + path: file.path, + reason: "Couldn't locate after injection pass.", + }; + } + const addsImport = + /\.(tsx|jsx)$/.test(file.path) && + / { const repoMeta = await getRepo({ token: input.token, @@ -174,9 +347,30 @@ export async function installTracker(input: InstallInput): Promise tag.`, + ); + } + target = r.file; + } + + // 1. Try the canonical candidate list (optionally prefixed by rootPath). + for (const path of !target ? candidates : []) { const r = await probe(path); if (r.kind === "already") { return {