From c625e9c8473752cfe09dd16e681761e5f0d11b74 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 4 Aug 2026 08:31:41 +0000 Subject: [PATCH] fix(careers): scan the repo for install locations instead of guessing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The careers PR flow blind-probed ten hardcoded paths at a single root (astro.config.*, app/layout.*, src/app/layout.*). Anything else — a site at web/, frontend/, packages/site/, or any monorepo path the customer didn't type into the subdirectory box — came back "No Next.js App Router or Astro site found there", which reads as a missing feature rather than a missed guess. The stats tracker installer already solved this: findInstallCandidates scans, ranks, and hands the UI a picker with a manual-path escape hatch. This brings the careers flow to the same shape. - repos: add listRepoTree, one recursive git-trees request for the whole file list. The contents API can only confirm paths you already know. - install-careers: careersCandidatesFromTree derives every possible route directory from that listing and ranks it with the tracker's heuristics (apps/ and sites/ up, examples/fixtures down, root-level app first). findCareersCandidates wraps it and still falls back to the direct probe when the tree is unavailable or GitHub truncated it, so a huge repo degrades to the old behaviour rather than claiming the site is missing. Locations that already have a careers page are listed and flagged, not hidden — "you already have this" beats an empty list. - route: add mode=candidates (read-only, no PR, no run row) and accept target_dir on submit. A directory chosen in the browser is user input, so verifyCareersDir re-probes for the framework marker before any write; the installer's promise is that it only writes where it can positively see a site. - ui: replace the single detect line with the ranked list, preselecting the best match, plus a manual directory box for truncated repos. Co-Authored-By: Claude Opus 5 (1M context) --- .../[id]/stats/careers/careers-install.tsx | 143 +++++++++-- .../[id]/github/install-careers/route.ts | 61 ++++- lib/github/install-careers.ts | 241 +++++++++++++++++- lib/github/repos.ts | 44 ++++ tests/careers-install-pr.test.ts | 171 ++++++++++++- 5 files changed, 631 insertions(+), 29 deletions(-) diff --git a/app/(app)/projects/[id]/stats/careers/careers-install.tsx b/app/(app)/projects/[id]/stats/careers/careers-install.tsx index a49d0e7b..29fe553b 100644 --- a/app/(app)/projects/[id]/stats/careers/careers-install.tsx +++ b/app/(app)/projects/[id]/stats/careers/careers-install.tsx @@ -3,9 +3,14 @@ // "Add a real /careers page to my repo" — the crawlable counterpart to the // drop-in widget. // -// Two steps on purpose. Detection is read-only and tells the user what we -// found before anything is written; only the second button opens a PR. People -// are reasonably wary of a button that commits to their repo sight unseen. +// Two steps on purpose. Scanning is read-only and shows what we found before +// anything is written; only the second button opens a PR. People are reasonably +// wary of a button that commits to their repo sight unseen. +// +// The scan lists every place the route could go, ranked, the way the stats +// tracker installer does. Asking someone to type their own monorepo path and +// answering a miss with "not supported" was the wrong shape: in a monorepo the +// site is rarely at the root, and a wrong guess looked like a missing feature. import { useState } from "react"; import type { RepoOption } from "@/lib/github/repo-options"; @@ -17,6 +22,11 @@ interface Detected { evidence: string; } +interface Candidate extends Detected { + score: number; + existingPath?: string; +} + interface InstallResult { status: "opened" | "noop"; prUrl?: string; @@ -45,12 +55,25 @@ export function CareersInstall({ const [rootPath, setRootPath] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const [detected, setDetected] = useState(undefined); + const [candidates, setCandidates] = useState(undefined); + const [truncated, setTruncated] = useState(false); + const [chosenDir, setChosenDir] = useState(""); + const [manualDir, setManualDir] = useState(""); const [result, setResult] = useState(null); const selected = repos.find((r) => r.full_name === repo); + const chosen = candidates?.find((c) => c.dir === chosenDir); + const targetDir = chosenDir || manualDir.trim(); + + /** Any change to repo or root invalidates a scan taken against the old one. */ + function resetScan() { + setCandidates(undefined); + setChosenDir(""); + setResult(null); + setError(null); + } - async function call(mode: "detect" | "submit") { + async function call(mode: "candidates" | "submit") { if (!selected) return; const [owner, name] = selected.full_name.split("/"); setBusy(true); @@ -65,6 +88,7 @@ export function CareersInstall({ installation_id: selected.installation_id, default_branch: selected.default_branch ?? undefined, root_path: rootPath.trim() || undefined, + target_dir: mode === "submit" ? targetDir || undefined : undefined, mode, }), }); @@ -73,8 +97,16 @@ export function CareersInstall({ setError(payload?.error ?? "Something went wrong."); return; } - if (mode === "detect") setDetected(payload.data.detected); - else setResult(payload.data); + if (mode === "candidates") { + const found: Candidate[] = payload.data.candidates ?? []; + setCandidates(found); + setTruncated(Boolean(payload.data.truncated)); + // Preselect the best match; it is right in the common case and the + // list is there when it isn't. + setChosenDir(found[0]?.dir ?? ""); + } else { + setResult(payload.data); + } } catch { setError("Could not reach GitHub. Try again."); } finally { @@ -119,8 +151,7 @@ export function CareersInstall({ value={repo} onChange={(e) => { setRepo(e.target.value); - setDetected(undefined); - setResult(null); + resetScan(); }} className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm" > @@ -135,34 +166,102 @@ export function CareersInstall({
{ setRootPath(e.target.value); - setDetected(undefined); + resetScan(); }} - placeholder="apps/web — leave blank for the repo root" + placeholder="apps/web — leave blank to scan the whole repo" className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm" />
{error &&

{error}

} - {detected === null && ( + {candidates?.length === 0 && (

- No Next.js App Router or Astro site found there. Nothing was changed — your - board still works through the tracker snippet, it just renders client-side. + No Next.js App Router or Astro site found in this repo. Nothing was changed — + your board still works through the tracker snippet, it just renders + client-side. If the site lives somewhere unusual, enter the directory holding{" "} + layout.tsx below.

)} - {detected && ( + {!!candidates?.length && ( +
+

+ {candidates.length === 1 + ? "Found one place the page can go:" + : `Found ${candidates.length} places the page can go, best match first:`} +

+
    + {candidates.map((c) => ( +
  • + +
  • + ))} +
+ {truncated && ( +

+ This repo is large enough that GitHub truncated the file listing, so the + scan may have missed a location. Enter it below if it isn't here. +

+ )} +
+ )} + + {candidates !== undefined && ( +
+ + { + setManualDir(e.target.value); + setChosenDir(""); + setResult(null); + }} + placeholder="apps/web/src/app — the directory containing layout.tsx" + className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 font-mono text-sm" + /> +
+ )} + + {chosen && (

- Found {FRAMEWORK_LABEL[detected.framework]} from{" "} - {detected.evidence}. The PR will add the - page under {detected.dir}. + The PR will add the page under {chosen.dir}{" "} + as {FRAMEWORK_LABEL[chosen.framework]}.

)} @@ -185,16 +284,16 @@ export function CareersInstall({