Skip to content

Commit 9662df3

Browse files
committed
Add multi-repository setup support
1 parent 74bcea5 commit 9662df3

File tree

12 files changed

+288
-66
lines changed

12 files changed

+288
-66
lines changed

apps/api/src/app.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import cors from "@fastify/cors";
22
import Fastify, { type FastifyInstance } from "fastify";
33
import fastifyRawBody from "fastify-raw-body";
4-
import { loadVergeConfig } from "@verge/core";
4+
import { loadVergeConfigs } from "@verge/core";
55
import { migrateDatabase, syncRepositoryConfiguration, type DatabaseConnection } from "@verge/db";
66

77
import type { ApiContext } from "./context.js";
@@ -58,22 +58,22 @@ export const bootstrapApiApp = async (
5858
connection: DatabaseConnection,
5959
input?: {
6060
configPath?: string;
61+
configPaths?: string[];
6162
},
6263
): Promise<FastifyInstance> => {
63-
const config = await loadVergeConfig(input);
64+
const configs = await loadVergeConfigs(input);
65+
const seenRepositorySlugs = new Set<string>();
6466

6567
await migrateDatabase(connection.db);
66-
await syncRepositoryConfiguration(connection.db, config.repository, config.steps);
68+
for (const config of configs) {
69+
if (seenRepositorySlugs.has(config.repository.slug)) {
70+
throw new Error(`Duplicate repository slug: ${config.repository.slug}`);
71+
}
72+
seenRepositorySlugs.add(config.repository.slug);
73+
await syncRepositoryConfiguration(connection.db, config.repository, config.steps);
74+
}
6775

6876
return createApiApp({
6977
connection,
70-
repositorySlug: config.repository.slug,
71-
repositoryDefinition: {
72-
slug: config.repository.slug,
73-
areas: config.repository.areas.map((area) => ({
74-
key: area.key,
75-
pathPrefixes: area.pathPrefixes,
76-
})),
77-
},
7878
});
7979
};

apps/api/src/context.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,4 @@ import type { DatabaseConnection } from "@verge/db";
22

33
export type ApiContext = {
44
connection: DatabaseConnection;
5-
repositorySlug: string;
6-
repositoryDefinition: {
7-
slug: string;
8-
areas: Array<{
9-
key: string;
10-
pathPrefixes: string[];
11-
}>;
12-
};
135
};

apps/api/src/planning.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { randomUUID } from "node:crypto";
22

