Skip to content

Commit 2fbc90d

Browse files
ralyodioclaude
andauthored
fix(careers): scan the repo for install locations instead of guessing (#189)
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) <noreply@anthropic.com>
1 parent a0ef66b commit 2fbc90d

5 files changed

Lines changed: 631 additions & 29 deletions

File tree

‎app/(app)/projects/[id]/stats/careers/careers-install.tsx‎

Lines changed: 121 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@
33
// "Add a real /careers page to my repo" — the crawlable counterpart to the
44
// drop-in widget.
55
//
6-
// Two steps on purpose. Detection is read-only and tells the user what we
7-
// found before anything is written; only the second button opens a PR. People
8-
// are reasonably wary of a button that commits to their repo sight unseen.
6+
// Two steps on purpose. Scanning is read-only and shows what we found before
7+
// anything is written; only the second button opens a PR. People are reasonably
8+
// wary of a button that commits to their repo sight unseen.
9+
//
10+
// The scan lists every place the route could go, ranked, the way the stats
11+
// tracker installer does. Asking someone to type their own monorepo path and
12+
// answering a miss with "not supported" was the wrong shape: in a monorepo the
13+
// site is rarely at the root, and a wrong guess looked like a missing feature.
914

1015
import { useState } from "react";
1116
import type { RepoOption } from "@/lib/github/repo-options";
@@ -17,6 +22,11 @@ interface Detected {
1722
evidence: string;
1823
}
1924

