Skip to content

Commit 3cd8ebe

Browse files
committed
Give the app an HTTP layer, so there is something for a screen to talk to
Route handlers for the flow a person actually follows: add a project, watch it clone, list its branches, create a review pinned to those branches, start it, watch it, cancel it. Plus rulesets, models, and one read that gives a review page everything it needs. Cloning happens in the background and the row is created immediately with a pending state. A large repository takes minutes, and a request that waited for it would time out somewhere unhelpful; the screen watches the clone state instead. A clone that fails keeps git's stderr verbatim, because that text is the only part a person can act on. Creating a review fetches and then pins from the refs that fetch produced, both sides of a linked pair before either is pinned, so the pair describes one moment rather than two. A fetch that fails blocks creation rather than falling back to stale refs. Listing branches fetches too, but is allowed to fall back and say so: browsing offline is reasonable, starting a review from stale refs is not. Two things the tests found rather than the design anticipated. An address git would read as a command-line option was answering 500, which reads as a server fault rather than a mistake on the form; it is a 400 now. And importing a document that produced no rules was accepted, which would have given someone a ruleset that checks nothing and a review that comes back clean for the wrong reason. That is refused, with a message saying what to look at. Errors reach the client as the message the thing that failed actually produced. A route that replaced git's stderr or the state machine's complaint with "something went wrong" would throw away the only part worth reading.
1 parent 3db669b commit 3cd8ebe

15 files changed

Lines changed: 816 additions & 0 deletions

File tree

