@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,122 @@
|
|
|
1
|
+
import { baseArgs, createClientFromContext, parseJsonValue } from "../utils/options.js";
|
|
2
|
+
import { printResult } from "../utils/output.js";
|
|
3
|
+
import { resolveWebhookSecret } from "../utils/config.js";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { define } from "gunshi";
|
|
6
|
+
import { createHmac, randomUUID } from "node:crypto";
|
|
7
|
+
|
|
8
|
+
//#region src/cli/commands/webhooks.ts
|
|
9
|
+
const webhooksCommand = define({
|
|
10
|
+
name: "webhooks",
|
|
11
|
+
description: "Webhook utilities"
|
|
12
|
+
});
|
|
13
|
+
const isRecord = (value) => typeof value === "object" && value !== null;
|
|
14
|
+
const resolvePayload = (ctx) => {
|
|
15
|
+
const payloadText = ctx.values["payload"];
|
|
16
|
+
const payloadFile = ctx.values["payload-file"];
|
|
17
|
+
if (payloadText && payloadFile) throw new Error("Provide either --payload or --payload-file, not both.");
|
|
18
|
+
if (payloadFile) {
|
|
19
|
+
const parsed = parseJsonValue("payload", readFileSync(payloadFile, "utf-8"));
|
|
20
|
+
if (!isRecord(parsed)) throw new Error("--payload-file must contain a JSON object");
|
|
21
|
+
return parsed;
|
|
22
|
+
}
|
|
23
|
+
if (payloadText) {
|
|
24
|
+
const parsed = parseJsonValue("payload", payloadText);
|
|
25
|
+
if (!isRecord(parsed)) throw new Error("--payload must be a JSON object");
|
|
26
|
+
return parsed;
|
|
27
|
+
}
|
|
28
|
+
return {};
|
|
29
|
+
};
|
|
30
|
+
const ensureInstallationId = (payload, installationId) => {
|
|
31
|
+
if (!installationId) return;
|
|
32
|
+
if (payload["installation_id"]) return;
|
|
33
|
+
const installation = payload["installation"];
|
|
34
|
+
if (isRecord(installation)) {
|
|
35
|
+
if ("id" in installation) return;
|
|
36
|
+
installation["id"] = installationId;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
payload["installation"] = { id: installationId };
|
|
40
|
+
};
|
|
41
|
+
const webhooksSendCommand = define({
|
|
42
|
+
name: "send",
|
|
43
|
+
description: "Send a signed webhook payload",
|
|
44
|
+
args: {
|
|
45
|
+
...baseArgs,
|
|
46
|
+
event: {
|
|
47
|
+
type: "string",
|
|
48
|
+
description: "GitHub event name"
|
|
49
|
+
},
|
|
50
|
+
action: {
|
|
51
|
+
type: "string",
|
|
52
|
+
description: "Action name"
|
|
53
|
+
},
|
|
54
|
+
"installation-id": {
|
|
55
|
+
type: "string",
|
|
56
|
+
description: "Installation ID"
|
|
57
|
+
},
|
|
58
|
+
delivery: {
|
|
59
|
+
type: "string",
|
|
60
|
+
description: "Delivery id (defaults to random UUID)"
|
|
61
|
+
},
|
|
62
|
+
payload: {
|
|
63
|
+
type: "string",
|
|
64
|
+
description: "Payload JSON string"
|
|
65
|
+
},
|
|
66
|
+
"payload-file": {
|
|
67
|
+
type: "string",
|
|
68
|
+
description: "Path to JSON payload file"
|
|
69
|
+
},
|
|
70
|
+
"webhook-secret": {
|
|
71
|
+
type: "string",
|
|
72
|
+
description: "Webhook secret (env: GITHUB_APP_WEBHOOK_SECRET)"
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
run: async (ctx) => {
|
|
76
|
+
const event = ctx.values["event"];
|
|
77
|
+
if (!event) throw new Error("Missing --event");
|
|
78
|
+
const payload = resolvePayload(ctx);
|
|
79
|
+
const action = ctx.values["action"];
|
|
80
|
+
const installationId = ctx.values["installation-id"];
|
|
81
|
+
if (action && typeof payload["action"] !== "string") payload["action"] = action;
|
|
82
|
+
ensureInstallationId(payload, installationId);
|
|
83
|
+
if (!payload["installation_id"] && !payload["installation"]) throw new Error("Payload must include installation info. Provide --installation-id or include installation data in the payload.");
|
|
84
|
+
const rawBody = JSON.stringify(payload);
|
|
85
|
+
const signature = createHmac("sha256", resolveWebhookSecret(ctx)).update(rawBody).digest("hex");
|
|
86
|
+
const client = createClientFromContext(ctx);
|
|
87
|
+
const delivery = ctx.values["delivery"] ?? randomUUID();
|
|
88
|
+
const response = await client.request({
|
|
89
|
+
method: "POST",
|
|
90
|
+
path: "/webhooks",
|
|
91
|
+
body: rawBody,
|
|
92
|
+
headers: {
|
|
93
|
+
"content-type": "application/json",
|
|
94
|
+
"x-github-event": event,
|
|
95
|
+
"x-github-delivery": delivery,
|
|
96
|
+
"x-hub-signature-256": `sha256=${signature}`
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
if (response.status === 204) {
|
|
100
|
+
printResult(void 0);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const text = await response.text();
|
|
104
|
+
if (!text) {
|
|
105
|
+
printResult(void 0);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
let parsed = null;
|
|
109
|
+
try {
|
|
110
|
+
parsed = JSON.parse(text);
|
|
111
|
+
} catch {
|
|
112
|
+
parsed = null;
|
|
113
|
+
}
|
|
114
|
+
printResult(parsed ?? text);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
const webhooksSubCommands = /* @__PURE__ */ new Map();
|
|
118
|
+
webhooksSubCommands.set("send", webhooksSendCommand);
|
|
119
|
+
|
|
120
|
+
//#endregion
|
|
121
|
+
export { webhooksCommand, webhooksSubCommands };
|
|
122
|
+
//# sourceMappingURL=webhooks.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhooks.js","names":[],"sources":["../../../src/cli/commands/webhooks.ts"],"sourcesContent":["import { createHmac, randomUUID } from \"node:crypto\";\nimport { readFileSync } from \"node:fs\";\n\nimport { define } from \"gunshi\";\n\nimport { resolveWebhookSecret } from \"../utils/config.js\";\nimport { baseArgs, createClientFromContext, parseJsonValue } from \"../utils/options.js\";\nimport { printResult } from \"../utils/output.js\";\n\nexport const webhooksCommand = define({\n name: \"webhooks\",\n description: \"Webhook utilities\",\n});\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === \"object\" && value !== null;\n\nconst resolvePayload = (ctx: { values: Record<string, unknown> }): Record<string, unknown> => {\n const payloadText = ctx.values[\"payload\"] as string | undefined;\n const payloadFile = ctx.values[\"payload-file\"] as string | undefined;\n\n if (payloadText && payloadFile) {\n throw new Error(\"Provide either --payload or --payload-file, not both.\");\n }\n\n if (payloadFile) {\n const raw = readFileSync(payloadFile, \"utf-8\");\n const parsed = parseJsonValue(\"payload\", raw);\n if (!isRecord(parsed)) {\n throw new Error(\"--payload-file must contain a JSON object\");\n }\n return parsed;\n }\n\n if (payloadText) {\n const parsed = parseJsonValue(\"payload\", payloadText);\n if (!isRecord(parsed)) {\n throw new Error(\"--payload must be a JSON object\");\n }\n return parsed;\n }\n\n return {};\n};\n\nconst ensureInstallationId = (\n payload: Record<string, unknown>,\n installationId: string | undefined,\n) => {\n if (!installationId) {\n return;\n }\n\n if (payload[\"installation_id\"]) {\n return;\n }\n\n const installation = payload[\"installation\"];\n if (isRecord(installation)) {\n if (\"id\" in installation) {\n return;\n }\n installation[\"id\"] = installationId;\n return;\n }\n\n payload[\"installation\"] = { id: installationId };\n};\n\nexport const webhooksSendCommand = define({\n name: \"send\",\n description: \"Send a signed webhook payload\",\n args: {\n ...baseArgs,\n event: {\n type: \"string\",\n description: \"GitHub event name\",\n },\n action: {\n type: \"string\",\n description: \"Action name\",\n },\n \"installation-id\": {\n type: \"string\",\n description: \"Installation ID\",\n },\n delivery: {\n type: \"string\",\n description: \"Delivery id (defaults to random UUID)\",\n },\n payload: {\n type: \"string\",\n description: \"Payload JSON string\",\n },\n \"payload-file\": {\n type: \"string\",\n description: \"Path to JSON payload file\",\n },\n \"webhook-secret\": {\n type: \"string\",\n description: \"Webhook secret (env: GITHUB_APP_WEBHOOK_SECRET)\",\n },\n },\n run: async (ctx) => {\n const event = ctx.values[\"event\"] as string | undefined;\n if (!event) {\n throw new Error(\"Missing --event\");\n }\n\n const payload = resolvePayload(ctx);\n const action = ctx.values[\"action\"] as string | undefined;\n const installationId = ctx.values[\"installation-id\"] as string | undefined;\n\n if (action && typeof payload[\"action\"] !== \"string\") {\n payload[\"action\"] = action;\n }\n\n ensureInstallationId(payload, installationId);\n\n if (!payload[\"installation_id\"] && !payload[\"installation\"]) {\n throw new Error(\n \"Payload must include installation info. Provide --installation-id or include installation data in the payload.\",\n );\n }\n\n const rawBody = JSON.stringify(payload);\n const secret = resolveWebhookSecret(ctx);\n const signature = createHmac(\"sha256\", secret).update(rawBody).digest(\"hex\");\n\n const client = createClientFromContext(ctx);\n const delivery = (ctx.values[\"delivery\"] as string | undefined) ?? randomUUID();\n\n const response = await client.request({\n method: \"POST\",\n path: \"/webhooks\",\n body: rawBody,\n headers: {\n \"content-type\": \"application/json\",\n \"x-github-event\": event,\n \"x-github-delivery\": delivery,\n \"x-hub-signature-256\": `sha256=${signature}`,\n },\n });\n\n if (response.status === 204) {\n printResult(undefined);\n return;\n }\n\n const text = await response.text();\n if (!text) {\n printResult(undefined);\n return;\n }\n\n let parsed: unknown = null;\n try {\n parsed = JSON.parse(text) as unknown;\n } catch {\n parsed = null;\n }\n printResult(parsed ?? text);\n },\n});\n\nexport const webhooksSubCommands: Map<string, ReturnType<typeof define>> = new Map();\nwebhooksSubCommands.set(\"send\", webhooksSendCommand);\n"],"mappings":";;;;;;;;AASA,MAAa,kBAAkB,OAAO;CACpC,MAAM;CACN,aAAa;CACd,CAAC;AAEF,MAAM,YAAY,UAChB,OAAO,UAAU,YAAY,UAAU;AAEzC,MAAM,kBAAkB,QAAsE;CAC5F,MAAM,cAAc,IAAI,OAAO;CAC/B,MAAM,cAAc,IAAI,OAAO;AAE/B,KAAI,eAAe,YACjB,OAAM,IAAI,MAAM,wDAAwD;AAG1E,KAAI,aAAa;EAEf,MAAM,SAAS,eAAe,WADlB,aAAa,aAAa,QAAQ,CACD;AAC7C,MAAI,CAAC,SAAS,OAAO,CACnB,OAAM,IAAI,MAAM,4CAA4C;AAE9D,SAAO;;AAGT,KAAI,aAAa;EACf,MAAM,SAAS,eAAe,WAAW,YAAY;AACrD,MAAI,CAAC,SAAS,OAAO,CACnB,OAAM,IAAI,MAAM,kCAAkC;AAEpD,SAAO;;AAGT,QAAO,EAAE;;AAGX,MAAM,wBACJ,SACA,mBACG;AACH,KAAI,CAAC,eACH;AAGF,KAAI,QAAQ,mBACV;CAGF,MAAM,eAAe,QAAQ;AAC7B,KAAI,SAAS,aAAa,EAAE;AAC1B,MAAI,QAAQ,aACV;AAEF,eAAa,QAAQ;AACrB;;AAGF,SAAQ,kBAAkB,EAAE,IAAI,gBAAgB;;AAGlD,MAAa,sBAAsB,OAAO;CACxC,MAAM;CACN,aAAa;CACb,MAAM;EACJ,GAAG;EACH,OAAO;GACL,MAAM;GACN,aAAa;GACd;EACD,QAAQ;GACN,MAAM;GACN,aAAa;GACd;EACD,mBAAmB;GACjB,MAAM;GACN,aAAa;GACd;EACD,UAAU;GACR,MAAM;GACN,aAAa;GACd;EACD,SAAS;GACP,MAAM;GACN,aAAa;GACd;EACD,gBAAgB;GACd,MAAM;GACN,aAAa;GACd;EACD,kBAAkB;GAChB,MAAM;GACN,aAAa;GACd;EACF;CACD,KAAK,OAAO,QAAQ;EAClB,MAAM,QAAQ,IAAI,OAAO;AACzB,MAAI,CAAC,MACH,OAAM,IAAI,MAAM,kBAAkB;EAGpC,MAAM,UAAU,eAAe,IAAI;EACnC,MAAM,SAAS,IAAI,OAAO;EAC1B,MAAM,iBAAiB,IAAI,OAAO;AAElC,MAAI,UAAU,OAAO,QAAQ,cAAc,SACzC,SAAQ,YAAY;AAGtB,uBAAqB,SAAS,eAAe;AAE7C,MAAI,CAAC,QAAQ,sBAAsB,CAAC,QAAQ,gBAC1C,OAAM,IAAI,MACR,iHACD;EAGH,MAAM,UAAU,KAAK,UAAU,QAAQ;EAEvC,MAAM,YAAY,WAAW,UADd,qBAAqB,IAAI,CACM,CAAC,OAAO,QAAQ,CAAC,OAAO,MAAM;EAE5E,MAAM,SAAS,wBAAwB,IAAI;EAC3C,MAAM,WAAY,IAAI,OAAO,eAAsC,YAAY;EAE/E,MAAM,WAAW,MAAM,OAAO,QAAQ;GACpC,QAAQ;GACR,MAAM;GACN,MAAM;GACN,SAAS;IACP,gBAAgB;IAChB,kBAAkB;IAClB,qBAAqB;IACrB,uBAAuB,UAAU;IAClC;GACF,CAAC;AAEF,MAAI,SAAS,WAAW,KAAK;AAC3B,eAAY,OAAU;AACtB;;EAGF,MAAM,OAAO,MAAM,SAAS,MAAM;AAClC,MAAI,CAAC,MAAM;AACT,eAAY,OAAU;AACtB;;EAGF,IAAI,SAAkB;AACtB,MAAI;AACF,YAAS,KAAK,MAAM,KAAK;UACnB;AACN,YAAS;;AAEX,cAAY,UAAU,KAAK;;CAE9B,CAAC;AAEF,MAAa,sCAA8D,IAAI,KAAK;AACpF,oBAAoB,IAAI,QAAQ,oBAAoB"}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { App, Octokit } from "octokit";
|
|
2
|
+
|
|
3
|
+
//#region src/github/api.ts
|
|
4
|
+
const DEFAULT_API_BASE_URL = "https://api.github.com";
|
|
5
|
+
const DEFAULT_API_VERSION = "2022-11-28";
|
|
6
|
+
const createGitHubApiClient = (config, options = {}) => {
|
|
7
|
+
const fetchImpl = options.fetch ?? globalThis.fetch ?? void 0;
|
|
8
|
+
const apiBaseUrl = config.apiBaseUrl ?? DEFAULT_API_BASE_URL;
|
|
9
|
+
const apiVersion = config.apiVersion ?? DEFAULT_API_VERSION;
|
|
10
|
+
const OctokitForApp = Octokit.defaults({
|
|
11
|
+
baseUrl: apiBaseUrl,
|
|
12
|
+
request: {
|
|
13
|
+
...fetchImpl ? { fetch: fetchImpl } : {},
|
|
14
|
+
retries: 0
|
|
15
|
+
},
|
|
16
|
+
headers: { "x-github-api-version": apiVersion }
|
|
17
|
+
});
|
|
18
|
+
const octokitApp = new App({
|
|
19
|
+
appId: config.appId,
|
|
20
|
+
privateKey: config.privateKeyPem,
|
|
21
|
+
webhooks: { secret: config.webhookSecret },
|
|
22
|
+
Octokit: OctokitForApp
|
|
23
|
+
});
|
|
24
|
+
const app = {
|
|
25
|
+
octokit: octokitApp.octokit,
|
|
26
|
+
webhooks: octokitApp.webhooks,
|
|
27
|
+
getInstallationOctokit: async (installationId) => await octokitApp.getInstallationOctokit(installationId)
|
|
28
|
+
};
|
|
29
|
+
const resolveInstallationId = (installationId) => {
|
|
30
|
+
const numeric = Number.parseInt(installationId, 10);
|
|
31
|
+
if (!Number.isSafeInteger(numeric)) throw new Error(`Invalid installation id: ${installationId}`);
|
|
32
|
+
return numeric;
|
|
33
|
+
};
|
|
34
|
+
const getInstallationOctokit = async (installationId) => {
|
|
35
|
+
return await octokitApp.getInstallationOctokit(resolveInstallationId(installationId));
|
|
36
|
+
};
|
|
37
|
+
const listInstallationRepos = async (installationId) => {
|
|
38
|
+
const installationOctokit = await getInstallationOctokit(installationId);
|
|
39
|
+
const repositories = [];
|
|
40
|
+
const perPage = 100;
|
|
41
|
+
let page = 1;
|
|
42
|
+
while (true) {
|
|
43
|
+
const response = await installationOctokit.request("GET /installation/repositories", {
|
|
44
|
+
page,
|
|
45
|
+
per_page: perPage
|
|
46
|
+
});
|
|
47
|
+
const pageRepositories = response.data.repositories;
|
|
48
|
+
repositories.push(...pageRepositories);
|
|
49
|
+
const totalCount = response.data.total_count;
|
|
50
|
+
if (pageRepositories.length === 0) break;
|
|
51
|
+
if (typeof totalCount === "number" && repositories.length >= totalCount) break;
|
|
52
|
+
if (pageRepositories.length < perPage) break;
|
|
53
|
+
page += 1;
|
|
54
|
+
}
|
|
55
|
+
return { repositories };
|
|
56
|
+
};
|
|
57
|
+
const getInstallation = async (installationId) => {
|
|
58
|
+
const data = (await octokitApp.octokit.request("GET /app/installations/{installation_id}", { installation_id: resolveInstallationId(installationId) })).data;
|
|
59
|
+
const account = data.account;
|
|
60
|
+
if (!account || typeof account.id !== "number") throw new Error("GitHub installation response is missing account information.");
|
|
61
|
+
const accountLogin = "login" in account && typeof account.login === "string" && account.login.length > 0 ? account.login : "slug" in account && typeof account.slug === "string" && account.slug.length > 0 ? account.slug : String(account.id);
|
|
62
|
+
const accountType = "type" in account && typeof account.type === "string" && account.type.length > 0 ? account.type : "login" in account ? "User" : "Enterprise";
|
|
63
|
+
const status = typeof data.suspended_at === "string" && data.suspended_at.length > 0 ? "suspended" : "active";
|
|
64
|
+
return {
|
|
65
|
+
id: typeof data.id === "number" ? String(data.id) : installationId,
|
|
66
|
+
accountId: String(account.id),
|
|
67
|
+
accountLogin,
|
|
68
|
+
accountType,
|
|
69
|
+
status,
|
|
70
|
+
permissions: data.permissions ?? {},
|
|
71
|
+
events: data.events ?? []
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
const verifyWebhookSignature = async (options) => {
|
|
75
|
+
if (!options.signatureHeader) return false;
|
|
76
|
+
try {
|
|
77
|
+
return await octokitApp.webhooks.verify(options.payload, options.signatureHeader);
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
app,
|
|
84
|
+
apiVersion,
|
|
85
|
+
resolveInstallationId,
|
|
86
|
+
getInstallation,
|
|
87
|
+
listInstallationRepos,
|
|
88
|
+
verifyWebhookSignature
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
//#endregion
|
|
93
|
+
export { createGitHubApiClient };
|
|
94
|
+
//# sourceMappingURL=api.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api.js","names":[],"sources":["../../../src/github/api.ts"],"sourcesContent":["import { App, Octokit } from \"octokit\";\n\nimport type { GitHubAppFragmentConfig } from \"./types\";\n\ntype GitHubApiClientOptions = {\n fetch?: typeof fetch;\n};\n\ntype OctokitAppInstance = InstanceType<typeof App>;\ntype GitHubOctokit = OctokitAppInstance[\"octokit\"];\n\nexport type GitHubAppInstance = {\n octokit: GitHubOctokit;\n webhooks: OctokitAppInstance[\"webhooks\"];\n getInstallationOctokit: (installationId: number) => Promise<GitHubOctokit>;\n};\n\nexport type GitHubInstallationRepository = {\n id: number;\n name: string;\n full_name: string;\n private: boolean;\n fork?: boolean;\n default_branch?: string | null;\n owner: { login: string };\n};\n\nexport type GitHubInstallationDetails = {\n id: string;\n accountId: string;\n accountLogin: string;\n accountType: string;\n status: \"active\" | \"suspended\";\n permissions: Record<string, unknown>;\n events: unknown[];\n};\n\ntype GitHubInstallationRepositoriesResponse = {\n repositories: GitHubInstallationRepository[];\n};\n\nexport type GitHubApiClient = {\n app: GitHubAppInstance;\n apiVersion: string;\n resolveInstallationId: (installationId: string) => number;\n getInstallation: (installationId: string) => Promise<GitHubInstallationDetails>;\n listInstallationRepos: (\n installationId: string,\n ) => Promise<GitHubInstallationRepositoriesResponse>;\n verifyWebhookSignature: (options: {\n payload: string;\n signatureHeader: string | null;\n }) => Promise<boolean>;\n};\n\nconst DEFAULT_API_BASE_URL = \"https://api.github.com\";\nconst DEFAULT_API_VERSION = \"2022-11-28\";\n\nexport const createGitHubApiClient = (\n config: GitHubAppFragmentConfig,\n options: GitHubApiClientOptions = {},\n): GitHubApiClient => {\n const fetchImpl = options.fetch ?? globalThis.fetch ?? undefined;\n const apiBaseUrl = config.apiBaseUrl ?? DEFAULT_API_BASE_URL;\n const apiVersion = config.apiVersion ?? DEFAULT_API_VERSION;\n\n const OctokitForApp = Octokit.defaults({\n baseUrl: apiBaseUrl,\n request: {\n ...(fetchImpl ? { fetch: fetchImpl } : {}),\n retries: 0,\n },\n headers: {\n \"x-github-api-version\": apiVersion,\n },\n });\n\n const octokitApp = new App({\n appId: config.appId,\n privateKey: config.privateKeyPem,\n webhooks: { secret: config.webhookSecret },\n Octokit: OctokitForApp,\n });\n const app: GitHubAppInstance = {\n octokit: octokitApp.octokit,\n webhooks: octokitApp.webhooks,\n getInstallationOctokit: async (installationId) =>\n await octokitApp.getInstallationOctokit(installationId),\n };\n\n const resolveInstallationId = (installationId: string) => {\n const numeric = Number.parseInt(installationId, 10);\n if (!Number.isSafeInteger(numeric)) {\n throw new Error(`Invalid installation id: ${installationId}`);\n }\n return numeric;\n };\n\n const getInstallationOctokit = async (installationId: string) => {\n return await octokitApp.getInstallationOctokit(resolveInstallationId(installationId));\n };\n\n const listInstallationRepos = async (installationId: string) => {\n const installationOctokit = await getInstallationOctokit(installationId);\n\n const repositories: GitHubInstallationRepository[] = [];\n const perPage = 100;\n let page = 1;\n\n while (true) {\n const response = await installationOctokit.request(\"GET /installation/repositories\", {\n page,\n per_page: perPage,\n });\n const pageRepositories = response.data.repositories;\n repositories.push(...pageRepositories);\n\n const totalCount = response.data.total_count;\n if (pageRepositories.length === 0) {\n break;\n }\n if (typeof totalCount === \"number\" && repositories.length >= totalCount) {\n break;\n }\n if (pageRepositories.length < perPage) {\n break;\n }\n page += 1;\n }\n\n return {\n repositories,\n } satisfies GitHubInstallationRepositoriesResponse;\n };\n\n const getInstallation = async (installationId: string): Promise<GitHubInstallationDetails> => {\n const response = await octokitApp.octokit.request(\"GET /app/installations/{installation_id}\", {\n installation_id: resolveInstallationId(installationId),\n });\n\n const data = response.data;\n const account = data.account;\n if (!account || typeof account.id !== \"number\") {\n throw new Error(\"GitHub installation response is missing account information.\");\n }\n\n const accountLogin =\n \"login\" in account && typeof account.login === \"string\" && account.login.length > 0\n ? account.login\n : \"slug\" in account && typeof account.slug === \"string\" && account.slug.length > 0\n ? account.slug\n : String(account.id);\n\n const accountType =\n \"type\" in account && typeof account.type === \"string\" && account.type.length > 0\n ? account.type\n : \"login\" in account\n ? \"User\"\n : \"Enterprise\";\n\n const status: GitHubInstallationDetails[\"status\"] =\n typeof data.suspended_at === \"string\" && data.suspended_at.length > 0\n ? \"suspended\"\n : \"active\";\n\n return {\n id: typeof data.id === \"number\" ? String(data.id) : installationId,\n accountId: String(account.id),\n accountLogin,\n accountType,\n status,\n permissions: data.permissions ?? {},\n events: data.events ?? [],\n };\n };\n\n const verifyWebhookSignature = async (options: {\n payload: string;\n signatureHeader: string | null;\n }) => {\n if (!options.signatureHeader) {\n return false;\n }\n try {\n return await octokitApp.webhooks.verify(options.payload, options.signatureHeader);\n } catch {\n return false;\n }\n };\n\n return {\n app,\n apiVersion,\n resolveInstallationId,\n getInstallation,\n listInstallationRepos,\n verifyWebhookSignature,\n };\n};\n"],"mappings":";;;AAuDA,MAAM,uBAAuB;AAC7B,MAAM,sBAAsB;AAE5B,MAAa,yBACX,QACA,UAAkC,EAAE,KAChB;CACpB,MAAM,YAAY,QAAQ,SAAS,WAAW,SAAS;CACvD,MAAM,aAAa,OAAO,cAAc;CACxC,MAAM,aAAa,OAAO,cAAc;CAExC,MAAM,gBAAgB,QAAQ,SAAS;EACrC,SAAS;EACT,SAAS;GACP,GAAI,YAAY,EAAE,OAAO,WAAW,GAAG,EAAE;GACzC,SAAS;GACV;EACD,SAAS,EACP,wBAAwB,YACzB;EACF,CAAC;CAEF,MAAM,aAAa,IAAI,IAAI;EACzB,OAAO,OAAO;EACd,YAAY,OAAO;EACnB,UAAU,EAAE,QAAQ,OAAO,eAAe;EAC1C,SAAS;EACV,CAAC;CACF,MAAM,MAAyB;EAC7B,SAAS,WAAW;EACpB,UAAU,WAAW;EACrB,wBAAwB,OAAO,mBAC7B,MAAM,WAAW,uBAAuB,eAAe;EAC1D;CAED,MAAM,yBAAyB,mBAA2B;EACxD,MAAM,UAAU,OAAO,SAAS,gBAAgB,GAAG;AACnD,MAAI,CAAC,OAAO,cAAc,QAAQ,CAChC,OAAM,IAAI,MAAM,4BAA4B,iBAAiB;AAE/D,SAAO;;CAGT,MAAM,yBAAyB,OAAO,mBAA2B;AAC/D,SAAO,MAAM,WAAW,uBAAuB,sBAAsB,eAAe,CAAC;;CAGvF,MAAM,wBAAwB,OAAO,mBAA2B;EAC9D,MAAM,sBAAsB,MAAM,uBAAuB,eAAe;EAExE,MAAM,eAA+C,EAAE;EACvD,MAAM,UAAU;EAChB,IAAI,OAAO;AAEX,SAAO,MAAM;GACX,MAAM,WAAW,MAAM,oBAAoB,QAAQ,kCAAkC;IACnF;IACA,UAAU;IACX,CAAC;GACF,MAAM,mBAAmB,SAAS,KAAK;AACvC,gBAAa,KAAK,GAAG,iBAAiB;GAEtC,MAAM,aAAa,SAAS,KAAK;AACjC,OAAI,iBAAiB,WAAW,EAC9B;AAEF,OAAI,OAAO,eAAe,YAAY,aAAa,UAAU,WAC3D;AAEF,OAAI,iBAAiB,SAAS,QAC5B;AAEF,WAAQ;;AAGV,SAAO,EACL,cACD;;CAGH,MAAM,kBAAkB,OAAO,mBAA+D;EAK5F,MAAM,QAJW,MAAM,WAAW,QAAQ,QAAQ,4CAA4C,EAC5F,iBAAiB,sBAAsB,eAAe,EACvD,CAAC,EAEoB;EACtB,MAAM,UAAU,KAAK;AACrB,MAAI,CAAC,WAAW,OAAO,QAAQ,OAAO,SACpC,OAAM,IAAI,MAAM,+DAA+D;EAGjF,MAAM,eACJ,WAAW,WAAW,OAAO,QAAQ,UAAU,YAAY,QAAQ,MAAM,SAAS,IAC9E,QAAQ,QACR,UAAU,WAAW,OAAO,QAAQ,SAAS,YAAY,QAAQ,KAAK,SAAS,IAC7E,QAAQ,OACR,OAAO,QAAQ,GAAG;EAE1B,MAAM,cACJ,UAAU,WAAW,OAAO,QAAQ,SAAS,YAAY,QAAQ,KAAK,SAAS,IAC3E,QAAQ,OACR,WAAW,UACT,SACA;EAER,MAAM,SACJ,OAAO,KAAK,iBAAiB,YAAY,KAAK,aAAa,SAAS,IAChE,cACA;AAEN,SAAO;GACL,IAAI,OAAO,KAAK,OAAO,WAAW,OAAO,KAAK,GAAG,GAAG;GACpD,WAAW,OAAO,QAAQ,GAAG;GAC7B;GACA;GACA;GACA,aAAa,KAAK,eAAe,EAAE;GACnC,QAAQ,KAAK,UAAU,EAAE;GAC1B;;CAGH,MAAM,yBAAyB,OAAO,YAGhC;AACJ,MAAI,CAAC,QAAQ,gBACX,QAAO;AAET,MAAI;AACF,UAAO,MAAM,WAAW,SAAS,OAAO,QAAQ,SAAS,QAAQ,gBAAgB;UAC3E;AACN,UAAO;;;AAIX,QAAO;EACL;EACA;EACA;EACA;EACA;EACA;EACD"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { githubAppSchema } from "../schema.js";
|
|
2
|
+
import { createGitHubApiClient } from "./api.js";
|
|
3
|
+
import { createWebhookProcessor } from "./webhook-processing.js";
|
|
4
|
+
import { withDatabase } from "@fragno-dev/db";
|
|
5
|
+
import { defineFragment } from "@fragno-dev/core";
|
|
6
|
+
|
|
7
|
+
//#region src/github/definition.ts
|
|
8
|
+
const githubAppFragmentDefinition = defineFragment("github-app-fragment").extend(withDatabase(githubAppSchema)).withDependencies(({ config }) => ({ githubApiClient: createGitHubApiClient(config) })).providesBaseService(({ deps, defineService }) => defineService({
|
|
9
|
+
app: deps.githubApiClient.app,
|
|
10
|
+
githubApiClient: deps.githubApiClient
|
|
11
|
+
})).provideHooks(({ defineHook, config }) => ({ processWebhook: defineHook(createWebhookProcessor({ webhook: config.webhook })) })).build();
|
|
12
|
+
|
|
13
|
+
//#endregion
|
|
14
|
+
export { githubAppFragmentDefinition };
|
|
15
|
+
//# sourceMappingURL=definition.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"definition.js","names":[],"sources":["../../../src/github/definition.ts"],"sourcesContent":["import { defineFragment } from \"@fragno-dev/core\";\nimport { withDatabase } from \"@fragno-dev/db\";\n\nimport { githubAppSchema } from \"../schema\";\nimport { createGitHubApiClient } from \"./api\";\nimport type { GitHubAppFragmentConfig } from \"./types\";\nimport { createWebhookProcessor } from \"./webhook-processing\";\n\nexport type GitHubAppFragmentDependencies = {\n githubApiClient: ReturnType<typeof createGitHubApiClient>;\n};\nexport type GitHubAppFragmentServices = {\n app: ReturnType<typeof createGitHubApiClient>[\"app\"];\n githubApiClient: ReturnType<typeof createGitHubApiClient>;\n};\n\nexport const githubAppFragmentDefinition = defineFragment<GitHubAppFragmentConfig>(\n \"github-app-fragment\",\n)\n .extend(withDatabase(githubAppSchema))\n .withDependencies(({ config }) => ({\n githubApiClient: createGitHubApiClient(config),\n }))\n .providesBaseService(({ deps, defineService }) =>\n defineService({\n app: deps.githubApiClient.app,\n githubApiClient: deps.githubApiClient,\n }),\n )\n .provideHooks(({ defineHook, config }) => ({\n processWebhook: defineHook(\n createWebhookProcessor({\n webhook: config.webhook,\n }),\n ),\n }))\n .build();\n"],"mappings":";;;;;;;AAgBA,MAAa,8BAA8B,eACzC,sBACD,CACE,OAAO,aAAa,gBAAgB,CAAC,CACrC,kBAAkB,EAAE,cAAc,EACjC,iBAAiB,sBAAsB,OAAO,EAC/C,EAAE,CACF,qBAAqB,EAAE,MAAM,oBAC5B,cAAc;CACZ,KAAK,KAAK,gBAAgB;CAC1B,iBAAiB,KAAK;CACvB,CAAC,CACH,CACA,cAAc,EAAE,YAAY,cAAc,EACzC,gBAAgB,WACd,uBAAuB,EACrB,SAAS,OAAO,SACjB,CAAC,CACH,EACF,EAAE,CACF,OAAO"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { githubAppFragmentDefinition } from "./definition.js";
|
|
2
|
+
import { githubAppRoutesFactory } from "../routes.js";
|
|
3
|
+
import { instantiate } from "@fragno-dev/core";
|
|
4
|
+
|
|
5
|
+
//#region src/github/factory.ts
|
|
6
|
+
function createGitHubAppFragment(config, options) {
|
|
7
|
+
return instantiate(githubAppFragmentDefinition).withConfig(config).withRoutes([githubAppRoutesFactory]).withOptions(options).build();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
//#endregion
|
|
11
|
+
export { createGitHubAppFragment };
|
|
12
|
+
//# sourceMappingURL=factory.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"factory.js","names":[],"sources":["../../../src/github/factory.ts"],"sourcesContent":["import { instantiate } from \"@fragno-dev/core\";\nimport type { FragnoPublicConfigWithDatabase } from \"@fragno-dev/db\";\n\nimport { githubAppRoutesFactory } from \"../routes\";\nimport { githubAppFragmentDefinition, type GitHubAppFragmentServices } from \"./definition\";\nimport type { GitHubAppFragmentConfig } from \"./types\";\n\nexport function createGitHubAppFragment(\n config: GitHubAppFragmentConfig,\n options: FragnoPublicConfigWithDatabase,\n) {\n return instantiate(githubAppFragmentDefinition)\n .withConfig(config)\n .withRoutes([githubAppRoutesFactory])\n .withOptions(options)\n .build();\n}\n\nexport function getGitHubApiClientFromFragment(fragment: { services: GitHubAppFragmentServices }) {\n return fragment.services.githubApiClient;\n}\n\nexport function getGitHubAppFromFragment(fragment: { services: GitHubAppFragmentServices }) {\n return fragment.services.app;\n}\n"],"mappings":";;;;;AAOA,SAAgB,wBACd,QACA,SACA;AACA,QAAO,YAAY,4BAA4B,CAC5C,WAAW,OAAO,CAClB,WAAW,CAAC,uBAAuB,CAAC,CACpC,YAAY,QAAQ,CACpB,OAAO"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import "../schema.js";
|
|
2
|
+
|
|
3
|
+
//#region src/github/repo-sync.ts
|
|
4
|
+
const toRepoRecord = (installationId, repo, now) => {
|
|
5
|
+
const ownerLogin = repo.owner?.login ?? "";
|
|
6
|
+
const record = {
|
|
7
|
+
id: `${repo.id}`,
|
|
8
|
+
installationId,
|
|
9
|
+
ownerLogin,
|
|
10
|
+
name: repo.name,
|
|
11
|
+
fullName: repo.full_name ?? `${ownerLogin}/${repo.name}`,
|
|
12
|
+
isPrivate: Boolean(repo.private),
|
|
13
|
+
removedAt: null,
|
|
14
|
+
updatedAt: now
|
|
15
|
+
};
|
|
16
|
+
if (repo.fork !== void 0) record.isFork = Boolean(repo.fork);
|
|
17
|
+
if (repo.default_branch !== void 0) record.defaultBranch = repo.default_branch ?? null;
|
|
18
|
+
return record;
|
|
19
|
+
};
|
|
20
|
+
const toRepoCreateRecord = (record) => ({
|
|
21
|
+
...record,
|
|
22
|
+
isFork: record.isFork ?? null,
|
|
23
|
+
defaultBranch: record.defaultBranch ?? null
|
|
24
|
+
});
|
|
25
|
+
const hasRepoChanges = (existing, record) => {
|
|
26
|
+
const forkChanged = typeof record.isFork === "boolean" && existing.isFork !== record.isFork;
|
|
27
|
+
const defaultBranchChanged = record.defaultBranch !== void 0 && (existing.defaultBranch ?? null) !== record.defaultBranch;
|
|
28
|
+
return existing.ownerLogin !== record.ownerLogin || existing.name !== record.name || existing.fullName !== record.fullName || existing.isPrivate !== record.isPrivate || forkChanged || defaultBranchChanged || existing.installationId == null || existing.removedAt !== null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
//#endregion
|
|
32
|
+
export { hasRepoChanges, toRepoCreateRecord, toRepoRecord };
|
|
33
|
+
//# sourceMappingURL=repo-sync.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"repo-sync.js","names":[],"sources":["../../../src/github/repo-sync.ts"],"sourcesContent":["import type { TableToColumnValues } from \"@fragno-dev/db/query\";\n\nimport { githubAppSchema } from \"../schema\";\nimport type { GitHubInstallationRepository } from \"./api\";\n\nexport type InstallationRepoRow = TableToColumnValues<\n (typeof githubAppSchema)[\"tables\"][\"installation_repo\"]\n>;\n\nexport type RepoRecord = {\n id: string;\n installationId: string;\n ownerLogin: string;\n name: string;\n fullName: string;\n isPrivate: boolean;\n isFork?: boolean;\n defaultBranch?: string | null;\n removedAt: null;\n updatedAt: Date;\n};\n\nexport type RepoCreateRecord = Omit<RepoRecord, \"isFork\" | \"defaultBranch\"> & {\n isFork: boolean | null;\n defaultBranch: string | null;\n};\n\nexport const toRepoRecord = (\n installationId: string,\n repo: GitHubInstallationRepository,\n now: Date,\n): RepoRecord => {\n const ownerLogin = repo.owner?.login ?? \"\";\n const record: RepoRecord = {\n id: `${repo.id}`,\n installationId,\n ownerLogin,\n name: repo.name,\n fullName: repo.full_name ?? `${ownerLogin}/${repo.name}`,\n isPrivate: Boolean(repo.private),\n removedAt: null,\n updatedAt: now,\n };\n\n if (repo.fork !== undefined) {\n record.isFork = Boolean(repo.fork);\n }\n if (repo.default_branch !== undefined) {\n record.defaultBranch = repo.default_branch ?? null;\n }\n\n return record;\n};\n\nexport const toRepoCreateRecord = (record: RepoRecord): RepoCreateRecord => ({\n ...record,\n isFork: record.isFork ?? null,\n defaultBranch: record.defaultBranch ?? null,\n});\n\nexport const hasRepoChanges = (existing: InstallationRepoRow, record: RepoRecord) => {\n const forkChanged = typeof record.isFork === \"boolean\" && existing.isFork !== record.isFork;\n const defaultBranchChanged =\n record.defaultBranch !== undefined && (existing.defaultBranch ?? null) !== record.defaultBranch;\n\n return (\n existing.ownerLogin !== record.ownerLogin ||\n existing.name !== record.name ||\n existing.fullName !== record.fullName ||\n existing.isPrivate !== record.isPrivate ||\n forkChanged ||\n defaultBranchChanged ||\n existing.installationId == null ||\n existing.removedAt !== null\n );\n};\n"],"mappings":";;;AA2BA,MAAa,gBACX,gBACA,MACA,QACe;CACf,MAAM,aAAa,KAAK,OAAO,SAAS;CACxC,MAAM,SAAqB;EACzB,IAAI,GAAG,KAAK;EACZ;EACA;EACA,MAAM,KAAK;EACX,UAAU,KAAK,aAAa,GAAG,WAAW,GAAG,KAAK;EAClD,WAAW,QAAQ,KAAK,QAAQ;EAChC,WAAW;EACX,WAAW;EACZ;AAED,KAAI,KAAK,SAAS,OAChB,QAAO,SAAS,QAAQ,KAAK,KAAK;AAEpC,KAAI,KAAK,mBAAmB,OAC1B,QAAO,gBAAgB,KAAK,kBAAkB;AAGhD,QAAO;;AAGT,MAAa,sBAAsB,YAA0C;CAC3E,GAAG;CACH,QAAQ,OAAO,UAAU;CACzB,eAAe,OAAO,iBAAiB;CACxC;AAED,MAAa,kBAAkB,UAA+B,WAAuB;CACnF,MAAM,cAAc,OAAO,OAAO,WAAW,aAAa,SAAS,WAAW,OAAO;CACrF,MAAM,uBACJ,OAAO,kBAAkB,WAAc,SAAS,iBAAiB,UAAU,OAAO;AAEpF,QACE,SAAS,eAAe,OAAO,cAC/B,SAAS,SAAS,OAAO,QACzB,SAAS,aAAa,OAAO,YAC7B,SAAS,cAAc,OAAO,aAC9B,eACA,wBACA,SAAS,kBAAkB,QAC3B,SAAS,cAAc"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { FragnoId } from "@fragno-dev/db/schema";
|
|
2
|
+
|
|
3
|
+
//#region src/github/utils.ts
|
|
4
|
+
const isRecord = (value) => typeof value === "object" && value !== null;
|
|
5
|
+
const toExternalId = (value) => {
|
|
6
|
+
if (value === null || value === void 0) return "";
|
|
7
|
+
if (typeof value === "string") return value;
|
|
8
|
+
if (typeof value === "number") return `${value}`;
|
|
9
|
+
if (value instanceof FragnoId) return value.externalId;
|
|
10
|
+
throw new Error("Expected external id to be a string, number, or FragnoId.");
|
|
11
|
+
};
|
|
12
|
+
const toStringValue = (value) => toExternalId(value);
|
|
13
|
+
const normalizeJoinedLinks = (links) => {
|
|
14
|
+
return (Array.isArray(links) ? links : links ? [links] : []).filter((link) => typeof link.linkKey === "string" && link.linkKey.length > 0);
|
|
15
|
+
};
|
|
16
|
+
const normalizeJoinedInstallation = (installation) => {
|
|
17
|
+
if (!installation) return null;
|
|
18
|
+
return toExternalId(installation.id) ? installation : null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
//#endregion
|
|
22
|
+
export { isRecord, normalizeJoinedInstallation, normalizeJoinedLinks, toExternalId, toStringValue };
|
|
23
|
+
//# sourceMappingURL=utils.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"utils.js","names":[],"sources":["../../../src/github/utils.ts"],"sourcesContent":["import { FragnoId } from \"@fragno-dev/db/schema\";\n\nexport const isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === \"object\" && value !== null;\n\nexport const toExternalId = (value: unknown) => {\n if (value === null || value === undefined) {\n return \"\";\n }\n if (typeof value === \"string\") {\n return value;\n }\n if (typeof value === \"number\") {\n return `${value}`;\n }\n if (value instanceof FragnoId) {\n return value.externalId;\n }\n throw new Error(\"Expected external id to be a string, number, or FragnoId.\");\n};\n\nexport const toStringValue = (value: unknown) => toExternalId(value);\n\nexport const normalizeJoinedLinks = <T extends { linkKey?: string | null }>(\n links: T | T[] | null | undefined,\n) => {\n const entries = Array.isArray(links) ? links : links ? [links] : [];\n return entries.filter((link) => typeof link.linkKey === \"string\" && link.linkKey.length > 0);\n};\n\nexport const normalizeJoinedInstallation = <T extends { id?: unknown }>(\n installation: T | null | undefined,\n) => {\n if (!installation) {\n return null;\n }\n return toExternalId(installation.id) ? installation : null;\n};\n"],"mappings":";;;AAEA,MAAa,YAAY,UACvB,OAAO,UAAU,YAAY,UAAU;AAEzC,MAAa,gBAAgB,UAAmB;AAC9C,KAAI,UAAU,QAAQ,UAAU,OAC9B,QAAO;AAET,KAAI,OAAO,UAAU,SACnB,QAAO;AAET,KAAI,OAAO,UAAU,SACnB,QAAO,GAAG;AAEZ,KAAI,iBAAiB,SACnB,QAAO,MAAM;AAEf,OAAM,IAAI,MAAM,4DAA4D;;AAG9E,MAAa,iBAAiB,UAAmB,aAAa,MAAM;AAEpE,MAAa,wBACX,UACG;AAEH,SADgB,MAAM,QAAQ,MAAM,GAAG,QAAQ,QAAQ,CAAC,MAAM,GAAG,EAAE,EACpD,QAAQ,SAAS,OAAO,KAAK,YAAY,YAAY,KAAK,QAAQ,SAAS,EAAE;;AAG9F,MAAa,+BACX,iBACG;AACH,KAAI,CAAC,aACH,QAAO;AAET,QAAO,aAAa,aAAa,GAAG,GAAG,eAAe"}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { githubAppSchema } from "../schema.js";
|
|
2
|
+
import { hasRepoChanges, toRepoCreateRecord, toRepoRecord } from "./repo-sync.js";
|
|
3
|
+
import { normalizeJoinedLinks, toExternalId } from "./utils.js";
|
|
4
|
+
|
|
5
|
+
//#region src/github/webhook-processing.ts
|
|
6
|
+
const SUPPORTED_EVENTS = ["installation", "installation_repositories"];
|
|
7
|
+
function asSupportedWebhook(data) {
|
|
8
|
+
if (!SUPPORTED_EVENTS.includes(data.event)) return null;
|
|
9
|
+
return data;
|
|
10
|
+
}
|
|
11
|
+
const toEmitterWebhookEvent = (data) => {
|
|
12
|
+
return {
|
|
13
|
+
id: data.deliveryId,
|
|
14
|
+
name: data.event,
|
|
15
|
+
payload: data.payload
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
const createWebhookEventDispatcher = (configureOn) => {
|
|
19
|
+
const handlersByEvent = /* @__PURE__ */ new Map();
|
|
20
|
+
const registerOn = (event, handler) => {
|
|
21
|
+
const events = Array.isArray(event) ? event : [event];
|
|
22
|
+
for (const eventName of events) {
|
|
23
|
+
const handlers = handlersByEvent.get(eventName);
|
|
24
|
+
if (handlers) handlers.push(handler);
|
|
25
|
+
else handlersByEvent.set(eventName, [handler]);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
configureOn?.(registerOn);
|
|
29
|
+
return async (event, idempotencyKey) => {
|
|
30
|
+
if (handlersByEvent.size === 0) return;
|
|
31
|
+
const action = "action" in event.payload && typeof event.payload.action === "string" ? event.payload.action : null;
|
|
32
|
+
const dispatchEvents = action ? [`${event.name}.${action}`, event.name] : [event.name];
|
|
33
|
+
const handlers = [];
|
|
34
|
+
for (const eventName of dispatchEvents) {
|
|
35
|
+
const registered = handlersByEvent.get(eventName);
|
|
36
|
+
if (registered && registered.length > 0) handlers.push(...registered);
|
|
37
|
+
}
|
|
38
|
+
if (handlers.length === 0) return;
|
|
39
|
+
const failures = (await Promise.allSettled(handlers.map(async (handler) => await handler(event, idempotencyKey)))).filter((result) => result.status === "rejected").map((result) => result.reason);
|
|
40
|
+
if (failures.length === 1) throw failures[0];
|
|
41
|
+
if (failures.length > 1) throw new AggregateError(failures, "One or more configured webhook handlers failed.");
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
const normalizeInstallationStatus = (action) => {
|
|
45
|
+
switch (action) {
|
|
46
|
+
case "created":
|
|
47
|
+
case "new_permissions_accepted":
|
|
48
|
+
case "unsuspend": return "active";
|
|
49
|
+
case "suspend": return "suspended";
|
|
50
|
+
case "deleted": return "deleted";
|
|
51
|
+
default: return null;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const resolveReceivedAt = (receivedAt) => {
|
|
55
|
+
if (!receivedAt) return /* @__PURE__ */ new Date();
|
|
56
|
+
const parsed = new Date(receivedAt);
|
|
57
|
+
return Number.isNaN(parsed.getTime()) ? /* @__PURE__ */ new Date() : parsed;
|
|
58
|
+
};
|
|
59
|
+
const parseOwnerLoginFromFullName = (fullName) => {
|
|
60
|
+
const separatorIndex = fullName.indexOf("/");
|
|
61
|
+
if (separatorIndex <= 0) return "";
|
|
62
|
+
return fullName.slice(0, separatorIndex);
|
|
63
|
+
};
|
|
64
|
+
const toInstallationRepoFromWebhookRepository = (repo) => {
|
|
65
|
+
const ownerLogin = parseOwnerLoginFromFullName(repo.full_name);
|
|
66
|
+
const mapped = {
|
|
67
|
+
id: repo.id,
|
|
68
|
+
name: repo.name,
|
|
69
|
+
full_name: repo.full_name,
|
|
70
|
+
private: repo.private,
|
|
71
|
+
owner: { login: ownerLogin }
|
|
72
|
+
};
|
|
73
|
+
if ("fork" in repo && typeof repo.fork === "boolean") mapped.fork = repo.fork;
|
|
74
|
+
if ("default_branch" in repo) {
|
|
75
|
+
const defaultBranch = repo.default_branch;
|
|
76
|
+
if (typeof defaultBranch === "string" || defaultBranch === null) mapped.default_branch = defaultBranch;
|
|
77
|
+
}
|
|
78
|
+
return mapped;
|
|
79
|
+
};
|
|
80
|
+
function extractAccountFromInstallation(installation) {
|
|
81
|
+
const account = installation.account;
|
|
82
|
+
if (!account) throw new Error("Webhook payload installation missing account");
|
|
83
|
+
const accountId = `${account.id}`;
|
|
84
|
+
if ("login" in account) return {
|
|
85
|
+
accountId,
|
|
86
|
+
accountLogin: account.login,
|
|
87
|
+
accountType: account.type ?? ""
|
|
88
|
+
};
|
|
89
|
+
return {
|
|
90
|
+
accountId,
|
|
91
|
+
accountLogin: account.slug,
|
|
92
|
+
accountType: "Enterprise"
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const isRepoSyncAction = (action) => {
|
|
96
|
+
return action === "created" || action === "new_permissions_accepted";
|
|
97
|
+
};
|
|
98
|
+
const toRepoUpdateData = (record, now) => ({
|
|
99
|
+
installationId: record.installationId,
|
|
100
|
+
ownerLogin: record.ownerLogin,
|
|
101
|
+
name: record.name,
|
|
102
|
+
fullName: record.fullName,
|
|
103
|
+
isPrivate: record.isPrivate,
|
|
104
|
+
...record.isFork !== void 0 ? { isFork: record.isFork } : {},
|
|
105
|
+
...record.defaultBranch !== void 0 ? { defaultBranch: record.defaultBranch } : {},
|
|
106
|
+
removedAt: null,
|
|
107
|
+
updatedAt: now
|
|
108
|
+
});
|
|
109
|
+
const createWebhookProcessor = (config = {}) => {
|
|
110
|
+
const dispatchWebhookEvent = createWebhookEventDispatcher(config.webhook);
|
|
111
|
+
return async function processWebhook(data) {
|
|
112
|
+
const emitterEvent = toEmitterWebhookEvent(data);
|
|
113
|
+
const webhook = asSupportedWebhook(data);
|
|
114
|
+
if (!webhook) {
|
|
115
|
+
await dispatchWebhookEvent(emitterEvent, this.idempotencyKey);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const { event, action, payload } = webhook;
|
|
119
|
+
const now = resolveReceivedAt(data.receivedAt);
|
|
120
|
+
const installationId = data.installationId;
|
|
121
|
+
const installation = payload.installation;
|
|
122
|
+
if (!installation) throw new Error("Webhook payload missing installation (required for installation events)");
|
|
123
|
+
const { accountId, accountLogin, accountType } = extractAccountFromInstallation(installation);
|
|
124
|
+
const statusOverride = normalizeInstallationStatus(action);
|
|
125
|
+
const needsRepoSync = event === "installation_repositories" || event === "installation" && isRepoSyncAction(action);
|
|
126
|
+
let existingInstallation = null;
|
|
127
|
+
let existingRepos = [];
|
|
128
|
+
if (needsRepoSync) [existingInstallation, existingRepos] = await this.handlerTx().retrieve(({ forSchema }) => {
|
|
129
|
+
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()));
|
|
130
|
+
}).execute();
|
|
131
|
+
else [existingInstallation] = await this.handlerTx().retrieve(({ forSchema }) => {
|
|
132
|
+
return forSchema(githubAppSchema).findFirst("installation", (b) => b.whereIndex("uniq_installation_id", (eb) => eb("id", "=", installationId)));
|
|
133
|
+
}).execute();
|
|
134
|
+
const existingStatus = existingInstallation && typeof existingInstallation.status === "string" ? existingInstallation.status : null;
|
|
135
|
+
const installationUpdate = {
|
|
136
|
+
accountId,
|
|
137
|
+
accountLogin,
|
|
138
|
+
accountType,
|
|
139
|
+
status: statusOverride || existingStatus || "active",
|
|
140
|
+
permissions: installation.permissions ?? existingInstallation?.permissions ?? null,
|
|
141
|
+
events: installation.events ?? existingInstallation?.events ?? null,
|
|
142
|
+
updatedAt: now,
|
|
143
|
+
lastWebhookAt: now
|
|
144
|
+
};
|
|
145
|
+
const creates = [];
|
|
146
|
+
const updates = [];
|
|
147
|
+
const removals = [];
|
|
148
|
+
const linksToDelete = [];
|
|
149
|
+
if (needsRepoSync) {
|
|
150
|
+
const existingById = /* @__PURE__ */ new Map();
|
|
151
|
+
for (const repo of existingRepos) {
|
|
152
|
+
const id = toExternalId(repo.id);
|
|
153
|
+
if (id) existingById.set(id, repo);
|
|
154
|
+
}
|
|
155
|
+
const repoLinksByRepoId = /* @__PURE__ */ new Map();
|
|
156
|
+
for (const repo of existingRepos) {
|
|
157
|
+
const repoId = toExternalId(repo.id);
|
|
158
|
+
if (!repoId) continue;
|
|
159
|
+
const linkEntries = normalizeJoinedLinks(repo.links);
|
|
160
|
+
if (linkEntries.length === 0) continue;
|
|
161
|
+
repoLinksByRepoId.set(repoId, linkEntries);
|
|
162
|
+
}
|
|
163
|
+
if (event === "installation") {
|
|
164
|
+
const repos = (payload.repositories ?? []).map(toInstallationRepoFromWebhookRepository);
|
|
165
|
+
const seen = /* @__PURE__ */ new Set();
|
|
166
|
+
for (const repo of repos) {
|
|
167
|
+
const record = toRepoRecord(installationId, repo, now);
|
|
168
|
+
seen.add(record.id);
|
|
169
|
+
const existing = existingById.get(record.id);
|
|
170
|
+
if (!existing) {
|
|
171
|
+
creates.push(record);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (hasRepoChanges(existing, record)) updates.push({
|
|
175
|
+
id: existing.id,
|
|
176
|
+
data: toRepoUpdateData(record, now)
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
for (const [repoId, repo] of existingById.entries()) {
|
|
180
|
+
if (seen.has(repoId)) continue;
|
|
181
|
+
if (repo.removedAt === null) {
|
|
182
|
+
removals.push(repo.id);
|
|
183
|
+
const links = repoLinksByRepoId.get(repoId);
|
|
184
|
+
if (links) for (const link of links) linksToDelete.push(link.id);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (event === "installation_repositories") {
|
|
189
|
+
const added = payload.repositories_added;
|
|
190
|
+
const removed = payload.repositories_removed;
|
|
191
|
+
for (const repo of added) {
|
|
192
|
+
const record = toRepoRecord(installationId, toInstallationRepoFromWebhookRepository(repo), now);
|
|
193
|
+
const existing = existingById.get(record.id);
|
|
194
|
+
if (!existing) {
|
|
195
|
+
creates.push(record);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (hasRepoChanges(existing, record)) updates.push({
|
|
199
|
+
id: existing.id,
|
|
200
|
+
data: toRepoUpdateData(record, now)
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
for (const repo of removed) {
|
|
204
|
+
const repoId = toExternalId(repo.id) || null;
|
|
205
|
+
if (!repoId) continue;
|
|
206
|
+
const existing = existingById.get(repoId);
|
|
207
|
+
if (existing && existing.removedAt === null) {
|
|
208
|
+
removals.push(existing.id);
|
|
209
|
+
const links = repoLinksByRepoId.get(repoId);
|
|
210
|
+
if (links) for (const link of links) linksToDelete.push(link.id);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const hasRepoMutations = creates.length > 0 || updates.length > 0 || removals.length > 0 || linksToDelete.length > 0;
|
|
216
|
+
await this.handlerTx().mutate(({ forSchema }) => {
|
|
217
|
+
const uow = forSchema(githubAppSchema);
|
|
218
|
+
if (existingInstallation) uow.update("installation", installationId, (b) => b.set(installationUpdate));
|
|
219
|
+
else uow.create("installation", {
|
|
220
|
+
id: installationId,
|
|
221
|
+
accountId: installationUpdate.accountId,
|
|
222
|
+
accountLogin: installationUpdate.accountLogin,
|
|
223
|
+
accountType: installationUpdate.accountType,
|
|
224
|
+
status: installationUpdate.status,
|
|
225
|
+
permissions: installationUpdate.permissions,
|
|
226
|
+
events: installationUpdate.events,
|
|
227
|
+
createdAt: now,
|
|
228
|
+
updatedAt: now,
|
|
229
|
+
lastWebhookAt: now
|
|
230
|
+
});
|
|
231
|
+
if (hasRepoMutations) {
|
|
232
|
+
for (const record of creates) uow.create("installation_repo", toRepoCreateRecord(record));
|
|
233
|
+
for (const update of updates) uow.update("installation_repo", update.id, (b) => b.set(update.data));
|
|
234
|
+
for (const id of removals) uow.update("installation_repo", id, (b) => b.set({
|
|
235
|
+
removedAt: now,
|
|
236
|
+
updatedAt: now
|
|
237
|
+
}));
|
|
238
|
+
for (const id of linksToDelete) uow.delete("repo_link", id);
|
|
239
|
+
}
|
|
240
|
+
}).execute();
|
|
241
|
+
await dispatchWebhookEvent(emitterEvent, this.idempotencyKey);
|
|
242
|
+
};
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
//#endregion
|
|
246
|
+
export { createWebhookProcessor };
|
|
247
|
+
//# sourceMappingURL=webhook-processing.js.map
|