Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 121 additions & 22 deletions app/(app)/projects/[id]/stats/careers/careers-install.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -17,6 +22,11 @@ interface Detected {
evidence: string;
}

interface Candidate extends Detected {
score: number;
existingPath?: string;
}

interface InstallResult {
status: "opened" | "noop";
prUrl?: string;
Expand Down Expand Up @@ -45,12 +55,25 @@ export function CareersInstall({
const [rootPath, setRootPath] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [detected, setDetected] = useState<Detected | null | undefined>(undefined);
const [candidates, setCandidates] = useState<Candidate[] | undefined>(undefined);
const [truncated, setTruncated] = useState(false);
const [chosenDir, setChosenDir] = useState("");
const [manualDir, setManualDir] = useState("");
const [result, setResult] = useState<InstallResult | null>(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);
Expand All @@ -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,
}),
});
Expand All @@ -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 {
Expand Down Expand Up @@ -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"
>
Expand All @@ -135,34 +166,102 @@ export function CareersInstall({

<div className="space-y-1">
<label className="text-xs text-[var(--color-muted)]">
Site subdirectory (optional, for monorepos)
Limit the scan to a subdirectory (optional)
</label>
<input
type="text"
value={rootPath}
onChange={(e) => {
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"
/>
</div>

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

{detected === null && (
{candidates?.length === 0 && (
<p className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm text-[var(--color-muted)]">
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{" "}
<code className="font-mono">layout.tsx</code> below.
</p>
)}

{detected && (
{!!candidates?.length && (
<div className="space-y-1">
<p className="text-xs text-[var(--color-muted)]">
{candidates.length === 1
? "Found one place the page can go:"
: `Found ${candidates.length} places the page can go, best match first:`}
</p>
<ul className="max-h-60 divide-y divide-[var(--color-border)] overflow-y-auto rounded-md border border-[var(--color-border)]">
{candidates.map((c) => (
<li key={c.dir}>
<label className="flex cursor-pointer items-start gap-3 p-2">
<input
type="radio"
name="careers-dir"
className="mt-1"
checked={chosenDir === c.dir}
onChange={() => {
setChosenDir(c.dir);
setManualDir("");
setResult(null);
}}
/>
<span className="min-w-0">
<span className="block truncate font-mono text-sm">{c.dir}</span>
<span className="block text-xs text-[var(--color-muted)]">
{FRAMEWORK_LABEL[c.framework]} · from{" "}
<code className="font-mono">{c.evidence}</code>
</span>
{c.existingPath && (
<span className="block text-xs text-[var(--color-muted)]">
Already has <code className="font-mono">{c.existingPath}</code> —
CrawlProof will leave it alone.
</span>
)}
</span>
</label>
</li>
))}
</ul>
{truncated && (
<p className="text-xs text-[var(--color-muted)]">
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&apos;t here.
</p>
)}
</div>
)}

{candidates !== undefined && (
<div className="space-y-1">
<label className="text-xs text-[var(--color-muted)]">
Or enter the route directory yourself
</label>
<input
type="text"
value={manualDir}
onChange={(e) => {
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"
/>
</div>
)}

{chosen && (
<p className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm text-[var(--color-muted)]">
Found <strong>{FRAMEWORK_LABEL[detected.framework]}</strong> from{" "}
<code className="font-mono">{detected.evidence}</code>. The PR will add the
page under <code className="font-mono">{detected.dir}</code>.
The PR will add the page under <code className="font-mono">{chosen.dir}</code>{" "}
as {FRAMEWORK_LABEL[chosen.framework]}.
</p>
)}

Expand All @@ -185,16 +284,16 @@ export function CareersInstall({
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => call("detect")}
onClick={() => call("candidates")}
disabled={busy || !selected}
className="btn btn-secondary text-sm"
>
{busy ? "Checking…" : "Check repo"}
{busy ? "Scanning…" : candidates === undefined ? "Scan repo" : "Rescan"}
</button>
<button
type="button"
onClick={() => call("submit")}
disabled={busy || !selected || detected === null || !!result}
disabled={busy || !selected || !targetDir || !!result}
className="btn btn-primary text-sm"
>
{busy ? "Working…" : "Open pull request"}
Expand Down
61 changes: 56 additions & 5 deletions app/api/projects/[id]/github/install-careers/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
// POST /api/projects/[id]/github/install-careers
// Body: { owner, repo, installation_id, root_path?, default_branch?, mode? }
// Body: { owner, repo, installation_id, root_path?, target_dir?, default_branch?, mode? }
//
// mode=detect → report the framework we'd write for, without touching the repo.
// mode=submit → open the PR (default).
// mode=candidates → scan the repo and rank every place a careers route could
// go. Read-only, no PR, no run row.
// mode=detect → report the framework we'd write for at one root, without
// touching the repo.
// mode=submit → open the PR (default). Honours target_dir when the user
// picked a location, after re-verifying it server-side.
//
// Auth mirrors install-tracker: a signed-in user, with access to the project,
// using an installation connected to their own account.
Expand All @@ -13,7 +17,12 @@ import { createClient } from "@/lib/supabase/server";
import { serviceClient } from "@/lib/supabase/service";
import { requireProjectAccess } from "@/lib/lx/currentSite";
import { getOrMintInstallationToken } from "@/lib/github/installations";
import { detectFramework, installCareersPage } from "@/lib/github/install-careers";
import {
detectFramework,
findCareersCandidates,
installCareersPage,
verifyCareersDir,
} from "@/lib/github/install-careers";
import { getRepo } from "@/lib/github/repos";

export const runtime = "nodejs";
Expand All @@ -23,8 +32,9 @@ const bodySchema = z.object({
repo: z.string().min(1),
installation_id: z.number().int().positive(),
root_path: z.string().max(500).optional(),
target_dir: z.string().max(500).optional(),
default_branch: z.string().max(200).optional(),
mode: z.enum(["detect", "submit"]).optional(),
mode: z.enum(["candidates", "detect", "submit"]).optional(),
});

export async function POST(
Expand Down Expand Up @@ -79,6 +89,23 @@ export async function POST(

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

// Read-only scan: no PR, no run row.
if (mode === "candidates") {
try {
const token = await getOrMintInstallationToken(body.installation_id);
const { candidates, truncated } = await findCareersCandidates({
token,
owner: body.owner,
repo: body.repo,
rootPath: body.root_path,
});
return NextResponse.json({ data: { candidates, truncated } });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

if (mode === "detect") {
try {
const token = await getOrMintInstallationToken(body.installation_id);
Expand Down Expand Up @@ -123,12 +150,36 @@ export async function POST(

try {
const token = await getOrMintInstallationToken(body.installation_id);

// A directory chosen in the browser is never trusted on its own — re-probe
// for the framework marker so we still only write where we can see a site.
let detected;
if (body.target_dir?.trim()) {
const repoMeta = await getRepo({ token, owner: body.owner, repo: body.repo });
detected =
(await verifyCareersDir({
token,
owner: body.owner,
repo: body.repo,
ref: repoMeta.default_branch,
dir: body.target_dir.trim(),
})) ?? undefined;
if (!detected) {
const msg =
`No Next.js App Router or Astro site at ${body.target_dir.trim()}. ` +
"Pick one of the scanned locations, or point at the directory holding layout.tsx.";
await finalize({ status: "failed", error: msg });
return NextResponse.json({ error: msg }, { status: 422 });
}
}

const result = await installCareersPage({
token,
owner: body.owner,
repo: body.repo,
projectId,
rootPath: body.root_path,
detected,
});
await finalize({
status: result.status,
Expand Down
Loading
Loading