@theholocron/github-client 1.32.0 → 1.33.0
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/dist/index.d.mts +570 -0
- package/dist/index.mjs +733 -0
- package/package.json +3 -3
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
import "@theholocron/http-client";
|
|
2
|
+
import { ErrorSink, Logger } from "@theholocron/observability/core";
|
|
3
|
+
//#region ../../../../../src/utils.d.ts
|
|
4
|
+
interface GitHubClientOptions {
|
|
5
|
+
token: string;
|
|
6
|
+
baseUrl?: string;
|
|
7
|
+
fetch?: typeof fetch;
|
|
8
|
+
/** Structured logger for request/response diagnostics. Defaults to a no-op. */
|
|
9
|
+
logger?: Logger;
|
|
10
|
+
/** Reports transport failures and unexpected 5xx. Defaults to a no-op. */
|
|
11
|
+
errors?: ErrorSink;
|
|
12
|
+
}
|
|
13
|
+
//#endregion
|
|
14
|
+
//#region ../../../../../src/checks/checks.d.ts
|
|
15
|
+
type CheckRunStatus = "queued" | "in_progress" | "completed";
|
|
16
|
+
type CheckRunConclusion = "success" | "failure" | "neutral" | "cancelled" | "timed_out" | "action_required" | "skipped";
|
|
17
|
+
type CheckRunAnnotationLevel = "notice" | "warning" | "failure";
|
|
18
|
+
interface CheckRunAnnotation {
|
|
19
|
+
/** File path relative to the repo root. */
|
|
20
|
+
path: string;
|
|
21
|
+
/** 1-indexed. */
|
|
22
|
+
start_line: number;
|
|
23
|
+
end_line: number;
|
|
24
|
+
/** Only valid when `start_line === end_line`. */
|
|
25
|
+
start_column?: number;
|
|
26
|
+
end_column?: number;
|
|
27
|
+
annotation_level: CheckRunAnnotationLevel;
|
|
28
|
+
/** Max 64KB. */
|
|
29
|
+
message: string;
|
|
30
|
+
/** Max 255 characters. */
|
|
31
|
+
title?: string;
|
|
32
|
+
/** Max 64KB. */
|
|
33
|
+
raw_details?: string;
|
|
34
|
+
}
|
|
35
|
+
interface CheckRunOutput {
|
|
36
|
+
title: string;
|
|
37
|
+
summary: string;
|
|
38
|
+
text?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Inline PR annotations — up to 50 per request. Sending more requires a
|
|
41
|
+
* follow-up `updateCheckRun()` PATCH per GitHub's own docs; this client
|
|
42
|
+
* doesn't paginate them for you.
|
|
43
|
+
*/
|
|
44
|
+
annotations?: CheckRunAnnotation[];
|
|
45
|
+
}
|
|
46
|
+
interface CreateCheckRunInput {
|
|
47
|
+
name: string;
|
|
48
|
+
/** The commit SHA the check run attaches to. */
|
|
49
|
+
head_sha: string;
|
|
50
|
+
/** Defaults to `"queued"` (GitHub's own default) when omitted. */
|
|
51
|
+
status?: CheckRunStatus;
|
|
52
|
+
/** Required when `status` is `"completed"`. */
|
|
53
|
+
conclusion?: CheckRunConclusion;
|
|
54
|
+
output?: CheckRunOutput;
|
|
55
|
+
/** URL for the "Details" link on the check run. GitHub defaults to the check suite's own summary page when omitted. */
|
|
56
|
+
details_url?: string;
|
|
57
|
+
}
|
|
58
|
+
interface GitHubCheckRun {
|
|
59
|
+
id: number;
|
|
60
|
+
name: string;
|
|
61
|
+
head_sha: string;
|
|
62
|
+
status: CheckRunStatus;
|
|
63
|
+
conclusion: CheckRunConclusion | null;
|
|
64
|
+
html_url: string;
|
|
65
|
+
}
|
|
66
|
+
interface UpdateCheckRunInput {
|
|
67
|
+
status?: CheckRunStatus;
|
|
68
|
+
/** Required when `status` is `"completed"`. */
|
|
69
|
+
conclusion?: CheckRunConclusion;
|
|
70
|
+
output?: CheckRunOutput;
|
|
71
|
+
/** URL for the "Details" link on the check run. */
|
|
72
|
+
details_url?: string;
|
|
73
|
+
}
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region ../../../../../src/environments/environments.d.ts
|
|
76
|
+
interface GitHubEnvironment {
|
|
77
|
+
name: string;
|
|
78
|
+
wait_timer?: number;
|
|
79
|
+
prevent_self_review?: boolean;
|
|
80
|
+
protection_rules?: Array<{
|
|
81
|
+
type: string;
|
|
82
|
+
reviewers?: Array<{
|
|
83
|
+
type: "User" | "Team";
|
|
84
|
+
reviewer: {
|
|
85
|
+
id: number;
|
|
86
|
+
};
|
|
87
|
+
}>;
|
|
88
|
+
}>;
|
|
89
|
+
}
|
|
90
|
+
//#endregion
|
|
91
|
+
//#region ../../../../../src/git/git.d.ts
|
|
92
|
+
interface GitRef {
|
|
93
|
+
object: {
|
|
94
|
+
sha: string;
|
|
95
|
+
type: string;
|
|
96
|
+
url: string;
|
|
97
|
+
};
|
|
98
|
+
ref: string;
|
|
99
|
+
url: string;
|
|
100
|
+
}
|
|
101
|
+
interface GitCommit {
|
|
102
|
+
sha: string;
|
|
103
|
+
tree: {
|
|
104
|
+
sha: string;
|
|
105
|
+
url: string;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
interface GitTreeItem {
|
|
109
|
+
path: string;
|
|
110
|
+
sha: string;
|
|
111
|
+
type: string;
|
|
112
|
+
mode?: string;
|
|
113
|
+
size?: number;
|
|
114
|
+
}
|
|
115
|
+
interface GitTree {
|
|
116
|
+
sha: string;
|
|
117
|
+
tree: GitTreeItem[];
|
|
118
|
+
truncated: boolean;
|
|
119
|
+
}
|
|
120
|
+
interface GitContents {
|
|
121
|
+
content: string;
|
|
122
|
+
encoding: string;
|
|
123
|
+
sha: string;
|
|
124
|
+
name: string;
|
|
125
|
+
path: string;
|
|
126
|
+
}
|
|
127
|
+
interface GitBlob {
|
|
128
|
+
sha: string;
|
|
129
|
+
url: string;
|
|
130
|
+
}
|
|
131
|
+
interface GitPull {
|
|
132
|
+
number: number;
|
|
133
|
+
html_url: string;
|
|
134
|
+
title: string;
|
|
135
|
+
}
|
|
136
|
+
interface CreatePullInput {
|
|
137
|
+
title: string;
|
|
138
|
+
head: string;
|
|
139
|
+
base: string;
|
|
140
|
+
body?: string;
|
|
141
|
+
}
|
|
142
|
+
//#endregion
|
|
143
|
+
//#region ../../../../../src/issues/issues.d.ts
|
|
144
|
+
interface GitHubIssue {
|
|
145
|
+
id: number;
|
|
146
|
+
number: number;
|
|
147
|
+
title: string;
|
|
148
|
+
body?: string | null;
|
|
149
|
+
state: "open" | "closed";
|
|
150
|
+
labels: Array<{
|
|
151
|
+
name: string;
|
|
152
|
+
}>;
|
|
153
|
+
assignee: {
|
|
154
|
+
login: string;
|
|
155
|
+
name?: string | null;
|
|
156
|
+
email?: string | null;
|
|
157
|
+
} | null;
|
|
158
|
+
updated_at: string;
|
|
159
|
+
html_url: string;
|
|
160
|
+
pull_request?: unknown;
|
|
161
|
+
}
|
|
162
|
+
interface GitHubMilestone {
|
|
163
|
+
number: number;
|
|
164
|
+
title: string;
|
|
165
|
+
}
|
|
166
|
+
interface IssueSearchParams {
|
|
167
|
+
state?: "open" | "closed" | "all";
|
|
168
|
+
sort?: string;
|
|
169
|
+
direction?: string;
|
|
170
|
+
per_page?: number;
|
|
171
|
+
filter?: string;
|
|
172
|
+
assignee?: string;
|
|
173
|
+
}
|
|
174
|
+
//#endregion
|
|
175
|
+
//#region ../../../../../src/labels/labels.d.ts
|
|
176
|
+
interface GitHubLabel {
|
|
177
|
+
name: string;
|
|
178
|
+
color: string;
|
|
179
|
+
description: string | null;
|
|
180
|
+
}
|
|
181
|
+
//#endregion
|
|
182
|
+
//#region ../../../../../src/pages/pages.d.ts
|
|
183
|
+
type PagesBuildType = "workflow" | "legacy";
|
|
184
|
+
type PagesBuildStatus = "built" | "building" | "errored" | "unknown";
|
|
185
|
+
interface GitHubPages {
|
|
186
|
+
status: PagesBuildStatus | null;
|
|
187
|
+
build_type: PagesBuildType | null;
|
|
188
|
+
source?: {
|
|
189
|
+
branch: string;
|
|
190
|
+
path: string;
|
|
191
|
+
};
|
|
192
|
+
https_enforced: boolean;
|
|
193
|
+
custom_domain: string | null;
|
|
194
|
+
}
|
|
195
|
+
interface CreatePagesPayload {
|
|
196
|
+
build_type: PagesBuildType;
|
|
197
|
+
/** Required when build_type is "legacy". */
|
|
198
|
+
source?: {
|
|
199
|
+
branch: string;
|
|
200
|
+
path: string;
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
interface UpdatePagesPayload {
|
|
204
|
+
build_type?: PagesBuildType;
|
|
205
|
+
source?: {
|
|
206
|
+
branch: string;
|
|
207
|
+
path: string;
|
|
208
|
+
};
|
|
209
|
+
cname?: string | null;
|
|
210
|
+
https_enforced?: boolean;
|
|
211
|
+
}
|
|
212
|
+
//#endregion
|
|
213
|
+
//#region ../../../../../src/pulls/pulls.d.ts
|
|
214
|
+
interface GitHubPullRequest {
|
|
215
|
+
number: number;
|
|
216
|
+
title: string;
|
|
217
|
+
state: "open" | "closed";
|
|
218
|
+
merged_at: string | null;
|
|
219
|
+
html_url: string;
|
|
220
|
+
head: {
|
|
221
|
+
ref: string;
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
/** One commit as returned by `GET /pulls/{pull_number}/commits` — only the fields any known consumer needs. */
|
|
225
|
+
interface GitHubPullRequestCommit {
|
|
226
|
+
sha: string;
|
|
227
|
+
commit: {
|
|
228
|
+
message: string;
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/** One file as returned by `GET /pulls/{pull_number}/files` — only the fields any known consumer needs. */
|
|
232
|
+
interface GitHubPullRequestFile {
|
|
233
|
+
filename: string;
|
|
234
|
+
status: "added" | "removed" | "modified" | "renamed" | "copied" | "changed" | "unchanged";
|
|
235
|
+
/** Set only when `status === "renamed"`. */
|
|
236
|
+
previous_filename?: string;
|
|
237
|
+
}
|
|
238
|
+
/** One inline comment in a `POST /pulls/{pull_number}/reviews` request body. */
|
|
239
|
+
interface CreateReviewComment {
|
|
240
|
+
/** File path relative to the repo root. */
|
|
241
|
+
path: string;
|
|
242
|
+
/** 1-indexed line in the file's current (`RIGHT`) version — GitHub anchors a review comment to a line, not a column. */
|
|
243
|
+
line: number;
|
|
244
|
+
/** Almost always `"RIGHT"` (the PR's own new content) — `"LEFT"` targets the base/pre-PR version instead. */
|
|
245
|
+
side: "LEFT" | "RIGHT";
|
|
246
|
+
body: string;
|
|
247
|
+
}
|
|
248
|
+
interface CreateReviewInput {
|
|
249
|
+
/** The PR's head SHA the review attaches to. */
|
|
250
|
+
commit_id: string;
|
|
251
|
+
/** The review's own top-level summary, shown above its inline comments. */
|
|
252
|
+
body: string;
|
|
253
|
+
/**
|
|
254
|
+
* `"COMMENT"` never blocks a merge by itself — the right choice for an
|
|
255
|
+
* advisory bot review. `"REQUEST_CHANGES"`/`"APPROVE"` carry the same
|
|
256
|
+
* real weight as a human reviewer's own review state, affecting
|
|
257
|
+
* required-approval counts and dismissal.
|
|
258
|
+
*/
|
|
259
|
+
event: "COMMENT" | "REQUEST_CHANGES" | "APPROVE";
|
|
260
|
+
comments?: CreateReviewComment[];
|
|
261
|
+
}
|
|
262
|
+
/** `POST /pulls/{pull_number}/reviews`'s response — only the fields any known consumer needs. */
|
|
263
|
+
interface GitHubPullRequestReview {
|
|
264
|
+
id: number;
|
|
265
|
+
html_url: string;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* One review thread — GraphQL-only (`resolveReviewThread`, the mutation
|
|
269
|
+
* this exists to support, has no REST equivalent at all). `id` here is a
|
|
270
|
+
* GraphQL node ID (`PRRT_...`), not the same numeric ID space as REST's
|
|
271
|
+
* review-comment `id` — pass it straight through to `resolveReviewThread()`,
|
|
272
|
+
* never compare it against a REST-sourced ID.
|
|
273
|
+
*/
|
|
274
|
+
interface ReviewThread {
|
|
275
|
+
id: string;
|
|
276
|
+
isResolved: boolean;
|
|
277
|
+
/** The thread's first comment's author login (e.g. `"the-holocron-sentinel[bot]"`) — `undefined` if GitHub ever returns a thread with no comments, which shouldn't happen in practice. Callers filter on this before resolving anything, so a bot never touches a human reviewer's own thread. */
|
|
278
|
+
authorLogin: string | undefined;
|
|
279
|
+
/** The file the thread is anchored to on the PR's current diff — `null` once GitHub can no longer place it there (e.g. the file was since deleted). */
|
|
280
|
+
path: string | null;
|
|
281
|
+
/** 1-indexed line the thread is anchored to — `null` in the same "can no longer place it" case as `path`. */
|
|
282
|
+
line: number | null;
|
|
283
|
+
/**
|
|
284
|
+
* The thread's first comment's own body — the exact text a caller like
|
|
285
|
+
* Sentinel posted for a specific finding. Lets a caller diff a prior
|
|
286
|
+
* thread's own content against the current push's findings (holocron#860)
|
|
287
|
+
* to decide whether that finding is still live, rather than only knowing
|
|
288
|
+
* a thread exists. `undefined` in the same no-comments case as
|
|
289
|
+
* `authorLogin`.
|
|
290
|
+
*/
|
|
291
|
+
body: string | undefined;
|
|
292
|
+
}
|
|
293
|
+
//#endregion
|
|
294
|
+
//#region ../../../../../src/repos/repos.d.ts
|
|
295
|
+
interface GitHubRepo {
|
|
296
|
+
default_branch: string;
|
|
297
|
+
full_name: string;
|
|
298
|
+
}
|
|
299
|
+
interface GitHubContents {
|
|
300
|
+
content: string;
|
|
301
|
+
encoding: string;
|
|
302
|
+
}
|
|
303
|
+
//#endregion
|
|
304
|
+
//#region ../../../../../src/rulesets/rulesets.d.ts
|
|
305
|
+
interface GitHubRuleset {
|
|
306
|
+
id: number;
|
|
307
|
+
name: string;
|
|
308
|
+
enforcement: string;
|
|
309
|
+
}
|
|
310
|
+
//#endregion
|
|
311
|
+
//#region ../../../../../src/secrets/secrets.d.ts
|
|
312
|
+
type SecretScope = {
|
|
313
|
+
kind: "repo";
|
|
314
|
+
} | {
|
|
315
|
+
kind: "environment";
|
|
316
|
+
name: string;
|
|
317
|
+
} | {
|
|
318
|
+
kind: "organization";
|
|
319
|
+
org: string;
|
|
320
|
+
};
|
|
321
|
+
interface GitHubPublicKey {
|
|
322
|
+
key_id: string;
|
|
323
|
+
key: string;
|
|
324
|
+
}
|
|
325
|
+
//#endregion
|
|
326
|
+
//#region ../../../../../src/security/security.d.ts
|
|
327
|
+
interface CodeScanningSetupResult {
|
|
328
|
+
run_id: number;
|
|
329
|
+
run_url: string;
|
|
330
|
+
}
|
|
331
|
+
//#endregion
|
|
332
|
+
//#region ../../../../../src/teams/teams.d.ts
|
|
333
|
+
type TeamPermission = "pull" | "triage" | "push" | "maintain" | "admin";
|
|
334
|
+
//#endregion
|
|
335
|
+
//#region ../../../../../src/user/user.d.ts
|
|
336
|
+
interface GitHubUser {
|
|
337
|
+
login: string;
|
|
338
|
+
name: string | null;
|
|
339
|
+
email: string | null;
|
|
340
|
+
}
|
|
341
|
+
//#endregion
|
|
342
|
+
//#region ../../../../../src/workflows/workflows.d.ts
|
|
343
|
+
interface GitHubWorkflowRun {
|
|
344
|
+
id: number;
|
|
345
|
+
name: string | null;
|
|
346
|
+
display_title: string;
|
|
347
|
+
head_branch: string;
|
|
348
|
+
head_sha: string;
|
|
349
|
+
status: string;
|
|
350
|
+
conclusion: string | null;
|
|
351
|
+
html_url: string;
|
|
352
|
+
created_at: string;
|
|
353
|
+
updated_at: string;
|
|
354
|
+
}
|
|
355
|
+
interface WorkflowRunFilter {
|
|
356
|
+
branch?: string;
|
|
357
|
+
limit?: number;
|
|
358
|
+
status?: string;
|
|
359
|
+
}
|
|
360
|
+
//#endregion
|
|
361
|
+
//#region ../../../../../src/app-auth/app-auth.d.ts
|
|
362
|
+
interface GitHubAppCredentials {
|
|
363
|
+
/** The App's numeric id (not the client id). */
|
|
364
|
+
appId: string;
|
|
365
|
+
/** PEM-encoded RSA private key — PKCS#1 (GitHub's default download format) or PKCS#8, either works. */
|
|
366
|
+
privateKey: string;
|
|
367
|
+
}
|
|
368
|
+
interface InstallationAccessToken {
|
|
369
|
+
token: string;
|
|
370
|
+
/** ISO 8601 — installation tokens are short-lived, about an hour. */
|
|
371
|
+
expiresAt: string;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Signs a JWT identifying this App (not a specific installation) — the
|
|
375
|
+
* credential used only to request an installation access token, never
|
|
376
|
+
* to call the REST API directly.
|
|
377
|
+
*/
|
|
378
|
+
declare function createAppJWT(creds: GitHubAppCredentials, now?: number): Promise<string>;
|
|
379
|
+
/**
|
|
380
|
+
* Exchanges an App JWT for an installation access token — the credential
|
|
381
|
+
* that actually authenticates REST calls, scoped to exactly one
|
|
382
|
+
* installation (D10: never a hardcoded org — the caller supplies which
|
|
383
|
+
* installation, from the webhook payload that triggered this).
|
|
384
|
+
*/
|
|
385
|
+
declare function getInstallationAccessToken(creds: GitHubAppCredentials, installationId: number, opts?: Pick<GitHubClientOptions, "baseUrl" | "fetch">): Promise<InstallationAccessToken>;
|
|
386
|
+
/** The whole flow in one call — an already-authenticated `GitHubClient` scoped to one installation. */
|
|
387
|
+
declare function createInstallationClient(creds: GitHubAppCredentials, installationId: number, opts?: Pick<GitHubClientOptions, "baseUrl" | "fetch">): Promise<GitHubClient>;
|
|
388
|
+
//#endregion
|
|
389
|
+
//#region ../../../../../src/webhooks/webhooks.d.ts
|
|
390
|
+
/**
|
|
391
|
+
* GitHub's own webhook mechanics — signature verification and header/
|
|
392
|
+
* payload shapes. Pure functions, no REST call, no auth token: these
|
|
393
|
+
* operate on an *inbound* delivery, the mirror of everything else this
|
|
394
|
+
* package does (outbound REST calls), so they're standalone exports
|
|
395
|
+
* rather than part of `createGitHubClient()`'s returned object.
|
|
396
|
+
*
|
|
397
|
+
* A consumer (a GitHub App receiving webhooks) owns what to *do* with a
|
|
398
|
+
* verified delivery — which event categories matter, how to normalize
|
|
399
|
+
* them — the same split `holocron-plugin-clerk`'s `parseWebhook` draws
|
|
400
|
+
* between Svix verification and Clerk-specific `AuthEvent` normalization,
|
|
401
|
+
* just with the verification half living here instead of inline, since
|
|
402
|
+
* "how GitHub signs and shapes a webhook" is vendor knowledge this
|
|
403
|
+
* package already owns for every other GitHub API surface.
|
|
404
|
+
*/
|
|
405
|
+
/**
|
|
406
|
+
* Verifies a GitHub webhook delivery's `X-Hub-Signature-256` header —
|
|
407
|
+
* HMAC-SHA256 over the raw body, keyed by the webhook's configured
|
|
408
|
+
* secret, `timingSafeEqual`-compared.
|
|
409
|
+
* https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries
|
|
410
|
+
*
|
|
411
|
+
* Returns `false` (never throws) for any failure to verify — a missing/
|
|
412
|
+
* empty secret, a missing/malformed signature header, or a mismatch —
|
|
413
|
+
* so the caller decides how to surface that (e.g. as its own error type).
|
|
414
|
+
*/
|
|
415
|
+
declare function verifyGitHubWebhookSignature(input: {
|
|
416
|
+
body: string | Buffer;
|
|
417
|
+
signature: string | undefined;
|
|
418
|
+
secret: string;
|
|
419
|
+
}): boolean;
|
|
420
|
+
interface GitHubWebhookHeaders {
|
|
421
|
+
/** `X-GitHub-Event` — the event category, e.g. `"push"`, `"pull_request"`. */
|
|
422
|
+
event: string | undefined;
|
|
423
|
+
/** `X-GitHub-Delivery` — GitHub's per-delivery id, useful for idempotency/logging. */
|
|
424
|
+
delivery: string | undefined;
|
|
425
|
+
/** `X-Hub-Signature-256` — pass straight to `verifyGitHubWebhookSignature`. */
|
|
426
|
+
signature: string | undefined;
|
|
427
|
+
}
|
|
428
|
+
/** Extracts GitHub's three webhook headers, case-insensitively (Node's raw headers may arrive lower-cased, or as an array when a header repeats). */
|
|
429
|
+
declare function parseGitHubWebhookHeaders(headers: Record<string, string | string[] | undefined>): GitHubWebhookHeaders;
|
|
430
|
+
interface GitHubInstallationWebhookPayload {
|
|
431
|
+
action: string;
|
|
432
|
+
installation: {
|
|
433
|
+
id: number;
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
interface GitHubPushWebhookPayload {
|
|
437
|
+
ref: string;
|
|
438
|
+
installation?: {
|
|
439
|
+
id: number;
|
|
440
|
+
};
|
|
441
|
+
repository: {
|
|
442
|
+
full_name: string;
|
|
443
|
+
default_branch: string;
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
interface GitHubPullRequestWebhookPayload {
|
|
447
|
+
action: string;
|
|
448
|
+
installation?: {
|
|
449
|
+
id: number;
|
|
450
|
+
};
|
|
451
|
+
repository: {
|
|
452
|
+
full_name: string;
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
//#endregion
|
|
456
|
+
//#region ../../../../../src/index.d.ts
|
|
457
|
+
declare function createGitHubClient(opts: GitHubClientOptions): {
|
|
458
|
+
branches: {
|
|
459
|
+
protectBranch: (repo: string, branch: string, payload: Record<string, unknown>) => Promise<void>;
|
|
460
|
+
};
|
|
461
|
+
checks: {
|
|
462
|
+
createCheckRun: (repo: string, input: CreateCheckRunInput) => Promise<GitHubCheckRun>;
|
|
463
|
+
updateCheckRun: (repo: string, checkRunId: number, input: UpdateCheckRunInput) => Promise<GitHubCheckRun>;
|
|
464
|
+
};
|
|
465
|
+
environments: {
|
|
466
|
+
listEnvironments: (repo: string) => Promise<GitHubEnvironment[]>;
|
|
467
|
+
upsertEnvironment: (repo: string, name: string, body: Record<string, unknown>) => Promise<void>;
|
|
468
|
+
deleteEnvironment: (repo: string, name: string) => Promise<void>;
|
|
469
|
+
};
|
|
470
|
+
git: {
|
|
471
|
+
getRef: (repo: string, branch: string) => Promise<GitRef>;
|
|
472
|
+
getCommit: (repo: string, sha: string) => Promise<GitCommit>;
|
|
473
|
+
getTree: (repo: string, sha: string, recursive?: boolean) => Promise<GitTree>;
|
|
474
|
+
getContents: (repo: string, path: string, ref?: string) => Promise<GitContents>;
|
|
475
|
+
createBlob: (repo: string, content: string, encoding?: string) => Promise<GitBlob>;
|
|
476
|
+
createTree: (repo: string, tree: Array<{
|
|
477
|
+
path: string;
|
|
478
|
+
mode: string;
|
|
479
|
+
type: string;
|
|
480
|
+
sha: string;
|
|
481
|
+
}>, baseTree?: string) => Promise<GitTree>;
|
|
482
|
+
createCommit: (repo: string, message: string, tree: string, parents: string[]) => Promise<GitCommit>;
|
|
483
|
+
createRef: (repo: string, ref: string, sha: string) => Promise<GitRef>;
|
|
484
|
+
updateRef: (repo: string, ref: string, sha: string, force?: boolean) => Promise<GitRef>;
|
|
485
|
+
createPull: (repo: string, input: CreatePullInput) => Promise<GitPull>;
|
|
486
|
+
};
|
|
487
|
+
issues: {
|
|
488
|
+
listIssues: (repo: string, params?: IssueSearchParams) => Promise<GitHubIssue[]>;
|
|
489
|
+
getIssue: (repo: string, number: number) => Promise<GitHubIssue>;
|
|
490
|
+
createIssue: (repo: string, body: Record<string, unknown>) => Promise<GitHubIssue>;
|
|
491
|
+
updateIssue: (repo: string, number: number, body: Record<string, unknown>) => Promise<GitHubIssue>;
|
|
492
|
+
addLabels: (repo: string, number: number, labels: string[]) => Promise<void>;
|
|
493
|
+
removeLabel: (repo: string, number: number, label: string) => Promise<void>;
|
|
494
|
+
createComment: (repo: string, number: number, body: string) => Promise<void>;
|
|
495
|
+
listMilestones: (repo: string, params?: {
|
|
496
|
+
state?: "open" | "closed" | "all";
|
|
497
|
+
}) => Promise<GitHubMilestone[]>;
|
|
498
|
+
};
|
|
499
|
+
labels: {
|
|
500
|
+
listLabels: (repo: string) => Promise<GitHubLabel[]>;
|
|
501
|
+
createLabel: (repo: string, body: {
|
|
502
|
+
name: string;
|
|
503
|
+
color: string;
|
|
504
|
+
description: string;
|
|
505
|
+
}) => Promise<GitHubLabel>;
|
|
506
|
+
updateLabel: (repo: string, name: string, body: {
|
|
507
|
+
color?: string;
|
|
508
|
+
description?: string;
|
|
509
|
+
}) => Promise<GitHubLabel>;
|
|
510
|
+
deleteLabel: (repo: string, name: string) => Promise<void>;
|
|
511
|
+
};
|
|
512
|
+
pages: {
|
|
513
|
+
getPages: (repo: string) => Promise<GitHubPages | null>;
|
|
514
|
+
createPages: (repo: string, payload: CreatePagesPayload) => Promise<GitHubPages>;
|
|
515
|
+
updatePages: (repo: string, payload: UpdatePagesPayload) => Promise<void>;
|
|
516
|
+
};
|
|
517
|
+
properties: {
|
|
518
|
+
setProperties: (repo: string, values: Record<string, string | string[]>) => Promise<void>;
|
|
519
|
+
};
|
|
520
|
+
pulls: {
|
|
521
|
+
getPullRequest: (repo: string, number: number) => Promise<GitHubPullRequest>;
|
|
522
|
+
listCommits: (repo: string, number: number) => Promise<GitHubPullRequestCommit[]>;
|
|
523
|
+
listFiles: (repo: string, number: number) => Promise<GitHubPullRequestFile[]>;
|
|
524
|
+
createReview: (repo: string, number: number, input: CreateReviewInput) => Promise<GitHubPullRequestReview>;
|
|
525
|
+
listReviewThreads: (repo: string, number: number) => Promise<ReviewThread[]>;
|
|
526
|
+
resolveReviewThread: (threadId: string) => Promise<void>;
|
|
527
|
+
};
|
|
528
|
+
repos: {
|
|
529
|
+
getRepo: (repo: string) => Promise<GitHubRepo>;
|
|
530
|
+
updateRepo: (repo: string, settings: Record<string, unknown>) => Promise<void>;
|
|
531
|
+
getContents: (repo: string, path: string) => Promise<GitHubContents>;
|
|
532
|
+
};
|
|
533
|
+
rulesets: {
|
|
534
|
+
listRulesets: (repo: string) => Promise<GitHubRuleset[]>;
|
|
535
|
+
createRuleset: (repo: string, payload: Record<string, unknown>) => Promise<GitHubRuleset>;
|
|
536
|
+
updateRuleset: (repo: string, id: number, payload: Record<string, unknown>) => Promise<GitHubRuleset>;
|
|
537
|
+
};
|
|
538
|
+
secrets: {
|
|
539
|
+
listSecrets: (repo: string, scope: SecretScope) => Promise<string[]>;
|
|
540
|
+
getPublicKey: (repo: string, scope: SecretScope) => Promise<GitHubPublicKey>;
|
|
541
|
+
putSecret: (repo: string, scope: SecretScope, name: string, body: Record<string, unknown>) => Promise<void>;
|
|
542
|
+
deleteSecret: (repo: string, scope: SecretScope, name: string) => Promise<void>;
|
|
543
|
+
};
|
|
544
|
+
security: {
|
|
545
|
+
enableVulnerabilityAlerts: (repo: string) => Promise<void>;
|
|
546
|
+
enableAutomatedSecurityFixes: (repo: string) => Promise<void>;
|
|
547
|
+
enableSecretScanning: (repo: string) => Promise<void>;
|
|
548
|
+
enablePrivateVulnerabilityReporting: (repo: string) => Promise<void>;
|
|
549
|
+
enableDependencyGraph: (repo: string) => Promise<void>;
|
|
550
|
+
enableCodeScanning: (repo: string) => Promise<CodeScanningSetupResult>;
|
|
551
|
+
disableDefaultCodeScanning: (repo: string) => Promise<void>;
|
|
552
|
+
};
|
|
553
|
+
teams: {
|
|
554
|
+
addRepo: (org: string, teamSlug: string, owner: string, repo: string, permission: TeamPermission) => Promise<void>;
|
|
555
|
+
};
|
|
556
|
+
topics: {
|
|
557
|
+
setTopics: (repo: string, names: string[]) => Promise<void>;
|
|
558
|
+
};
|
|
559
|
+
user: {
|
|
560
|
+
getCurrentUser: () => Promise<GitHubUser>;
|
|
561
|
+
};
|
|
562
|
+
workflows: {
|
|
563
|
+
listRuns: (repo: string, filter?: WorkflowRunFilter) => Promise<GitHubWorkflowRun[]>;
|
|
564
|
+
getRun: (repo: string, id: string | number) => Promise<GitHubWorkflowRun>;
|
|
565
|
+
createWorkflowDispatch: (repo: string, workflowFile: string, ref: string, inputs?: Record<string, string>) => Promise<void>;
|
|
566
|
+
};
|
|
567
|
+
};
|
|
568
|
+
type GitHubClient = ReturnType<typeof createGitHubClient>;
|
|
569
|
+
//#endregion
|
|
570
|
+
export { type CheckRunAnnotation, type CheckRunAnnotationLevel, type CheckRunConclusion, type CheckRunOutput, type CheckRunStatus, type CodeScanningSetupResult, type CreateCheckRunInput, type CreatePagesPayload, type CreatePullInput, type CreateReviewComment, type CreateReviewInput, type GitBlob, type GitCommit, type GitContents, type GitHubAppCredentials, type GitHubCheckRun, GitHubClient, type GitHubClientOptions, type GitHubContents, type GitHubEnvironment, type GitHubInstallationWebhookPayload, type GitHubIssue, type GitHubLabel, type GitHubMilestone, type GitHubPages, type GitHubPublicKey, type GitHubPullRequest, type GitHubPullRequestCommit, type GitHubPullRequestFile, type GitHubPullRequestReview, type GitHubPullRequestWebhookPayload, type GitHubPushWebhookPayload, type GitHubRepo, type GitHubRuleset, type GitHubUser, type GitHubWebhookHeaders, type GitHubWorkflowRun, type GitPull, type GitRef, type GitTree, type GitTreeItem, type InstallationAccessToken, type IssueSearchParams, type PagesBuildStatus, type PagesBuildType, type ReviewThread, type SecretScope, type TeamPermission, type UpdateCheckRunInput, type UpdatePagesPayload, type WorkflowRunFilter, createAppJWT, createGitHubClient, createInstallationClient, getInstallationAccessToken, parseGitHubWebhookHeaders, verifyGitHubWebhookSignature };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
import { ProviderApiError, createRestClient } from "@theholocron/http-client";
|
|
2
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
3
|
+
//#region ../../../../../src/graphql/graphql.ts
|
|
4
|
+
function createGitHubGraphQLClient(config) {
|
|
5
|
+
const rest = createRestClient({
|
|
6
|
+
baseUrl: config.baseUrl ?? "https://api.github.com",
|
|
7
|
+
token: config.token,
|
|
8
|
+
vendor: "GitHub GraphQL",
|
|
9
|
+
fetch: config.fetch,
|
|
10
|
+
logger: config.logger,
|
|
11
|
+
errors: config.errors
|
|
12
|
+
});
|
|
13
|
+
return { async query(document, variables) {
|
|
14
|
+
const result = await rest.request("/graphql", {
|
|
15
|
+
method: "POST",
|
|
16
|
+
body: {
|
|
17
|
+
query: document,
|
|
18
|
+
variables
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
if (result.errors && result.errors.length > 0) throw new ProviderApiError(`GitHub GraphQL → ${result.errors.map((e) => e.message).join("; ")}`, 200, result.errors);
|
|
22
|
+
return result.data;
|
|
23
|
+
} };
|
|
24
|
+
}
|
|
25
|
+
//#endregion
|
|
26
|
+
//#region ../../../../../src/utils.ts
|
|
27
|
+
function createGitHubRestClient(opts) {
|
|
28
|
+
return createRestClient({
|
|
29
|
+
baseUrl: opts.baseUrl ?? "https://api.github.com",
|
|
30
|
+
token: opts.token,
|
|
31
|
+
extraHeaders: {
|
|
32
|
+
accept: "application/vnd.github+json",
|
|
33
|
+
"x-github-api-version": "2022-11-28"
|
|
34
|
+
},
|
|
35
|
+
vendor: "GitHub",
|
|
36
|
+
fetch: opts.fetch,
|
|
37
|
+
logger: opts.logger,
|
|
38
|
+
errors: opts.errors
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/** GitHub's GraphQL endpoint — a distinct client from `createGitHubRestClient()`'s REST one; see `graphql/graphql.ts` for why a shared transport still needed its own response-error handling. */
|
|
42
|
+
function createGitHubGraphQLApiClient(opts) {
|
|
43
|
+
return createGitHubGraphQLClient({
|
|
44
|
+
baseUrl: opts.baseUrl,
|
|
45
|
+
token: opts.token,
|
|
46
|
+
fetch: opts.fetch,
|
|
47
|
+
logger: opts.logger,
|
|
48
|
+
errors: opts.errors
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/** `/repos/owner/name` prefix from a `"owner/name"` repo string. */
|
|
52
|
+
function repoBase(repo) {
|
|
53
|
+
const [owner, name] = repo.split("/", 2);
|
|
54
|
+
return `/repos/${owner}/${name}`;
|
|
55
|
+
}
|
|
56
|
+
/** `["owner", "name"]` from a `"owner/name"` repo string — GraphQL's `repository(owner:, name:)` args take them separately, unlike REST's single path segment. */
|
|
57
|
+
function splitRepo(repo) {
|
|
58
|
+
const [owner, name] = repo.split("/", 2);
|
|
59
|
+
return [owner ?? "", name ?? ""];
|
|
60
|
+
}
|
|
61
|
+
//#endregion
|
|
62
|
+
//#region ../../../../../src/branches/branches.ts
|
|
63
|
+
function branches(rest) {
|
|
64
|
+
return { protectBranch: (repo, branch, payload) => rest.request(`${repoBase(repo)}/branches/${encodeURIComponent(branch)}/protection`, {
|
|
65
|
+
method: "PUT",
|
|
66
|
+
body: payload
|
|
67
|
+
}) };
|
|
68
|
+
}
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region ../../../../../src/checks/checks.ts
|
|
71
|
+
function checks(rest) {
|
|
72
|
+
return {
|
|
73
|
+
createCheckRun: (repo, input) => rest.request(`${repoBase(repo)}/check-runs`, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
body: input
|
|
76
|
+
}),
|
|
77
|
+
/** Patches an existing check run — used to move a `"queued"` run posted before a dispatched task ran to `"completed"` once it finishes. */
|
|
78
|
+
updateCheckRun: (repo, checkRunId, input) => rest.request(`${repoBase(repo)}/check-runs/${checkRunId}`, {
|
|
79
|
+
method: "PATCH",
|
|
80
|
+
body: input
|
|
81
|
+
})
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
//#endregion
|
|
85
|
+
//#region ../../../../../src/environments/environments.ts
|
|
86
|
+
function environments(rest) {
|
|
87
|
+
return {
|
|
88
|
+
listEnvironments: (repo) => rest.request(`${repoBase(repo)}/environments`).then((r) => r.environments),
|
|
89
|
+
upsertEnvironment: (repo, name, body) => rest.request(`${repoBase(repo)}/environments/${encodeURIComponent(name)}`, {
|
|
90
|
+
method: "PUT",
|
|
91
|
+
body
|
|
92
|
+
}),
|
|
93
|
+
deleteEnvironment: (repo, name) => rest.request(`${repoBase(repo)}/environments/${encodeURIComponent(name)}`, {
|
|
94
|
+
method: "DELETE",
|
|
95
|
+
expectNoContent: true
|
|
96
|
+
})
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
//#endregion
|
|
100
|
+
//#region ../../../../../src/git/git.ts
|
|
101
|
+
function git(rest) {
|
|
102
|
+
return {
|
|
103
|
+
getRef: (repo, branch) => rest.request(`${repoBase(repo)}/git/ref/heads/${branch}`),
|
|
104
|
+
getCommit: (repo, sha) => rest.request(`${repoBase(repo)}/git/commits/${sha}`),
|
|
105
|
+
getTree: (repo, sha, recursive = false) => rest.request(`${repoBase(repo)}/git/trees/${sha}${recursive ? "?recursive=1" : ""}`),
|
|
106
|
+
/**
|
|
107
|
+
* `ref` defaults to the repo's default branch (GitHub's own default)
|
|
108
|
+
* when omitted — the security-relevant choice every existing caller
|
|
109
|
+
* relies on (D4/D6, `validateConfig()`'s own docstring). Pass it
|
|
110
|
+
* explicitly to read a specific commit/branch's content instead — a
|
|
111
|
+
* same-repo PR branch is no longer a new trust boundary for this org
|
|
112
|
+
* (D6-amended, `tech-sentinel-ci-runner.spec.md`), just never a fork's.
|
|
113
|
+
*/
|
|
114
|
+
getContents: (repo, path, ref) => rest.request(`${repoBase(repo)}/contents/${path}`, ref ? { query: { ref } } : void 0),
|
|
115
|
+
createBlob: (repo, content, encoding = "utf-8") => rest.request(`${repoBase(repo)}/git/blobs`, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
body: {
|
|
118
|
+
content,
|
|
119
|
+
encoding
|
|
120
|
+
}
|
|
121
|
+
}),
|
|
122
|
+
createTree: (repo, tree, baseTree) => rest.request(`${repoBase(repo)}/git/trees`, {
|
|
123
|
+
method: "POST",
|
|
124
|
+
body: {
|
|
125
|
+
base_tree: baseTree,
|
|
126
|
+
tree
|
|
127
|
+
}
|
|
128
|
+
}),
|
|
129
|
+
createCommit: (repo, message, tree, parents) => rest.request(`${repoBase(repo)}/git/commits`, {
|
|
130
|
+
method: "POST",
|
|
131
|
+
body: {
|
|
132
|
+
message,
|
|
133
|
+
tree,
|
|
134
|
+
parents
|
|
135
|
+
}
|
|
136
|
+
}),
|
|
137
|
+
createRef: (repo, ref, sha) => rest.request(`${repoBase(repo)}/git/refs`, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
body: {
|
|
140
|
+
ref,
|
|
141
|
+
sha
|
|
142
|
+
}
|
|
143
|
+
}),
|
|
144
|
+
updateRef: (repo, ref, sha, force = false) => rest.request(`${repoBase(repo)}/git/refs/${ref}`, {
|
|
145
|
+
method: "PATCH",
|
|
146
|
+
body: {
|
|
147
|
+
sha,
|
|
148
|
+
force
|
|
149
|
+
}
|
|
150
|
+
}),
|
|
151
|
+
createPull: (repo, input) => rest.request(`${repoBase(repo)}/pulls`, {
|
|
152
|
+
method: "POST",
|
|
153
|
+
body: input
|
|
154
|
+
})
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
//#endregion
|
|
158
|
+
//#region ../../../../../src/issues/issues.ts
|
|
159
|
+
function issues(rest) {
|
|
160
|
+
return {
|
|
161
|
+
listIssues: (repo, params = {}) => {
|
|
162
|
+
const qs = new URLSearchParams();
|
|
163
|
+
if (params.state) qs.set("state", params.state);
|
|
164
|
+
if (params.sort) qs.set("sort", params.sort);
|
|
165
|
+
if (params.direction) qs.set("direction", params.direction);
|
|
166
|
+
if (params.per_page) qs.set("per_page", String(params.per_page));
|
|
167
|
+
if (params.filter) qs.set("filter", params.filter);
|
|
168
|
+
if (params.assignee) qs.set("assignee", params.assignee);
|
|
169
|
+
const q = qs.toString();
|
|
170
|
+
const path = q ? `${repoBase(repo)}/issues?${q}` : `${repoBase(repo)}/issues`;
|
|
171
|
+
return rest.request(path);
|
|
172
|
+
},
|
|
173
|
+
getIssue: (repo, number) => rest.request(`${repoBase(repo)}/issues/${number}`),
|
|
174
|
+
createIssue: (repo, body) => rest.request(`${repoBase(repo)}/issues`, {
|
|
175
|
+
method: "POST",
|
|
176
|
+
body
|
|
177
|
+
}),
|
|
178
|
+
updateIssue: (repo, number, body) => rest.request(`${repoBase(repo)}/issues/${number}`, {
|
|
179
|
+
method: "PATCH",
|
|
180
|
+
body
|
|
181
|
+
}),
|
|
182
|
+
addLabels: (repo, number, labels) => rest.request(`${repoBase(repo)}/issues/${number}/labels`, {
|
|
183
|
+
method: "POST",
|
|
184
|
+
body: { labels }
|
|
185
|
+
}),
|
|
186
|
+
removeLabel: (repo, number, label) => rest.request(`${repoBase(repo)}/issues/${number}/labels/${encodeURIComponent(label)}`, { method: "DELETE" }),
|
|
187
|
+
createComment: (repo, number, body) => rest.request(`${repoBase(repo)}/issues/${number}/comments`, {
|
|
188
|
+
method: "POST",
|
|
189
|
+
body: { body }
|
|
190
|
+
}),
|
|
191
|
+
listMilestones: (repo, params = {}) => {
|
|
192
|
+
const qs = new URLSearchParams({ per_page: "100" });
|
|
193
|
+
if (params.state) qs.set("state", params.state);
|
|
194
|
+
return rest.request(`${repoBase(repo)}/milestones?${qs.toString()}`);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
//#endregion
|
|
199
|
+
//#region ../../../../../src/labels/labels.ts
|
|
200
|
+
function labels(rest) {
|
|
201
|
+
return {
|
|
202
|
+
listLabels: (repo) => rest.request(`${repoBase(repo)}/labels?per_page=100`),
|
|
203
|
+
createLabel: (repo, body) => rest.request(`${repoBase(repo)}/labels`, {
|
|
204
|
+
method: "POST",
|
|
205
|
+
body
|
|
206
|
+
}),
|
|
207
|
+
updateLabel: (repo, name, body) => rest.request(`${repoBase(repo)}/labels/${encodeURIComponent(name)}`, {
|
|
208
|
+
method: "PATCH",
|
|
209
|
+
body
|
|
210
|
+
}),
|
|
211
|
+
deleteLabel: (repo, name) => rest.request(`${repoBase(repo)}/labels/${encodeURIComponent(name)}`, { method: "DELETE" })
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
//#endregion
|
|
215
|
+
//#region ../../../../../src/pages/pages.ts
|
|
216
|
+
function pages(rest) {
|
|
217
|
+
return {
|
|
218
|
+
/** Returns null when Pages is not yet enabled (404). */
|
|
219
|
+
getPages: async (repo) => {
|
|
220
|
+
try {
|
|
221
|
+
return await rest.request(`${repoBase(repo)}/pages`);
|
|
222
|
+
} catch (err) {
|
|
223
|
+
if (err instanceof ProviderApiError && err.status === 404) return null;
|
|
224
|
+
throw err;
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
/**
|
|
228
|
+
* Enables Pages for the first time.
|
|
229
|
+
* Throws on all errors except 409 Conflict (already enabled — callers
|
|
230
|
+
* should catch that and fall through to updatePages).
|
|
231
|
+
*/
|
|
232
|
+
createPages: (repo, payload) => rest.request(`${repoBase(repo)}/pages`, {
|
|
233
|
+
method: "POST",
|
|
234
|
+
body: payload
|
|
235
|
+
}),
|
|
236
|
+
/**
|
|
237
|
+
* Updates Pages settings on an existing site (build type, cname, https).
|
|
238
|
+
* GitHub returns 204 No Content.
|
|
239
|
+
*/
|
|
240
|
+
updatePages: (repo, payload) => rest.request(`${repoBase(repo)}/pages`, {
|
|
241
|
+
method: "PUT",
|
|
242
|
+
body: payload
|
|
243
|
+
})
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
//#endregion
|
|
247
|
+
//#region ../../../../../src/properties/properties.ts
|
|
248
|
+
function properties(rest) {
|
|
249
|
+
return {
|
|
250
|
+
/**
|
|
251
|
+
* `value` is a plain string for `string`/`single_select`/`true_false`
|
|
252
|
+
* custom properties, or a string array for `multi_select` ones —
|
|
253
|
+
* GitHub's own PATCH body accepts either per property. A single-string
|
|
254
|
+
* comma-joined value has no length ceiling GitHub documents, but real
|
|
255
|
+
* usage hits one in practice (a `string`-type property with enough
|
|
256
|
+
* joined values 422s as "too long"); `multi_select` has none.
|
|
257
|
+
*/
|
|
258
|
+
setProperties: (repo, values) => {
|
|
259
|
+
const propertyList = Object.entries(values).map(([property_name, value]) => ({
|
|
260
|
+
property_name,
|
|
261
|
+
value
|
|
262
|
+
}));
|
|
263
|
+
return rest.request(`${repoBase(repo)}/properties/values`, {
|
|
264
|
+
method: "PATCH",
|
|
265
|
+
body: { properties: propertyList }
|
|
266
|
+
});
|
|
267
|
+
} };
|
|
268
|
+
}
|
|
269
|
+
//#endregion
|
|
270
|
+
//#region ../../../../../src/pulls/pulls.ts
|
|
271
|
+
const LIST_REVIEW_THREADS_QUERY = `
|
|
272
|
+
query($owner: String!, $name: String!, $number: Int!) {
|
|
273
|
+
repository(owner: $owner, name: $name) {
|
|
274
|
+
pullRequest(number: $number) {
|
|
275
|
+
reviewThreads(first: 100) {
|
|
276
|
+
nodes {
|
|
277
|
+
id
|
|
278
|
+
isResolved
|
|
279
|
+
path
|
|
280
|
+
line
|
|
281
|
+
comments(first: 1) {
|
|
282
|
+
nodes {
|
|
283
|
+
author { login }
|
|
284
|
+
body
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
`;
|
|
293
|
+
const RESOLVE_REVIEW_THREAD_MUTATION = `
|
|
294
|
+
mutation($threadId: ID!) {
|
|
295
|
+
resolveReviewThread(input: { threadId: $threadId }) {
|
|
296
|
+
thread { id isResolved }
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
`;
|
|
300
|
+
function pulls(rest, graphql) {
|
|
301
|
+
return {
|
|
302
|
+
getPullRequest: (repo, number) => rest.request(`${repoBase(repo)}/pulls/${number}`),
|
|
303
|
+
/**
|
|
304
|
+
* A PR's own commits, in commit order. `per_page=100` — GitHub's max —
|
|
305
|
+
* covers every real PR in this org; a PR genuinely exceeding 100
|
|
306
|
+
* commits (GitHub caps this endpoint at 250 regardless of pagination)
|
|
307
|
+
* is an edge case no consumer here needs to paginate for today.
|
|
308
|
+
*/
|
|
309
|
+
listCommits: (repo, number) => rest.request(`${repoBase(repo)}/pulls/${number}/commits?per_page=100`),
|
|
310
|
+
/**
|
|
311
|
+
* A PR's own changed files. `per_page=100` — GitHub's max — covers
|
|
312
|
+
* every real PR in this org; a PR genuinely exceeding 100 changed
|
|
313
|
+
* files (GitHub caps this endpoint at 3000 regardless of pagination)
|
|
314
|
+
* is an edge case no consumer here needs to paginate for today.
|
|
315
|
+
*/
|
|
316
|
+
listFiles: (repo, number) => rest.request(`${repoBase(repo)}/pulls/${number}/files?per_page=100`),
|
|
317
|
+
/**
|
|
318
|
+
* Submits a review in one call — GitHub has no separate "create draft,
|
|
319
|
+
* then submit" step when `event` is provided directly (the multi-step
|
|
320
|
+
* `PENDING` review flow exists for the web UI's own draft-comments
|
|
321
|
+
* experience, not needed here).
|
|
322
|
+
*/
|
|
323
|
+
createReview: (repo, number, input) => rest.request(`${repoBase(repo)}/pulls/${number}/reviews`, {
|
|
324
|
+
method: "POST",
|
|
325
|
+
body: input
|
|
326
|
+
}),
|
|
327
|
+
/** Every review thread on a PR, resolved or not — GraphQL-only, `first: 100` covers every real PR's review-thread count in this org today; no consumer needs pagination yet. */
|
|
328
|
+
listReviewThreads: async (repo, number) => {
|
|
329
|
+
const [owner, name] = splitRepo(repo);
|
|
330
|
+
return (await graphql.query(LIST_REVIEW_THREADS_QUERY, {
|
|
331
|
+
owner,
|
|
332
|
+
name,
|
|
333
|
+
number
|
|
334
|
+
})).repository.pullRequest.reviewThreads.nodes.map((node) => ({
|
|
335
|
+
id: node.id,
|
|
336
|
+
isResolved: node.isResolved,
|
|
337
|
+
path: node.path,
|
|
338
|
+
line: node.line,
|
|
339
|
+
authorLogin: node.comments.nodes[0]?.author?.login,
|
|
340
|
+
body: node.comments.nodes[0]?.body
|
|
341
|
+
}));
|
|
342
|
+
},
|
|
343
|
+
/**
|
|
344
|
+
* Marks one review thread resolved. Requires `Contents: Write` —
|
|
345
|
+
* confirmed directly against GitHub's own behavior (surprising, since
|
|
346
|
+
* resolving a thread creates no commit, touches no file, moves no
|
|
347
|
+
* ref — it only toggles a boolean on PR conversation metadata), not
|
|
348
|
+
* `Pull requests: Write` the way `createReview()` needs.
|
|
349
|
+
*/
|
|
350
|
+
resolveReviewThread: (threadId) => graphql.query(RESOLVE_REVIEW_THREAD_MUTATION, { threadId })
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
//#endregion
|
|
354
|
+
//#region ../../../../../src/repos/repos.ts
|
|
355
|
+
function repos(rest) {
|
|
356
|
+
return {
|
|
357
|
+
getRepo: (repo) => rest.request(repoBase(repo)),
|
|
358
|
+
updateRepo: (repo, settings) => rest.request(repoBase(repo), {
|
|
359
|
+
method: "PATCH",
|
|
360
|
+
body: settings
|
|
361
|
+
}),
|
|
362
|
+
getContents: (repo, path) => rest.request(`${repoBase(repo)}/contents/${path}`)
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
//#endregion
|
|
366
|
+
//#region ../../../../../src/rulesets/rulesets.ts
|
|
367
|
+
function rulesets(rest) {
|
|
368
|
+
return {
|
|
369
|
+
listRulesets: (repo) => rest.request(`${repoBase(repo)}/rulesets`),
|
|
370
|
+
createRuleset: (repo, payload) => rest.request(`${repoBase(repo)}/rulesets`, {
|
|
371
|
+
method: "POST",
|
|
372
|
+
body: payload
|
|
373
|
+
}),
|
|
374
|
+
updateRuleset: (repo, id, payload) => rest.request(`${repoBase(repo)}/rulesets/${id}`, {
|
|
375
|
+
method: "PUT",
|
|
376
|
+
body: payload
|
|
377
|
+
})
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
//#endregion
|
|
381
|
+
//#region ../../../../../src/secrets/secrets.ts
|
|
382
|
+
function scopeBase(repo, scope) {
|
|
383
|
+
switch (scope.kind) {
|
|
384
|
+
case "repo": return `${repoBase(repo)}/actions`;
|
|
385
|
+
case "environment": return `${repoBase(repo)}/environments/${scope.name}`;
|
|
386
|
+
case "organization": return `/orgs/${scope.org}/actions`;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
function secrets(rest) {
|
|
390
|
+
return {
|
|
391
|
+
listSecrets: (repo, scope) => rest.request(`${scopeBase(repo, scope)}/secrets`).then((r) => r.secrets.map((s) => s.name)),
|
|
392
|
+
getPublicKey: (repo, scope) => rest.request(`${scopeBase(repo, scope)}/secrets/public-key`),
|
|
393
|
+
putSecret: (repo, scope, name, body) => rest.request(`${scopeBase(repo, scope)}/secrets/${name}`, {
|
|
394
|
+
method: "PUT",
|
|
395
|
+
body,
|
|
396
|
+
expectNoContent: true
|
|
397
|
+
}),
|
|
398
|
+
deleteSecret: (repo, scope, name) => rest.request(`${scopeBase(repo, scope)}/secrets/${name}`, {
|
|
399
|
+
method: "DELETE",
|
|
400
|
+
expectNoContent: true
|
|
401
|
+
})
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
//#endregion
|
|
405
|
+
//#region ../../../../../src/security/security.ts
|
|
406
|
+
function security(rest) {
|
|
407
|
+
return {
|
|
408
|
+
enableVulnerabilityAlerts: (repo) => rest.request(`${repoBase(repo)}/vulnerability-alerts`, {
|
|
409
|
+
method: "PUT",
|
|
410
|
+
expectNoContent: true
|
|
411
|
+
}),
|
|
412
|
+
enableAutomatedSecurityFixes: (repo) => rest.request(`${repoBase(repo)}/automated-security-fixes`, {
|
|
413
|
+
method: "PUT",
|
|
414
|
+
expectNoContent: true
|
|
415
|
+
}),
|
|
416
|
+
enableSecretScanning: (repo) => rest.request(repoBase(repo), {
|
|
417
|
+
method: "PATCH",
|
|
418
|
+
body: { security_and_analysis: {
|
|
419
|
+
secret_scanning: { status: "enabled" },
|
|
420
|
+
secret_scanning_push_protection: { status: "enabled" },
|
|
421
|
+
secret_scanning_validity_checks: { status: "enabled" },
|
|
422
|
+
secret_scanning_non_provider_patterns: { status: "enabled" }
|
|
423
|
+
} }
|
|
424
|
+
}),
|
|
425
|
+
enablePrivateVulnerabilityReporting: (repo) => rest.request(`${repoBase(repo)}/private-vulnerability-reporting`, {
|
|
426
|
+
method: "PUT",
|
|
427
|
+
expectNoContent: true
|
|
428
|
+
}),
|
|
429
|
+
enableDependencyGraph: (repo) => rest.request(repoBase(repo), {
|
|
430
|
+
method: "PATCH",
|
|
431
|
+
body: { security_and_analysis: {
|
|
432
|
+
dependency_graph: { status: "enabled" },
|
|
433
|
+
dependency_graph_autosubmit_action: { status: "enabled" }
|
|
434
|
+
} }
|
|
435
|
+
}),
|
|
436
|
+
enableCodeScanning: (repo) => rest.request(`${repoBase(repo)}/code-scanning/default-setup`, {
|
|
437
|
+
method: "PATCH",
|
|
438
|
+
body: {
|
|
439
|
+
state: "configured",
|
|
440
|
+
query_suite: "extended",
|
|
441
|
+
threat_model: "remote_and_local"
|
|
442
|
+
}
|
|
443
|
+
}),
|
|
444
|
+
disableDefaultCodeScanning: (repo) => rest.request(`${repoBase(repo)}/code-scanning/default-setup`, {
|
|
445
|
+
method: "PATCH",
|
|
446
|
+
body: { state: "not-configured" }
|
|
447
|
+
})
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
//#endregion
|
|
451
|
+
//#region ../../../../../src/teams/teams.ts
|
|
452
|
+
function teams(rest) {
|
|
453
|
+
return { addRepo: (org, teamSlug, owner, repo, permission) => rest.request(`/orgs/${org}/teams/${teamSlug}/repos/${owner}/${repo}`, {
|
|
454
|
+
method: "PUT",
|
|
455
|
+
body: { permission }
|
|
456
|
+
}) };
|
|
457
|
+
}
|
|
458
|
+
//#endregion
|
|
459
|
+
//#region ../../../../../src/topics/topics.ts
|
|
460
|
+
function topics(rest) {
|
|
461
|
+
return { setTopics: (repo, names) => rest.request(`${repoBase(repo)}/topics`, {
|
|
462
|
+
method: "PUT",
|
|
463
|
+
body: { names }
|
|
464
|
+
}) };
|
|
465
|
+
}
|
|
466
|
+
//#endregion
|
|
467
|
+
//#region ../../../../../src/user/user.ts
|
|
468
|
+
function user(rest) {
|
|
469
|
+
return { getCurrentUser: () => rest.request("/user") };
|
|
470
|
+
}
|
|
471
|
+
//#endregion
|
|
472
|
+
//#region ../../../../../src/workflows/workflows.ts
|
|
473
|
+
function workflows(rest) {
|
|
474
|
+
return {
|
|
475
|
+
listRuns: (repo, filter) => {
|
|
476
|
+
const params = new URLSearchParams();
|
|
477
|
+
if (filter?.branch) params.set("branch", filter.branch);
|
|
478
|
+
if (filter?.limit) params.set("per_page", String(filter.limit));
|
|
479
|
+
if (filter?.status) params.set("status", filter.status);
|
|
480
|
+
const qs = params.toString();
|
|
481
|
+
const path = qs ? `${repoBase(repo)}/actions/runs?${qs}` : `${repoBase(repo)}/actions/runs`;
|
|
482
|
+
return rest.request(path).then((r) => r.workflow_runs);
|
|
483
|
+
},
|
|
484
|
+
getRun: (repo, id) => rest.request(`${repoBase(repo)}/actions/runs/${id}`),
|
|
485
|
+
/**
|
|
486
|
+
* Triggers a `workflow_dispatch` run. `workflowFile` is the workflow's
|
|
487
|
+
* filename (e.g. `"platform.dispatchedCheck.yml"`) — GitHub accepts the
|
|
488
|
+
* filename directly, no numeric workflow ID lookup needed. Fire-and-forget:
|
|
489
|
+
* the API returns `204` with no run ID, so callers needing to correlate
|
|
490
|
+
* the resulting run back to this call must pass their own correlation
|
|
491
|
+
* value through `inputs` (visible in the Actions UI/logs — never a secret).
|
|
492
|
+
*/
|
|
493
|
+
createWorkflowDispatch: (repo, workflowFile, ref, inputs) => rest.request(`${repoBase(repo)}/actions/workflows/${workflowFile}/dispatches`, {
|
|
494
|
+
method: "POST",
|
|
495
|
+
body: {
|
|
496
|
+
ref,
|
|
497
|
+
inputs
|
|
498
|
+
},
|
|
499
|
+
expectNoContent: true
|
|
500
|
+
})
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
//#endregion
|
|
504
|
+
//#region ../../../../../src/app-auth/pem.ts
|
|
505
|
+
/**
|
|
506
|
+
* PEM/DER handling for GitHub App private keys — Web Crypto only (no
|
|
507
|
+
* `node:crypto`), so this runs unchanged on Cloudflare Workers (no
|
|
508
|
+
* `nodejs_compat` needed) and in Node.
|
|
509
|
+
*
|
|
510
|
+
* GitHub hands out App private keys in PKCS#1 ("-----BEGIN RSA PRIVATE
|
|
511
|
+
* KEY-----") by default; `SubtleCrypto.importKey("pkcs8", …)` only
|
|
512
|
+
* accepts PKCS#8 ("-----BEGIN PRIVATE KEY-----"). `pkcs1ToPkcs8` wraps a
|
|
513
|
+
* PKCS#1 `RSAPrivateKey` DER in the fixed `PrivateKeyInfo` DER structure
|
|
514
|
+
* PKCS#8 requires — a fixed `AlgorithmIdentifier` prefix for
|
|
515
|
+
* `rsaEncryption` (OID 1.2.840.113549.1.1.1) around the unchanged
|
|
516
|
+
* PKCS#1 bytes. Verified against a real generated key pair: imported,
|
|
517
|
+
* signed, and checked against `node:crypto`'s `verify()` on the
|
|
518
|
+
* original key's public half.
|
|
519
|
+
*/
|
|
520
|
+
function pemToDer(pem) {
|
|
521
|
+
const base64 = pem.replace(/-----BEGIN [^-]+-----/, "").replace(/-----END [^-]+-----/, "").replace(/\s/g, "");
|
|
522
|
+
return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
|
|
523
|
+
}
|
|
524
|
+
function concat(...arrays) {
|
|
525
|
+
const total = arrays.reduce((sum, a) => sum + a.length, 0);
|
|
526
|
+
const out = new Uint8Array(total);
|
|
527
|
+
let offset = 0;
|
|
528
|
+
for (const a of arrays) {
|
|
529
|
+
out.set(a, offset);
|
|
530
|
+
offset += a.length;
|
|
531
|
+
}
|
|
532
|
+
return out;
|
|
533
|
+
}
|
|
534
|
+
function encodeDerLength(len) {
|
|
535
|
+
/* istanbul ignore next -- see comment above */
|
|
536
|
+
if (len < 128) return new Uint8Array([len]);
|
|
537
|
+
const bytes = [];
|
|
538
|
+
let n = len;
|
|
539
|
+
while (n > 0) {
|
|
540
|
+
bytes.unshift(n & 255);
|
|
541
|
+
n >>= 8;
|
|
542
|
+
}
|
|
543
|
+
return new Uint8Array([128 | bytes.length, ...bytes]);
|
|
544
|
+
}
|
|
545
|
+
function derEncode(tag, content) {
|
|
546
|
+
return concat(new Uint8Array([tag]), encodeDerLength(content.length), content);
|
|
547
|
+
}
|
|
548
|
+
/** SEQUENCE { OID rsaEncryption, NULL } — the fixed PKCS#8 AlgorithmIdentifier for RSA. */
|
|
549
|
+
const RSA_ALGORITHM_IDENTIFIER = new Uint8Array([
|
|
550
|
+
48,
|
|
551
|
+
13,
|
|
552
|
+
6,
|
|
553
|
+
9,
|
|
554
|
+
42,
|
|
555
|
+
134,
|
|
556
|
+
72,
|
|
557
|
+
134,
|
|
558
|
+
247,
|
|
559
|
+
13,
|
|
560
|
+
1,
|
|
561
|
+
1,
|
|
562
|
+
1,
|
|
563
|
+
5,
|
|
564
|
+
0
|
|
565
|
+
]);
|
|
566
|
+
function pkcs1ToPkcs8(pkcs1) {
|
|
567
|
+
const version = new Uint8Array([
|
|
568
|
+
2,
|
|
569
|
+
1,
|
|
570
|
+
0
|
|
571
|
+
]);
|
|
572
|
+
const octetString = derEncode(4, pkcs1);
|
|
573
|
+
return derEncode(48, concat(version, RSA_ALGORITHM_IDENTIFIER, octetString));
|
|
574
|
+
}
|
|
575
|
+
/** Imports a PEM-encoded RSA private key — PKCS#1 or PKCS#8, either works — for RS256 signing. */
|
|
576
|
+
async function importRsaPrivateKey(pem) {
|
|
577
|
+
const der = pemToDer(pem);
|
|
578
|
+
const pkcs8Der = (pem.includes("BEGIN RSA PRIVATE KEY") ? pkcs1ToPkcs8(der) : der).slice();
|
|
579
|
+
return crypto.subtle.importKey("pkcs8", pkcs8Der, {
|
|
580
|
+
name: "RSASSA-PKCS1-v1_5",
|
|
581
|
+
hash: "SHA-256"
|
|
582
|
+
}, false, ["sign"]);
|
|
583
|
+
}
|
|
584
|
+
/** Base64url (no padding) — the encoding both JWT segments and the signature use. */
|
|
585
|
+
function base64UrlEncode(bytes) {
|
|
586
|
+
let binary = "";
|
|
587
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
588
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
589
|
+
}
|
|
590
|
+
//#endregion
|
|
591
|
+
//#region ../../../../../src/app-auth/app-auth.ts
|
|
592
|
+
/**
|
|
593
|
+
* GitHub App authentication — a signed App JWT, exchanged for a
|
|
594
|
+
* short-lived installation access token, exchanged for a ready-to-use
|
|
595
|
+
* `GitHubClient`. Web Crypto only (see `pem.ts`), so this runs unchanged
|
|
596
|
+
* on Cloudflare Workers and in Node — no vendor-specific JWT library, no
|
|
597
|
+
* `nodejs_compat` flag.
|
|
598
|
+
*
|
|
599
|
+
* https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app
|
|
600
|
+
* https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation
|
|
601
|
+
*/
|
|
602
|
+
const CLOCK_DRIFT_BUFFER_SECONDS = 60;
|
|
603
|
+
/** GitHub rejects an App JWT with more than 10 minutes between iat and exp. */
|
|
604
|
+
const JWT_LIFETIME_SECONDS = 600;
|
|
605
|
+
/**
|
|
606
|
+
* Signs a JWT identifying this App (not a specific installation) — the
|
|
607
|
+
* credential used only to request an installation access token, never
|
|
608
|
+
* to call the REST API directly.
|
|
609
|
+
*/
|
|
610
|
+
async function createAppJWT(creds, now = Date.now()) {
|
|
611
|
+
const key = await importRsaPrivateKey(creds.privateKey);
|
|
612
|
+
const iat = Math.floor(now / 1e3) - CLOCK_DRIFT_BUFFER_SECONDS;
|
|
613
|
+
const exp = iat + JWT_LIFETIME_SECONDS;
|
|
614
|
+
const signingInput = `${base64UrlEncode(new TextEncoder().encode(JSON.stringify({
|
|
615
|
+
alg: "RS256",
|
|
616
|
+
typ: "JWT"
|
|
617
|
+
})))}.${base64UrlEncode(new TextEncoder().encode(JSON.stringify({
|
|
618
|
+
iat,
|
|
619
|
+
exp,
|
|
620
|
+
iss: creds.appId
|
|
621
|
+
})))}`;
|
|
622
|
+
const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, new TextEncoder().encode(signingInput));
|
|
623
|
+
return `${signingInput}.${base64UrlEncode(new Uint8Array(signature))}`;
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Exchanges an App JWT for an installation access token — the credential
|
|
627
|
+
* that actually authenticates REST calls, scoped to exactly one
|
|
628
|
+
* installation (D10: never a hardcoded org — the caller supplies which
|
|
629
|
+
* installation, from the webhook payload that triggered this).
|
|
630
|
+
*/
|
|
631
|
+
async function getInstallationAccessToken(creds, installationId, opts = {}) {
|
|
632
|
+
const jwt = await createAppJWT(creds);
|
|
633
|
+
const baseUrl = opts.baseUrl ?? "https://api.github.com";
|
|
634
|
+
const res = await (opts.fetch ?? globalThis.fetch)(`${baseUrl}/app/installations/${installationId}/access_tokens`, {
|
|
635
|
+
method: "POST",
|
|
636
|
+
headers: {
|
|
637
|
+
authorization: `Bearer ${jwt}`,
|
|
638
|
+
accept: "application/vnd.github+json",
|
|
639
|
+
"x-github-api-version": "2022-11-28"
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
if (!res.ok) {
|
|
643
|
+
const body = await res.text().catch(() => "");
|
|
644
|
+
throw new ProviderApiError(`GitHub POST /app/installations/${installationId}/access_tokens → ${res.status}`, res.status, body);
|
|
645
|
+
}
|
|
646
|
+
const json = await res.json();
|
|
647
|
+
return {
|
|
648
|
+
token: json.token,
|
|
649
|
+
expiresAt: json.expires_at
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
/** The whole flow in one call — an already-authenticated `GitHubClient` scoped to one installation. */
|
|
653
|
+
async function createInstallationClient(creds, installationId, opts = {}) {
|
|
654
|
+
const { token } = await getInstallationAccessToken(creds, installationId, opts);
|
|
655
|
+
return createGitHubClient({
|
|
656
|
+
token,
|
|
657
|
+
baseUrl: opts.baseUrl,
|
|
658
|
+
fetch: opts.fetch
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
//#endregion
|
|
662
|
+
//#region ../../../../../src/webhooks/webhooks.ts
|
|
663
|
+
/**
|
|
664
|
+
* GitHub's own webhook mechanics — signature verification and header/
|
|
665
|
+
* payload shapes. Pure functions, no REST call, no auth token: these
|
|
666
|
+
* operate on an *inbound* delivery, the mirror of everything else this
|
|
667
|
+
* package does (outbound REST calls), so they're standalone exports
|
|
668
|
+
* rather than part of `createGitHubClient()`'s returned object.
|
|
669
|
+
*
|
|
670
|
+
* A consumer (a GitHub App receiving webhooks) owns what to *do* with a
|
|
671
|
+
* verified delivery — which event categories matter, how to normalize
|
|
672
|
+
* them — the same split `holocron-plugin-clerk`'s `parseWebhook` draws
|
|
673
|
+
* between Svix verification and Clerk-specific `AuthEvent` normalization,
|
|
674
|
+
* just with the verification half living here instead of inline, since
|
|
675
|
+
* "how GitHub signs and shapes a webhook" is vendor knowledge this
|
|
676
|
+
* package already owns for every other GitHub API surface.
|
|
677
|
+
*/
|
|
678
|
+
/**
|
|
679
|
+
* Verifies a GitHub webhook delivery's `X-Hub-Signature-256` header —
|
|
680
|
+
* HMAC-SHA256 over the raw body, keyed by the webhook's configured
|
|
681
|
+
* secret, `timingSafeEqual`-compared.
|
|
682
|
+
* https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries
|
|
683
|
+
*
|
|
684
|
+
* Returns `false` (never throws) for any failure to verify — a missing/
|
|
685
|
+
* empty secret, a missing/malformed signature header, or a mismatch —
|
|
686
|
+
* so the caller decides how to surface that (e.g. as its own error type).
|
|
687
|
+
*/
|
|
688
|
+
function verifyGitHubWebhookSignature(input) {
|
|
689
|
+
if (!input.secret || !input.signature || !input.signature.startsWith("sha256=")) return false;
|
|
690
|
+
const bodyStr = typeof input.body === "string" ? input.body : input.body.toString("utf8");
|
|
691
|
+
const provided = Buffer.from(input.signature.slice(7), "hex");
|
|
692
|
+
const computed = createHmac("sha256", input.secret).update(bodyStr).digest();
|
|
693
|
+
return provided.length === computed.length && timingSafeEqual(provided, computed);
|
|
694
|
+
}
|
|
695
|
+
/** Extracts GitHub's three webhook headers, case-insensitively (Node's raw headers may arrive lower-cased, or as an array when a header repeats). */
|
|
696
|
+
function parseGitHubWebhookHeaders(headers) {
|
|
697
|
+
const find = (name) => {
|
|
698
|
+
const target = name.toLowerCase();
|
|
699
|
+
for (const [k, v] of Object.entries(headers)) if (k.toLowerCase() === target) return Array.isArray(v) ? v[0] : v;
|
|
700
|
+
};
|
|
701
|
+
return {
|
|
702
|
+
event: find("x-github-event"),
|
|
703
|
+
delivery: find("x-github-delivery"),
|
|
704
|
+
signature: find("x-hub-signature-256")
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
//#endregion
|
|
708
|
+
//#region ../../../../../src/index.ts
|
|
709
|
+
function createGitHubClient(opts) {
|
|
710
|
+
const rest = createGitHubRestClient(opts);
|
|
711
|
+
const graphql = createGitHubGraphQLApiClient(opts);
|
|
712
|
+
return {
|
|
713
|
+
branches: branches(rest),
|
|
714
|
+
checks: checks(rest),
|
|
715
|
+
environments: environments(rest),
|
|
716
|
+
git: git(rest),
|
|
717
|
+
issues: issues(rest),
|
|
718
|
+
labels: labels(rest),
|
|
719
|
+
pages: pages(rest),
|
|
720
|
+
properties: properties(rest),
|
|
721
|
+
pulls: pulls(rest, graphql),
|
|
722
|
+
repos: repos(rest),
|
|
723
|
+
rulesets: rulesets(rest),
|
|
724
|
+
secrets: secrets(rest),
|
|
725
|
+
security: security(rest),
|
|
726
|
+
teams: teams(rest),
|
|
727
|
+
topics: topics(rest),
|
|
728
|
+
user: user(rest),
|
|
729
|
+
workflows: workflows(rest)
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
//#endregion
|
|
733
|
+
export { createAppJWT, createGitHubClient, createInstallationClient, getInstallationAccessToken, parseGitHubWebhookHeaders, verifyGitHubWebhookSignature };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@theholocron/github-client",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.33.0",
|
|
4
4
|
"description": "A TypeScript client for the GitHub REST API",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"github",
|
|
@@ -33,13 +33,13 @@
|
|
|
33
33
|
],
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@theholocron/observability": "^0.3.0",
|
|
36
|
-
"@theholocron/http-client": "^1.
|
|
36
|
+
"@theholocron/http-client": "^1.33.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@theholocron/cli": "5.0.0-alpha.79",
|
|
40
40
|
"@theholocron/eslint-config": "^8.4.3",
|
|
41
41
|
"@theholocron/tsconfig": "^8.2.0",
|
|
42
|
-
"@theholocron/tsdown-config": "^8.
|
|
42
|
+
"@theholocron/tsdown-config": "^8.6.1",
|
|
43
43
|
"@theholocron/vitest-config": "^8.2.0",
|
|
44
44
|
"@types/node": "^26.4.1",
|
|
45
45
|
"@vitest/coverage-v8": "^5.0.0",
|