src/app/api/models/route.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/** The models this account can actually use, and what each one is good for. */
2+
3+
import { availabilityOf, listModels } from "@/server/db/repositories/models";
4+
import { handler, ok } from "@/server/api/respond";
5+
import { runtime } from "@/server/runtime";
6+
7+
export const dynamic = "force-dynamic";
8+
9+
export function GET(): Promise<Response> {
10+
return handler(async () => {
11+
const now = Date.now();
12+
const models = listModels(runtime().db).map((model) => ({
13+
...model,
14+
availability: availabilityOf(model, now),
15+
}));
16+
return ok({ models });
17+
});
18+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* The branches a project has, fetched before they are listed.
3+
*
4+
* Fetching first is the point (D-29): a picker showing yesterday's refs would
5+
* let someone review a branch tip that has already moved. When the remote is
6+
* unreachable the cached refs are still listed, marked stale, because browsing
7+
* offline is reasonable even though starting a review that way is not.
8+
*/
9+
10+
import { detectDefaultBranch, divergence, fetchAll, listBranches } from "@/server/gitops/repo";
11+
import { recordFetch, requireProject } from "@/server/db/repositories/projects";
12+
import { handler, ok } from "@/server/api/respond";
13+
import { runtime } from "@/server/runtime";
14+
15+
export const dynamic = "force-dynamic";
16+
17+
export async function GET(
18+
_request: Request,
19+
context: { params: Promise<{ id: string }> },
20+
): Promise<Response> {
21+
return handler(async () => {
22+
const { db } = runtime();
23+
const { id } = await context.params;
24+
const project = requireProject(db, id);
25+
26+
let stale: string | null = null;
27+
try {
28+
await fetchAll(project.clonePath);
29+
recordFetch(db, project.id);
30+
} catch (error) {
31+
stale = error instanceof Error ? error.message : String(error);
32+
}
33+
34+
const branches = await listBranches(project.clonePath);
35+
const into = project.defaultBranch || (await detectDefaultBranch(project.clonePath));
36+
37+
// Ahead and behind are what tell someone whether a branch is worth
38+
// reviewing yet, and they are cheap to compute while we are here.
39+
const withDivergence = await Promise.all(
40+
branches.map(async (branch) => ({
41+
...branch,
42+
...(branch.name === into
43+
? { ahead: 0, behind: 0 }
44+
: await divergence(project.clonePath, branch.name, into)),
45+
})),
46+
);
47+
48+
return ok({
49+
branches: withDivergence,
50+
defaultBranch: into,
51+
lastFetchedAt: requireProject(db, id).lastFetchedAt,
52+
stale,
53+
});
54+
});
55+
}

src/app/api/projects/[id]/route.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/** One project, with its dependency links and its reviews. */
2+
3+
import { listDependencyLinks, requireProject } from "@/server/db/repositories/projects";
4+
import { listReviewsForProject } from "@/server/db/repositories/reviews";
5+
import { handler, ok } from "@/server/api/respond";
6+
import { runtime } from "@/server/runtime";
7+
8+
export const dynamic = "force-dynamic";
9+
10+
export async function GET(
11+
_request: Request,
12+
context: { params: Promise<{ id: string }> },
13+
): Promise<Response> {
14+
return handler(async () => {
15+
const { db } = runtime();
16+
const { id } = await context.params;
17+
return ok({
18+
project: requireProject(db, id),
19+
links: listDependencyLinks(db, id),
20+
reviews: listReviewsForProject(db, id),
21+
});
22+
});
23+
}

src/app/api/projects/route.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* The projects this app knows about, and adding one.
3+
*
4+
* Cloning happens in the background: a large repository takes minutes and a
5+
* request that waited for it would time out somewhere unhelpful. The row is
6+
* created immediately with a pending clone state, and the projects screen
7+
* shows that state until it resolves.
8+
*/
9+
10+
import { z } from "zod";
11+
import { repoSlug, validateGitUrl } from "@/lib/git/url";
12+
import { projectRepoDir } from "@/lib/paths";
13+
import {
14+
createProject,
15+
listProjects,
16+
setCloneStatus,
17+
setClonePath,
18+
setDefaultBranch,
19+
} from "@/server/db/repositories/projects";
20+
import { cloneBare, detectDefaultBranch } from "@/server/gitops/repo";
21+
import { created, failed, handler, ok, readJson } from "@/server/api/respond";
22+
import { runtime as appRuntime } from "@/server/runtime";
23+
24+
export const runtime = "nodejs";
25+
export const dynamic = "force-dynamic";
26+
27+
const addProject = z.object({
28+
gitUrl: z.string().min(1),
29+
name: z.string().trim().min(1).optional(),
30+
});
31+
32+
export function GET(): Promise<Response> {
33+
return handler(async () => ok({ projects: listProjects(appRuntime().db) }));
34+
}
35+
36+
export function POST(request: Request): Promise<Response> {
37+
return handler(async () => {
38+
const { db, dataDir } = appRuntime();
39+
const body = await readJson(request, addProject);
40+
41+
// Validated before the row exists, so a bad address is an error the user
42+
// sees on the form rather than a project stuck in a failed clone.
43+
let url: string;
44+
try {
45+
url = validateGitUrl(body.gitUrl);
46+
} catch (error) {
47+
return failed(error, 400);
48+
}
49+
const name = body.name ?? nameFromUrl(url);
50+
51+
const project = createProject(db, {
52+
name,
53+
gitUrl: url,
54+
defaultBranch: "main",
55+
clonePath: "",
56+
});
57+
const clonePath = projectRepoDir(dataDir, project.id);
58+
setClonePath(db, project.id, clonePath);
59+
setCloneStatus(db, project.id, "pending");
60+
61+
void cloneInBackground(db, project.id, url, clonePath);
62+
return created({ project: { ...project, clonePath } });
63+
});
64+
}
65+
66+
async function cloneInBackground(
67+
db: ReturnType<typeof appRuntime>["db"],
68+
projectId: string,
69+
url: string,
70+
clonePath: string,
71+
): Promise<void> {
72+
try {
73+
await cloneBare(url, clonePath);
74+
setDefaultBranch(db, projectId, await detectDefaultBranch(clonePath));
75+
setCloneStatus(db, projectId, "ready");
76+
} catch (error) {
77+
// Verbatim: git's stderr says what is actually wrong, and a summary of it
78+
// would leave the user guessing at an authentication or address problem.
79+
setCloneStatus(db, projectId, "failed", error instanceof Error ? error.message : String(error));
80+
}
81+
}
82+
83+
/** A readable name from the address, which is what a person would have typed. */
84+
function nameFromUrl(url: string): string {
85+
const last =
86+
url
87+
.replace(/\.git$/, "")
88+
.split(/[/:]/)
89+
.filter(Boolean)
90+
.pop() ?? "project";
91+
return repoSlug(last);
92+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/** Stops a running review, or takes a queued one out of the line. */
2+
3+
import { handler, ok } from "@/server/api/respond";
4+
import { runtime } from "@/server/runtime";
5+
6+
export const dynamic = "force-dynamic";
7+
8+
export async function POST(
9+
_request: Request,
10+
context: { params: Promise<{ id: string }> },
11+
): Promise<Response> {
12+
return handler(async () => {
13+
const { manager } = runtime();
14+
const { id } = await context.params;
15+
return ok({ cancelled: manager.cancel(id), snapshot: manager.snapshot(id) });
16+
});
17+
}

src/app/api/reviews/[id]/route.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/** Everything a review page needs in one read. */
2+
3+
import { listFindings } from "@/server/db/repositories/findings";
4+
import { handler, ok } from "@/server/api/respond";
5+
import { runtime } from "@/server/runtime";
6+
7+
export const dynamic = "force-dynamic";
8+
9+
export async function GET(
10+
_request: Request,
11+
context: { params: Promise<{ id: string }> },
12+
): Promise<Response> {
13+
return handler(async () => {
14+
const { db, manager } = runtime();
15+
const { id } = await context.params;
16+
return ok({ ...manager.snapshot(id), findings: listFindings(db, id) });
17+
});
18+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* Starts or resumes a review.
3+
*
4+
* Returns as soon as the review is scheduled rather than when it finishes: a
5+
* review takes minutes, and the page watches the event stream for the rest.
6+
*/
7+
8+
import { z } from "zod";
9+
import { rulesetTierSchema } from "@/lib/domain/enums";
10+
import { loadRuleset, readReviewSnapshot, requireRuleset } from "@/server/db/repositories/rulesets";
11+
import { handler, ok, readJson } from "@/server/api/respond";
12+
import { runtime } from "@/server/runtime";
13+
14+
export const dynamic = "force-dynamic";
15+
16+
const body = z.object({ rulesetId: z.string().min(1).optional() });
17+
18+
export async function POST(
19+
request: Request,
20+
context: { params: Promise<{ id: string }> },
21+
): Promise<Response> {
22+
return handler(async () => {
23+
const { db, manager } = runtime();
24+
const { id } = await context.params;
25+
const input = await readJson(request, body).catch(() => ({ rulesetId: undefined }));
26+
27+
// A resumed review already carries its frozen ruleset and must not be
28+
// handed a different one.
29+
let ruleset;
30+
try {
31+
readReviewSnapshot(db, id);
32+
} catch {
33+
if (!input.rulesetId) {
34+
throw Response.json(
35+
{ error: "Choose a ruleset before starting this review.", code: "RulesetRequired" },
36+
{ status: 400 },
37+
);
38+
}
39+
const row = requireRuleset(db, input.rulesetId);
40+
ruleset = {
41+
imported: loadRuleset(db, input.rulesetId),
42+
name: row.name,
43+
tier: rulesetTierSchema.parse(row.tier),
44+
};
45+
}
46+
47+
const state = manager.start(id, ...(ruleset ? [{ ruleset }] : []));
48+
return ok({ state, snapshot: manager.snapshot(id) });
49+
});
50+
}

src/app/api/reviews/route.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* Creating a review, and listing them.
3+
*
4+
* Creating one is where the commits are pinned, so it fetches first and then
5+
* resolves the tips from the refs that fetch produced (D-27). A stale pin is
6+
* the worst failure this app can have: the run completes, the report reads as
7+
* authoritative, and it describes code the branch has already moved past. A
8+
* fetch that fails therefore blocks creation rather than falling back.
9+
*/
10+
11+
import { z } from "zod";
12+
import { reviewEffortSchema, reviewProfileSchema } from "@/lib/domain/enums";
13+
import {
14+
listDependencyLinks,
15+
recordFetch,
16+
requireProject,
17+
} from "@/server/db/repositories/projects";
18+
import { createReview, listActiveReviews } from "@/server/db/repositories/reviews";
19+
import { fetchAll, mergeBase, resolveCommit } from "@/server/gitops/repo";
20+
import { created, handler, ok, readJson } from "@/server/api/respond";
21+
import { runtime } from "@/server/runtime";
22+
import { listProjects } from "@/server/db/repositories/projects";
23+
import { listReviewsForProject } from "@/server/db/repositories/reviews";
24+
25+
export const dynamic = "force-dynamic";
26+
27+
const body = z.object({
28+
projectId: z.string().min(1),
29+
fromBranch: z.string().min(1),
30+
intoBranch: z.string().min(1),
31+
model: z.string().min(1),
32+
profileId: reviewProfileSchema.default("full-context"),
33+
effort: reviewEffortSchema.default("high"),
34+
intent: z.string().optional(),
35+
linked: z
36+
.object({
37+
projectId: z.string().min(1),
38+
fromBranch: z.string().min(1),
39+
intoBranch: z.string().min(1),
40+
})
41+
.optional(),
42+
});
43+
44+
export function GET(): Promise<Response> {
45+
return handler(async () => {
46+
const { db } = runtime();
47+
const reviews = listProjects(db).flatMap((project) =>
48+
listReviewsForProject(db, project.id).map((review) => ({
49+
...review,
50+
projectName: project.name,
51+
})),
52+
);
53+
reviews.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
54+
return ok({ reviews, active: listActiveReviews(db).map((review) => review.id) });
55+
});
56+
}
57+
58+
export function POST(request: Request): Promise<Response> {
59+
return handler(async () => {
60+
const { db } = runtime();
61+
const input = await readJson(request, body);
62+
const project = requireProject(db, input.projectId);
63+
64+
// Fetched immediately before pinning, so the commits recorded are the tips
65+
// the remote has now rather than whatever the last clone left behind.
66+
await fetchAll(project.clonePath);
67+
recordFetch(db, project.id);
68+
const pins = await pin(project.clonePath, input.fromBranch, input.intoBranch);
69+
70+
let linked;
71+
if (input.linked) {
72+
const dependency = requireProject(db, input.linked.projectId);
73+
const isLinked = listDependencyLinks(db, project.id).some(
74+
(link) => link.dependencyProjectId === dependency.id,
75+
);
76+
if (!isLinked) {
77+
throw Response.json(
78+
{
79+
error: `${dependency.name} is not a dependency of ${project.name}.`,
80+
code: "NotLinked",
81+
},
82+
{ status: 400 },
83+
);
84+
}
85+
// Both sides fetched before either is pinned, so the pair describes one
86+
// moment rather than two.
87+
await fetchAll(dependency.clonePath);
88+
recordFetch(db, dependency.id);
89+
linked = {
90+
...input.linked,
91+
projectId: dependency.id,
92+
...(await pin(dependency.clonePath, input.linked.fromBranch, input.linked.intoBranch)),
93+
};
94+
}
95+
96+
const review = createReview(db, {
97+
projectId: project.id,
98+
fromBranch: input.fromBranch,
99+
intoBranch: input.intoBranch,
100+
...pins,
101+
model: input.model,
102+
profileId: input.profileId,
103+
engineMode: "headless",
104+
effort: input.effort,
105+
...(input.intent === undefined ? {} : { intent: input.intent }),
106+
...(linked === undefined ? {} : { linked }),
107+
});
108+
109+
return created({ review });
110+
});
111+
}
112+
113+
async function pin(repoDir: string, fromBranch: string, intoBranch: string) {
114+
return {
115+
fromCommit: await resolveCommit(repoDir, fromBranch),
116+
intoCommit: await resolveCommit(repoDir, intoBranch),
117+
mergeBaseCommit: await mergeBase(repoDir, intoBranch, fromBranch),
118+
};
119+
}

0 commit comments

Comments
 (0)