@theholocron/github-client 1.32.0 → 1.32.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.
@@ -0,0 +1,557 @@
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
+ }
280
+ //#endregion
281
+ //#region ../../../../../src/repos/repos.d.ts
282
+ interface GitHubRepo {
283
+ default_branch: string;
284
+ full_name: string;
285
+ }
286
+ interface GitHubContents {
287
+ content: string;
288
+ encoding: string;
289
+ }
290
+ //#endregion
291
+ //#region ../../../../../src/rulesets/rulesets.d.ts
292
+ interface GitHubRuleset {
293
+ id: number;
294
+ name: string;
295
+ enforcement: string;
296
+ }
297
+ //#endregion
298
+ //#region ../../../../../src/secrets/secrets.d.ts
299
+ type SecretScope = {
300
+ kind: "repo";
301
+ } | {
302
+ kind: "environment";
303
+ name: string;
304
+ } | {
305
+ kind: "organization";
306
+ org: string;
307
+ };
308
+ interface GitHubPublicKey {
309
+ key_id: string;
310
+ key: string;
311
+ }
312
+ //#endregion
313
+ //#region ../../../../../src/security/security.d.ts
314
+ interface CodeScanningSetupResult {
315
+ run_id: number;
316
+ run_url: string;
317
+ }
318
+ //#endregion
319
+ //#region ../../../../../src/teams/teams.d.ts
320
+ type TeamPermission = "pull" | "triage" | "push" | "maintain" | "admin";
321
+ //#endregion
322
+ //#region ../../../../../src/user/user.d.ts
323
+ interface GitHubUser {
324
+ login: string;
325
+ name: string | null;
326
+ email: string | null;
327
+ }
328
+ //#endregion
329
+ //#region ../../../../../src/workflows/workflows.d.ts
330
+ interface GitHubWorkflowRun {
331
+ id: number;
332
+ name: string | null;
333
+ display_title: string;
334
+ head_branch: string;
335
+ head_sha: string;
336
+ status: string;
337
+ conclusion: string | null;
338
+ html_url: string;
339
+ created_at: string;
340
+ updated_at: string;
341
+ }
342
+ interface WorkflowRunFilter {
343
+ branch?: string;
344
+ limit?: number;
345
+ status?: string;
346
+ }
347
+ //#endregion
348
+ //#region ../../../../../src/app-auth/app-auth.d.ts
349
+ interface GitHubAppCredentials {
350
+ /** The App's numeric id (not the client id). */
351
+ appId: string;
352
+ /** PEM-encoded RSA private key — PKCS#1 (GitHub's default download format) or PKCS#8, either works. */
353
+ privateKey: string;
354
+ }
355
+ interface InstallationAccessToken {
356
+ token: string;
357
+ /** ISO 8601 — installation tokens are short-lived, about an hour. */
358
+ expiresAt: string;
359
+ }
360
+ /**
361
+ * Signs a JWT identifying this App (not a specific installation) — the
362
+ * credential used only to request an installation access token, never
363
+ * to call the REST API directly.
364
+ */
365
+ declare function createAppJWT(creds: GitHubAppCredentials, now?: number): Promise<string>;
366
+ /**
367
+ * Exchanges an App JWT for an installation access token — the credential
368
+ * that actually authenticates REST calls, scoped to exactly one
369
+ * installation (D10: never a hardcoded org — the caller supplies which
370
+ * installation, from the webhook payload that triggered this).
371
+ */
372
+ declare function getInstallationAccessToken(creds: GitHubAppCredentials, installationId: number, opts?: Pick<GitHubClientOptions, "baseUrl" | "fetch">): Promise<InstallationAccessToken>;
373
+ /** The whole flow in one call — an already-authenticated `GitHubClient` scoped to one installation. */
374
+ declare function createInstallationClient(creds: GitHubAppCredentials, installationId: number, opts?: Pick<GitHubClientOptions, "baseUrl" | "fetch">): Promise<GitHubClient>;
375
+ //#endregion
376
+ //#region ../../../../../src/webhooks/webhooks.d.ts
377
+ /**
378
+ * GitHub's own webhook mechanics — signature verification and header/
379
+ * payload shapes. Pure functions, no REST call, no auth token: these
380
+ * operate on an *inbound* delivery, the mirror of everything else this
381
+ * package does (outbound REST calls), so they're standalone exports
382
+ * rather than part of `createGitHubClient()`'s returned object.
383
+ *
384
+ * A consumer (a GitHub App receiving webhooks) owns what to *do* with a
385
+ * verified delivery — which event categories matter, how to normalize
386
+ * them — the same split `holocron-plugin-clerk`'s `parseWebhook` draws
387
+ * between Svix verification and Clerk-specific `AuthEvent` normalization,
388
+ * just with the verification half living here instead of inline, since
389
+ * "how GitHub signs and shapes a webhook" is vendor knowledge this
390
+ * package already owns for every other GitHub API surface.
391
+ */
392
+ /**
393
+ * Verifies a GitHub webhook delivery's `X-Hub-Signature-256` header —
394
+ * HMAC-SHA256 over the raw body, keyed by the webhook's configured
395
+ * secret, `timingSafeEqual`-compared.
396
+ * https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries
397
+ *
398
+ * Returns `false` (never throws) for any failure to verify — a missing/
399
+ * empty secret, a missing/malformed signature header, or a mismatch —
400
+ * so the caller decides how to surface that (e.g. as its own error type).
401
+ */
402
+ declare function verifyGitHubWebhookSignature(input: {
403
+ body: string | Buffer;
404
+ signature: string | undefined;
405
+ secret: string;
406
+ }): boolean;
407
+ interface GitHubWebhookHeaders {
408
+ /** `X-GitHub-Event` — the event category, e.g. `"push"`, `"pull_request"`. */
409
+ event: string | undefined;
410
+ /** `X-GitHub-Delivery` — GitHub's per-delivery id, useful for idempotency/logging. */
411
+ delivery: string | undefined;
412
+ /** `X-Hub-Signature-256` — pass straight to `verifyGitHubWebhookSignature`. */
413
+ signature: string | undefined;
414
+ }
415
+ /** Extracts GitHub's three webhook headers, case-insensitively (Node's raw headers may arrive lower-cased, or as an array when a header repeats). */
416
+ declare function parseGitHubWebhookHeaders(headers: Record<string, string | string[] | undefined>): GitHubWebhookHeaders;
417
+ interface GitHubInstallationWebhookPayload {
418
+ action: string;
419
+ installation: {
420
+ id: number;
421
+ };
422
+ }
423
+ interface GitHubPushWebhookPayload {
424
+ ref: string;
425
+ installation?: {
426
+ id: number;
427
+ };
428
+ repository: {
429
+ full_name: string;
430
+ default_branch: string;
431
+ };
432
+ }
433
+ interface GitHubPullRequestWebhookPayload {
434
+ action: string;
435
+ installation?: {
436
+ id: number;
437
+ };
438
+ repository: {
439
+ full_name: string;
440
+ };
441
+ }
442
+ //#endregion
443
+ //#region ../../../../../src/index.d.ts
444
+ declare function createGitHubClient(opts: GitHubClientOptions): {
445
+ branches: {
446
+ protectBranch: (repo: string, branch: string, payload: Record<string, unknown>) => Promise<void>;
447
+ };
448
+ checks: {
449
+ createCheckRun: (repo: string, input: CreateCheckRunInput) => Promise<GitHubCheckRun>;
450
+ updateCheckRun: (repo: string, checkRunId: number, input: UpdateCheckRunInput) => Promise<GitHubCheckRun>;
451
+ };
452
+ environments: {
453
+ listEnvironments: (repo: string) => Promise<GitHubEnvironment[]>;
454
+ upsertEnvironment: (repo: string, name: string, body: Record<string, unknown>) => Promise<void>;
455
+ deleteEnvironment: (repo: string, name: string) => Promise<void>;
456
+ };
457
+ git: {
458
+ getRef: (repo: string, branch: string) => Promise<GitRef>;
459
+ getCommit: (repo: string, sha: string) => Promise<GitCommit>;
460
+ getTree: (repo: string, sha: string, recursive?: boolean) => Promise<GitTree>;
461
+ getContents: (repo: string, path: string, ref?: string) => Promise<GitContents>;
462
+ createBlob: (repo: string, content: string, encoding?: string) => Promise<GitBlob>;
463
+ createTree: (repo: string, tree: Array<{
464
+ path: string;
465
+ mode: string;
466
+ type: string;
467
+ sha: string;
468
+ }>, baseTree?: string) => Promise<GitTree>;
469
+ createCommit: (repo: string, message: string, tree: string, parents: string[]) => Promise<GitCommit>;
470
+ createRef: (repo: string, ref: string, sha: string) => Promise<GitRef>;
471
+ updateRef: (repo: string, ref: string, sha: string, force?: boolean) => Promise<GitRef>;
472
+ createPull: (repo: string, input: CreatePullInput) => Promise<GitPull>;
473
+ };
474
+ issues: {
475
+ listIssues: (repo: string, params?: IssueSearchParams) => Promise<GitHubIssue[]>;
476
+ getIssue: (repo: string, number: number) => Promise<GitHubIssue>;
477
+ createIssue: (repo: string, body: Record<string, unknown>) => Promise<GitHubIssue>;
478
+ updateIssue: (repo: string, number: number, body: Record<string, unknown>) => Promise<GitHubIssue>;
479
+ addLabels: (repo: string, number: number, labels: string[]) => Promise<void>;
480
+ removeLabel: (repo: string, number: number, label: string) => Promise<void>;
481
+ createComment: (repo: string, number: number, body: string) => Promise<void>;
482
+ listMilestones: (repo: string, params?: {
483
+ state?: "open" | "closed" | "all";
484
+ }) => Promise<GitHubMilestone[]>;
485
+ };
486
+ labels: {
487
+ listLabels: (repo: string) => Promise<GitHubLabel[]>;
488
+ createLabel: (repo: string, body: {
489
+ name: string;
490
+ color: string;
491
+ description: string;
492
+ }) => Promise<GitHubLabel>;
493
+ updateLabel: (repo: string, name: string, body: {
494
+ color?: string;
495
+ description?: string;
496
+ }) => Promise<GitHubLabel>;
497
+ deleteLabel: (repo: string, name: string) => Promise<void>;
498
+ };
499
+ pages: {
500
+ getPages: (repo: string) => Promise<GitHubPages | null>;
501
+ createPages: (repo: string, payload: CreatePagesPayload) => Promise<GitHubPages>;
502
+ updatePages: (repo: string, payload: UpdatePagesPayload) => Promise<void>;
503
+ };
504
+ properties: {
505
+ setProperties: (repo: string, values: Record<string, string | string[]>) => Promise<void>;
506
+ };
507
+ pulls: {
508
+ getPullRequest: (repo: string, number: number) => Promise<GitHubPullRequest>;
509
+ listCommits: (repo: string, number: number) => Promise<GitHubPullRequestCommit[]>;
510
+ listFiles: (repo: string, number: number) => Promise<GitHubPullRequestFile[]>;
511
+ createReview: (repo: string, number: number, input: CreateReviewInput) => Promise<GitHubPullRequestReview>;
512
+ listReviewThreads: (repo: string, number: number) => Promise<ReviewThread[]>;
513
+ resolveReviewThread: (threadId: string) => Promise<void>;
514
+ };
515
+ repos: {
516
+ getRepo: (repo: string) => Promise<GitHubRepo>;
517
+ updateRepo: (repo: string, settings: Record<string, unknown>) => Promise<void>;
518
+ getContents: (repo: string, path: string) => Promise<GitHubContents>;
519
+ };
520
+ rulesets: {
521
+ listRulesets: (repo: string) => Promise<GitHubRuleset[]>;
522
+ createRuleset: (repo: string, payload: Record<string, unknown>) => Promise<GitHubRuleset>;
523
+ updateRuleset: (repo: string, id: number, payload: Record<string, unknown>) => Promise<GitHubRuleset>;
524
+ };
525
+ secrets: {
526
+ listSecrets: (repo: string, scope: SecretScope) => Promise<string[]>;
527
+ getPublicKey: (repo: string, scope: SecretScope) => Promise<GitHubPublicKey>;
528
+ putSecret: (repo: string, scope: SecretScope, name: string, body: Record<string, unknown>) => Promise<void>;
529
+ deleteSecret: (repo: string, scope: SecretScope, name: string) => Promise<void>;
530
+ };
531
+ security: {
532
+ enableVulnerabilityAlerts: (repo: string) => Promise<void>;
533
+ enableAutomatedSecurityFixes: (repo: string) => Promise<void>;
534
+ enableSecretScanning: (repo: string) => Promise<void>;
535
+ enablePrivateVulnerabilityReporting: (repo: string) => Promise<void>;
536
+ enableDependencyGraph: (repo: string) => Promise<void>;
537
+ enableCodeScanning: (repo: string) => Promise<CodeScanningSetupResult>;
538
+ disableDefaultCodeScanning: (repo: string) => Promise<void>;
539
+ };
540
+ teams: {
541
+ addRepo: (org: string, teamSlug: string, owner: string, repo: string, permission: TeamPermission) => Promise<void>;
542
+ };
543
+ topics: {
544
+ setTopics: (repo: string, names: string[]) => Promise<void>;
545
+ };
546
+ user: {
547
+ getCurrentUser: () => Promise<GitHubUser>;
548
+ };
549
+ workflows: {
550
+ listRuns: (repo: string, filter?: WorkflowRunFilter) => Promise<GitHubWorkflowRun[]>;
551
+ getRun: (repo: string, id: string | number) => Promise<GitHubWorkflowRun>;
552
+ createWorkflowDispatch: (repo: string, workflowFile: string, ref: string, inputs?: Record<string, string>) => Promise<void>;
553
+ };
554
+ };
555
+ type GitHubClient = ReturnType<typeof createGitHubClient>;
556
+ //#endregion
557
+ 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,727 @@
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
+ comments(first: 1) {
280
+ nodes {
281
+ author { login }
282
+ }
283
+ }
284
+ }
285
+ }
286
+ }
287
+ }
288
+ }
289
+ `;
290
+ const RESOLVE_REVIEW_THREAD_MUTATION = `
291
+ mutation($threadId: ID!) {
292
+ resolveReviewThread(input: { threadId: $threadId }) {
293
+ thread { id isResolved }
294
+ }
295
+ }
296
+ `;
297
+ function pulls(rest, graphql) {
298
+ return {
299
+ getPullRequest: (repo, number) => rest.request(`${repoBase(repo)}/pulls/${number}`),
300
+ /**
301
+ * A PR's own commits, in commit order. `per_page=100` — GitHub's max —
302
+ * covers every real PR in this org; a PR genuinely exceeding 100
303
+ * commits (GitHub caps this endpoint at 250 regardless of pagination)
304
+ * is an edge case no consumer here needs to paginate for today.
305
+ */
306
+ listCommits: (repo, number) => rest.request(`${repoBase(repo)}/pulls/${number}/commits?per_page=100`),
307
+ /**
308
+ * A PR's own changed files. `per_page=100` — GitHub's max — covers
309
+ * every real PR in this org; a PR genuinely exceeding 100 changed
310
+ * files (GitHub caps this endpoint at 3000 regardless of pagination)
311
+ * is an edge case no consumer here needs to paginate for today.
312
+ */
313
+ listFiles: (repo, number) => rest.request(`${repoBase(repo)}/pulls/${number}/files?per_page=100`),
314
+ /**
315
+ * Submits a review in one call — GitHub has no separate "create draft,
316
+ * then submit" step when `event` is provided directly (the multi-step
317
+ * `PENDING` review flow exists for the web UI's own draft-comments
318
+ * experience, not needed here).
319
+ */
320
+ createReview: (repo, number, input) => rest.request(`${repoBase(repo)}/pulls/${number}/reviews`, {
321
+ method: "POST",
322
+ body: input
323
+ }),
324
+ /** 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. */
325
+ listReviewThreads: async (repo, number) => {
326
+ const [owner, name] = splitRepo(repo);
327
+ return (await graphql.query(LIST_REVIEW_THREADS_QUERY, {
328
+ owner,
329
+ name,
330
+ number
331
+ })).repository.pullRequest.reviewThreads.nodes.map((node) => ({
332
+ id: node.id,
333
+ isResolved: node.isResolved,
334
+ authorLogin: node.comments.nodes[0]?.author?.login
335
+ }));
336
+ },
337
+ /**
338
+ * Marks one review thread resolved. Requires `Contents: Write` —
339
+ * confirmed directly against GitHub's own behavior (surprising, since
340
+ * resolving a thread creates no commit, touches no file, moves no
341
+ * ref — it only toggles a boolean on PR conversation metadata), not
342
+ * `Pull requests: Write` the way `createReview()` needs.
343
+ */
344
+ resolveReviewThread: (threadId) => graphql.query(RESOLVE_REVIEW_THREAD_MUTATION, { threadId })
345
+ };
346
+ }
347
+ //#endregion
348
+ //#region ../../../../../src/repos/repos.ts
349
+ function repos(rest) {
350
+ return {
351
+ getRepo: (repo) => rest.request(repoBase(repo)),
352
+ updateRepo: (repo, settings) => rest.request(repoBase(repo), {
353
+ method: "PATCH",
354
+ body: settings
355
+ }),
356
+ getContents: (repo, path) => rest.request(`${repoBase(repo)}/contents/${path}`)
357
+ };
358
+ }
359
+ //#endregion
360
+ //#region ../../../../../src/rulesets/rulesets.ts
361
+ function rulesets(rest) {
362
+ return {
363
+ listRulesets: (repo) => rest.request(`${repoBase(repo)}/rulesets`),
364
+ createRuleset: (repo, payload) => rest.request(`${repoBase(repo)}/rulesets`, {
365
+ method: "POST",
366
+ body: payload
367
+ }),
368
+ updateRuleset: (repo, id, payload) => rest.request(`${repoBase(repo)}/rulesets/${id}`, {
369
+ method: "PUT",
370
+ body: payload
371
+ })
372
+ };
373
+ }
374
+ //#endregion
375
+ //#region ../../../../../src/secrets/secrets.ts
376
+ function scopeBase(repo, scope) {
377
+ switch (scope.kind) {
378
+ case "repo": return `${repoBase(repo)}/actions`;
379
+ case "environment": return `${repoBase(repo)}/environments/${scope.name}`;
380
+ case "organization": return `/orgs/${scope.org}/actions`;
381
+ }
382
+ }
383
+ function secrets(rest) {
384
+ return {
385
+ listSecrets: (repo, scope) => rest.request(`${scopeBase(repo, scope)}/secrets`).then((r) => r.secrets.map((s) => s.name)),
386
+ getPublicKey: (repo, scope) => rest.request(`${scopeBase(repo, scope)}/secrets/public-key`),
387
+ putSecret: (repo, scope, name, body) => rest.request(`${scopeBase(repo, scope)}/secrets/${name}`, {
388
+ method: "PUT",
389
+ body,
390
+ expectNoContent: true
391
+ }),
392
+ deleteSecret: (repo, scope, name) => rest.request(`${scopeBase(repo, scope)}/secrets/${name}`, {
393
+ method: "DELETE",
394
+ expectNoContent: true
395
+ })
396
+ };
397
+ }
398
+ //#endregion
399
+ //#region ../../../../../src/security/security.ts
400
+ function security(rest) {
401
+ return {
402
+ enableVulnerabilityAlerts: (repo) => rest.request(`${repoBase(repo)}/vulnerability-alerts`, {
403
+ method: "PUT",
404
+ expectNoContent: true
405
+ }),
406
+ enableAutomatedSecurityFixes: (repo) => rest.request(`${repoBase(repo)}/automated-security-fixes`, {
407
+ method: "PUT",
408
+ expectNoContent: true
409
+ }),
410
+ enableSecretScanning: (repo) => rest.request(repoBase(repo), {
411
+ method: "PATCH",
412
+ body: { security_and_analysis: {
413
+ secret_scanning: { status: "enabled" },
414
+ secret_scanning_push_protection: { status: "enabled" },
415
+ secret_scanning_validity_checks: { status: "enabled" },
416
+ secret_scanning_non_provider_patterns: { status: "enabled" }
417
+ } }
418
+ }),
419
+ enablePrivateVulnerabilityReporting: (repo) => rest.request(`${repoBase(repo)}/private-vulnerability-reporting`, {
420
+ method: "PUT",
421
+ expectNoContent: true
422
+ }),
423
+ enableDependencyGraph: (repo) => rest.request(repoBase(repo), {
424
+ method: "PATCH",
425
+ body: { security_and_analysis: {
426
+ dependency_graph: { status: "enabled" },
427
+ dependency_graph_autosubmit_action: { status: "enabled" }
428
+ } }
429
+ }),
430
+ enableCodeScanning: (repo) => rest.request(`${repoBase(repo)}/code-scanning/default-setup`, {
431
+ method: "PATCH",
432
+ body: {
433
+ state: "configured",
434
+ query_suite: "extended",
435
+ threat_model: "remote_and_local"
436
+ }
437
+ }),
438
+ disableDefaultCodeScanning: (repo) => rest.request(`${repoBase(repo)}/code-scanning/default-setup`, {
439
+ method: "PATCH",
440
+ body: { state: "not-configured" }
441
+ })
442
+ };
443
+ }
444
+ //#endregion
445
+ //#region ../../../../../src/teams/teams.ts
446
+ function teams(rest) {
447
+ return { addRepo: (org, teamSlug, owner, repo, permission) => rest.request(`/orgs/${org}/teams/${teamSlug}/repos/${owner}/${repo}`, {
448
+ method: "PUT",
449
+ body: { permission }
450
+ }) };
451
+ }
452
+ //#endregion
453
+ //#region ../../../../../src/topics/topics.ts
454
+ function topics(rest) {
455
+ return { setTopics: (repo, names) => rest.request(`${repoBase(repo)}/topics`, {
456
+ method: "PUT",
457
+ body: { names }
458
+ }) };
459
+ }
460
+ //#endregion
461
+ //#region ../../../../../src/user/user.ts
462
+ function user(rest) {
463
+ return { getCurrentUser: () => rest.request("/user") };
464
+ }
465
+ //#endregion
466
+ //#region ../../../../../src/workflows/workflows.ts
467
+ function workflows(rest) {
468
+ return {
469
+ listRuns: (repo, filter) => {
470
+ const params = new URLSearchParams();
471
+ if (filter?.branch) params.set("branch", filter.branch);
472
+ if (filter?.limit) params.set("per_page", String(filter.limit));
473
+ if (filter?.status) params.set("status", filter.status);
474
+ const qs = params.toString();
475
+ const path = qs ? `${repoBase(repo)}/actions/runs?${qs}` : `${repoBase(repo)}/actions/runs`;
476
+ return rest.request(path).then((r) => r.workflow_runs);
477
+ },
478
+ getRun: (repo, id) => rest.request(`${repoBase(repo)}/actions/runs/${id}`),
479
+ /**
480
+ * Triggers a `workflow_dispatch` run. `workflowFile` is the workflow's
481
+ * filename (e.g. `"platform.dispatchedCheck.yml"`) — GitHub accepts the
482
+ * filename directly, no numeric workflow ID lookup needed. Fire-and-forget:
483
+ * the API returns `204` with no run ID, so callers needing to correlate
484
+ * the resulting run back to this call must pass their own correlation
485
+ * value through `inputs` (visible in the Actions UI/logs — never a secret).
486
+ */
487
+ createWorkflowDispatch: (repo, workflowFile, ref, inputs) => rest.request(`${repoBase(repo)}/actions/workflows/${workflowFile}/dispatches`, {
488
+ method: "POST",
489
+ body: {
490
+ ref,
491
+ inputs
492
+ },
493
+ expectNoContent: true
494
+ })
495
+ };
496
+ }
497
+ //#endregion
498
+ //#region ../../../../../src/app-auth/pem.ts
499
+ /**
500
+ * PEM/DER handling for GitHub App private keys — Web Crypto only (no
501
+ * `node:crypto`), so this runs unchanged on Cloudflare Workers (no
502
+ * `nodejs_compat` needed) and in Node.
503
+ *
504
+ * GitHub hands out App private keys in PKCS#1 ("-----BEGIN RSA PRIVATE
505
+ * KEY-----") by default; `SubtleCrypto.importKey("pkcs8", …)` only
506
+ * accepts PKCS#8 ("-----BEGIN PRIVATE KEY-----"). `pkcs1ToPkcs8` wraps a
507
+ * PKCS#1 `RSAPrivateKey` DER in the fixed `PrivateKeyInfo` DER structure
508
+ * PKCS#8 requires — a fixed `AlgorithmIdentifier` prefix for
509
+ * `rsaEncryption` (OID 1.2.840.113549.1.1.1) around the unchanged
510
+ * PKCS#1 bytes. Verified against a real generated key pair: imported,
511
+ * signed, and checked against `node:crypto`'s `verify()` on the
512
+ * original key's public half.
513
+ */
514
+ function pemToDer(pem) {
515
+ const base64 = pem.replace(/-----BEGIN [^-]+-----/, "").replace(/-----END [^-]+-----/, "").replace(/\s/g, "");
516
+ return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
517
+ }
518
+ function concat(...arrays) {
519
+ const total = arrays.reduce((sum, a) => sum + a.length, 0);
520
+ const out = new Uint8Array(total);
521
+ let offset = 0;
522
+ for (const a of arrays) {
523
+ out.set(a, offset);
524
+ offset += a.length;
525
+ }
526
+ return out;
527
+ }
528
+ function encodeDerLength(len) {
529
+ /* istanbul ignore next -- see comment above */
530
+ if (len < 128) return new Uint8Array([len]);
531
+ const bytes = [];
532
+ let n = len;
533
+ while (n > 0) {
534
+ bytes.unshift(n & 255);
535
+ n >>= 8;
536
+ }
537
+ return new Uint8Array([128 | bytes.length, ...bytes]);
538
+ }
539
+ function derEncode(tag, content) {
540
+ return concat(new Uint8Array([tag]), encodeDerLength(content.length), content);
541
+ }
542
+ /** SEQUENCE { OID rsaEncryption, NULL } — the fixed PKCS#8 AlgorithmIdentifier for RSA. */
543
+ const RSA_ALGORITHM_IDENTIFIER = new Uint8Array([
544
+ 48,
545
+ 13,
546
+ 6,
547
+ 9,
548
+ 42,
549
+ 134,
550
+ 72,
551
+ 134,
552
+ 247,
553
+ 13,
554
+ 1,
555
+ 1,
556
+ 1,
557
+ 5,
558
+ 0
559
+ ]);
560
+ function pkcs1ToPkcs8(pkcs1) {
561
+ const version = new Uint8Array([
562
+ 2,
563
+ 1,
564
+ 0
565
+ ]);
566
+ const octetString = derEncode(4, pkcs1);
567
+ return derEncode(48, concat(version, RSA_ALGORITHM_IDENTIFIER, octetString));
568
+ }
569
+ /** Imports a PEM-encoded RSA private key — PKCS#1 or PKCS#8, either works — for RS256 signing. */
570
+ async function importRsaPrivateKey(pem) {
571
+ const der = pemToDer(pem);
572
+ const pkcs8Der = (pem.includes("BEGIN RSA PRIVATE KEY") ? pkcs1ToPkcs8(der) : der).slice();
573
+ return crypto.subtle.importKey("pkcs8", pkcs8Der, {
574
+ name: "RSASSA-PKCS1-v1_5",
575
+ hash: "SHA-256"
576
+ }, false, ["sign"]);
577
+ }
578
+ /** Base64url (no padding) — the encoding both JWT segments and the signature use. */
579
+ function base64UrlEncode(bytes) {
580
+ let binary = "";
581
+ for (const byte of bytes) binary += String.fromCharCode(byte);
582
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
583
+ }
584
+ //#endregion
585
+ //#region ../../../../../src/app-auth/app-auth.ts
586
+ /**
587
+ * GitHub App authentication — a signed App JWT, exchanged for a
588
+ * short-lived installation access token, exchanged for a ready-to-use
589
+ * `GitHubClient`. Web Crypto only (see `pem.ts`), so this runs unchanged
590
+ * on Cloudflare Workers and in Node — no vendor-specific JWT library, no
591
+ * `nodejs_compat` flag.
592
+ *
593
+ * https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app
594
+ * https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation
595
+ */
596
+ const CLOCK_DRIFT_BUFFER_SECONDS = 60;
597
+ /** GitHub rejects an App JWT with more than 10 minutes between iat and exp. */
598
+ const JWT_LIFETIME_SECONDS = 600;
599
+ /**
600
+ * Signs a JWT identifying this App (not a specific installation) — the
601
+ * credential used only to request an installation access token, never
602
+ * to call the REST API directly.
603
+ */
604
+ async function createAppJWT(creds, now = Date.now()) {
605
+ const key = await importRsaPrivateKey(creds.privateKey);
606
+ const iat = Math.floor(now / 1e3) - CLOCK_DRIFT_BUFFER_SECONDS;
607
+ const exp = iat + JWT_LIFETIME_SECONDS;
608
+ const signingInput = `${base64UrlEncode(new TextEncoder().encode(JSON.stringify({
609
+ alg: "RS256",
610
+ typ: "JWT"
611
+ })))}.${base64UrlEncode(new TextEncoder().encode(JSON.stringify({
612
+ iat,
613
+ exp,
614
+ iss: creds.appId
615
+ })))}`;
616
+ const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, new TextEncoder().encode(signingInput));
617
+ return `${signingInput}.${base64UrlEncode(new Uint8Array(signature))}`;
618
+ }
619
+ /**
620
+ * Exchanges an App JWT for an installation access token — the credential
621
+ * that actually authenticates REST calls, scoped to exactly one
622
+ * installation (D10: never a hardcoded org — the caller supplies which
623
+ * installation, from the webhook payload that triggered this).
624
+ */
625
+ async function getInstallationAccessToken(creds, installationId, opts = {}) {
626
+ const jwt = await createAppJWT(creds);
627
+ const baseUrl = opts.baseUrl ?? "https://api.github.com";
628
+ const res = await (opts.fetch ?? globalThis.fetch)(`${baseUrl}/app/installations/${installationId}/access_tokens`, {
629
+ method: "POST",
630
+ headers: {
631
+ authorization: `Bearer ${jwt}`,
632
+ accept: "application/vnd.github+json",
633
+ "x-github-api-version": "2022-11-28"
634
+ }
635
+ });
636
+ if (!res.ok) {
637
+ const body = await res.text().catch(() => "");
638
+ throw new ProviderApiError(`GitHub POST /app/installations/${installationId}/access_tokens → ${res.status}`, res.status, body);
639
+ }
640
+ const json = await res.json();
641
+ return {
642
+ token: json.token,
643
+ expiresAt: json.expires_at
644
+ };
645
+ }
646
+ /** The whole flow in one call — an already-authenticated `GitHubClient` scoped to one installation. */
647
+ async function createInstallationClient(creds, installationId, opts = {}) {
648
+ const { token } = await getInstallationAccessToken(creds, installationId, opts);
649
+ return createGitHubClient({
650
+ token,
651
+ baseUrl: opts.baseUrl,
652
+ fetch: opts.fetch
653
+ });
654
+ }
655
+ //#endregion
656
+ //#region ../../../../../src/webhooks/webhooks.ts
657
+ /**
658
+ * GitHub's own webhook mechanics — signature verification and header/
659
+ * payload shapes. Pure functions, no REST call, no auth token: these
660
+ * operate on an *inbound* delivery, the mirror of everything else this
661
+ * package does (outbound REST calls), so they're standalone exports
662
+ * rather than part of `createGitHubClient()`'s returned object.
663
+ *
664
+ * A consumer (a GitHub App receiving webhooks) owns what to *do* with a
665
+ * verified delivery — which event categories matter, how to normalize
666
+ * them — the same split `holocron-plugin-clerk`'s `parseWebhook` draws
667
+ * between Svix verification and Clerk-specific `AuthEvent` normalization,
668
+ * just with the verification half living here instead of inline, since
669
+ * "how GitHub signs and shapes a webhook" is vendor knowledge this
670
+ * package already owns for every other GitHub API surface.
671
+ */
672
+ /**
673
+ * Verifies a GitHub webhook delivery's `X-Hub-Signature-256` header —
674
+ * HMAC-SHA256 over the raw body, keyed by the webhook's configured
675
+ * secret, `timingSafeEqual`-compared.
676
+ * https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries
677
+ *
678
+ * Returns `false` (never throws) for any failure to verify — a missing/
679
+ * empty secret, a missing/malformed signature header, or a mismatch —
680
+ * so the caller decides how to surface that (e.g. as its own error type).
681
+ */
682
+ function verifyGitHubWebhookSignature(input) {
683
+ if (!input.secret || !input.signature || !input.signature.startsWith("sha256=")) return false;
684
+ const bodyStr = typeof input.body === "string" ? input.body : input.body.toString("utf8");
685
+ const provided = Buffer.from(input.signature.slice(7), "hex");
686
+ const computed = createHmac("sha256", input.secret).update(bodyStr).digest();
687
+ return provided.length === computed.length && timingSafeEqual(provided, computed);
688
+ }
689
+ /** Extracts GitHub's three webhook headers, case-insensitively (Node's raw headers may arrive lower-cased, or as an array when a header repeats). */
690
+ function parseGitHubWebhookHeaders(headers) {
691
+ const find = (name) => {
692
+ const target = name.toLowerCase();
693
+ for (const [k, v] of Object.entries(headers)) if (k.toLowerCase() === target) return Array.isArray(v) ? v[0] : v;
694
+ };
695
+ return {
696
+ event: find("x-github-event"),
697
+ delivery: find("x-github-delivery"),
698
+ signature: find("x-hub-signature-256")
699
+ };
700
+ }
701
+ //#endregion
702
+ //#region ../../../../../src/index.ts
703
+ function createGitHubClient(opts) {
704
+ const rest = createGitHubRestClient(opts);
705
+ const graphql = createGitHubGraphQLApiClient(opts);
706
+ return {
707
+ branches: branches(rest),
708
+ checks: checks(rest),
709
+ environments: environments(rest),
710
+ git: git(rest),
711
+ issues: issues(rest),
712
+ labels: labels(rest),
713
+ pages: pages(rest),
714
+ properties: properties(rest),
715
+ pulls: pulls(rest, graphql),
716
+ repos: repos(rest),
717
+ rulesets: rulesets(rest),
718
+ secrets: secrets(rest),
719
+ security: security(rest),
720
+ teams: teams(rest),
721
+ topics: topics(rest),
722
+ user: user(rest),
723
+ workflows: workflows(rest)
724
+ };
725
+ }
726
+ //#endregion
727
+ 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.32.0",
3
+ "version": "1.32.1",
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.32.0"
36
+ "@theholocron/http-client": "^1.32.1"
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.5.2",
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",