@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.
Files changed (104) hide show
  1. package/LICENSE.md +16 -0
  2. package/README.md +163 -0
  3. package/bin/run.js +5 -0
  4. package/dist/browser/client/react.d.ts +37 -0
  5. package/dist/browser/client/react.d.ts.map +1 -0
  6. package/dist/browser/client/react.js +166 -0
  7. package/dist/browser/client/react.js.map +1 -0
  8. package/dist/browser/client/solid.d.ts +35 -0
  9. package/dist/browser/client/solid.d.ts.map +1 -0
  10. package/dist/browser/client/solid.js +136 -0
  11. package/dist/browser/client/solid.js.map +1 -0
  12. package/dist/browser/client/svelte.d.ts +30 -0
  13. package/dist/browser/client/svelte.d.ts.map +1 -0
  14. package/dist/browser/client/svelte.js +134 -0
  15. package/dist/browser/client/svelte.js.map +1 -0
  16. package/dist/browser/client/vanilla.d.ts +16 -0
  17. package/dist/browser/client/vanilla.d.ts.map +1 -0
  18. package/dist/browser/client/vanilla.js +11 -0
  19. package/dist/browser/client/vanilla.js.map +1 -0
  20. package/dist/browser/client/vue.d.ts +33 -0
  21. package/dist/browser/client/vue.d.ts.map +1 -0
  22. package/dist/browser/client/vue.js +133 -0
  23. package/dist/browser/client/vue.js.map +1 -0
  24. package/dist/browser/factory-BIj4C6PD.js +2210 -0
  25. package/dist/browser/factory-BIj4C6PD.js.map +1 -0
  26. package/dist/browser/index.d.ts +343 -0
  27. package/dist/browser/index.d.ts.map +1 -0
  28. package/dist/browser/index.js +3 -0
  29. package/dist/browser/types-BzeSSOQU.d.ts +660 -0
  30. package/dist/browser/types-BzeSSOQU.d.ts.map +1 -0
  31. package/dist/cli/commands/installations.js +92 -0
  32. package/dist/cli/commands/installations.js.map +1 -0
  33. package/dist/cli/commands/pulls.js +123 -0
  34. package/dist/cli/commands/pulls.js.map +1 -0
  35. package/dist/cli/commands/repositories.js +105 -0
  36. package/dist/cli/commands/repositories.js.map +1 -0
  37. package/dist/cli/commands/serve.js +187 -0
  38. package/dist/cli/commands/serve.js.map +1 -0
  39. package/dist/cli/commands/webhooks.js +122 -0
  40. package/dist/cli/commands/webhooks.js.map +1 -0
  41. package/dist/cli/github/api.js +94 -0
  42. package/dist/cli/github/api.js.map +1 -0
  43. package/dist/cli/github/definition.js +15 -0
  44. package/dist/cli/github/definition.js.map +1 -0
  45. package/dist/cli/github/factory.js +12 -0
  46. package/dist/cli/github/factory.js.map +1 -0
  47. package/dist/cli/github/repo-sync.js +33 -0
  48. package/dist/cli/github/repo-sync.js.map +1 -0
  49. package/dist/cli/github/utils.js +23 -0
  50. package/dist/cli/github/utils.js.map +1 -0
  51. package/dist/cli/github/webhook-processing.js +247 -0
  52. package/dist/cli/github/webhook-processing.js.map +1 -0
  53. package/dist/cli/index.d.ts +5 -0
  54. package/dist/cli/index.d.ts.map +1 -0
  55. package/dist/cli/index.js +263 -0
  56. package/dist/cli/index.js.map +1 -0
  57. package/dist/cli/routes.js +718 -0
  58. package/dist/cli/routes.js.map +1 -0
  59. package/dist/cli/schema.js +47 -0
  60. package/dist/cli/schema.js.map +1 -0
  61. package/dist/cli/utils/client.js +120 -0
  62. package/dist/cli/utils/client.js.map +1 -0
  63. package/dist/cli/utils/config.js +113 -0
  64. package/dist/cli/utils/config.js.map +1 -0
  65. package/dist/cli/utils/options.js +90 -0
  66. package/dist/cli/utils/options.js.map +1 -0
  67. package/dist/cli/utils/output.js +12 -0
  68. package/dist/cli/utils/output.js.map +1 -0
  69. package/dist/node/github/api.d.ts +52 -0
  70. package/dist/node/github/api.d.ts.map +1 -0
  71. package/dist/node/github/api.js +94 -0
  72. package/dist/node/github/api.js.map +1 -0
  73. package/dist/node/github/clients.d.ts +19 -0
  74. package/dist/node/github/clients.d.ts.map +1 -0
  75. package/dist/node/github/clients.js +12 -0
  76. package/dist/node/github/clients.js.map +1 -0
  77. package/dist/node/github/definition.d.ts +33 -0
  78. package/dist/node/github/definition.d.ts.map +1 -0
  79. package/dist/node/github/definition.js +15 -0
  80. package/dist/node/github/definition.js.map +1 -0
  81. package/dist/node/github/factory.d.ts +139 -0
  82. package/dist/node/github/factory.d.ts.map +1 -0
  83. package/dist/node/github/factory.js +18 -0
  84. package/dist/node/github/factory.js.map +1 -0
  85. package/dist/node/github/repo-sync.js +33 -0
  86. package/dist/node/github/repo-sync.js.map +1 -0
  87. package/dist/node/github/types.d.ts +24 -0
  88. package/dist/node/github/types.d.ts.map +1 -0
  89. package/dist/node/github/utils.js +23 -0
  90. package/dist/node/github/utils.js.map +1 -0
  91. package/dist/node/github/webhook-processing.d.ts +15 -0
  92. package/dist/node/github/webhook-processing.d.ts.map +1 -0
  93. package/dist/node/github/webhook-processing.js +247 -0
  94. package/dist/node/github/webhook-processing.js.map +1 -0
  95. package/dist/node/index.d.ts +7 -0
  96. package/dist/node/index.js +6 -0
  97. package/dist/node/routes.d.ts +127 -0
  98. package/dist/node/routes.d.ts.map +1 -0
  99. package/dist/node/routes.js +718 -0
  100. package/dist/node/routes.js.map +1 -0
  101. package/dist/node/schema.js +47 -0
  102. package/dist/node/schema.js.map +1 -0
  103. package/dist/tsconfig.tsbuildinfo +1 -0
  104. 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