Skip to content

Commit dce0e71

Browse files
committed
Make repository UI commit-first
1 parent 774bfe3 commit dce0e71

29 files changed

+776
-373
lines changed

apps/api/src/app.integration.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,23 @@ describe.runIf(runIntegration)("api integration", () => {
8282
page: 1,
8383
pageSize: 5,
8484
});
85+
86+
const commitsResponse = await app.inject({
87+
method: "GET",
88+
url: "/repositories/verge/commits?page=1&pageSize=5",
89+
});
90+
expect(commitsResponse.statusCode).toBe(200);
91+
expect(commitsResponse.json()).toMatchObject({
92+
page: 1,
93+
pageSize: 5,
94+
items: [
95+
expect.objectContaining({
96+
commitSha: "integration-sha",
97+
attemptCount: 1,
98+
coveragePercent: expect.any(Number),
99+
}),
100+
],
101+
});
85102
}, 30_000);
86103

87104
it("returns 404 for missing runs and steps after a reset", async () => {

apps/api/src/planning.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
type DatabaseExecutor,
1717
} from "@verge/db";
1818

19-
import { parseStringArray } from "./utils.js";
19+
import { parseStringArray, resolveCommitTitle } from "./utils.js";
2020

2121
const interruptPendingProcessesForStepRun = async (
2222
db: DatabaseExecutor,
@@ -63,6 +63,7 @@ export const createPlannedRun = async (
6363
input: {
6464
trigger: "manual" | "push" | "pull_request";
6565
commitSha: string;
66+
commitTitle?: string | null;
6667
branch?: string;
6768
changedFiles?: string[];
6869
requestedStepKeys?: string[];
@@ -76,11 +77,17 @@ export const createPlannedRun = async (
7677
stepRunIds: string[];
7778
}> => {
7879
const stepSpecs = await getStepSpecsForRepository(db, repository.id);
80+
const commitTitle = await resolveCommitTitle(
81+
repository.root_path,
82+
input.commitSha,
83+
input.commitTitle,
84+
);
7985

8086
const run = await createRun(db, {
8187
repositoryId: repository.id,
8288
trigger: input.trigger,
8389
commitSha: input.commitSha,
90+
commitTitle,
8491
changedFiles: input.changedFiles ?? [],
8592
...(input.eventIngestionId ? { eventIngestionId: input.eventIngestionId } : {}),
8693
...(input.branch ? { branch: input.branch } : {}),

apps/api/src/routes/public.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import type { FastifyInstance } from "fastify";
22

3-
import { createManualRunInputSchema, runListQuerySchema } from "@verge/contracts";
3+
import {
4+
commitListQuerySchema,
5+
createManualRunInputSchema,
6+
runListQuerySchema,
7+
} from "@verge/contracts";
48
import {
59
getCommitDetail,
610
getCommitTreemap,
@@ -11,6 +15,7 @@ import {
1115
getRunDetail,
1216
getRunTreemap,
1317
getStepRunDetail,
18+
listRepositoryCommits,
1419
listRepositories,
1520
listRepositoryRuns,
1621
listStepSpecSummaries,
@@ -102,6 +107,14 @@ export const registerPublicRoutes = (app: FastifyInstance, context: ApiContext):
102107
),
103108
);
104109

110+
app.get("/repositories/:repo/commits", async (request) =>
111+
listRepositoryCommits(
112+
context.connection.db,
113+
(request.params as { repo: string }).repo,
114+
commitListQuerySchema.parse(request.query),
115+
),
116+
);
117+
105118
app.get("/repositories/:repo/commits/:sha", async (request, reply) => {
106119
const { repo, sha } = request.params as { repo: string; sha: string };
107120
const detail = await getCommitDetail(context.connection.db, repo, sha);

apps/api/src/routes/webhooks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export const registerWebhookRoutes = (app: FastifyInstance, context: ApiContext)
7272
await createPlannedRun(trx, repository, repositoryDefinition, {
7373
trigger: "push",
7474
commitSha: payload.after,
75+
commitTitle: payload.head_commit?.message ?? null,
7576
branch: payload.ref.replace("refs/heads/", ""),
7677
changedFiles: collectChangedFilesFromPushPayload(payload),
7778
eventIngestionId: eventIngestion.id,

apps/api/src/utils.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createHmac, timingSafeEqual } from "node:crypto";
2+
import { spawn } from "node:child_process";
23

34
type PushPayload = {
45
commits: Array<{
@@ -55,6 +56,52 @@ export const parseStringArray = (value: unknown): string[] => {
5556
return [];
5657
};
5758

59+
const runCommand = async (command: string, args: string[], cwd: string): Promise<string> =>
60+
new Promise((resolve, reject) => {
61+
const stdout: string[] = [];
62+
const stderr: string[] = [];
63+
const child = spawn(command, args, {
64+
cwd,
65+
stdio: ["ignore", "pipe", "pipe"],
66+
});
67+
68+
child.stdout?.on("data", (chunk: Buffer | string) => stdout.push(String(chunk)));
69+
child.stderr?.on("data", (chunk: Buffer | string) => stderr.push(String(chunk)));
70+
child.on("error", reject);
71+
child.on("close", (code) => {
72+
if ((code ?? 1) !== 0) {
73+
reject(new Error(stderr.join("").trim() || `${command} exited with ${code ?? 1}`));
74+
return;
75+
}
76+
77+
resolve(stdout.join(""));
78+
});
79+
});
80+
81+
export const resolveCommitTitle = async (
82+
repositoryRootPath: string,
83+
commitSha: string,
84+
fallbackTitle?: string | null,
85+
): Promise<string> => {
86+
if (fallbackTitle && fallbackTitle.trim().length > 0) {
87+
return fallbackTitle.trim().split("\n")[0] ?? fallbackTitle.trim();
88+
}
89+
90+
try {
91+
const subject = await runCommand(
92+
"git",
93+
["show", "-s", "--format=%s", commitSha],
94+
repositoryRootPath,
95+
);
96+
const normalized = subject.trim();
97+
if (normalized.length > 0) {
98+
return normalized.split("\n")[0] ?? normalized;
99+
}
100+
} catch {}
101+
102+
return `Commit ${commitSha.slice(0, 7)}`;
103+
};
104+
58105
export const sendSse = (reply: {
59106
raw: NodeJS.WritableStream & {
60107
writeHead?: (statusCode: number, headers: Record<string, string>) => void;

apps/web/src/App.tsx

Lines changed: 38 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,24 @@ import { useEffect, useMemo, useState } from "react";
22

33
import type { RepositorySummary } from "@verge/contracts";
44

5-
import { NavLink, StatusPill } from "./components/common.js";
5+
import { NavLink } from "./components/common.js";
66
import { useAppRoute } from "./hooks/use-app-route.js";
7+
import { useCommitListData } from "./hooks/use-commit-list-data.js";
78
import { useCommitDetailData } from "./hooks/use-commit-detail-data.js";
8-
import { useOverviewData } from "./hooks/use-overview-data.js";
99
import { useRunDetailData } from "./hooks/use-run-detail-data.js";
1010
import { useRunsPageData } from "./hooks/use-runs-page-data.js";
11+
import { useStepSpecs } from "./hooks/use-step-specs.js";
1112
import {
1213
buildCommitPath,
13-
buildRepositoryOverviewPath,
14+
buildRepositoryCommitsPath,
1415
buildRepositoryRunsPath,
1516
buildRunPath,
1617
buildStepPath,
1718
navigate,
1819
} from "./lib/routing.js";
1920
import { statusTone } from "./lib/format.js";
21+
import { CommitsPage } from "./pages/CommitsPage.js";
2022
import { CommitDetailPage } from "./pages/CommitDetailPage.js";
21-
import { OverviewPage } from "./pages/OverviewPage.js";
2223
import { RunDetailPage } from "./pages/RunDetailPage.js";
2324
import { RunsPage } from "./pages/RunsPage.js";
2425
import { StepDetailPage } from "./pages/StepDetailPage.js";
@@ -32,7 +33,8 @@ export const App = () => {
3233
const [preferredRepositorySlug, setPreferredRepositorySlug] = useState<string | null>(null);
3334
const [repositoriesError, setRepositoriesError] = useState<string | null>(null);
3435
const currentRepositorySlug = route.repositorySlug ?? preferredRepositorySlug;
35-
const { health, processSpecs, error: overviewError } = useOverviewData(currentRepositorySlug);
36+
const { commitsPage, error: commitsError } = useCommitListData(route, currentRepositorySlug);
37+
const { stepSpecs, error: stepSpecsError } = useStepSpecs(currentRepositorySlug);
3638
const { runsPage, error: runsError } = useRunsPageData(route, currentRepositorySlug);
3739
const { run, treemap, step, error: runError, treemapError } = useRunDetailData(route);
3840
const {
@@ -105,8 +107,8 @@ export const App = () => {
105107
return;
106108
}
107109

108-
if (route.name === "overview") {
109-
navigate(buildRepositoryOverviewPath(fallbackRepositorySlug));
110+
if (route.name === "commits") {
111+
navigate(buildRepositoryCommitsPath(fallbackRepositorySlug, { page: route.page }));
110112
return;
111113
}
112114

@@ -147,7 +149,6 @@ export const App = () => {
147149
});
148150
}, [route]);
149151

150-
const activeRunCount = useMemo(() => health?.activeRuns.length ?? 0, [health]);
151152
const selectedRepository = useMemo(
152153
() => repositories.find((repository) => repository.slug === currentRepositorySlug) ?? null,
153154
[currentRepositorySlug, repositories],
@@ -217,11 +218,19 @@ export const App = () => {
217218
);
218219
};
219220

221+
const changeCommitsPage = (page: number): void => {
222+
if (route.name !== "commits" || !selectedRepositorySlug) {
223+
return;
224+
}
225+
226+
navigate(buildRepositoryCommitsPath(selectedRepositorySlug, { page }));
227+
};
228+
220229
const navigateToRepository = (nextRepositorySlug: string): void => {
221230
setPreferredRepositorySlug(nextRepositorySlug);
222231

223-
if (route.name === "overview") {
224-
navigate(buildRepositoryOverviewPath(nextRepositorySlug));
232+
if (route.name === "commits") {
233+
navigate(buildRepositoryCommitsPath(nextRepositorySlug, { page: route.page }));
225234
return;
226235
}
227236

@@ -249,12 +258,12 @@ export const App = () => {
249258
repositoriesError ??
250259
submitError ??
251260
(route.name === "runs"
252-
? runsError
261+
? (stepSpecsError ?? runsError)
253262
: route.name === "run" || route.name === "step"
254263
? runError
255264
: route.name === "commit"
256265
? commitError
257-
: overviewError);
266+
: commitsError);
258267

259268
return (
260269
<main className="appShell">
@@ -286,57 +295,46 @@ export const App = () => {
286295
</div>
287296
<nav className="topnav">
288297
<NavLink
289-
active={route.name === "overview"}
290-
href={
291-
selectedRepositorySlug ? buildRepositoryOverviewPath(selectedRepositorySlug) : "/"
292-
}
293-
label="Overview"
298+
active={route.name === "commits" || route.name === "commit"}
299+
href={selectedRepositorySlug ? buildRepositoryCommitsPath(selectedRepositorySlug) : "/"}
300+
label="Commits"
294301
/>
295302
<NavLink
296-
active={
297-
route.name === "runs" ||
298-
route.name === "run" ||
299-
route.name === "step" ||
300-
route.name === "commit"
301-
}
303+
active={route.name === "runs" || route.name === "run" || route.name === "step"}
302304
href={
303305
selectedRepositorySlug ? buildRepositoryRunsPath(selectedRepositorySlug) : "/runs"
304306
}
305307
label="Runs"
306308
/>
307309
</nav>
308-
<div className="topbarMeta">
309-
<StatusPill status={activeRunCount > 0 ? "running" : "passed"} />
310-
<span className="secondaryText">{activeRunCount} active</span>
311-
</div>
312310
</header>
313311

314312
{error ? <div className="errorBanner">{error}</div> : null}
315313

316-
{route.name === "overview" ? (
317-
<OverviewPage
314+
{route.name === "commits" ? (
315+
<CommitsPage
318316
repositorySlug={selectedRepositorySlug}
319-
health={health}
320-
processSpecs={processSpecs}
317+
commitsPage={commitsPage}
318+
onPageChange={changeCommitsPage}
319+
/>
320+
) : null}
321+
322+
{route.name === "runs" ? (
323+
<RunsPage
324+
repositorySlug={selectedRepositorySlug}
325+
runsPage={runsPage}
326+
processSpecs={stepSpecs}
321327
commitSha={commitSha}
322328
branch={branch}
323329
changedFiles={changedFiles}
324330
resumeFromCheckpoint={resumeFromCheckpoint}
325331
submitting={submitting}
332+
draftFilters={draftFilters}
326333
onCommitShaChange={setCommitSha}
327334
onBranchChange={setBranch}
328335
onChangedFilesChange={setChangedFiles}
329336
onResumeFromCheckpointChange={setResumeFromCheckpoint}
330337
onSubmit={() => void submitManualRun()}
331-
/>
332-
) : null}
333-
334-
{route.name === "runs" ? (
335-
<RunsPage
336-
repositorySlug={selectedRepositorySlug}
337-
runsPage={runsPage}
338-
processSpecs={processSpecs}
339-
draftFilters={draftFilters}
340338
onDraftFilterChange={(key, value) => {
341339
setDraftFilters((current) => ({ ...current, [key]: value }));
342340
}}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { useEffect, useState } from "react";
2+
3+
import type { PaginatedCommitList } from "@verge/contracts";
4+
5+
import { describeLoadError, fetchJson } from "../lib/api.js";
6+
import type { AppRoute } from "../lib/routing.js";
7+
8+
export const useCommitListData = (route: AppRoute, repositorySlug: string | null) => {
9+
const [commitsPage, setCommitsPage] = useState<PaginatedCommitList | null>(null);
10+
const [error, setError] = useState<string | null>(null);
11+
12+
useEffect(() => {
13+
if (route.name !== "commits" || !repositorySlug) {
14+
setCommitsPage(null);
15+
setError(null);
16+
return;
17+
}
18+
19+
setCommitsPage(null);
20+
21+
const refresh = async (): Promise<void> => {
22+
try {
23+
const search = new URLSearchParams({
24+
page: String(route.page),
25+
pageSize: "20",
26+
});
27+
const nextCommitsPage = await fetchJson<PaginatedCommitList>(
28+
`/repositories/${repositorySlug}/commits?${search.toString()}`,
29+
);
30+
setError(null);
31+
setCommitsPage(nextCommitsPage);
32+
} catch (nextError) {
33+
setError(describeLoadError(route, nextError, "Failed to load commits"));
34+
}
35+
};
36+
37+
void refresh();
38+
const interval = window.setInterval(() => {
39+
void refresh();
40+
}, 4000);
41+
return () => window.clearInterval(interval);
42+
}, [route, repositorySlug]);
43+
44+
return { commitsPage, error };
45+
};

0 commit comments

Comments
 (0)