25+
interface Candidate extends Detected {
26+
score: number;
27+
existingPath?: string;
28+
}
29+
2030
interface InstallResult {
2131
status: "opened" | "noop";
2232
prUrl?: string;
@@ -45,12 +55,25 @@ export function CareersInstall({
4555
const [rootPath, setRootPath] = useState("");
4656
const [busy, setBusy] = useState(false);
4757
const [error, setError] = useState<string | null>(null);
48-
const [detected, setDetected] = useState<Detected | null | undefined>(undefined);
58+
const [candidates, setCandidates] = useState<Candidate[] | undefined>(undefined);
59+
const [truncated, setTruncated] = useState(false);
60+
const [chosenDir, setChosenDir] = useState("");
61+
const [manualDir, setManualDir] = useState("");
4962
const [result, setResult] = useState<InstallResult | null>(null);
5063

5164
const selected = repos.find((r) => r.full_name === repo);
65+
const chosen = candidates?.find((c) => c.dir === chosenDir);
66+
const targetDir = chosenDir || manualDir.trim();
67+
68+
/** Any change to repo or root invalidates a scan taken against the old one. */
69+
function resetScan() {
70+
setCandidates(undefined);
71+
setChosenDir("");
72+
setResult(null);
73+
setError(null);
74+
}
5275

53-
async function call(mode: "detect" | "submit") {
76+
async function call(mode: "candidates" | "submit") {
5477
if (!selected) return;
5578
const [owner, name] = selected.full_name.split("/");
5679
setBusy(true);
@@ -65,6 +88,7 @@ export function CareersInstall({
6588
installation_id: selected.installation_id,
6689
default_branch: selected.default_branch ?? undefined,
6790
root_path: rootPath.trim() || undefined,
91+
target_dir: mode === "submit" ? targetDir || undefined : undefined,
6892
mode,
6993
}),
7094
});
@@ -73,8 +97,16 @@ export function CareersInstall({
7397
setError(payload?.error ?? "Something went wrong.");
7498
return;
7599
}
76-
if (mode === "detect") setDetected(payload.data.detected);
77-
else setResult(payload.data);
100+
if (mode === "candidates") {
101+
const found: Candidate[] = payload.data.candidates ?? [];
102+
setCandidates(found);
103+
setTruncated(Boolean(payload.data.truncated));
104+
// Preselect the best match; it is right in the common case and the
105+
// list is there when it isn't.
106+
setChosenDir(found[0]?.dir ?? "");
107+
} else {
108+
setResult(payload.data);
109+
}
78110
} catch {
79111
setError("Could not reach GitHub. Try again.");
80112
} finally {
@@ -119,8 +151,7 @@ export function CareersInstall({
119151
value={repo}
120152
onChange={(e) => {
121153
setRepo(e.target.value);
122-
setDetected(undefined);
123-
setResult(null);
154+
resetScan();
124155
}}
125156
className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm"
126157
>
@@ -135,34 +166,102 @@ export function CareersInstall({
135166

136167
<div className="space-y-1">
137168
<label className="text-xs text-[var(--color-muted)]">
138-
Site subdirectory (optional, for monorepos)
169+
Limit the scan to a subdirectory (optional)
139170
</label>
140171
<input
141172
type="text"
142173
value={rootPath}
143174
onChange={(e) => {
144175
setRootPath(e.target.value);
145-
setDetected(undefined);
176+
resetScan();
146177
}}
147-
placeholder="apps/web — leave blank for the repo root"
178+
placeholder="apps/web — leave blank to scan the whole repo"
148179
className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm"
149180
/>
150181
</div>
151182

152183
{error && <p className="text-sm text-[var(--color-danger,#dc2626)]">{error}</p>}
153184

154-
{detected === null && (
185+
{candidates?.length === 0 && (
155186
<p className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm text-[var(--color-muted)]">
156-
No Next.js App Router or Astro site found there. Nothing was changed — your
157-
board still works through the tracker snippet, it just renders client-side.
187+
No Next.js App Router or Astro site found in this repo. Nothing was changed —
188+
your board still works through the tracker snippet, it just renders
189+
client-side. If the site lives somewhere unusual, enter the directory holding{" "}
190+
<code className="font-mono">layout.tsx</code> below.
158191
</p>
159192
)}
160193

161-
{detected && (
194+
{!!candidates?.length && (
195+
<div className="space-y-1">
196+
<p className="text-xs text-[var(--color-muted)]">
197+
{candidates.length === 1
198+
? "Found one place the page can go:"
199+
: `Found ${candidates.length} places the page can go, best match first:`}
200+
</p>
201+
<ul className="max-h-60 divide-y divide-[var(--color-border)] overflow-y-auto rounded-md border border-[var(--color-border)]">
202+
{candidates.map((c) => (
203+
<li key={c.dir}>
204+
<label className="flex cursor-pointer items-start gap-3 p-2">
205+
<input
206+
type="radio"
207+
name="careers-dir"
208+
className="mt-1"
209+
checked={chosenDir === c.dir}
210+
onChange={() => {
211+
setChosenDir(c.dir);
212+
setManualDir("");
213+
setResult(null);
214+
}}
215+
/>
216+
<span className="min-w-0">
217+
<span className="block truncate font-mono text-sm">{c.dir}</span>
218+
<span className="block text-xs text-[var(--color-muted)]">
219+
{FRAMEWORK_LABEL[c.framework]} · from{" "}
220+
<code className="font-mono">{c.evidence}</code>
221+
</span>
222+
{c.existingPath && (
223+
<span className="block text-xs text-[var(--color-muted)]">
224+
Already has <code className="font-mono">{c.existingPath}</code> —
225+
CrawlProof will leave it alone.
226+
</span>
227+
)}
228+
</span>
229+
</label>
230+
</li>
231+
))}
232+
</ul>
233+
{truncated && (
234+
<p className="text-xs text-[var(--color-muted)]">
235+
This repo is large enough that GitHub truncated the file listing, so the
236+
scan may have missed a location. Enter it below if it isn&apos;t here.
237+
</p>
238+
)}
239+
</div>
240+
)}
241+
242+
{candidates !== undefined && (
243+
<div className="space-y-1">
244+
<label className="text-xs text-[var(--color-muted)]">
245+
Or enter the route directory yourself
246+
</label>
247+
<input
248+
type="text"
249+
value={manualDir}
250+
onChange={(e) => {
251+
setManualDir(e.target.value);
252+
setChosenDir("");
253+
setResult(null);
254+
}}
255+
placeholder="apps/web/src/app — the directory containing layout.tsx"
256+
className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 font-mono text-sm"
257+
/>
258+
</div>
259+
)}
260+
261+
{chosen && (
162262
<p className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm text-[var(--color-muted)]">
163-
Found <strong>{FRAMEWORK_LABEL[detected.framework]}</strong> from{" "}
164-
<code className="font-mono">{detected.evidence}</code>. The PR will add the
165-
page under <code className="font-mono">{detected.dir}</code>.
263+
The PR will add the page under <code className="font-mono">{chosen.dir}</code>{" "}
264+
as {FRAMEWORK_LABEL[chosen.framework]}.
166265
</p>
167266
)}
168267

@@ -185,16 +284,16 @@ export function CareersInstall({
185284
<div className="flex flex-wrap gap-2">
186285
<button
187286
type="button"
188-
onClick={() => call("detect")}
287+
onClick={() => call("candidates")}
189288
disabled={busy || !selected}
190289
className="btn btn-secondary text-sm"
191290
>
192-
{busy ? "Checking…" : "Check repo"}
291+
{busy ? "Scanning…" : candidates === undefined ? "Scan repo" : "Rescan"}
193292
</button>
194293
<button
195294
type="button"
196295
onClick={() => call("submit")}
197-
disabled={busy || !selected || detected === null || !!result}
296+
disabled={busy || !selected || !targetDir || !!result}
198297
className="btn btn-primary text-sm"
199298
>
200299
{busy ? "Working…" : "Open pull request"}

‎app/api/projects/[id]/github/install-careers/route.ts‎

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
// POST /api/projects/[id]/github/install-careers
2-
// Body: { owner, repo, installation_id, root_path?, default_branch?, mode? }
2+
// Body: { owner, repo, installation_id, root_path?, target_dir?, default_branch?, mode? }
33
//
4-
// mode=detect → report the framework we'd write for, without touching the repo.
5-
// mode=submit → open the PR (default).
4+
// mode=candidates → scan the repo and rank every place a careers route could
5+
// go. Read-only, no PR, no run row.
6+
// mode=detect → report the framework we'd write for at one root, without
7+
// touching the repo.
8+
// mode=submit → open the PR (default). Honours target_dir when the user
9+
// picked a location, after re-verifying it server-side.
610
//
711
// Auth mirrors install-tracker: a signed-in user, with access to the project,
812
// using an installation connected to their own account.
@@ -13,7 +17,12 @@ import { createClient } from "@/lib/supabase/server";
1317
import { serviceClient } from "@/lib/supabase/service";
1418
import { requireProjectAccess } from "@/lib/lx/currentSite";
1519
import { getOrMintInstallationToken } from "@/lib/github/installations";
16-
import { detectFramework, installCareersPage } from "@/lib/github/install-careers";
20+
import {
21+
detectFramework,
22+
findCareersCandidates,
23+
installCareersPage,
24+
verifyCareersDir,
25+
} from "@/lib/github/install-careers";
1726
import { getRepo } from "@/lib/github/repos";
1827

1928
export const runtime = "nodejs";
@@ -23,8 +32,9 @@ const bodySchema = z.object({
2332
repo: z.string().min(1),
2433
installation_id: z.number().int().positive(),
2534
root_path: z.string().max(500).optional(),
35+
target_dir: z.string().max(500).optional(),
2636
default_branch: z.string().max(200).optional(),
27-
mode: z.enum(["detect", "submit"]).optional(),
37+
mode: z.enum(["candidates", "detect", "submit"]).optional(),
2838
});
2939

3040
export async function POST(
@@ -79,6 +89,23 @@ export async function POST(
7989

8090
const mode = body.mode ?? "submit";
8191

92+
// Read-only scan: no PR, no run row.
93+
if (mode === "candidates") {
94+
try {
95+
const token = await getOrMintInstallationToken(body.installation_id);
96+
const { candidates, truncated } = await findCareersCandidates({
97+
token,
98+
owner: body.owner,
99+
repo: body.repo,
100+
rootPath: body.root_path,
101+
});
102+
return NextResponse.json({ data: { candidates, truncated } });
103+
} catch (err) {
104+
const msg = err instanceof Error ? err.message : String(err);
105+
return NextResponse.json({ error: msg }, { status: 500 });
106+
}
107+
}
108+
82109
if (mode === "detect") {
83110
try {
84111
const token = await getOrMintInstallationToken(body.installation_id);
@@ -123,12 +150,36 @@ export async function POST(
123150

124151
try {
125152
const token = await getOrMintInstallationToken(body.installation_id);
153+
154+
// A directory chosen in the browser is never trusted on its own — re-probe
155+
// for the framework marker so we still only write where we can see a site.
156+
let detected;
157+
if (body.target_dir?.trim()) {
158+
const repoMeta = await getRepo({ token, owner: body.owner, repo: body.repo });
159+
detected =
160+
(await verifyCareersDir({
161+
token,
162+
owner: body.owner,
163+
repo: body.repo,
164+
ref: repoMeta.default_branch,
165+
dir: body.target_dir.trim(),
166+
})) ?? undefined;
167+
if (!detected) {
168+
const msg =
169+
`No Next.js App Router or Astro site at ${body.target_dir.trim()}. ` +
170+
"Pick one of the scanned locations, or point at the directory holding layout.tsx.";
171+
await finalize({ status: "failed", error: msg });
172+
return NextResponse.json({ error: msg }, { status: 422 });
173+
}
174+
}
175+
126176
const result = await installCareersPage({
127177
token,
128178
owner: body.owner,
129179
repo: body.repo,
130180
projectId,
131181
rootPath: body.root_path,
182+
detected,
132183
});
133184
await finalize({
134185
status: result.status,

0 commit comments

Comments
 (0)