@happyvertical/repos 0.84.0 → 0.85.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/README.md +100 -0
- package/dist/index.d.ts +638 -0
- package/dist/index.js +1097 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHmac, createSign, timingSafeEqual } from "node:crypto";
|
|
1
2
|
import { GraphQLClient } from "@happyvertical/graphql";
|
|
2
3
|
import { readFile } from "node:fs/promises";
|
|
3
4
|
import yaml from "js-yaml";
|
|
@@ -127,6 +128,704 @@ async function getRepository(options) {
|
|
|
127
128
|
}
|
|
128
129
|
}
|
|
129
130
|
//#endregion
|
|
131
|
+
//#region src/forge/errors.ts
|
|
132
|
+
var ForgeError = class extends Error {
|
|
133
|
+
provider;
|
|
134
|
+
code;
|
|
135
|
+
status;
|
|
136
|
+
requestId;
|
|
137
|
+
rateLimit;
|
|
138
|
+
details;
|
|
139
|
+
retryable;
|
|
140
|
+
constructor(message, code, options = {}) {
|
|
141
|
+
super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
|
|
142
|
+
this.name = "ForgeError";
|
|
143
|
+
this.code = code;
|
|
144
|
+
this.provider = options.provider;
|
|
145
|
+
this.status = options.status;
|
|
146
|
+
this.requestId = options.requestId;
|
|
147
|
+
this.rateLimit = options.rateLimit;
|
|
148
|
+
this.details = options.details;
|
|
149
|
+
this.retryable = options.retryable ?? false;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
var ForgeSignatureError = class extends ForgeError {
|
|
153
|
+
constructor(message = "Forge webhook signature is invalid") {
|
|
154
|
+
super(message, "SIGNATURE_INVALID");
|
|
155
|
+
this.name = "ForgeSignatureError";
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
//#endregion
|
|
159
|
+
//#region src/github/forge.ts
|
|
160
|
+
function repositoryPath(owner, repo) {
|
|
161
|
+
return `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
|
|
162
|
+
}
|
|
163
|
+
function normalizeRepository(data) {
|
|
164
|
+
const owner = data.owner?.login ?? "";
|
|
165
|
+
const name = typeof data.name === "string" ? data.name : "";
|
|
166
|
+
return {
|
|
167
|
+
id: data.node_id === void 0 ? void 0 : String(data.node_id),
|
|
168
|
+
owner,
|
|
169
|
+
name,
|
|
170
|
+
fullName: typeof data.full_name === "string" ? data.full_name : `${owner}/${name}`,
|
|
171
|
+
defaultBranch: typeof data.default_branch === "string" ? data.default_branch : void 0,
|
|
172
|
+
private: typeof data.private === "boolean" ? data.private : void 0,
|
|
173
|
+
url: typeof data.html_url === "string" ? data.html_url : void 0
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
function normalizePullRequest(data) {
|
|
177
|
+
const head = data.head ?? {};
|
|
178
|
+
const base = data.base ?? {};
|
|
179
|
+
return {
|
|
180
|
+
id: data.node_id === void 0 ? void 0 : String(data.node_id),
|
|
181
|
+
number: Number(data.number),
|
|
182
|
+
state: data.state === "closed" ? "closed" : "open",
|
|
183
|
+
draft: typeof data.draft === "boolean" ? data.draft : void 0,
|
|
184
|
+
headSha: String(head.sha ?? ""),
|
|
185
|
+
headRef: typeof head.ref === "string" ? head.ref : void 0,
|
|
186
|
+
baseSha: typeof base.sha === "string" ? base.sha : void 0,
|
|
187
|
+
baseRef: typeof base.ref === "string" ? base.ref : void 0,
|
|
188
|
+
merged: typeof data.merged === "boolean" ? data.merged : void 0,
|
|
189
|
+
mergeCommitSha: typeof data.merge_commit_sha === "string" ? data.merge_commit_sha : void 0,
|
|
190
|
+
url: typeof data.html_url === "string" ? data.html_url : void 0
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
function normalizeStatus(data) {
|
|
194
|
+
return {
|
|
195
|
+
id: String(data.id ?? data.node_id ?? ""),
|
|
196
|
+
sha: String(data.sha ?? ""),
|
|
197
|
+
state: String(data.state ?? ""),
|
|
198
|
+
context: String(data.context ?? ""),
|
|
199
|
+
description: typeof data.description === "string" ? data.description : void 0,
|
|
200
|
+
targetUrl: typeof data.target_url === "string" ? data.target_url : void 0,
|
|
201
|
+
createdAt: typeof data.created_at === "string" ? new Date(data.created_at) : void 0,
|
|
202
|
+
raw: data
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
function normalizeCheck(data) {
|
|
206
|
+
return {
|
|
207
|
+
id: String(data.id ?? data.node_id ?? ""),
|
|
208
|
+
name: String(data.name ?? ""),
|
|
209
|
+
headSha: String(data.head_sha ?? ""),
|
|
210
|
+
status: data.status ?? "queued",
|
|
211
|
+
conclusion: typeof data.conclusion === "string" ? data.conclusion : void 0,
|
|
212
|
+
detailsUrl: typeof data.details_url === "string" ? data.details_url : void 0,
|
|
213
|
+
externalId: typeof data.external_id === "string" ? data.external_id : void 0,
|
|
214
|
+
startedAt: typeof data.started_at === "string" ? new Date(data.started_at) : void 0,
|
|
215
|
+
completedAt: typeof data.completed_at === "string" ? new Date(data.completed_at) : void 0,
|
|
216
|
+
raw: data
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function checkInput(input) {
|
|
220
|
+
return {
|
|
221
|
+
..."name" in input ? { name: input.name } : {},
|
|
222
|
+
..."headSha" in input ? { head_sha: input.headSha } : {},
|
|
223
|
+
status: input.status,
|
|
224
|
+
conclusion: input.conclusion,
|
|
225
|
+
details_url: input.detailsUrl,
|
|
226
|
+
external_id: input.externalId,
|
|
227
|
+
started_at: input.startedAt instanceof Date ? input.startedAt.toISOString() : input.startedAt,
|
|
228
|
+
completed_at: input.completedAt instanceof Date ? input.completedAt.toISOString() : input.completedAt,
|
|
229
|
+
output: input.output
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Repository-scoped GitHub implementation of provider-neutral forge operations.
|
|
234
|
+
* Every operation may throw `ForgeError` for transport or provider failures.
|
|
235
|
+
*/
|
|
236
|
+
var GitHubForgeProvider = class {
|
|
237
|
+
transport;
|
|
238
|
+
path;
|
|
239
|
+
/**
|
|
240
|
+
* Creates a repository-scoped GitHub forge provider.
|
|
241
|
+
* @param options Repository coordinates and authenticated transport.
|
|
242
|
+
*/
|
|
243
|
+
constructor(options) {
|
|
244
|
+
this.transport = options.transport;
|
|
245
|
+
this.path = repositoryPath(options.owner, options.repo);
|
|
246
|
+
}
|
|
247
|
+
/** @returns Normalized repository data and request metadata. */
|
|
248
|
+
async getRepository() {
|
|
249
|
+
const response = await this.transport.request({
|
|
250
|
+
method: "GET",
|
|
251
|
+
path: this.path
|
|
252
|
+
});
|
|
253
|
+
return {
|
|
254
|
+
...response,
|
|
255
|
+
data: normalizeRepository(response.data)
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Returns one normalized pull request.
|
|
260
|
+
* @param number Repository-local pull-request number.
|
|
261
|
+
* @returns Pull-request data and request metadata.
|
|
262
|
+
* @throws {ForgeError} When GitHub rejects the request.
|
|
263
|
+
*/
|
|
264
|
+
async getPullRequest(number) {
|
|
265
|
+
const response = await this.transport.request({
|
|
266
|
+
method: "GET",
|
|
267
|
+
path: `${this.path}/pulls/${number}`
|
|
268
|
+
});
|
|
269
|
+
return {
|
|
270
|
+
...response,
|
|
271
|
+
data: normalizePullRequest(response.data)
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Lists every raw provider review for one pull request.
|
|
276
|
+
* @param number Repository-local pull-request number.
|
|
277
|
+
* @returns All reviews and final-page request metadata.
|
|
278
|
+
*/
|
|
279
|
+
listPullRequestReviews(number) {
|
|
280
|
+
return this.paginate(`${this.path}/pulls/${number}/reviews`, (data) => data);
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Returns one raw provider commit payload.
|
|
284
|
+
* @param sha Exact commit SHA.
|
|
285
|
+
* @returns Commit payload and request metadata.
|
|
286
|
+
*/
|
|
287
|
+
getCommit(sha) {
|
|
288
|
+
return this.transport.request({
|
|
289
|
+
method: "GET",
|
|
290
|
+
path: `${this.path}/commits/${encodeURIComponent(sha)}`
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Returns complete normalized commit-status history.
|
|
295
|
+
* @param sha Exact commit SHA.
|
|
296
|
+
* @returns All status pages and final-page request metadata.
|
|
297
|
+
*/
|
|
298
|
+
async listCommitStatuses(sha) {
|
|
299
|
+
return this.paginate(`${this.path}/commits/${encodeURIComponent(sha)}/statuses`, (data) => data.map(normalizeStatus));
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Publishes one commit status.
|
|
303
|
+
* @param input Exact SHA and status attributes.
|
|
304
|
+
* @returns Published status and request metadata.
|
|
305
|
+
*/
|
|
306
|
+
async createCommitStatus(input) {
|
|
307
|
+
const response = await this.transport.request({
|
|
308
|
+
method: "POST",
|
|
309
|
+
path: `${this.path}/statuses/${encodeURIComponent(input.sha)}`,
|
|
310
|
+
body: {
|
|
311
|
+
state: input.state,
|
|
312
|
+
context: input.context,
|
|
313
|
+
description: input.description,
|
|
314
|
+
target_url: input.targetUrl
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
return {
|
|
318
|
+
...response,
|
|
319
|
+
data: normalizeStatus(response.data)
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Returns all normalized check runs, including reruns.
|
|
324
|
+
* @param sha Exact commit SHA.
|
|
325
|
+
* @returns All check pages and final-page request metadata.
|
|
326
|
+
*/
|
|
327
|
+
async listCheckRuns(sha) {
|
|
328
|
+
return this.paginate(`${this.path}/commits/${encodeURIComponent(sha)}/check-runs?filter=all`, (data) => (data.check_runs ?? []).map(normalizeCheck), (data) => data.total_count);
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Publishes one check run.
|
|
332
|
+
* @param input Check identity, exact head SHA, state, and output.
|
|
333
|
+
* @returns Published check and request metadata.
|
|
334
|
+
*/
|
|
335
|
+
async createCheckRun(input) {
|
|
336
|
+
const response = await this.transport.request({
|
|
337
|
+
method: "POST",
|
|
338
|
+
path: `${this.path}/check-runs`,
|
|
339
|
+
body: checkInput(input)
|
|
340
|
+
});
|
|
341
|
+
return {
|
|
342
|
+
...response,
|
|
343
|
+
data: normalizeCheck(response.data)
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Changes one check run by provider ID.
|
|
348
|
+
* @param id GitHub check-run ID.
|
|
349
|
+
* @param input Attributes to change.
|
|
350
|
+
* @returns Updated check and request metadata.
|
|
351
|
+
*/
|
|
352
|
+
async updateCheckRun(id, input) {
|
|
353
|
+
const response = await this.transport.request({
|
|
354
|
+
method: "PATCH",
|
|
355
|
+
path: `${this.path}/check-runs/${encodeURIComponent(id)}`,
|
|
356
|
+
body: checkInput(input)
|
|
357
|
+
});
|
|
358
|
+
return {
|
|
359
|
+
...response,
|
|
360
|
+
data: normalizeCheck(response.data)
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Lists raw deployment payloads using narrow filters.
|
|
365
|
+
* @param options Optional SHA, environment, and result limit.
|
|
366
|
+
* @returns Matching deployments and request metadata.
|
|
367
|
+
*/
|
|
368
|
+
listDeployments(options = {}) {
|
|
369
|
+
const params = new URLSearchParams();
|
|
370
|
+
if (options.sha) params.set("sha", options.sha);
|
|
371
|
+
if (options.environment) params.set("environment", options.environment);
|
|
372
|
+
params.set("per_page", String(options.limit ?? 30));
|
|
373
|
+
return this.transport.request({
|
|
374
|
+
method: "GET",
|
|
375
|
+
path: `${this.path}/deployments?${params}`
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Returns one raw deployment payload.
|
|
380
|
+
* @param id GitHub deployment ID.
|
|
381
|
+
* @returns Deployment payload and request metadata.
|
|
382
|
+
*/
|
|
383
|
+
getDeployment(id) {
|
|
384
|
+
return this.transport.request({
|
|
385
|
+
method: "GET",
|
|
386
|
+
path: `${this.path}/deployments/${encodeURIComponent(id)}`
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
async paginate(path, items, totalCount) {
|
|
390
|
+
const separator = path.includes("?") ? "&" : "?";
|
|
391
|
+
const collected = [];
|
|
392
|
+
let page = 1;
|
|
393
|
+
let response;
|
|
394
|
+
let providerTotal;
|
|
395
|
+
do {
|
|
396
|
+
response = await this.transport.request({
|
|
397
|
+
method: "GET",
|
|
398
|
+
path: `${path}${separator}per_page=100&page=${page}`
|
|
399
|
+
});
|
|
400
|
+
const pageItems = items(response.data);
|
|
401
|
+
collected.push(...pageItems);
|
|
402
|
+
providerTotal ??= totalCount?.(response.data);
|
|
403
|
+
page += 1;
|
|
404
|
+
if (pageItems.length < 100) break;
|
|
405
|
+
} while (providerTotal === void 0 || collected.length < providerTotal);
|
|
406
|
+
return {
|
|
407
|
+
data: collected,
|
|
408
|
+
metadata: {
|
|
409
|
+
...response.metadata,
|
|
410
|
+
pagination: {
|
|
411
|
+
pages: page - 1,
|
|
412
|
+
totalCount: providerTotal ?? collected.length
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
//#endregion
|
|
419
|
+
//#region src/github/transport.ts
|
|
420
|
+
function optionalInteger(value) {
|
|
421
|
+
if (value === null || value.trim() === "") return void 0;
|
|
422
|
+
const parsed = Number.parseInt(value, 10);
|
|
423
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
424
|
+
}
|
|
425
|
+
function responseRateLimit(headers) {
|
|
426
|
+
const limit = optionalInteger(headers.get("x-ratelimit-limit"));
|
|
427
|
+
const remaining = optionalInteger(headers.get("x-ratelimit-remaining"));
|
|
428
|
+
const used = optionalInteger(headers.get("x-ratelimit-used"));
|
|
429
|
+
const reset = optionalInteger(headers.get("x-ratelimit-reset"));
|
|
430
|
+
const retryAfter = optionalInteger(headers.get("retry-after"));
|
|
431
|
+
if (limit === void 0 && remaining === void 0 && used === void 0 && reset === void 0 && retryAfter === void 0) return;
|
|
432
|
+
return {
|
|
433
|
+
limit,
|
|
434
|
+
remaining,
|
|
435
|
+
used,
|
|
436
|
+
resetAt: reset === void 0 ? void 0 : /* @__PURE__ */ new Date(reset * 1e3),
|
|
437
|
+
retryAfterMs: retryAfter === void 0 ? void 0 : Math.max(0, retryAfter * 1e3)
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
function errorCode(status, rateLimit) {
|
|
441
|
+
if (status === 401) return "AUTHENTICATION_FAILED";
|
|
442
|
+
if (status === 404) return "NOT_FOUND";
|
|
443
|
+
if (status === 422) return "INVALID_INPUT";
|
|
444
|
+
if (status === 429 || status === 403 && (rateLimit?.remaining === 0 || rateLimit?.retryAfterMs !== void 0)) return "RATE_LIMITED";
|
|
445
|
+
return "PROVIDER_ERROR";
|
|
446
|
+
}
|
|
447
|
+
var GitHubTransport = class {
|
|
448
|
+
token;
|
|
449
|
+
baseUrl;
|
|
450
|
+
fetchImplementation;
|
|
451
|
+
/**
|
|
452
|
+
* Creates a GitHub REST transport with fixed or lazy credentials.
|
|
453
|
+
* @param options Token source, provider URL, and fetch override.
|
|
454
|
+
*/
|
|
455
|
+
constructor(options) {
|
|
456
|
+
this.token = options.token;
|
|
457
|
+
this.baseUrl = (options.baseUrl ?? "https://api.github.com").replace(/\/+$/, "");
|
|
458
|
+
this.fetchImplementation = options.fetch ?? globalThis.fetch;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Executes one GitHub REST request.
|
|
462
|
+
* @param request Method, provider-relative path, body, headers, and signal.
|
|
463
|
+
* @returns Parsed data and metadata for this exact request.
|
|
464
|
+
* @throws {ForgeError} For transport, authentication, rate, or provider failures.
|
|
465
|
+
*/
|
|
466
|
+
async request(request) {
|
|
467
|
+
const token = typeof this.token === "function" ? await this.token() : this.token;
|
|
468
|
+
let response;
|
|
469
|
+
try {
|
|
470
|
+
response = await this.fetchImplementation(`${this.baseUrl}${request.path}`, {
|
|
471
|
+
method: request.method,
|
|
472
|
+
headers: {
|
|
473
|
+
Accept: "application/vnd.github+json",
|
|
474
|
+
Authorization: `Bearer ${token}`,
|
|
475
|
+
"Content-Type": "application/json",
|
|
476
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
477
|
+
...request.headers
|
|
478
|
+
},
|
|
479
|
+
body: request.body === void 0 ? void 0 : JSON.stringify(request.body),
|
|
480
|
+
signal: request.signal
|
|
481
|
+
});
|
|
482
|
+
} catch (cause) {
|
|
483
|
+
throw new ForgeError("GitHub transport request failed", "TRANSPORT_ERROR", {
|
|
484
|
+
cause,
|
|
485
|
+
provider: "github",
|
|
486
|
+
retryable: true
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
const requestId = response.headers.get("x-github-request-id") ?? void 0;
|
|
490
|
+
const rateLimit = responseRateLimit(response.headers);
|
|
491
|
+
const metadata = {
|
|
492
|
+
provider: "github",
|
|
493
|
+
requestId,
|
|
494
|
+
status: response.status,
|
|
495
|
+
rateLimit
|
|
496
|
+
};
|
|
497
|
+
let text;
|
|
498
|
+
try {
|
|
499
|
+
text = response.status === 204 ? "" : await response.text();
|
|
500
|
+
} catch (cause) {
|
|
501
|
+
throw new ForgeError("GitHub transport response body failed", "TRANSPORT_ERROR", {
|
|
502
|
+
cause,
|
|
503
|
+
provider: "github",
|
|
504
|
+
status: response.status,
|
|
505
|
+
requestId,
|
|
506
|
+
rateLimit,
|
|
507
|
+
retryable: true
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
let data;
|
|
511
|
+
try {
|
|
512
|
+
data = text === "" ? void 0 : JSON.parse(text);
|
|
513
|
+
} catch {
|
|
514
|
+
data = text;
|
|
515
|
+
}
|
|
516
|
+
if (!response.ok) {
|
|
517
|
+
const code = errorCode(response.status, rateLimit);
|
|
518
|
+
throw new ForgeError(`GitHub API error: ${typeof data === "object" && data !== null && "message" in data && typeof data.message === "string" ? data.message : response.statusText}`, code, {
|
|
519
|
+
provider: "github",
|
|
520
|
+
status: response.status,
|
|
521
|
+
requestId,
|
|
522
|
+
rateLimit,
|
|
523
|
+
details: data,
|
|
524
|
+
retryable: code === "RATE_LIMITED" || response.status >= 500
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
return {
|
|
528
|
+
data,
|
|
529
|
+
metadata
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
//#endregion
|
|
534
|
+
//#region src/github/app.ts
|
|
535
|
+
function base64UrlJson(value) {
|
|
536
|
+
return Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Creates a short-lived GitHub App JWT without mutating process credentials.
|
|
540
|
+
* @param credentials GitHub App ID and RSA private key.
|
|
541
|
+
* @param now Clock value used for issued-at and expiry claims.
|
|
542
|
+
* @returns Signed RS256 GitHub App JWT.
|
|
543
|
+
* @throws {ForgeError} When credentials are missing or the key cannot sign.
|
|
544
|
+
*/
|
|
545
|
+
function createGitHubAppJwt(credentials, now = /* @__PURE__ */ new Date()) {
|
|
546
|
+
if (!credentials.appId || !credentials.privateKey) throw new ForgeError("GitHub App id and private key are required", "CONFIGURATION_ERROR", { provider: "github" });
|
|
547
|
+
const issuedAt = Math.floor(now.getTime() / 1e3) - 60;
|
|
548
|
+
const unsigned = `${base64UrlJson({
|
|
549
|
+
alg: "RS256",
|
|
550
|
+
typ: "JWT"
|
|
551
|
+
})}.${base64UrlJson({
|
|
552
|
+
iat: issuedAt,
|
|
553
|
+
exp: issuedAt + 600,
|
|
554
|
+
iss: String(credentials.appId)
|
|
555
|
+
})}`;
|
|
556
|
+
try {
|
|
557
|
+
const signer = createSign("RSA-SHA256");
|
|
558
|
+
signer.update(unsigned);
|
|
559
|
+
signer.end();
|
|
560
|
+
return `${unsigned}.${signer.sign(credentials.privateKey, "base64url")}`;
|
|
561
|
+
} catch (cause) {
|
|
562
|
+
throw new ForgeError("GitHub App private key could not sign a JWT", "CONFIGURATION_ERROR", {
|
|
563
|
+
cause,
|
|
564
|
+
provider: "github"
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Request/job-scoped GitHub App authority.
|
|
570
|
+
*
|
|
571
|
+
* Keep this object inside a single request or job. Its token cache and in-flight
|
|
572
|
+
* acquisitions are instance fields, so installations cannot share credentials.
|
|
573
|
+
*/
|
|
574
|
+
var GitHubAppAuth = class {
|
|
575
|
+
credentials;
|
|
576
|
+
baseUrl;
|
|
577
|
+
fetchImplementation;
|
|
578
|
+
now;
|
|
579
|
+
expirySkewMs;
|
|
580
|
+
tokens = /* @__PURE__ */ new Map();
|
|
581
|
+
issuedTokens = /* @__PURE__ */ new Map();
|
|
582
|
+
tokenRequests = /* @__PURE__ */ new Map();
|
|
583
|
+
authorizationRequests = /* @__PURE__ */ new Map();
|
|
584
|
+
revokedInstallations = /* @__PURE__ */ new Set();
|
|
585
|
+
nextTokenGeneration = 1;
|
|
586
|
+
/**
|
|
587
|
+
* Creates isolated GitHub App authority from credentials and dependencies.
|
|
588
|
+
* @param options Credentials, clock, provider URL, and fetch override.
|
|
589
|
+
*/
|
|
590
|
+
constructor(options) {
|
|
591
|
+
this.credentials = {
|
|
592
|
+
appId: options.appId,
|
|
593
|
+
privateKey: options.privateKey
|
|
594
|
+
};
|
|
595
|
+
this.baseUrl = options.baseUrl;
|
|
596
|
+
this.fetchImplementation = options.fetch;
|
|
597
|
+
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
598
|
+
this.expirySkewMs = options.expirySkewMs ?? 6e4;
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Clears locally cached credentials without remote revocation.
|
|
602
|
+
* @param installationId Optional installation to clear; omitted clears all.
|
|
603
|
+
* @returns Nothing.
|
|
604
|
+
*/
|
|
605
|
+
clear(installationId) {
|
|
606
|
+
if (installationId === void 0) {
|
|
607
|
+
this.tokens.clear();
|
|
608
|
+
this.issuedTokens.clear();
|
|
609
|
+
this.tokenRequests.clear();
|
|
610
|
+
this.authorizationRequests.clear();
|
|
611
|
+
this.revokedInstallations.clear();
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
const id = String(installationId);
|
|
615
|
+
this.clearCachedInstallation(id);
|
|
616
|
+
this.revokedInstallations.delete(id);
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Acquires and verifies one installation/repository boundary.
|
|
620
|
+
* @param scope Installation ID and exact repository coordinates.
|
|
621
|
+
* @returns An isolated forge provider and revocation handle.
|
|
622
|
+
* @throws {ForgeError} When authentication, provider access, or scope fails.
|
|
623
|
+
*/
|
|
624
|
+
async createInstallationContext(scope) {
|
|
625
|
+
const installationId = String(scope.installationId);
|
|
626
|
+
const fullName = `${scope.owner}/${scope.repo}`.toLowerCase();
|
|
627
|
+
await this.getAuthorizedToken(installationId, fullName);
|
|
628
|
+
let active = true;
|
|
629
|
+
let revocationInFlight;
|
|
630
|
+
const token = () => {
|
|
631
|
+
if (!active) throw new ForgeError("GitHub installation context has been revoked", "AUTHENTICATION_FAILED", { provider: "github" });
|
|
632
|
+
return this.getAuthorizedToken(installationId, fullName);
|
|
633
|
+
};
|
|
634
|
+
const forge = new GitHubForgeProvider({
|
|
635
|
+
owner: scope.owner,
|
|
636
|
+
repo: scope.repo,
|
|
637
|
+
transport: new GitHubTransport({
|
|
638
|
+
token,
|
|
639
|
+
baseUrl: this.baseUrl,
|
|
640
|
+
fetch: this.fetchImplementation
|
|
641
|
+
})
|
|
642
|
+
});
|
|
643
|
+
return {
|
|
644
|
+
installation: { id: installationId },
|
|
645
|
+
repository: {
|
|
646
|
+
owner: scope.owner,
|
|
647
|
+
name: scope.repo,
|
|
648
|
+
fullName: `${scope.owner}/${scope.repo}`
|
|
649
|
+
},
|
|
650
|
+
forge,
|
|
651
|
+
revoke: async () => {
|
|
652
|
+
active = false;
|
|
653
|
+
if (!revocationInFlight) revocationInFlight = this.revokeInstallationTokens(installationId).finally(() => {
|
|
654
|
+
revocationInFlight = void 0;
|
|
655
|
+
});
|
|
656
|
+
await revocationInFlight;
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
async getAuthorizedToken(installationId, fullName) {
|
|
661
|
+
if (this.revokedInstallations.has(installationId)) throw new ForgeError("GitHub App installation authority has been revoked", "AUTHENTICATION_FAILED", { provider: "github" });
|
|
662
|
+
const cached = await this.getInstallationToken(installationId);
|
|
663
|
+
if (this.revokedInstallations.has(installationId)) throw new ForgeError("GitHub App installation authority has been revoked", "AUTHENTICATION_FAILED", { provider: "github" });
|
|
664
|
+
if (cached.authorizedRepositories.has(fullName)) return cached.token;
|
|
665
|
+
const key = `${installationId}:${cached.generation}:${fullName}`;
|
|
666
|
+
let pending = this.authorizationRequests.get(key);
|
|
667
|
+
if (!pending) {
|
|
668
|
+
pending = this.verifyRepositoryScope(cached, fullName).finally(() => {
|
|
669
|
+
this.authorizationRequests.delete(key);
|
|
670
|
+
});
|
|
671
|
+
this.authorizationRequests.set(key, pending);
|
|
672
|
+
}
|
|
673
|
+
await pending;
|
|
674
|
+
if (this.revokedInstallations.has(installationId)) throw new ForgeError("GitHub App installation authority has been revoked", "AUTHENTICATION_FAILED", { provider: "github" });
|
|
675
|
+
return cached.token;
|
|
676
|
+
}
|
|
677
|
+
async getInstallationToken(installationId) {
|
|
678
|
+
const cached = this.tokens.get(installationId);
|
|
679
|
+
if (cached && cached.expiresAt - this.expirySkewMs > this.now().getTime()) return cached;
|
|
680
|
+
let pending = this.tokenRequests.get(installationId);
|
|
681
|
+
if (!pending) {
|
|
682
|
+
pending = this.acquireInstallationToken(installationId).finally(() => {
|
|
683
|
+
this.tokenRequests.delete(installationId);
|
|
684
|
+
});
|
|
685
|
+
this.tokenRequests.set(installationId, pending);
|
|
686
|
+
}
|
|
687
|
+
return pending;
|
|
688
|
+
}
|
|
689
|
+
async acquireInstallationToken(installationId) {
|
|
690
|
+
const response = await new GitHubTransport({
|
|
691
|
+
token: () => createGitHubAppJwt(this.credentials, this.now()),
|
|
692
|
+
baseUrl: this.baseUrl,
|
|
693
|
+
fetch: this.fetchImplementation
|
|
694
|
+
}).request({
|
|
695
|
+
method: "POST",
|
|
696
|
+
path: `/app/installations/${encodeURIComponent(installationId)}/access_tokens`
|
|
697
|
+
});
|
|
698
|
+
const token = response.data.token;
|
|
699
|
+
const expiresAt = Date.parse(response.data.expires_at ?? "");
|
|
700
|
+
if (!token || !Number.isFinite(expiresAt)) throw new ForgeError("GitHub returned an invalid installation token", "AUTHENTICATION_FAILED", {
|
|
701
|
+
provider: "github",
|
|
702
|
+
requestId: response.metadata.requestId
|
|
703
|
+
});
|
|
704
|
+
const cached = {
|
|
705
|
+
token,
|
|
706
|
+
expiresAt,
|
|
707
|
+
generation: this.nextTokenGeneration,
|
|
708
|
+
authorizedRepositories: /* @__PURE__ */ new Set()
|
|
709
|
+
};
|
|
710
|
+
this.nextTokenGeneration += 1;
|
|
711
|
+
const issued = this.issuedTokens.get(installationId) ?? /* @__PURE__ */ new Map();
|
|
712
|
+
for (const [issuedToken, issuedExpiry] of issued) if (issuedExpiry <= this.now().getTime()) issued.delete(issuedToken);
|
|
713
|
+
issued.set(token, expiresAt);
|
|
714
|
+
this.issuedTokens.set(installationId, issued);
|
|
715
|
+
if (this.revokedInstallations.has(installationId)) {
|
|
716
|
+
try {
|
|
717
|
+
await this.revokeToken(token);
|
|
718
|
+
issued.delete(token);
|
|
719
|
+
} catch (cause) {
|
|
720
|
+
const providerError = cause instanceof ForgeError ? cause : void 0;
|
|
721
|
+
throw new ForgeError("A GitHub token acquired during revocation could not be revoked", "PROVIDER_ERROR", {
|
|
722
|
+
cause,
|
|
723
|
+
provider: "github",
|
|
724
|
+
status: providerError?.status,
|
|
725
|
+
requestId: providerError?.requestId,
|
|
726
|
+
rateLimit: providerError?.rateLimit,
|
|
727
|
+
retryable: providerError?.retryable
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
throw new ForgeError("GitHub App installation authority was revoked during token acquisition", "AUTHENTICATION_FAILED", { provider: "github" });
|
|
731
|
+
}
|
|
732
|
+
this.tokens.set(installationId, cached);
|
|
733
|
+
return cached;
|
|
734
|
+
}
|
|
735
|
+
async verifyRepositoryScope(cached, fullName) {
|
|
736
|
+
const transport = new GitHubTransport({
|
|
737
|
+
token: cached.token,
|
|
738
|
+
baseUrl: this.baseUrl,
|
|
739
|
+
fetch: this.fetchImplementation
|
|
740
|
+
});
|
|
741
|
+
const separator = fullName.indexOf("/");
|
|
742
|
+
const owner = fullName.slice(0, separator);
|
|
743
|
+
const repo = fullName.slice(separator + 1);
|
|
744
|
+
try {
|
|
745
|
+
await transport.request({
|
|
746
|
+
method: "GET",
|
|
747
|
+
path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`
|
|
748
|
+
});
|
|
749
|
+
} catch (cause) {
|
|
750
|
+
const providerError = cause instanceof ForgeError ? cause : void 0;
|
|
751
|
+
if (providerError?.retryable || providerError?.code === "AUTHENTICATION_FAILED" || providerError?.code === "TRANSPORT_ERROR" || providerError?.status !== 403 && providerError?.status !== 404) throw cause;
|
|
752
|
+
throw new ForgeError(`GitHub App installation is not authorized for ${fullName}`, "AUTHORITY_MISMATCH", {
|
|
753
|
+
cause,
|
|
754
|
+
provider: "github",
|
|
755
|
+
status: providerError?.status,
|
|
756
|
+
requestId: providerError?.requestId,
|
|
757
|
+
rateLimit: providerError?.rateLimit
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
cached.authorizedRepositories.add(fullName);
|
|
761
|
+
}
|
|
762
|
+
async revokeInstallationTokens(installationId) {
|
|
763
|
+
this.revokedInstallations.add(installationId);
|
|
764
|
+
const issued = [...this.issuedTokens.get(installationId)?.keys() ?? []];
|
|
765
|
+
this.clearCachedInstallation(installationId, false);
|
|
766
|
+
if (issued.length === 0) return;
|
|
767
|
+
const retained = this.issuedTokens.get(installationId) ?? /* @__PURE__ */ new Map();
|
|
768
|
+
const failures = [];
|
|
769
|
+
for (const token of issued) try {
|
|
770
|
+
await this.revokeToken(token);
|
|
771
|
+
retained.delete(token);
|
|
772
|
+
} catch (error) {
|
|
773
|
+
failures.push(error);
|
|
774
|
+
}
|
|
775
|
+
if (retained.size === 0) this.issuedTokens.delete(installationId);
|
|
776
|
+
else this.issuedTokens.set(installationId, retained);
|
|
777
|
+
if (failures.length > 0) {
|
|
778
|
+
const failure = failures[0];
|
|
779
|
+
const providerError = failure instanceof ForgeError ? failure : void 0;
|
|
780
|
+
throw new ForgeError("One or more GitHub installation tokens could not be revoked", "PROVIDER_ERROR", {
|
|
781
|
+
cause: failure,
|
|
782
|
+
provider: "github",
|
|
783
|
+
status: providerError?.status,
|
|
784
|
+
requestId: providerError?.requestId,
|
|
785
|
+
rateLimit: providerError?.rateLimit,
|
|
786
|
+
retryable: providerError?.retryable
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
async revokeToken(token) {
|
|
791
|
+
await new GitHubTransport({
|
|
792
|
+
token,
|
|
793
|
+
baseUrl: this.baseUrl,
|
|
794
|
+
fetch: this.fetchImplementation
|
|
795
|
+
}).request({
|
|
796
|
+
method: "DELETE",
|
|
797
|
+
path: "/installation/token"
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
clearCachedInstallation(installationId, clearIssued = true) {
|
|
801
|
+
this.tokens.delete(installationId);
|
|
802
|
+
if (clearIssued) this.issuedTokens.delete(installationId);
|
|
803
|
+
this.tokenRequests.delete(installationId);
|
|
804
|
+
for (const key of this.authorizationRequests.keys()) if (key.startsWith(`${installationId}:`)) this.authorizationRequests.delete(key);
|
|
805
|
+
}
|
|
806
|
+
};
|
|
807
|
+
//#endregion
|
|
808
|
+
//#region src/github/fixtures.ts
|
|
809
|
+
/**
|
|
810
|
+
* Produces exact, repeatable webhook bytes and headers for integration suites.
|
|
811
|
+
* Pass the same delivery id for duplicates, or a new id with the same payload
|
|
812
|
+
* for a provider redelivery.
|
|
813
|
+
* @param options Secret, delivery identity, event name, and payload.
|
|
814
|
+
* @returns Exact bytes and matching GitHub delivery headers.
|
|
815
|
+
*/
|
|
816
|
+
function createGitHubWebhookFixture(options) {
|
|
817
|
+
const rawBody = new TextEncoder().encode(JSON.stringify(options.payload));
|
|
818
|
+
const signature = createHmac("sha256", options.secret).update(rawBody).digest("hex");
|
|
819
|
+
return {
|
|
820
|
+
rawBody,
|
|
821
|
+
headers: {
|
|
822
|
+
"x-github-delivery": options.deliveryId,
|
|
823
|
+
"x-github-event": options.event,
|
|
824
|
+
"x-hub-signature-256": `sha256=${signature}`
|
|
825
|
+
}
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
//#endregion
|
|
130
829
|
//#region src/github/rest.ts
|
|
131
830
|
/**
|
|
132
831
|
* GitHub REST API client
|
|
@@ -488,6 +1187,111 @@ var GitHubRepository = class {
|
|
|
488
1187
|
}
|
|
489
1188
|
}
|
|
490
1189
|
/**
|
|
1190
|
+
* Publishes one GitHub commit status.
|
|
1191
|
+
* @param input Exact commit SHA and status attributes.
|
|
1192
|
+
* @returns Normalized published status.
|
|
1193
|
+
*/
|
|
1194
|
+
async createCommitStatus(input) {
|
|
1195
|
+
const data = await this.rest.post(`/repos/${this.owner}/${this.repo}/statuses/${encodeURIComponent(input.sha)}`, {
|
|
1196
|
+
state: input.state,
|
|
1197
|
+
context: input.context,
|
|
1198
|
+
description: input.description,
|
|
1199
|
+
target_url: input.targetUrl
|
|
1200
|
+
});
|
|
1201
|
+
return this.mapCommitStatus(data);
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Returns complete commit-status history across GitHub pages.
|
|
1205
|
+
* @param sha Exact commit SHA.
|
|
1206
|
+
* @returns Every normalized status for the commit.
|
|
1207
|
+
*/
|
|
1208
|
+
async listCommitStatuses(sha) {
|
|
1209
|
+
const statuses = [];
|
|
1210
|
+
let page = 1;
|
|
1211
|
+
while (true) {
|
|
1212
|
+
const data = await this.rest.get(`/repos/${this.owner}/${this.repo}/commits/${encodeURIComponent(sha)}/statuses?per_page=100&page=${page}`);
|
|
1213
|
+
statuses.push(...data.map((status) => this.mapCommitStatus(status)));
|
|
1214
|
+
if (data.length < 100) return statuses;
|
|
1215
|
+
page += 1;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* Publishes one GitHub check run.
|
|
1220
|
+
* @param input Check identity, exact head SHA, state, and output.
|
|
1221
|
+
* @returns Normalized published check run.
|
|
1222
|
+
*/
|
|
1223
|
+
async createCheckRun(input) {
|
|
1224
|
+
const data = await this.rest.post(`/repos/${this.owner}/${this.repo}/check-runs`, this.mapCheckRunInput(input));
|
|
1225
|
+
return this.mapCheckRun(data);
|
|
1226
|
+
}
|
|
1227
|
+
/**
|
|
1228
|
+
* Changes one GitHub check run.
|
|
1229
|
+
* @param id GitHub check-run ID.
|
|
1230
|
+
* @param input Attributes to change.
|
|
1231
|
+
* @returns Normalized updated check run.
|
|
1232
|
+
*/
|
|
1233
|
+
async updateCheckRun(id, input) {
|
|
1234
|
+
const data = await this.rest.patch(`/repos/${this.owner}/${this.repo}/check-runs/${encodeURIComponent(id)}`, this.mapCheckRunInput(input));
|
|
1235
|
+
return this.mapCheckRun(data);
|
|
1236
|
+
}
|
|
1237
|
+
/**
|
|
1238
|
+
* Returns complete GitHub check history, including reruns.
|
|
1239
|
+
* @param sha Exact commit SHA.
|
|
1240
|
+
* @returns Every normalized check run for the commit.
|
|
1241
|
+
*/
|
|
1242
|
+
async listCheckRuns(sha) {
|
|
1243
|
+
const checkRuns = [];
|
|
1244
|
+
let page = 1;
|
|
1245
|
+
let totalCount;
|
|
1246
|
+
while (true) {
|
|
1247
|
+
const data = await this.rest.get(`/repos/${this.owner}/${this.repo}/commits/${encodeURIComponent(sha)}/check-runs?filter=all&per_page=100&page=${page}`);
|
|
1248
|
+
const pageRuns = data.check_runs ?? [];
|
|
1249
|
+
totalCount ??= data.total_count;
|
|
1250
|
+
checkRuns.push(...pageRuns.map((checkRun) => this.mapCheckRun(checkRun)));
|
|
1251
|
+
if (pageRuns.length < 100 || totalCount !== void 0 && checkRuns.length >= totalCount) return checkRuns;
|
|
1252
|
+
page += 1;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
mapCommitStatus(data) {
|
|
1256
|
+
return {
|
|
1257
|
+
id: String(data.id ?? data.node_id ?? ""),
|
|
1258
|
+
sha: String(data.sha ?? ""),
|
|
1259
|
+
state: String(data.state ?? ""),
|
|
1260
|
+
context: String(data.context ?? ""),
|
|
1261
|
+
description: typeof data.description === "string" ? data.description : void 0,
|
|
1262
|
+
targetUrl: typeof data.target_url === "string" ? data.target_url : void 0,
|
|
1263
|
+
createdAt: typeof data.created_at === "string" ? new Date(data.created_at) : void 0,
|
|
1264
|
+
raw: data
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
mapCheckRunInput(input) {
|
|
1268
|
+
return {
|
|
1269
|
+
..."name" in input ? { name: input.name } : {},
|
|
1270
|
+
..."headSha" in input ? { head_sha: input.headSha } : {},
|
|
1271
|
+
status: input.status,
|
|
1272
|
+
conclusion: input.conclusion,
|
|
1273
|
+
details_url: input.detailsUrl,
|
|
1274
|
+
external_id: input.externalId,
|
|
1275
|
+
started_at: input.startedAt instanceof Date ? input.startedAt.toISOString() : input.startedAt,
|
|
1276
|
+
completed_at: input.completedAt instanceof Date ? input.completedAt.toISOString() : input.completedAt,
|
|
1277
|
+
output: input.output
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
mapCheckRun(data) {
|
|
1281
|
+
return {
|
|
1282
|
+
id: String(data.id ?? data.node_id ?? ""),
|
|
1283
|
+
name: String(data.name ?? ""),
|
|
1284
|
+
headSha: String(data.head_sha ?? ""),
|
|
1285
|
+
status: data.status ?? "queued",
|
|
1286
|
+
conclusion: typeof data.conclusion === "string" ? data.conclusion : void 0,
|
|
1287
|
+
detailsUrl: typeof data.details_url === "string" ? data.details_url : void 0,
|
|
1288
|
+
externalId: typeof data.external_id === "string" ? data.external_id : void 0,
|
|
1289
|
+
startedAt: typeof data.started_at === "string" ? new Date(data.started_at) : void 0,
|
|
1290
|
+
completedAt: typeof data.completed_at === "string" ? new Date(data.completed_at) : void 0,
|
|
1291
|
+
raw: data
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
/**
|
|
491
1295
|
* Create a new repository from this repository as a template.
|
|
492
1296
|
*
|
|
493
1297
|
* Uses the GitHub "Generate" API: POST /repos/{template_owner}/{template_repo}/generate
|
|
@@ -512,6 +1316,298 @@ var GitHubRepository = class {
|
|
|
512
1316
|
}
|
|
513
1317
|
};
|
|
514
1318
|
//#endregion
|
|
1319
|
+
//#region src/github/webhooks.ts
|
|
1320
|
+
function headerValue(headers, requestedName) {
|
|
1321
|
+
if (headers instanceof Headers) return headers.get(requestedName) ?? void 0;
|
|
1322
|
+
const requested = requestedName.toLowerCase();
|
|
1323
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
1324
|
+
if (name.toLowerCase() !== requested) continue;
|
|
1325
|
+
return typeof value === "string" ? value : value?.[0];
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
function object(value) {
|
|
1329
|
+
return typeof value === "object" && value !== null ? value : {};
|
|
1330
|
+
}
|
|
1331
|
+
function optionalString(value) {
|
|
1332
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
1333
|
+
}
|
|
1334
|
+
function actor(value) {
|
|
1335
|
+
const data = object(value);
|
|
1336
|
+
const login = optionalString(data.login);
|
|
1337
|
+
if (!login) return void 0;
|
|
1338
|
+
return {
|
|
1339
|
+
id: data.id === void 0 ? void 0 : String(data.id),
|
|
1340
|
+
login,
|
|
1341
|
+
type: optionalString(data.type)
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
function repository(value) {
|
|
1345
|
+
const data = object(value);
|
|
1346
|
+
const ownerData = object(data.owner);
|
|
1347
|
+
const fullName = optionalString(data.full_name);
|
|
1348
|
+
const name = optionalString(data.name);
|
|
1349
|
+
const owner = optionalString(ownerData.login);
|
|
1350
|
+
if (!name || !owner && !fullName) return void 0;
|
|
1351
|
+
const resolvedOwner = owner ?? fullName?.split("/")[0] ?? "";
|
|
1352
|
+
return {
|
|
1353
|
+
id: data.node_id === void 0 ? void 0 : String(data.node_id),
|
|
1354
|
+
owner: resolvedOwner,
|
|
1355
|
+
name,
|
|
1356
|
+
fullName: fullName ?? `${resolvedOwner}/${name}`,
|
|
1357
|
+
defaultBranch: optionalString(data.default_branch),
|
|
1358
|
+
private: typeof data.private === "boolean" ? data.private : void 0,
|
|
1359
|
+
url: optionalString(data.html_url)
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
function installation(value) {
|
|
1363
|
+
const data = object(value);
|
|
1364
|
+
if (data.id === void 0) return void 0;
|
|
1365
|
+
const accountData = object(data.account);
|
|
1366
|
+
return {
|
|
1367
|
+
id: String(data.id),
|
|
1368
|
+
account: optionalString(accountData.login),
|
|
1369
|
+
repositorySelection: data.repository_selection === "all" || data.repository_selection === "selected" ? data.repository_selection : void 0
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
function pullRequest(value) {
|
|
1373
|
+
const data = object(value);
|
|
1374
|
+
const head = object(data.head);
|
|
1375
|
+
const base = object(data.base);
|
|
1376
|
+
return {
|
|
1377
|
+
id: data.node_id === void 0 ? void 0 : String(data.node_id),
|
|
1378
|
+
number: Number(data.number),
|
|
1379
|
+
state: data.state === "closed" ? "closed" : "open",
|
|
1380
|
+
draft: typeof data.draft === "boolean" ? data.draft : void 0,
|
|
1381
|
+
headSha: String(head.sha ?? ""),
|
|
1382
|
+
headRef: optionalString(head.ref),
|
|
1383
|
+
baseSha: optionalString(base.sha),
|
|
1384
|
+
baseRef: optionalString(base.ref),
|
|
1385
|
+
merged: typeof data.merged === "boolean" ? data.merged : void 0,
|
|
1386
|
+
mergeCommitSha: optionalString(data.merge_commit_sha),
|
|
1387
|
+
url: optionalString(data.html_url)
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
function check(value) {
|
|
1391
|
+
const data = object(value);
|
|
1392
|
+
const app = object(data.app);
|
|
1393
|
+
return {
|
|
1394
|
+
id: String(data.id ?? data.node_id ?? ""),
|
|
1395
|
+
name: String(data.name ?? app.name ?? "check_suite"),
|
|
1396
|
+
headSha: String(data.head_sha ?? ""),
|
|
1397
|
+
status: data.status ?? "queued",
|
|
1398
|
+
conclusion: typeof data.conclusion === "string" ? data.conclusion : void 0,
|
|
1399
|
+
detailsUrl: optionalString(data.details_url),
|
|
1400
|
+
externalId: optionalString(data.external_id),
|
|
1401
|
+
startedAt: optionalString(data.started_at) ? new Date(String(data.started_at)) : void 0,
|
|
1402
|
+
completedAt: optionalString(data.completed_at) ? new Date(String(data.completed_at)) : void 0,
|
|
1403
|
+
raw: data
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1406
|
+
function occurrence(payload) {
|
|
1407
|
+
const candidates = [
|
|
1408
|
+
object(payload.review).submitted_at,
|
|
1409
|
+
object(payload.check_run).completed_at,
|
|
1410
|
+
object(payload.check_run).started_at,
|
|
1411
|
+
payload.created_at,
|
|
1412
|
+
object(payload.status).created_at,
|
|
1413
|
+
object(payload.deployment_status).created_at,
|
|
1414
|
+
object(payload.deployment).created_at,
|
|
1415
|
+
object(payload.pull_request).updated_at,
|
|
1416
|
+
object(payload.head_commit).timestamp
|
|
1417
|
+
];
|
|
1418
|
+
for (const candidate of candidates) {
|
|
1419
|
+
if (typeof candidate !== "string") continue;
|
|
1420
|
+
const timestamp = new Date(candidate);
|
|
1421
|
+
if (!Number.isNaN(timestamp.getTime())) return timestamp;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
function normalizeObservation(event, payload, action) {
|
|
1425
|
+
if (event === "ping") return {
|
|
1426
|
+
kind: "availability",
|
|
1427
|
+
available: true,
|
|
1428
|
+
message: optionalString(payload.zen)
|
|
1429
|
+
};
|
|
1430
|
+
if (event === "installation" || event === "installation_repositories") {
|
|
1431
|
+
const installationRef = installation(payload.installation);
|
|
1432
|
+
if (!installationRef) return { kind: "unknown" };
|
|
1433
|
+
return {
|
|
1434
|
+
kind: "installation",
|
|
1435
|
+
installation: installationRef,
|
|
1436
|
+
repositories: [
|
|
1437
|
+
...Array.isArray(payload.repositories) ? payload.repositories : [],
|
|
1438
|
+
...Array.isArray(payload.repositories_added) ? payload.repositories_added : [],
|
|
1439
|
+
...Array.isArray(payload.repositories_removed) ? payload.repositories_removed : []
|
|
1440
|
+
].map(repository).filter((item) => item !== void 0)
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
if (event === "repository") {
|
|
1444
|
+
const repositoryRef = repository(payload.repository);
|
|
1445
|
+
return repositoryRef ? {
|
|
1446
|
+
kind: "repository",
|
|
1447
|
+
repository: repositoryRef
|
|
1448
|
+
} : { kind: "unknown" };
|
|
1449
|
+
}
|
|
1450
|
+
if (event === "pull_request") {
|
|
1451
|
+
const pullRequestRef = pullRequest(payload.pull_request);
|
|
1452
|
+
return action === "closed" && pullRequestRef.merged ? {
|
|
1453
|
+
kind: "merge",
|
|
1454
|
+
pullRequest: pullRequestRef,
|
|
1455
|
+
mergeCommitSha: pullRequestRef.mergeCommitSha
|
|
1456
|
+
} : {
|
|
1457
|
+
kind: "pull_request",
|
|
1458
|
+
pullRequest: pullRequestRef
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
if (event === "pull_request_review") {
|
|
1462
|
+
const review = object(payload.review);
|
|
1463
|
+
return {
|
|
1464
|
+
kind: "review",
|
|
1465
|
+
pullRequest: pullRequest(payload.pull_request),
|
|
1466
|
+
review: {
|
|
1467
|
+
id: String(review.id ?? review.node_id ?? ""),
|
|
1468
|
+
state: String(review.state ?? ""),
|
|
1469
|
+
body: optionalString(review.body),
|
|
1470
|
+
commitSha: optionalString(review.commit_id),
|
|
1471
|
+
submittedAt: optionalString(review.submitted_at) ? new Date(String(review.submitted_at)) : void 0,
|
|
1472
|
+
author: actor(review.user)
|
|
1473
|
+
}
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
if (event === "push") return {
|
|
1477
|
+
kind: "push",
|
|
1478
|
+
ref: String(payload.ref ?? ""),
|
|
1479
|
+
beforeSha: optionalString(payload.before),
|
|
1480
|
+
afterSha: String(payload.after ?? ""),
|
|
1481
|
+
forced: typeof payload.forced === "boolean" ? payload.forced : void 0,
|
|
1482
|
+
created: typeof payload.created === "boolean" ? payload.created : void 0,
|
|
1483
|
+
deleted: typeof payload.deleted === "boolean" ? payload.deleted : void 0
|
|
1484
|
+
};
|
|
1485
|
+
if (event === "status") return {
|
|
1486
|
+
kind: "status",
|
|
1487
|
+
status: {
|
|
1488
|
+
id: String(payload.id ?? ""),
|
|
1489
|
+
sha: String(payload.sha ?? ""),
|
|
1490
|
+
state: String(payload.state ?? ""),
|
|
1491
|
+
context: String(payload.context ?? ""),
|
|
1492
|
+
description: optionalString(payload.description),
|
|
1493
|
+
targetUrl: optionalString(payload.target_url),
|
|
1494
|
+
createdAt: optionalString(payload.created_at) ? new Date(String(payload.created_at)) : void 0,
|
|
1495
|
+
raw: payload
|
|
1496
|
+
}
|
|
1497
|
+
};
|
|
1498
|
+
if (event === "check_run" || event === "check_suite") return {
|
|
1499
|
+
kind: "check",
|
|
1500
|
+
check: check(payload.check_run ?? payload.check_suite)
|
|
1501
|
+
};
|
|
1502
|
+
if (event === "merge_group") {
|
|
1503
|
+
const mergeGroup = object(payload.merge_group);
|
|
1504
|
+
return {
|
|
1505
|
+
kind: "merge_group",
|
|
1506
|
+
headSha: String(mergeGroup.head_sha ?? ""),
|
|
1507
|
+
headRef: optionalString(mergeGroup.head_ref),
|
|
1508
|
+
baseSha: optionalString(mergeGroup.base_sha),
|
|
1509
|
+
baseRef: optionalString(mergeGroup.base_ref)
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
if (event === "deployment" || event === "deployment_status") {
|
|
1513
|
+
const deployment = object(payload.deployment);
|
|
1514
|
+
const deploymentStatus = object(payload.deployment_status);
|
|
1515
|
+
return {
|
|
1516
|
+
kind: "deployment",
|
|
1517
|
+
deployment: {
|
|
1518
|
+
id: String(deployment.id ?? ""),
|
|
1519
|
+
sha: optionalString(deployment.sha),
|
|
1520
|
+
ref: optionalString(deployment.ref),
|
|
1521
|
+
environment: optionalString(deploymentStatus.environment) ?? optionalString(deployment.environment),
|
|
1522
|
+
state: optionalString(deploymentStatus.state),
|
|
1523
|
+
url: optionalString(deploymentStatus.environment_url) ?? optionalString(deploymentStatus.target_url)
|
|
1524
|
+
}
|
|
1525
|
+
};
|
|
1526
|
+
}
|
|
1527
|
+
return { kind: "unknown" };
|
|
1528
|
+
}
|
|
1529
|
+
/**
|
|
1530
|
+
* Normalizes one already-verified GitHub payload.
|
|
1531
|
+
* @param deliveryId Stable provider delivery identity.
|
|
1532
|
+
* @param event GitHub event header value.
|
|
1533
|
+
* @param raw Parsed provider payload.
|
|
1534
|
+
* @param receivedAt Local receipt timestamp.
|
|
1535
|
+
* @returns A provider-neutral event envelope preserving the parsed payload.
|
|
1536
|
+
*/
|
|
1537
|
+
function normalizeGitHubWebhook(deliveryId, event, raw, receivedAt = /* @__PURE__ */ new Date()) {
|
|
1538
|
+
const payload = object(raw);
|
|
1539
|
+
const action = optionalString(payload.action);
|
|
1540
|
+
return {
|
|
1541
|
+
provider: "github",
|
|
1542
|
+
deliveryId,
|
|
1543
|
+
event,
|
|
1544
|
+
action,
|
|
1545
|
+
occurredAt: occurrence(payload),
|
|
1546
|
+
receivedAt,
|
|
1547
|
+
installation: installation(payload.installation),
|
|
1548
|
+
repository: repository(payload.repository),
|
|
1549
|
+
actor: actor(payload.sender),
|
|
1550
|
+
observation: normalizeObservation(event, payload, action),
|
|
1551
|
+
raw
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
var GitHubWebhookVerifier = class {
|
|
1555
|
+
secrets;
|
|
1556
|
+
now;
|
|
1557
|
+
/**
|
|
1558
|
+
* Creates a verifier with a current secret and optional rotation secrets.
|
|
1559
|
+
* @param options Current/rotation secrets and optional clock.
|
|
1560
|
+
* @throws {ForgeError} When no non-empty secret is configured.
|
|
1561
|
+
*/
|
|
1562
|
+
constructor(options) {
|
|
1563
|
+
this.secrets = typeof options.secrets === "string" ? [options.secrets] : [...options.secrets];
|
|
1564
|
+
if (this.secrets.length === 0 || this.secrets.some((secret) => !secret)) throw new ForgeError("At least one non-empty GitHub webhook secret is required", "CONFIGURATION_ERROR", { provider: "github" });
|
|
1565
|
+
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
1566
|
+
}
|
|
1567
|
+
/**
|
|
1568
|
+
* Verifies unchanged provider bytes with constant-time comparisons.
|
|
1569
|
+
* @param rawBody Exact bytes received from the HTTP server.
|
|
1570
|
+
* @param signature GitHub SHA-256 signature header.
|
|
1571
|
+
* @returns Index of the configured secret that matched.
|
|
1572
|
+
* @throws {ForgeSignatureError} When the signature is missing or invalid.
|
|
1573
|
+
*/
|
|
1574
|
+
verify(rawBody, signature) {
|
|
1575
|
+
if (!signature?.startsWith("sha256=")) throw new ForgeSignatureError();
|
|
1576
|
+
const suppliedHex = signature.slice(7);
|
|
1577
|
+
if (!/^[a-fA-F0-9]{64}$/.test(suppliedHex)) throw new ForgeSignatureError();
|
|
1578
|
+
const supplied = Buffer.from(suppliedHex, "hex");
|
|
1579
|
+
let matchedIndex = -1;
|
|
1580
|
+
this.secrets.forEach((secret, index) => {
|
|
1581
|
+
if (timingSafeEqual(createHmac("sha256", secret).update(rawBody).digest(), supplied)) matchedIndex = index;
|
|
1582
|
+
});
|
|
1583
|
+
if (matchedIndex === -1) throw new ForgeSignatureError();
|
|
1584
|
+
return matchedIndex;
|
|
1585
|
+
}
|
|
1586
|
+
/**
|
|
1587
|
+
* Verifies raw bytes before decoding and normalizes the delivery.
|
|
1588
|
+
* @param rawBody Exact bytes received from the HTTP server.
|
|
1589
|
+
* @param headers Case-insensitive GitHub delivery headers.
|
|
1590
|
+
* @returns Verified normalized delivery.
|
|
1591
|
+
* @throws {ForgeError} For invalid signatures, headers, UTF-8, or JSON.
|
|
1592
|
+
*/
|
|
1593
|
+
verifyAndNormalize(rawBody, headers) {
|
|
1594
|
+
this.verify(rawBody, headerValue(headers, "x-hub-signature-256"));
|
|
1595
|
+
const deliveryId = headerValue(headers, "x-github-delivery");
|
|
1596
|
+
const event = headerValue(headers, "x-github-event");
|
|
1597
|
+
if (!deliveryId || !event) throw new ForgeError("GitHub delivery identity and event headers are required", "INVALID_INPUT", { provider: "github" });
|
|
1598
|
+
let payload;
|
|
1599
|
+
try {
|
|
1600
|
+
payload = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(rawBody));
|
|
1601
|
+
} catch (cause) {
|
|
1602
|
+
throw new ForgeError("GitHub webhook body is not valid UTF-8 JSON", "INVALID_INPUT", {
|
|
1603
|
+
cause,
|
|
1604
|
+
provider: "github"
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
1607
|
+
return normalizeGitHubWebhook(deliveryId, event, payload, this.now());
|
|
1608
|
+
}
|
|
1609
|
+
};
|
|
1610
|
+
//#endregion
|
|
515
1611
|
//#region src/parsing.ts
|
|
516
1612
|
/**
|
|
517
1613
|
* Issue body parsing and rendering utilities
|
|
@@ -738,6 +1834,6 @@ function detectTemplateFromLabels(labels, templates) {
|
|
|
738
1834
|
return bestMatch;
|
|
739
1835
|
}
|
|
740
1836
|
//#endregion
|
|
741
|
-
export { GitHubRepository, RepositoryError, RepositoryErrorCode, detectTemplateFromLabels, fetchIssueTemplates, getIssueField, getRepository, loadIssueTemplate, parseIssueBody, parseIssueTemplate, renderIssueBody, updateIssueField };
|
|
1837
|
+
export { ForgeError, ForgeSignatureError, GitHubAppAuth, GitHubForgeProvider, GitHubRepository, GitHubTransport, GitHubWebhookVerifier, RepositoryError, RepositoryErrorCode, createGitHubAppJwt, createGitHubWebhookFixture, detectTemplateFromLabels, fetchIssueTemplates, getIssueField, getRepository, loadIssueTemplate, normalizeGitHubWebhook, parseIssueBody, parseIssueTemplate, renderIssueBody, updateIssueField };
|
|
742
1838
|
|
|
743
1839
|
//# sourceMappingURL=index.js.map
|