33
import { computeStepConfigFingerprint, planStepRuns } from "@verge/core";
4+
import type { RepositoryDefinition } from "@verge/contracts";
45
import {
56
cloneCompletedProcessesFromCheckpoint,
67
cloneStepRunForReuse,
@@ -15,7 +16,6 @@ import {
1516
type DatabaseExecutor,
1617
} from "@verge/db";
1718

18-
import type { ApiContext } from "./context.js";
1919
import { parseStringArray } from "./utils.js";
2020

2121
const interruptPendingProcessesForStepRun = async (
@@ -59,7 +59,7 @@ export const createPlannedRun = async (
5959
slug: string;
6060
root_path: string;
6161
},
62-
repositoryDefinition: ApiContext["repositoryDefinition"],
62+
repositoryDefinition: Pick<RepositoryDefinition, "slug" | "areas">,
6363
input: {
6464
trigger: "manual" | "push" | "pull_request";
6565
commitSha: string;

apps/api/src/routes/public.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import type { FastifyInstance } from "fastify";
33
import { createManualRunInputSchema, runListQuerySchema } from "@verge/contracts";
44
import {
55
getCommitDetail,
6+
getRepositoryDefinitionBySlug,
67
getPullRequestDetail,
78
getRepositoryBySlug,
89
getRepositoryHealth,
910
getRunDetail,
1011
getStepRunDetail,
12+
listRepositories,
1113
listRepositoryRuns,
1214
listStepSpecSummaries,
1315
} from "@verge/db";
@@ -18,19 +20,25 @@ import { createPlannedRun } from "../planning.js";
1820
export const registerPublicRoutes = (app: FastifyInstance, context: ApiContext): void => {
1921
app.get("/healthz", async () => ({ ok: true }));
2022

21-
app.get("/step-specs", async () =>
22-
listStepSpecSummaries(context.connection.db, context.repositorySlug),
23+
app.get("/repositories", async () => listRepositories(context.connection.db));
24+
25+
app.get("/repositories/:repo/step-specs", async (request) =>
26+
listStepSpecSummaries(context.connection.db, (request.params as { repo: string }).repo),
2327
);
2428

2529
app.post("/runs/manual", async (request, reply) => {
2630
const input = createManualRunInputSchema.parse(request.body);
2731
const repository = await getRepositoryBySlug(context.connection.db, input.repositorySlug);
32+
const repositoryDefinition = await getRepositoryDefinitionBySlug(
33+
context.connection.db,
34+
input.repositorySlug,
35+
);
2836

29-
if (!repository) {
37+
if (!repository || !repositoryDefinition) {
3038
return reply.code(404).send({ message: "Repository not found" });
3139
}
3240

33-
return createPlannedRun(context.connection.db, repository, context.repositoryDefinition, {
41+
return createPlannedRun(context.connection.db, repository, repositoryDefinition, {
3442
trigger: "manual",
3543
commitSha: input.commitSha,
3644
...(input.changedFiles ? { changedFiles: input.changedFiles } : {}),

apps/api/src/routes/webhooks.ts

Lines changed: 74 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,22 @@ import {
44
githubWebhookPullRequestPayloadSchema,
55
githubWebhookPushPayloadSchema,
66
} from "@verge/contracts";
7-
import { createEventIngestion, getRepositoryBySlug } from "@verge/db";
7+
import {
8+
createEventIngestion,
9+
getRepositoryBySlug,
10+
getRepositoryDefinitionBySlug,
11+
} from "@verge/db";
812

913
import type { ApiContext } from "../context.js";
1014
import { createPlannedRun } from "../planning.js";
1115
import { collectChangedFilesFromPushPayload, validateGitHubSignature } from "../utils.js";
1216

1317
export const registerWebhookRoutes = (app: FastifyInstance, context: ApiContext): void => {
18+
const deriveRepositorySlug = (fullName: string): string => {
19+
const segments = fullName.split("/");
20+
return segments[segments.length - 1] ?? fullName;
21+
};
22+
1423
app.post("/webhooks/github", { config: { rawBody: true } }, async (request, reply) => {
1524
const deliveryId = String(request.headers["x-github-delivery"] ?? "");
1625
const eventName = String(request.headers["x-github-event"] ?? "");
@@ -34,38 +43,75 @@ export const registerWebhookRoutes = (app: FastifyInstance, context: ApiContext)
3443
return reply.code(401).send({ message: "Invalid webhook signature" });
3544
}
3645

37-
const repository = await getRepositoryBySlug(context.connection.db, context.repositorySlug);
38-
if (!repository) {
39-
return reply.code(404).send({ message: "Repository not found" });
40-
}
41-
42-
const result = await context.connection.db.transaction().execute(async (trx) => {
43-
const { eventIngestion, inserted } = await createEventIngestion(trx, {
44-
repositoryId: repository.id,
45-
source: "github",
46-
deliveryId,
47-
eventName,
48-
payload: request.body,
49-
});
46+
if (eventName === "push") {
47+
const payload = githubWebhookPushPayloadSchema.parse(request.body);
48+
const repositorySlug = deriveRepositorySlug(payload.repository.full_name);
49+
const repository = await getRepositoryBySlug(context.connection.db, repositorySlug);
50+
const repositoryDefinition = await getRepositoryDefinitionBySlug(
51+
context.connection.db,
52+
repositorySlug,
53+
);
5054

51-
if (!inserted) {
52-
return { duplicate: true } as const;
55+
if (!repository || !repositoryDefinition) {
56+
return reply.code(404).send({ message: "Repository not found" });
5357
}
5458

55-
if (eventName === "push") {
56-
const payload = githubWebhookPushPayloadSchema.parse(request.body);
57-
await createPlannedRun(trx, repository, context.repositoryDefinition, {
59+
const result = await context.connection.db.transaction().execute(async (trx) => {
60+
const { eventIngestion, inserted } = await createEventIngestion(trx, {
61+
repositoryId: repository.id,
62+
source: "github",
63+
deliveryId,
64+
eventName,
65+
payload: request.body,
66+
});
67+
68+
if (!inserted) {
69+
return { duplicate: true } as const;
70+
}
71+
72+
await createPlannedRun(trx, repository, repositoryDefinition, {
5873
trigger: "push",
5974
commitSha: payload.after,
6075
branch: payload.ref.replace("refs/heads/", ""),
6176
changedFiles: collectChangedFilesFromPushPayload(payload),
6277
eventIngestionId: eventIngestion.id,
6378
});
6479
return { duplicate: false, trigger: "push" } as const;
80+
});
81+
82+
if (result.duplicate) {
83+
return reply.code(202).send({ ok: true, duplicate: true });
6584
}
6685

67-
if (eventName === "pull_request") {
68-
const payload = githubWebhookPullRequestPayloadSchema.parse(request.body);
86+
return reply.code(202).send({ ok: true, ...result });
87+
}
88+
89+
if (eventName === "pull_request") {
90+
const payload = githubWebhookPullRequestPayloadSchema.parse(request.body);
91+
const repositorySlug = deriveRepositorySlug(payload.repository.full_name);
92+
const repository = await getRepositoryBySlug(context.connection.db, repositorySlug);
93+
const repositoryDefinition = await getRepositoryDefinitionBySlug(
94+
context.connection.db,
95+
repositorySlug,
96+
);
97+
98+
if (!repository || !repositoryDefinition) {
99+
return reply.code(404).send({ message: "Repository not found" });
100+
}
101+
102+
const result = await context.connection.db.transaction().execute(async (trx) => {
103+
const { eventIngestion, inserted } = await createEventIngestion(trx, {
104+
repositoryId: repository.id,
105+
source: "github",
106+
deliveryId,
107+
eventName,
108+
payload: request.body,
109+
});
110+
111+
if (!inserted) {
112+
return { duplicate: true } as const;
113+
}
114+
69115
if (!["opened", "reopened", "synchronize"].includes(payload.action)) {
70116
return {
71117
duplicate: false,
@@ -74,7 +120,7 @@ export const registerWebhookRoutes = (app: FastifyInstance, context: ApiContext)
74120
} as const;
75121
}
76122

77-
await createPlannedRun(trx, repository, context.repositoryDefinition, {
123+
await createPlannedRun(trx, repository, repositoryDefinition, {
78124
trigger: "pull_request",
79125
commitSha: payload.pull_request.head.sha,
80126
branch: payload.pull_request.head.ref,
@@ -83,15 +129,15 @@ export const registerWebhookRoutes = (app: FastifyInstance, context: ApiContext)
83129
eventIngestionId: eventIngestion.id,
84130
});
85131
return { duplicate: false, trigger: "pull_request" } as const;
86-
}
132+
});
87133

88-
return { duplicate: false, ignored: true, eventName } as const;
89-
});
134+
if (result.duplicate) {
135+
return reply.code(202).send({ ok: true, duplicate: true });
136+
}
90137

91-
if (result.duplicate) {
92-
return reply.code(202).send({ ok: true, duplicate: true });
138+
return reply.code(202).send({ ok: true, ...result });
93139
}
94140

95-
return reply.code(202).send({ ok: true, ...result });
141+
return reply.code(202).send({ ok: true, ignored: true, eventName });
96142
});
97143
};

0 commit comments

Comments
 (0)