@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.
@@ -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.32.0",
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.32.0"
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.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",