@fragno-dev/github-app-fragment 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +16 -0
- package/README.md +163 -0
- package/bin/run.js +5 -0
- package/dist/browser/client/react.d.ts +37 -0
- package/dist/browser/client/react.d.ts.map +1 -0
- package/dist/browser/client/react.js +166 -0
- package/dist/browser/client/react.js.map +1 -0
- package/dist/browser/client/solid.d.ts +35 -0
- package/dist/browser/client/solid.d.ts.map +1 -0
- package/dist/browser/client/solid.js +136 -0
- package/dist/browser/client/solid.js.map +1 -0
- package/dist/browser/client/svelte.d.ts +30 -0
- package/dist/browser/client/svelte.d.ts.map +1 -0
- package/dist/browser/client/svelte.js +134 -0
- package/dist/browser/client/svelte.js.map +1 -0
- package/dist/browser/client/vanilla.d.ts +16 -0
- package/dist/browser/client/vanilla.d.ts.map +1 -0
- package/dist/browser/client/vanilla.js +11 -0
- package/dist/browser/client/vanilla.js.map +1 -0
- package/dist/browser/client/vue.d.ts +33 -0
- package/dist/browser/client/vue.d.ts.map +1 -0
- package/dist/browser/client/vue.js +133 -0
- package/dist/browser/client/vue.js.map +1 -0
- package/dist/browser/factory-BIj4C6PD.js +2210 -0
- package/dist/browser/factory-BIj4C6PD.js.map +1 -0
- package/dist/browser/index.d.ts +343 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +3 -0
- package/dist/browser/types-BzeSSOQU.d.ts +660 -0
- package/dist/browser/types-BzeSSOQU.d.ts.map +1 -0
- package/dist/cli/commands/installations.js +92 -0
- package/dist/cli/commands/installations.js.map +1 -0
- package/dist/cli/commands/pulls.js +123 -0
- package/dist/cli/commands/pulls.js.map +1 -0
- package/dist/cli/commands/repositories.js +105 -0
- package/dist/cli/commands/repositories.js.map +1 -0
- package/dist/cli/commands/serve.js +187 -0
- package/dist/cli/commands/serve.js.map +1 -0
- package/dist/cli/commands/webhooks.js +122 -0
- package/dist/cli/commands/webhooks.js.map +1 -0
- package/dist/cli/github/api.js +94 -0
- package/dist/cli/github/api.js.map +1 -0
- package/dist/cli/github/definition.js +15 -0
- package/dist/cli/github/definition.js.map +1 -0
- package/dist/cli/github/factory.js +12 -0
- package/dist/cli/github/factory.js.map +1 -0
- package/dist/cli/github/repo-sync.js +33 -0
- package/dist/cli/github/repo-sync.js.map +1 -0
- package/dist/cli/github/utils.js +23 -0
- package/dist/cli/github/utils.js.map +1 -0
- package/dist/cli/github/webhook-processing.js +247 -0
- package/dist/cli/github/webhook-processing.js.map +1 -0
- package/dist/cli/index.d.ts +5 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +263 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/routes.js +718 -0
- package/dist/cli/routes.js.map +1 -0
- package/dist/cli/schema.js +47 -0
- package/dist/cli/schema.js.map +1 -0
- package/dist/cli/utils/client.js +120 -0
- package/dist/cli/utils/client.js.map +1 -0
- package/dist/cli/utils/config.js +113 -0
- package/dist/cli/utils/config.js.map +1 -0
- package/dist/cli/utils/options.js +90 -0
- package/dist/cli/utils/options.js.map +1 -0
- package/dist/cli/utils/output.js +12 -0
- package/dist/cli/utils/output.js.map +1 -0
- package/dist/node/github/api.d.ts +52 -0
- package/dist/node/github/api.d.ts.map +1 -0
- package/dist/node/github/api.js +94 -0
- package/dist/node/github/api.js.map +1 -0
- package/dist/node/github/clients.d.ts +19 -0
- package/dist/node/github/clients.d.ts.map +1 -0
- package/dist/node/github/clients.js +12 -0
- package/dist/node/github/clients.js.map +1 -0
- package/dist/node/github/definition.d.ts +33 -0
- package/dist/node/github/definition.d.ts.map +1 -0
- package/dist/node/github/definition.js +15 -0
- package/dist/node/github/definition.js.map +1 -0
- package/dist/node/github/factory.d.ts +139 -0
- package/dist/node/github/factory.d.ts.map +1 -0
- package/dist/node/github/factory.js +18 -0
- package/dist/node/github/factory.js.map +1 -0
- package/dist/node/github/repo-sync.js +33 -0
- package/dist/node/github/repo-sync.js.map +1 -0
- package/dist/node/github/types.d.ts +24 -0
- package/dist/node/github/types.d.ts.map +1 -0
- package/dist/node/github/utils.js +23 -0
- package/dist/node/github/utils.js.map +1 -0
- package/dist/node/github/webhook-processing.d.ts +15 -0
- package/dist/node/github/webhook-processing.d.ts.map +1 -0
- package/dist/node/github/webhook-processing.js +247 -0
- package/dist/node/github/webhook-processing.js.map +1 -0
- package/dist/node/index.d.ts +7 -0
- package/dist/node/index.js +6 -0
- package/dist/node/routes.d.ts +127 -0
- package/dist/node/routes.d.ts.map +1 -0
- package/dist/node/routes.js +718 -0
- package/dist/node/routes.js.map +1 -0
- package/dist/node/schema.js +47 -0
- package/dist/node/schema.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +114 -0
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
import { githubAppSchema } from "./schema.js";
|
|
2
|
+
import { hasRepoChanges, toRepoCreateRecord, toRepoRecord } from "./github/repo-sync.js";
|
|
3
|
+
import { isRecord, normalizeJoinedInstallation, normalizeJoinedLinks, toExternalId, toStringValue } from "./github/utils.js";
|
|
4
|
+
import { githubAppFragmentDefinition } from "./github/definition.js";
|
|
5
|
+
import { FragnoId } from "@fragno-dev/db/schema";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { defineRoutes } from "@fragno-dev/core";
|
|
8
|
+
|
|
9
|
+
//#region src/routes.ts
|
|
10
|
+
const toDebugSignature = (signatureHeader) => {
|
|
11
|
+
if (!signatureHeader) return null;
|
|
12
|
+
if (signatureHeader.length <= 20) return signatureHeader;
|
|
13
|
+
return `${signatureHeader.slice(0, 12)}…${signatureHeader.slice(-8)}`;
|
|
14
|
+
};
|
|
15
|
+
const normalizeLinkKey = (linkKey, defaultLinkKey) => {
|
|
16
|
+
const normalized = linkKey?.trim();
|
|
17
|
+
return normalized && normalized.length > 0 ? normalized : defaultLinkKey ?? "default";
|
|
18
|
+
};
|
|
19
|
+
const parseLinkedOnly = (value) => value === "true" || value === "1";
|
|
20
|
+
const getHttpStatusCode = (value) => {
|
|
21
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
22
|
+
if (typeof value === "object" && value && "status" in value) {
|
|
23
|
+
const status = value.status;
|
|
24
|
+
if (typeof status === "number" && Number.isFinite(status)) return status;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
};
|
|
28
|
+
const installationOutputSchema = z.object({
|
|
29
|
+
id: z.string(),
|
|
30
|
+
accountId: z.string(),
|
|
31
|
+
accountLogin: z.string(),
|
|
32
|
+
accountType: z.string(),
|
|
33
|
+
status: z.string(),
|
|
34
|
+
permissions: z.any(),
|
|
35
|
+
events: z.any(),
|
|
36
|
+
createdAt: z.date(),
|
|
37
|
+
updatedAt: z.date(),
|
|
38
|
+
lastWebhookAt: z.date().nullable()
|
|
39
|
+
});
|
|
40
|
+
const repoSummarySchema = z.object({
|
|
41
|
+
id: z.string(),
|
|
42
|
+
installationId: z.string(),
|
|
43
|
+
ownerLogin: z.string(),
|
|
44
|
+
name: z.string(),
|
|
45
|
+
fullName: z.string(),
|
|
46
|
+
isPrivate: z.boolean(),
|
|
47
|
+
isFork: z.boolean().nullable(),
|
|
48
|
+
defaultBranch: z.string().nullable(),
|
|
49
|
+
removedAt: z.date().nullable(),
|
|
50
|
+
updatedAt: z.date()
|
|
51
|
+
});
|
|
52
|
+
const repoWithLinksSchema = repoSummarySchema.extend({ linkKeys: z.array(z.string()) });
|
|
53
|
+
const repoLinkOutputSchema = z.object({
|
|
54
|
+
id: z.string(),
|
|
55
|
+
repoId: z.string(),
|
|
56
|
+
linkKey: z.string(),
|
|
57
|
+
linkedAt: z.date()
|
|
58
|
+
});
|
|
59
|
+
const githubAppRoutesFactory = defineRoutes(githubAppFragmentDefinition).create(({ config, defineRoute, deps }) => {
|
|
60
|
+
const api = deps.githubApiClient;
|
|
61
|
+
return [
|
|
62
|
+
defineRoute({
|
|
63
|
+
method: "POST",
|
|
64
|
+
path: "/webhooks",
|
|
65
|
+
errorCodes: [
|
|
66
|
+
"WEBHOOK_SIGNATURE_INVALID",
|
|
67
|
+
"WEBHOOK_DELIVERY_MISSING",
|
|
68
|
+
"WEBHOOK_PAYLOAD_INVALID"
|
|
69
|
+
],
|
|
70
|
+
handler: async function(ctx, { empty, error }) {
|
|
71
|
+
const rawBody = ctx.rawBody;
|
|
72
|
+
const logWebhook = config.webhookDebug === true ? (message, details) => {
|
|
73
|
+
if (details) console.log("[github-app-fragment webhook]", message, details);
|
|
74
|
+
else console.log("[github-app-fragment webhook]", message);
|
|
75
|
+
} : void 0;
|
|
76
|
+
logWebhook?.("received", {
|
|
77
|
+
hasRawBody: Boolean(rawBody),
|
|
78
|
+
rawBodyBytes: rawBody ? Buffer.byteLength(rawBody, "utf8") : 0,
|
|
79
|
+
signature: toDebugSignature(ctx.headers.get("x-hub-signature-256")),
|
|
80
|
+
deliveryId: ctx.headers.get("x-github-delivery") ?? null,
|
|
81
|
+
event: ctx.headers.get("x-github-event") ?? null,
|
|
82
|
+
contentType: ctx.headers.get("content-type") ?? null
|
|
83
|
+
});
|
|
84
|
+
if (!rawBody) {
|
|
85
|
+
logWebhook?.("rejected: missing payload");
|
|
86
|
+
return error({
|
|
87
|
+
message: "Missing webhook payload.",
|
|
88
|
+
code: "WEBHOOK_PAYLOAD_INVALID"
|
|
89
|
+
}, { status: 400 });
|
|
90
|
+
}
|
|
91
|
+
const signatureHeader = ctx.headers.get("x-hub-signature-256");
|
|
92
|
+
const signatureOk = await api.verifyWebhookSignature({
|
|
93
|
+
payload: rawBody,
|
|
94
|
+
signatureHeader
|
|
95
|
+
});
|
|
96
|
+
logWebhook?.("signature check", { ok: signatureOk });
|
|
97
|
+
if (!signatureOk) {
|
|
98
|
+
logWebhook?.("rejected: invalid signature");
|
|
99
|
+
return error({
|
|
100
|
+
message: "Invalid webhook signature.",
|
|
101
|
+
code: "WEBHOOK_SIGNATURE_INVALID"
|
|
102
|
+
}, { status: 401 });
|
|
103
|
+
}
|
|
104
|
+
const deliveryId = ctx.headers.get("x-github-delivery") ?? "";
|
|
105
|
+
if (!deliveryId) {
|
|
106
|
+
logWebhook?.("rejected: missing delivery id");
|
|
107
|
+
return error({
|
|
108
|
+
message: "Missing delivery id.",
|
|
109
|
+
code: "WEBHOOK_DELIVERY_MISSING"
|
|
110
|
+
}, { status: 400 });
|
|
111
|
+
}
|
|
112
|
+
const event = ctx.headers.get("x-github-event") ?? "";
|
|
113
|
+
if (!event) {
|
|
114
|
+
logWebhook?.("rejected: missing event");
|
|
115
|
+
return error({
|
|
116
|
+
message: "Missing webhook event type.",
|
|
117
|
+
code: "WEBHOOK_PAYLOAD_INVALID"
|
|
118
|
+
}, { status: 400 });
|
|
119
|
+
}
|
|
120
|
+
let payload;
|
|
121
|
+
try {
|
|
122
|
+
payload = JSON.parse(rawBody);
|
|
123
|
+
} catch {
|
|
124
|
+
logWebhook?.("rejected: invalid json");
|
|
125
|
+
return error({
|
|
126
|
+
message: "Invalid JSON payload.",
|
|
127
|
+
code: "WEBHOOK_PAYLOAD_INVALID"
|
|
128
|
+
}, { status: 400 });
|
|
129
|
+
}
|
|
130
|
+
if (!isRecord(payload)) {
|
|
131
|
+
logWebhook?.("rejected: payload not object");
|
|
132
|
+
return error({
|
|
133
|
+
message: "Invalid webhook payload.",
|
|
134
|
+
code: "WEBHOOK_PAYLOAD_INVALID"
|
|
135
|
+
}, { status: 400 });
|
|
136
|
+
}
|
|
137
|
+
const action = typeof payload["action"] === "string" ? payload["action"] : null;
|
|
138
|
+
const installationId = toStringValue((isRecord(payload["installation"]) ? payload["installation"] : null)?.["id"] ?? payload["installation_id"]);
|
|
139
|
+
if (!installationId) {
|
|
140
|
+
logWebhook?.("rejected: missing installation id");
|
|
141
|
+
return error({
|
|
142
|
+
message: "Missing installation id.",
|
|
143
|
+
code: "WEBHOOK_PAYLOAD_INVALID"
|
|
144
|
+
}, { status: 400 });
|
|
145
|
+
}
|
|
146
|
+
const now = /* @__PURE__ */ new Date();
|
|
147
|
+
logWebhook?.("accepted", {
|
|
148
|
+
deliveryId,
|
|
149
|
+
event,
|
|
150
|
+
action,
|
|
151
|
+
installationId
|
|
152
|
+
});
|
|
153
|
+
const webhookPayload = {
|
|
154
|
+
deliveryId,
|
|
155
|
+
event,
|
|
156
|
+
action,
|
|
157
|
+
installationId,
|
|
158
|
+
payload,
|
|
159
|
+
receivedAt: now.toISOString()
|
|
160
|
+
};
|
|
161
|
+
await this.handlerTx().mutate(({ forSchema }) => {
|
|
162
|
+
forSchema(githubAppSchema).triggerHook("processWebhook", webhookPayload);
|
|
163
|
+
}).execute();
|
|
164
|
+
return empty(204);
|
|
165
|
+
}
|
|
166
|
+
}),
|
|
167
|
+
defineRoute({
|
|
168
|
+
method: "GET",
|
|
169
|
+
path: "/installations",
|
|
170
|
+
queryParameters: ["status"],
|
|
171
|
+
outputSchema: z.array(installationOutputSchema),
|
|
172
|
+
errorCodes: ["INVALID_STATUS"],
|
|
173
|
+
handler: async function({ query }, { json, error }) {
|
|
174
|
+
const status = query.get("status");
|
|
175
|
+
if (status && ![
|
|
176
|
+
"active",
|
|
177
|
+
"suspended",
|
|
178
|
+
"deleted"
|
|
179
|
+
].includes(status)) return error({
|
|
180
|
+
message: `Invalid status: ${status}`,
|
|
181
|
+
code: "INVALID_STATUS"
|
|
182
|
+
}, { status: 400 });
|
|
183
|
+
const [installations] = await this.handlerTx().retrieve(({ forSchema }) => {
|
|
184
|
+
const uow = forSchema(githubAppSchema);
|
|
185
|
+
if (status) return uow.find("installation", (b) => b.whereIndex("idx_installation_status", (eb) => eb("status", "=", status)));
|
|
186
|
+
return uow.find("installation", (b) => b.whereIndex("idx_installation_status", (eb) => eb("status", "!=", "")));
|
|
187
|
+
}).execute();
|
|
188
|
+
return json(installations.map((installation) => ({
|
|
189
|
+
id: toExternalId(installation.id),
|
|
190
|
+
accountId: installation.accountId,
|
|
191
|
+
accountLogin: installation.accountLogin,
|
|
192
|
+
accountType: installation.accountType,
|
|
193
|
+
status: installation.status,
|
|
194
|
+
permissions: installation.permissions,
|
|
195
|
+
events: installation.events,
|
|
196
|
+
createdAt: installation.createdAt,
|
|
197
|
+
updatedAt: installation.updatedAt,
|
|
198
|
+
lastWebhookAt: installation.lastWebhookAt
|
|
199
|
+
})));
|
|
200
|
+
}
|
|
201
|
+
}),
|
|
202
|
+
defineRoute({
|
|
203
|
+
method: "GET",
|
|
204
|
+
path: "/installations/:installationId/repos",
|
|
205
|
+
queryParameters: ["linkedOnly", "linkKey"],
|
|
206
|
+
outputSchema: z.array(repoWithLinksSchema),
|
|
207
|
+
errorCodes: ["INSTALLATION_NOT_FOUND"],
|
|
208
|
+
handler: async function({ pathParams, query }, { json, error }) {
|
|
209
|
+
const installationId = pathParams.installationId;
|
|
210
|
+
const linkedOnly = parseLinkedOnly(query.get("linkedOnly"));
|
|
211
|
+
const linkKeyFilter = query.get("linkKey")?.trim() ?? null;
|
|
212
|
+
const [installation, repos] = await this.handlerTx().retrieve(({ forSchema }) => {
|
|
213
|
+
return forSchema(githubAppSchema).findFirst("installation", (b) => b.whereIndex("uniq_installation_id", (eb) => eb("id", "=", installationId))).find("installation_repo", (b) => b.whereIndex("idx_installation_repo_installation", (eb) => eb("installationId", "=", installationId)).join((jb) => jb.links()));
|
|
214
|
+
}).execute();
|
|
215
|
+
if (!installation) return error({
|
|
216
|
+
message: "Installation not found.",
|
|
217
|
+
code: "INSTALLATION_NOT_FOUND"
|
|
218
|
+
}, { status: 404 });
|
|
219
|
+
const output = [];
|
|
220
|
+
for (const repo of repos) {
|
|
221
|
+
if (repo.removedAt !== null) continue;
|
|
222
|
+
const repoId = toExternalId(repo.id);
|
|
223
|
+
if (!repoId) continue;
|
|
224
|
+
const linkKeys = normalizeJoinedLinks(repo.links).filter((link) => !linkKeyFilter || link.linkKey === linkKeyFilter).map((link) => link.linkKey);
|
|
225
|
+
if (linkedOnly && linkKeys.length === 0) continue;
|
|
226
|
+
output.push({
|
|
227
|
+
id: repoId,
|
|
228
|
+
installationId,
|
|
229
|
+
ownerLogin: repo.ownerLogin ?? "",
|
|
230
|
+
name: repo.name ?? "",
|
|
231
|
+
fullName: repo.fullName ?? "",
|
|
232
|
+
isPrivate: Boolean(repo.isPrivate),
|
|
233
|
+
isFork: repo.isFork ?? null,
|
|
234
|
+
defaultBranch: repo.defaultBranch ?? null,
|
|
235
|
+
removedAt: repo.removedAt ?? null,
|
|
236
|
+
updatedAt: repo.updatedAt,
|
|
237
|
+
linkKeys
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return json(output);
|
|
241
|
+
}
|
|
242
|
+
}),
|
|
243
|
+
defineRoute({
|
|
244
|
+
method: "GET",
|
|
245
|
+
path: "/repositories/linked",
|
|
246
|
+
queryParameters: ["linkKey"],
|
|
247
|
+
outputSchema: z.array(repoWithLinksSchema),
|
|
248
|
+
handler: async function({ query }, { json }) {
|
|
249
|
+
const linkKeyFilter = query.get("linkKey")?.trim() ?? null;
|
|
250
|
+
const [repos] = await this.handlerTx().retrieve(({ forSchema }) => {
|
|
251
|
+
return forSchema(githubAppSchema).find("installation_repo", (b) => b.whereIndex("idx_installation_repo_full_name", (eb) => eb("fullName", "!=", "")).join((jb) => jb.installation().links()));
|
|
252
|
+
}).execute();
|
|
253
|
+
const output = [];
|
|
254
|
+
for (const repo of repos) {
|
|
255
|
+
if (repo.removedAt !== null) continue;
|
|
256
|
+
const repoId = toExternalId(repo.id);
|
|
257
|
+
if (!repoId) continue;
|
|
258
|
+
const installation = normalizeJoinedInstallation(repo.installation);
|
|
259
|
+
if (!installation || installation.status !== "active") continue;
|
|
260
|
+
const linkKeys = normalizeJoinedLinks(repo.links).filter((link) => !linkKeyFilter || link.linkKey === linkKeyFilter).map((link) => link.linkKey);
|
|
261
|
+
if (linkKeys.length === 0) continue;
|
|
262
|
+
output.push({
|
|
263
|
+
id: repoId,
|
|
264
|
+
installationId: toExternalId(installation.id),
|
|
265
|
+
ownerLogin: repo.ownerLogin ?? "",
|
|
266
|
+
name: repo.name ?? "",
|
|
267
|
+
fullName: repo.fullName ?? "",
|
|
268
|
+
isPrivate: Boolean(repo.isPrivate),
|
|
269
|
+
isFork: repo.isFork ?? null,
|
|
270
|
+
defaultBranch: repo.defaultBranch ?? null,
|
|
271
|
+
removedAt: repo.removedAt ?? null,
|
|
272
|
+
updatedAt: repo.updatedAt,
|
|
273
|
+
linkKeys
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
return json(output);
|
|
277
|
+
}
|
|
278
|
+
}),
|
|
279
|
+
defineRoute({
|
|
280
|
+
method: "POST",
|
|
281
|
+
path: "/repositories/link",
|
|
282
|
+
inputSchema: z.object({
|
|
283
|
+
installationId: z.string(),
|
|
284
|
+
repoId: z.string(),
|
|
285
|
+
linkKey: z.string().optional()
|
|
286
|
+
}),
|
|
287
|
+
outputSchema: z.object({
|
|
288
|
+
link: repoLinkOutputSchema,
|
|
289
|
+
repo: repoSummarySchema
|
|
290
|
+
}),
|
|
291
|
+
errorCodes: [
|
|
292
|
+
"INSTALLATION_NOT_FOUND",
|
|
293
|
+
"INSTALLATION_INACTIVE",
|
|
294
|
+
"REPO_NOT_FOUND",
|
|
295
|
+
"REPO_REMOVED"
|
|
296
|
+
],
|
|
297
|
+
handler: async function({ input }, { json, error }) {
|
|
298
|
+
const values = await input.valid();
|
|
299
|
+
const linkKey = normalizeLinkKey(values.linkKey, config.defaultLinkKey);
|
|
300
|
+
const [installation, repos] = await this.handlerTx().retrieve(({ forSchema }) => {
|
|
301
|
+
return forSchema(githubAppSchema).findFirst("installation", (b) => b.whereIndex("uniq_installation_id", (eb) => eb("id", "=", values.installationId))).find("installation_repo", (b) => b.whereIndex("idx_installation_repo_installation", (eb) => eb("installationId", "=", values.installationId)).join((jb) => jb.links()));
|
|
302
|
+
}).execute();
|
|
303
|
+
if (!installation) return error({
|
|
304
|
+
message: "Installation not found.",
|
|
305
|
+
code: "INSTALLATION_NOT_FOUND"
|
|
306
|
+
}, { status: 404 });
|
|
307
|
+
if (installation.status !== "active") return error({
|
|
308
|
+
message: "Installation is not active.",
|
|
309
|
+
code: "INSTALLATION_INACTIVE"
|
|
310
|
+
}, { status: 409 });
|
|
311
|
+
const repo = repos.find((record) => toExternalId(record.id) === values.repoId);
|
|
312
|
+
if (!repo) return error({
|
|
313
|
+
message: "Repository not found.",
|
|
314
|
+
code: "REPO_NOT_FOUND"
|
|
315
|
+
}, { status: 404 });
|
|
316
|
+
if (repo.removedAt !== null) return error({
|
|
317
|
+
message: "Repository has been removed.",
|
|
318
|
+
code: "REPO_REMOVED"
|
|
319
|
+
}, { status: 409 });
|
|
320
|
+
const existingLink = normalizeJoinedLinks(repo.links).find((link) => link.linkKey === linkKey);
|
|
321
|
+
if (existingLink) return json({
|
|
322
|
+
link: {
|
|
323
|
+
id: toExternalId(existingLink.id),
|
|
324
|
+
repoId: values.repoId,
|
|
325
|
+
linkKey: existingLink.linkKey,
|
|
326
|
+
linkedAt: existingLink.linkedAt
|
|
327
|
+
},
|
|
328
|
+
repo: {
|
|
329
|
+
id: values.repoId,
|
|
330
|
+
installationId: values.installationId,
|
|
331
|
+
ownerLogin: repo.ownerLogin ?? "",
|
|
332
|
+
name: repo.name ?? "",
|
|
333
|
+
fullName: repo.fullName ?? "",
|
|
334
|
+
isPrivate: Boolean(repo.isPrivate),
|
|
335
|
+
isFork: repo.isFork ?? null,
|
|
336
|
+
defaultBranch: repo.defaultBranch ?? null,
|
|
337
|
+
removedAt: repo.removedAt ?? null,
|
|
338
|
+
updatedAt: repo.updatedAt
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
const linkedAt = /* @__PURE__ */ new Date();
|
|
342
|
+
return json({
|
|
343
|
+
link: {
|
|
344
|
+
id: toExternalId(await this.handlerTx().mutate(({ forSchema }) => {
|
|
345
|
+
return forSchema(githubAppSchema).create("repo_link", {
|
|
346
|
+
repoId: values.repoId,
|
|
347
|
+
linkKey,
|
|
348
|
+
linkedAt
|
|
349
|
+
});
|
|
350
|
+
}).execute()),
|
|
351
|
+
repoId: values.repoId,
|
|
352
|
+
linkKey,
|
|
353
|
+
linkedAt
|
|
354
|
+
},
|
|
355
|
+
repo: {
|
|
356
|
+
id: values.repoId,
|
|
357
|
+
installationId: values.installationId,
|
|
358
|
+
ownerLogin: repo.ownerLogin ?? "",
|
|
359
|
+
name: repo.name ?? "",
|
|
360
|
+
fullName: repo.fullName ?? "",
|
|
361
|
+
isPrivate: Boolean(repo.isPrivate),
|
|
362
|
+
isFork: repo.isFork ?? null,
|
|
363
|
+
defaultBranch: repo.defaultBranch ?? null,
|
|
364
|
+
removedAt: repo.removedAt ?? null,
|
|
365
|
+
updatedAt: repo.updatedAt
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
}),
|
|
370
|
+
defineRoute({
|
|
371
|
+
method: "POST",
|
|
372
|
+
path: "/repositories/unlink",
|
|
373
|
+
inputSchema: z.object({
|
|
374
|
+
repoId: z.string(),
|
|
375
|
+
linkKey: z.string().optional()
|
|
376
|
+
}),
|
|
377
|
+
outputSchema: z.object({ ok: z.literal(true) }),
|
|
378
|
+
errorCodes: [
|
|
379
|
+
"REPO_NOT_FOUND",
|
|
380
|
+
"INSTALLATION_NOT_FOUND",
|
|
381
|
+
"INSTALLATION_INACTIVE",
|
|
382
|
+
"LINK_NOT_FOUND"
|
|
383
|
+
],
|
|
384
|
+
handler: async function({ input }, { json, error }) {
|
|
385
|
+
const values = await input.valid();
|
|
386
|
+
const linkKey = normalizeLinkKey(values.linkKey, config.defaultLinkKey);
|
|
387
|
+
const [repos] = await this.handlerTx().retrieve(({ forSchema }) => {
|
|
388
|
+
return forSchema(githubAppSchema).find("installation_repo", (b) => b.whereIndex("idx_installation_repo_full_name", (eb) => eb("fullName", "!=", "")).join((jb) => jb.installation().links()));
|
|
389
|
+
}).execute();
|
|
390
|
+
const repo = repos.find((record) => toExternalId(record.id) === values.repoId);
|
|
391
|
+
if (!repo) return error({
|
|
392
|
+
message: "Repository not found.",
|
|
393
|
+
code: "REPO_NOT_FOUND"
|
|
394
|
+
}, { status: 404 });
|
|
395
|
+
const installation = normalizeJoinedInstallation(repo.installation);
|
|
396
|
+
if (!installation) return error({
|
|
397
|
+
message: "Installation not found.",
|
|
398
|
+
code: "INSTALLATION_NOT_FOUND"
|
|
399
|
+
}, { status: 404 });
|
|
400
|
+
if (installation.status !== "active") return error({
|
|
401
|
+
message: "Installation is not active.",
|
|
402
|
+
code: "INSTALLATION_INACTIVE"
|
|
403
|
+
}, { status: 409 });
|
|
404
|
+
const link = normalizeJoinedLinks(repo.links).find((entry) => entry.linkKey === linkKey);
|
|
405
|
+
if (!link) return error({
|
|
406
|
+
message: "Link not found.",
|
|
407
|
+
code: "LINK_NOT_FOUND"
|
|
408
|
+
}, { status: 404 });
|
|
409
|
+
await this.handlerTx().mutate(({ forSchema }) => {
|
|
410
|
+
forSchema(githubAppSchema).delete("repo_link", link.id);
|
|
411
|
+
}).execute();
|
|
412
|
+
return json({ ok: true });
|
|
413
|
+
}
|
|
414
|
+
}),
|
|
415
|
+
defineRoute({
|
|
416
|
+
method: "GET",
|
|
417
|
+
path: "/repositories/:owner/:repo/pulls",
|
|
418
|
+
queryParameters: [
|
|
419
|
+
"state",
|
|
420
|
+
"perPage",
|
|
421
|
+
"page"
|
|
422
|
+
],
|
|
423
|
+
outputSchema: z.object({
|
|
424
|
+
pulls: z.array(z.any()),
|
|
425
|
+
pageInfo: z.object({
|
|
426
|
+
page: z.number(),
|
|
427
|
+
perPage: z.number()
|
|
428
|
+
})
|
|
429
|
+
}),
|
|
430
|
+
errorCodes: [
|
|
431
|
+
"INVALID_STATE",
|
|
432
|
+
"INVALID_PER_PAGE",
|
|
433
|
+
"INVALID_PAGE",
|
|
434
|
+
"REPO_NOT_FOUND",
|
|
435
|
+
"REPO_REMOVED",
|
|
436
|
+
"REPO_NOT_LINKED",
|
|
437
|
+
"INSTALLATION_NOT_FOUND",
|
|
438
|
+
"INSTALLATION_INACTIVE",
|
|
439
|
+
"GITHUB_API_ERROR"
|
|
440
|
+
],
|
|
441
|
+
handler: async function({ pathParams, query }, { json, error }) {
|
|
442
|
+
const state = query.get("state");
|
|
443
|
+
if (state && ![
|
|
444
|
+
"open",
|
|
445
|
+
"closed",
|
|
446
|
+
"all"
|
|
447
|
+
].includes(state)) return error({
|
|
448
|
+
message: `Invalid state: ${state}`,
|
|
449
|
+
code: "INVALID_STATE"
|
|
450
|
+
}, { status: 400 });
|
|
451
|
+
const stateFilter = state ?? void 0;
|
|
452
|
+
const perPageRaw = query.get("perPage");
|
|
453
|
+
const perPage = perPageRaw ? Number.parseInt(perPageRaw, 10) : 30;
|
|
454
|
+
if (!Number.isFinite(perPage) || perPage <= 0 || perPage > 100) return error({
|
|
455
|
+
message: "perPage must be between 1 and 100.",
|
|
456
|
+
code: "INVALID_PER_PAGE"
|
|
457
|
+
}, { status: 400 });
|
|
458
|
+
const pageRaw = query.get("page");
|
|
459
|
+
const page = pageRaw ? Number.parseInt(pageRaw, 10) : 1;
|
|
460
|
+
if (!Number.isFinite(page) || page <= 0) return error({
|
|
461
|
+
message: "page must be a positive number.",
|
|
462
|
+
code: "INVALID_PAGE"
|
|
463
|
+
}, { status: 400 });
|
|
464
|
+
const fullName = `${pathParams.owner}/${pathParams.repo}`;
|
|
465
|
+
const [repos] = await this.handlerTx().retrieve(({ forSchema }) => {
|
|
466
|
+
return forSchema(githubAppSchema).find("installation_repo", (b) => b.whereIndex("idx_installation_repo_full_name", (eb) => eb("fullName", "=", fullName)).join((jb) => jb.installation().links()));
|
|
467
|
+
}).execute();
|
|
468
|
+
const repo = repos[0];
|
|
469
|
+
if (!repo) return error({
|
|
470
|
+
message: "Repository not found.",
|
|
471
|
+
code: "REPO_NOT_FOUND"
|
|
472
|
+
}, { status: 404 });
|
|
473
|
+
if (repo.removedAt !== null) return error({
|
|
474
|
+
message: "Repository has been removed.",
|
|
475
|
+
code: "REPO_REMOVED"
|
|
476
|
+
}, { status: 409 });
|
|
477
|
+
const installation = normalizeJoinedInstallation(repo.installation);
|
|
478
|
+
if (!installation) return error({
|
|
479
|
+
message: "Installation not found.",
|
|
480
|
+
code: "INSTALLATION_NOT_FOUND"
|
|
481
|
+
}, { status: 404 });
|
|
482
|
+
if (installation.status !== "active") return error({
|
|
483
|
+
message: "Installation is not active.",
|
|
484
|
+
code: "INSTALLATION_INACTIVE"
|
|
485
|
+
}, { status: 409 });
|
|
486
|
+
const installationId = toExternalId(installation.id);
|
|
487
|
+
if (!(normalizeJoinedLinks(repo.links).length > 0)) return error({
|
|
488
|
+
message: "Repository is not linked.",
|
|
489
|
+
code: "REPO_NOT_LINKED"
|
|
490
|
+
}, { status: 403 });
|
|
491
|
+
let pulls;
|
|
492
|
+
try {
|
|
493
|
+
pulls = (await (await api.app.getInstallationOctokit(api.resolveInstallationId(installationId))).request("GET /repos/{owner}/{repo}/pulls", {
|
|
494
|
+
owner: pathParams.owner,
|
|
495
|
+
repo: pathParams.repo,
|
|
496
|
+
state: stateFilter,
|
|
497
|
+
per_page: perPage,
|
|
498
|
+
page
|
|
499
|
+
})).data;
|
|
500
|
+
} catch (err) {
|
|
501
|
+
return error({
|
|
502
|
+
message: err instanceof Error ? err.message : "GitHub API request failed.",
|
|
503
|
+
code: "GITHUB_API_ERROR"
|
|
504
|
+
}, { status: 502 });
|
|
505
|
+
}
|
|
506
|
+
return json({
|
|
507
|
+
pulls,
|
|
508
|
+
pageInfo: {
|
|
509
|
+
page,
|
|
510
|
+
perPage
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
}),
|
|
515
|
+
defineRoute({
|
|
516
|
+
method: "POST",
|
|
517
|
+
path: "/repositories/:owner/:repo/pulls/:number/reviews",
|
|
518
|
+
inputSchema: z.object({
|
|
519
|
+
event: z.enum([
|
|
520
|
+
"APPROVE",
|
|
521
|
+
"REQUEST_CHANGES",
|
|
522
|
+
"COMMENT"
|
|
523
|
+
]).optional(),
|
|
524
|
+
body: z.string().optional(),
|
|
525
|
+
comments: z.array(z.any()).optional(),
|
|
526
|
+
commitId: z.string().optional()
|
|
527
|
+
}),
|
|
528
|
+
outputSchema: z.object({ review: z.any() }),
|
|
529
|
+
errorCodes: [
|
|
530
|
+
"INVALID_PULL_NUMBER",
|
|
531
|
+
"REPO_NOT_FOUND",
|
|
532
|
+
"REPO_REMOVED",
|
|
533
|
+
"REPO_NOT_LINKED",
|
|
534
|
+
"INSTALLATION_NOT_FOUND",
|
|
535
|
+
"INSTALLATION_INACTIVE",
|
|
536
|
+
"GITHUB_API_ERROR"
|
|
537
|
+
],
|
|
538
|
+
handler: async function({ pathParams, input }, { json, error }) {
|
|
539
|
+
const values = await input.valid();
|
|
540
|
+
const pullNumber = Number.parseInt(pathParams.number, 10);
|
|
541
|
+
if (!Number.isFinite(pullNumber) || pullNumber <= 0) return error({
|
|
542
|
+
message: "Invalid pull request number.",
|
|
543
|
+
code: "INVALID_PULL_NUMBER"
|
|
544
|
+
}, { status: 400 });
|
|
545
|
+
const fullName = `${pathParams.owner}/${pathParams.repo}`;
|
|
546
|
+
const [repos] = await this.handlerTx().retrieve(({ forSchema }) => {
|
|
547
|
+
return forSchema(githubAppSchema).find("installation_repo", (b) => b.whereIndex("idx_installation_repo_full_name", (eb) => eb("fullName", "=", fullName)).join((jb) => jb.installation().links()));
|
|
548
|
+
}).execute();
|
|
549
|
+
const repo = repos[0];
|
|
550
|
+
if (!repo) return error({
|
|
551
|
+
message: "Repository not found.",
|
|
552
|
+
code: "REPO_NOT_FOUND"
|
|
553
|
+
}, { status: 404 });
|
|
554
|
+
if (repo.removedAt !== null) return error({
|
|
555
|
+
message: "Repository has been removed.",
|
|
556
|
+
code: "REPO_REMOVED"
|
|
557
|
+
}, { status: 409 });
|
|
558
|
+
const installation = normalizeJoinedInstallation(repo.installation);
|
|
559
|
+
if (!installation) return error({
|
|
560
|
+
message: "Installation not found.",
|
|
561
|
+
code: "INSTALLATION_NOT_FOUND"
|
|
562
|
+
}, { status: 404 });
|
|
563
|
+
if (installation.status !== "active") return error({
|
|
564
|
+
message: "Installation is not active.",
|
|
565
|
+
code: "INSTALLATION_INACTIVE"
|
|
566
|
+
}, { status: 409 });
|
|
567
|
+
const installationId = toExternalId(installation.id);
|
|
568
|
+
if (!(normalizeJoinedLinks(repo.links).length > 0)) return error({
|
|
569
|
+
message: "Repository is not linked.",
|
|
570
|
+
code: "REPO_NOT_LINKED"
|
|
571
|
+
}, { status: 403 });
|
|
572
|
+
let review;
|
|
573
|
+
try {
|
|
574
|
+
review = (await (await api.app.getInstallationOctokit(api.resolveInstallationId(installationId))).request("POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews", {
|
|
575
|
+
owner: pathParams.owner,
|
|
576
|
+
repo: pathParams.repo,
|
|
577
|
+
pull_number: pullNumber,
|
|
578
|
+
event: values.event,
|
|
579
|
+
body: values.body,
|
|
580
|
+
comments: values.comments,
|
|
581
|
+
commit_id: values.commitId
|
|
582
|
+
})).data;
|
|
583
|
+
} catch (err) {
|
|
584
|
+
return error({
|
|
585
|
+
message: err instanceof Error ? err.message : "GitHub API request failed.",
|
|
586
|
+
code: "GITHUB_API_ERROR"
|
|
587
|
+
}, { status: 502 });
|
|
588
|
+
}
|
|
589
|
+
return json({ review });
|
|
590
|
+
}
|
|
591
|
+
}),
|
|
592
|
+
defineRoute({
|
|
593
|
+
method: "POST",
|
|
594
|
+
path: "/installations/:installationId/sync",
|
|
595
|
+
outputSchema: z.object({
|
|
596
|
+
added: z.number(),
|
|
597
|
+
removed: z.number(),
|
|
598
|
+
updated: z.number()
|
|
599
|
+
}),
|
|
600
|
+
errorCodes: ["GITHUB_API_ERROR", "INSTALLATION_NOT_FOUND"],
|
|
601
|
+
handler: async function({ pathParams }, { json, error }) {
|
|
602
|
+
const installationId = pathParams.installationId;
|
|
603
|
+
let bootstrapInstallation = null;
|
|
604
|
+
const [existingInstallation, existingRepos] = await this.handlerTx().retrieve(({ forSchema }) => {
|
|
605
|
+
return forSchema(githubAppSchema).findFirst("installation", (b) => b.whereIndex("uniq_installation_id", (eb) => eb("id", "=", installationId))).find("installation_repo", (b) => b.whereIndex("idx_installation_repo_installation", (eb) => eb("installationId", "=", installationId)).join((jb) => jb.links()));
|
|
606
|
+
}).execute();
|
|
607
|
+
if (!existingInstallation) try {
|
|
608
|
+
bootstrapInstallation = await api.getInstallation(installationId);
|
|
609
|
+
} catch (err) {
|
|
610
|
+
if (getHttpStatusCode(err) === 404) return error({
|
|
611
|
+
message: "Installation not found.",
|
|
612
|
+
code: "INSTALLATION_NOT_FOUND"
|
|
613
|
+
}, { status: 404 });
|
|
614
|
+
return error({
|
|
615
|
+
message: err instanceof Error ? err.message : "GitHub API request failed.",
|
|
616
|
+
code: "GITHUB_API_ERROR"
|
|
617
|
+
}, { status: 502 });
|
|
618
|
+
}
|
|
619
|
+
let response;
|
|
620
|
+
try {
|
|
621
|
+
response = await api.listInstallationRepos(installationId);
|
|
622
|
+
} catch (err) {
|
|
623
|
+
return error({
|
|
624
|
+
message: err instanceof Error ? err.message : "GitHub API request failed.",
|
|
625
|
+
code: "GITHUB_API_ERROR"
|
|
626
|
+
}, { status: 502 });
|
|
627
|
+
}
|
|
628
|
+
const repos = response.repositories ?? [];
|
|
629
|
+
const now = /* @__PURE__ */ new Date();
|
|
630
|
+
const existingById = /* @__PURE__ */ new Map();
|
|
631
|
+
for (const repo of existingRepos) {
|
|
632
|
+
const id = toExternalId(repo.id);
|
|
633
|
+
if (id) existingById.set(id, repo);
|
|
634
|
+
}
|
|
635
|
+
const repoLinksByRepoId = /* @__PURE__ */ new Map();
|
|
636
|
+
for (const repo of existingRepos) {
|
|
637
|
+
const repoId = toExternalId(repo.id);
|
|
638
|
+
if (!repoId) continue;
|
|
639
|
+
const linkEntries = normalizeJoinedLinks(repo.links);
|
|
640
|
+
if (linkEntries.length === 0) continue;
|
|
641
|
+
repoLinksByRepoId.set(repoId, linkEntries);
|
|
642
|
+
}
|
|
643
|
+
let added = 0;
|
|
644
|
+
let updated = 0;
|
|
645
|
+
let removed = 0;
|
|
646
|
+
const creates = [];
|
|
647
|
+
const updates = [];
|
|
648
|
+
const removals = [];
|
|
649
|
+
const linksToDelete = [];
|
|
650
|
+
const seen = /* @__PURE__ */ new Set();
|
|
651
|
+
for (const repo of repos) {
|
|
652
|
+
const record = toRepoRecord(installationId, repo, now);
|
|
653
|
+
seen.add(record.id);
|
|
654
|
+
const existing = existingById.get(record.id);
|
|
655
|
+
if (!existing) {
|
|
656
|
+
added += 1;
|
|
657
|
+
creates.push(record);
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
if (hasRepoChanges(existing, record)) {
|
|
661
|
+
updated += 1;
|
|
662
|
+
updates.push({
|
|
663
|
+
id: existing.id,
|
|
664
|
+
data: {
|
|
665
|
+
installationId: record.installationId,
|
|
666
|
+
ownerLogin: record.ownerLogin,
|
|
667
|
+
name: record.name,
|
|
668
|
+
fullName: record.fullName,
|
|
669
|
+
isPrivate: record.isPrivate,
|
|
670
|
+
...record.isFork !== void 0 ? { isFork: record.isFork } : {},
|
|
671
|
+
...record.defaultBranch !== void 0 ? { defaultBranch: record.defaultBranch } : {},
|
|
672
|
+
removedAt: null,
|
|
673
|
+
updatedAt: now
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
for (const [repoId, repo] of existingById.entries()) {
|
|
679
|
+
if (seen.has(repoId)) continue;
|
|
680
|
+
if (repo.removedAt === null) {
|
|
681
|
+
removed += 1;
|
|
682
|
+
removals.push(repo.id);
|
|
683
|
+
const links = repoLinksByRepoId.get(repoId);
|
|
684
|
+
if (links) for (const link of links) linksToDelete.push(link.id);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
if (bootstrapInstallation || creates.length > 0 || updates.length > 0 || removals.length > 0 || linksToDelete.length > 0) await this.handlerTx().mutate(({ forSchema }) => {
|
|
688
|
+
const uow = forSchema(githubAppSchema);
|
|
689
|
+
if (bootstrapInstallation) uow.create("installation", {
|
|
690
|
+
id: bootstrapInstallation.id,
|
|
691
|
+
accountId: bootstrapInstallation.accountId,
|
|
692
|
+
accountLogin: bootstrapInstallation.accountLogin,
|
|
693
|
+
accountType: bootstrapInstallation.accountType,
|
|
694
|
+
status: bootstrapInstallation.status,
|
|
695
|
+
permissions: bootstrapInstallation.permissions,
|
|
696
|
+
events: bootstrapInstallation.events
|
|
697
|
+
});
|
|
698
|
+
for (const record of creates) uow.create("installation_repo", toRepoCreateRecord(record));
|
|
699
|
+
for (const update of updates) uow.update("installation_repo", update.id, (b) => b.set(update.data));
|
|
700
|
+
for (const id of removals) uow.update("installation_repo", id, (b) => b.set({
|
|
701
|
+
removedAt: now,
|
|
702
|
+
updatedAt: now
|
|
703
|
+
}));
|
|
704
|
+
for (const id of linksToDelete) uow.delete("repo_link", id);
|
|
705
|
+
}).execute();
|
|
706
|
+
return json({
|
|
707
|
+
added,
|
|
708
|
+
removed,
|
|
709
|
+
updated
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
})
|
|
713
|
+
];
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
//#endregion
|
|
717
|
+
export { githubAppRoutesFactory };
|
|
718
|
+
//# sourceMappingURL=routes.js.map
|