@sentry/junior-github 0.68.0 → 0.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -1,8 +1,111 @@
1
+ import { createPrivateKey, createSign } from "node:crypto";
1
2
  import { defineJuniorPlugin } from "@sentry/junior-plugin-api";
3
+ import {
4
+ normalizePermissions,
5
+ permissionCapabilities,
6
+ readGrantPermissions,
7
+ } from "./permissions.js";
8
+
9
+ const GITHUB_APP_ID_ENV = "GITHUB_APP_ID";
10
+ const GITHUB_APP_PRIVATE_KEY_ENV = "GITHUB_APP_PRIVATE_KEY";
11
+ const GITHUB_INSTALLATION_ID_ENV = "GITHUB_INSTALLATION_ID";
12
+ const GITHUB_AUTH_TOKEN_ENV = "GITHUB_TOKEN";
13
+ const GITHUB_AUTH_TOKEN_PLACEHOLDER = "ghp_host_managed_credential";
14
+ const MAX_LEASE_MS = 60 * 60 * 1000;
15
+ const REFRESH_BUFFER_MS = 5 * 60 * 1000;
16
+ const HTTP_READ_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
17
+ const USER_TOKEN_GRANTS = new Set(["user-read", "user-write"]);
18
+ const CONTENTS_WRITE_REQUIREMENTS = [
19
+ "GitHub App Contents: write on the target repository",
20
+ "requesting GitHub user write access to the repository",
21
+ ];
22
+ const WORKFLOWS_WRITE_REQUIREMENTS = [
23
+ "GitHub App Contents: write and Workflows: write on the target repository",
24
+ "requesting GitHub user write access to the repository",
25
+ ];
26
+ const ISSUES_WRITE_REQUIREMENTS = [
27
+ "GitHub App Issues: write on the target repository",
28
+ "requesting GitHub user issue access to the repository",
29
+ ];
30
+ const PULL_REQUESTS_WRITE_REQUIREMENTS = [
31
+ "GitHub App Pull requests: write on the target repository",
32
+ "requesting GitHub user write access to the repository",
33
+ ];
34
+ const FORK_CREATE_REQUIREMENTS = [
35
+ "GitHub App Administration: write and Contents: read",
36
+ "app installation access on the source and destination accounts",
37
+ "requesting GitHub user permission to fork the repository",
38
+ ];
39
+
40
+ class GitHubUserRefreshRejectedError extends Error {
41
+ constructor(message) {
42
+ super(message);
43
+ this.name = "GitHubUserRefreshRejectedError";
44
+ }
45
+ }
46
+
47
+ class GitHubRequestError extends Error {
48
+ constructor(message, status) {
49
+ super(message);
50
+ this.name = "GitHubRequestError";
51
+ this.status = status;
52
+ }
53
+ }
54
+
55
+ class GitHubPluginSetupError extends Error {
56
+ constructor(message) {
57
+ super(message);
58
+ this.name = "GitHubPluginSetupError";
59
+ }
60
+ }
61
+
62
+ function isRecord(value) {
63
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
64
+ }
2
65
 
3
66
  function readEnv(name) {
4
67
  const value = process.env[name];
5
- return typeof value === "string" && value ? value : undefined;
68
+ if (typeof value !== "string") {
69
+ return undefined;
70
+ }
71
+ const trimmed = value.trim();
72
+ return trimmed ? trimmed : undefined;
73
+ }
74
+
75
+ function requireEnv(name) {
76
+ const value = readEnv(name);
77
+ if (!value) {
78
+ throw new GitHubPluginSetupError(`Missing ${name}`);
79
+ }
80
+ return value;
81
+ }
82
+
83
+ function normalizeScopeList(scopes) {
84
+ return [
85
+ ...new Set(
86
+ (scopes ?? [])
87
+ .flatMap((scope) => String(scope).split(/\s+/))
88
+ .map((scope) => scope.trim())
89
+ .filter(Boolean),
90
+ ),
91
+ ].sort();
92
+ }
93
+
94
+ function normalizeOAuthScope(scope) {
95
+ const normalized = normalizeScopeList(scope ? [scope] : []);
96
+ return normalized.length ? normalized.join(" ") : undefined;
97
+ }
98
+
99
+ function hasRequiredOAuthScope(storedScope, requiredScope) {
100
+ const required = normalizeScopeList(requiredScope ? [requiredScope] : []);
101
+ if (required.length === 0) {
102
+ return true;
103
+ }
104
+ const stored = new Set(normalizeScopeList(storedScope ? [storedScope] : []));
105
+ if (stored.size === 0) {
106
+ return false;
107
+ }
108
+ return required.every((scope) => stored.has(scope));
6
109
  }
7
110
 
8
111
  function cleanIdentityPart(value) {
@@ -58,21 +161,21 @@ if [ -z "$message_file" ]; then
58
161
  fi
59
162
 
60
163
  if [ -z "\${JUNIOR_GIT_AUTHOR_NAME:-}" ] || [ -z "\${JUNIOR_GIT_AUTHOR_EMAIL:-}" ]; then
61
- echo "Junior GitHub plugin internal error: bot commit attribution was not injected by the host runtime. Do not set Git author env vars manually; report this configuration error." >&2
164
+ echo "Junior GitHub plugin internal error: requester commit attribution was not injected by the host runtime. Do not set Git author env vars manually; report this configuration error." >&2
62
165
  exit 1
63
166
  fi
64
167
 
65
168
  if [ "\${GIT_AUTHOR_NAME:-}" != "$JUNIOR_GIT_AUTHOR_NAME" ] || [ "\${GIT_AUTHOR_EMAIL:-}" != "$JUNIOR_GIT_AUTHOR_EMAIL" ]; then
66
- echo "Junior GitHub plugin internal error: Git author was not set to the configured bot identity. Do not override Git author manually; report this configuration error." >&2
169
+ echo "Junior GitHub plugin internal error: Git author was not set to the resolved requester identity. Do not override Git author manually; report this configuration error." >&2
67
170
  exit 1
68
171
  fi
69
172
 
70
173
  if [ -z "\${JUNIOR_GIT_COAUTHOR_NAME:-}" ] || [ -z "\${JUNIOR_GIT_COAUTHOR_EMAIL:-}" ]; then
71
- echo "Junior GitHub plugin internal error: requester coauthor identity was not injected by the host runtime. Do not set coauthor env vars manually; report this configuration error." >&2
174
+ echo "Junior GitHub plugin internal error: Junior coauthor identity was not injected by the host runtime. Do not set coauthor env vars manually; report this configuration error." >&2
72
175
  exit 1
73
176
  fi
74
177
 
75
- trailer="Co-authored-by: $JUNIOR_GIT_COAUTHOR_NAME <$JUNIOR_GIT_COAUTHOR_EMAIL>"
178
+ trailer="Co-Authored-By: $JUNIOR_GIT_COAUTHOR_NAME <$JUNIOR_GIT_COAUTHOR_EMAIL>"
76
179
  if grep -Fqx "$trailer" "$message_file"; then
77
180
  exit 0
78
181
  fi
@@ -93,10 +196,677 @@ async function configureGit(ctx, key, value) {
93
196
  }
94
197
  }
95
198
 
96
- /** Register trusted GitHub runtime hooks for commit attribution and package loading. */
199
+ function base64Url(input) {
200
+ return Buffer.from(input)
201
+ .toString("base64")
202
+ .replace(/=/g, "")
203
+ .replace(/\+/g, "-")
204
+ .replace(/\//g, "_");
205
+ }
206
+
207
+ function getPrivateKey(envName) {
208
+ const raw = requireEnv(envName);
209
+ let key;
210
+ try {
211
+ key = createPrivateKey({ key: raw, format: "pem" });
212
+ } catch {
213
+ throw new GitHubPluginSetupError(
214
+ `Invalid ${envName}: expected a PEM-encoded RSA private key`,
215
+ );
216
+ }
217
+
218
+ if (key.asymmetricKeyType !== "rsa") {
219
+ throw new GitHubPluginSetupError(
220
+ `Invalid ${envName}: GitHub App signing requires an RSA private key`,
221
+ );
222
+ }
223
+ return key;
224
+ }
225
+
226
+ function createAppJwt(appId, privateKeyEnv) {
227
+ const now = Math.floor(Date.now() / 1000);
228
+ const header = { alg: "RS256", typ: "JWT" };
229
+ const payload = { iat: now - 60, exp: now + 9 * 60, iss: appId };
230
+ const encodedHeader = base64Url(JSON.stringify(header));
231
+ const encodedPayload = base64Url(JSON.stringify(payload));
232
+ const signingInput = `${encodedHeader}.${encodedPayload}`;
233
+ const signer = createSign("RSA-SHA256");
234
+ signer.update(signingInput);
235
+ signer.end();
236
+ const signature = signer
237
+ .sign(getPrivateKey(privateKeyEnv))
238
+ .toString("base64")
239
+ .replace(/=/g, "")
240
+ .replace(/\+/g, "-")
241
+ .replace(/\//g, "_");
242
+ return `${signingInput}.${signature}`;
243
+ }
244
+
245
+ async function githubRequest(apiBase, path, params) {
246
+ const response = await fetch(`${apiBase}${path}`, {
247
+ method: params.method ?? "GET",
248
+ headers: {
249
+ Accept: "application/vnd.github+json",
250
+ Authorization: `Bearer ${params.token}`,
251
+ "X-GitHub-Api-Version": "2022-11-28",
252
+ ...(params.body ? { "Content-Type": "application/json" } : {}),
253
+ },
254
+ ...(params.body ? { body: JSON.stringify(params.body) } : {}),
255
+ });
256
+
257
+ const text = await response.text();
258
+ let parsed;
259
+ if (text) {
260
+ try {
261
+ parsed = JSON.parse(text);
262
+ } catch {
263
+ parsed = undefined;
264
+ }
265
+ }
266
+
267
+ if (!response.ok) {
268
+ const message =
269
+ parsed && typeof parsed === "object" && typeof parsed.message === "string"
270
+ ? parsed.message
271
+ : `GitHub API error ${response.status}`;
272
+ throw new GitHubRequestError(message, response.status);
273
+ }
274
+ return parsed;
275
+ }
276
+
277
+ function buildOAuthTokenRequest(input) {
278
+ const payload = {
279
+ ...input.payload,
280
+ client_id: input.clientId,
281
+ client_secret: input.clientSecret,
282
+ };
283
+ return {
284
+ headers: {
285
+ Accept: "application/json",
286
+ "Content-Type": "application/x-www-form-urlencoded",
287
+ },
288
+ body: new URLSearchParams(payload),
289
+ };
290
+ }
291
+
292
+ function parseOAuthError(text) {
293
+ if (!text.trim()) {
294
+ return undefined;
295
+ }
296
+ try {
297
+ const parsed = JSON.parse(text);
298
+ return isRecord(parsed) && typeof parsed.error === "string"
299
+ ? parsed.error
300
+ : undefined;
301
+ } catch {
302
+ return undefined;
303
+ }
304
+ }
305
+
306
+ function parseOAuthTokenResponse(data, requestedScope) {
307
+ if (!isRecord(data)) {
308
+ throw new Error("OAuth token response is invalid");
309
+ }
310
+ if (typeof data.access_token !== "string" || !data.access_token.trim()) {
311
+ throw new Error("OAuth token response missing access_token");
312
+ }
313
+ if (typeof data.refresh_token !== "string" || !data.refresh_token.trim()) {
314
+ throw new Error("OAuth token response missing refresh_token");
315
+ }
316
+ let scope = normalizeOAuthScope(requestedScope);
317
+ if (data.scope !== undefined) {
318
+ if (typeof data.scope !== "string") {
319
+ throw new Error("OAuth token response returned invalid scope");
320
+ }
321
+ scope = normalizeOAuthScope(data.scope) ?? scope;
322
+ }
323
+ const result = {
324
+ accessToken: data.access_token,
325
+ refreshToken: data.refresh_token,
326
+ ...(scope ? { scope } : {}),
327
+ };
328
+ if (data.expires_in === undefined) {
329
+ return result;
330
+ }
331
+ if (
332
+ typeof data.expires_in !== "number" ||
333
+ !Number.isFinite(data.expires_in) ||
334
+ data.expires_in <= 0
335
+ ) {
336
+ throw new Error("OAuth token response returned invalid expires_in");
337
+ }
338
+ return {
339
+ ...result,
340
+ expiresAt: Date.now() + data.expires_in * 1000,
341
+ };
342
+ }
343
+
344
+ async function refreshUserAccessToken(input) {
345
+ const clientId = requireEnv(input.clientIdEnv);
346
+ const clientSecret = requireEnv(input.clientSecretEnv);
347
+ const request = buildOAuthTokenRequest({
348
+ clientId,
349
+ clientSecret,
350
+ payload: {
351
+ grant_type: "refresh_token",
352
+ refresh_token: input.refreshToken,
353
+ },
354
+ });
355
+ const response = await fetch("https://github.com/login/oauth/access_token", {
356
+ method: "POST",
357
+ headers: request.headers,
358
+ body: request.body,
359
+ });
360
+ if (!response.ok) {
361
+ const errorCode = parseOAuthError(await response.text());
362
+ if (errorCode === "bad_refresh_token" || errorCode === "invalid_grant") {
363
+ throw new GitHubUserRefreshRejectedError(
364
+ `GitHub user token refresh rejected: ${errorCode}`,
365
+ );
366
+ }
367
+ throw new Error(
368
+ `GitHub user token refresh failed: ${response.status}${errorCode ? ` ${errorCode}` : ""}`,
369
+ );
370
+ }
371
+ return parseOAuthTokenResponse(await response.json(), input.requestedScope);
372
+ }
373
+
374
+ function leaseExpiry(expiresAt) {
375
+ return expiresAt
376
+ ? Math.min(expiresAt, Date.now() + MAX_LEASE_MS)
377
+ : Date.now() + MAX_LEASE_MS;
378
+ }
379
+
380
+ function isGitSmartHttpDomain(domain) {
381
+ return domain.toLowerCase() === "github.com";
382
+ }
383
+
384
+ function authorizationFor(domain, token) {
385
+ if (isGitSmartHttpDomain(domain)) {
386
+ return `Basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`;
387
+ }
388
+ return `Bearer ${token}`;
389
+ }
390
+
391
+ function createCredentialLease(input) {
392
+ return {
393
+ type: "lease",
394
+ lease: {
395
+ ...(input.account ? { account: input.account } : {}),
396
+ ...(input.authorization ? { authorization: input.authorization } : {}),
397
+ expiresAt: new Date(input.expiresAtMs).toISOString(),
398
+ headerTransforms: ["api.github.com", "github.com"].map((domain) => ({
399
+ domain,
400
+ headers: {
401
+ Authorization: authorizationFor(domain, input.token),
402
+ },
403
+ })),
404
+ },
405
+ };
406
+ }
407
+
408
+ function githubUserAuthorization(scope) {
409
+ return {
410
+ type: "oauth",
411
+ provider: "github",
412
+ ...(scope ? { scope } : {}),
413
+ };
414
+ }
415
+
416
+ function credentialNeeded(message, scope, allowAuthorization = true) {
417
+ return {
418
+ type: "needed",
419
+ message,
420
+ ...(allowAuthorization
421
+ ? { authorization: githubUserAuthorization(scope) }
422
+ : {}),
423
+ };
424
+ }
425
+
426
+ function credentialUnavailable(message) {
427
+ return {
428
+ type: "unavailable",
429
+ message,
430
+ };
431
+ }
432
+
433
+ function parseInstallationTokenResponse(data) {
434
+ if (!isRecord(data)) {
435
+ throw new Error("GitHub installation token response is invalid");
436
+ }
437
+ const token = data.token;
438
+ if (typeof token !== "string" || !token.trim()) {
439
+ throw new Error("GitHub installation token response missing token");
440
+ }
441
+ const expiresAt = data.expires_at;
442
+ const expiresAtMs =
443
+ typeof expiresAt === "string" ? Date.parse(expiresAt) : Number.NaN;
444
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
445
+ throw new Error(
446
+ "GitHub installation token response returned invalid expires_at",
447
+ );
448
+ }
449
+ return { token, expiresAtMs };
450
+ }
451
+
452
+ function readInstallationPermissions(installation) {
453
+ if (!isRecord(installation) || !isRecord(installation.permissions)) {
454
+ throw new Error("GitHub installation response missing permissions");
455
+ }
456
+ return readGrantPermissions(installation.permissions);
457
+ }
458
+
459
+ async function resolveUserAccount(tokens) {
460
+ const account = await githubRequest("https://api.github.com", "/user", {
461
+ token: tokens.accessToken,
462
+ });
463
+ if (!isRecord(account)) {
464
+ throw new Error("GitHub user response is invalid");
465
+ }
466
+ const id = account.id;
467
+ const login = account.login;
468
+ if (
469
+ (typeof id !== "number" && typeof id !== "string") ||
470
+ typeof login !== "string" ||
471
+ !login.trim()
472
+ ) {
473
+ throw new Error("GitHub user response missing id or login");
474
+ }
475
+ const url =
476
+ typeof account.html_url === "string" ? account.html_url : undefined;
477
+ return {
478
+ id: String(id),
479
+ label: login.trim(),
480
+ ...(url ? { url } : {}),
481
+ };
482
+ }
483
+
484
+ async function tokensWithAccount(tokenSlot, stored, scope) {
485
+ if (stored.account) {
486
+ return { ok: true, tokens: stored };
487
+ }
488
+ let account;
489
+ try {
490
+ account = await resolveUserAccount(stored);
491
+ } catch (error) {
492
+ if (
493
+ error instanceof GitHubRequestError &&
494
+ (error.status === 401 || error.status === 403)
495
+ ) {
496
+ return {
497
+ ok: false,
498
+ result: credentialNeeded(
499
+ "Your GitHub authorization needs to be refreshed.",
500
+ scope,
501
+ ),
502
+ };
503
+ }
504
+ throw error;
505
+ }
506
+ const updated = { ...stored, account };
507
+ await tokenSlot.set(updated);
508
+ return { ok: true, tokens: updated };
509
+ }
510
+
511
+ async function issueUserCredential(ctx, options) {
512
+ const scope = options.userScope;
513
+ const tokenSlot = ctx.tokens.currentUser ?? ctx.tokens.credentialSubject;
514
+ if (!tokenSlot) {
515
+ return credentialNeeded(
516
+ "GitHub write access requires a current user or delegated user credential subject.",
517
+ scope,
518
+ false,
519
+ );
520
+ }
521
+
522
+ const stored = await tokenSlot.get();
523
+ if (!stored) {
524
+ return credentialNeeded(
525
+ "GitHub write access requires user authorization.",
526
+ scope,
527
+ );
528
+ }
529
+ if (!hasRequiredOAuthScope(stored.scope, scope)) {
530
+ return credentialNeeded(
531
+ "Your GitHub authorization needs to be refreshed.",
532
+ scope,
533
+ );
534
+ }
535
+
536
+ const now = Date.now();
537
+ if (
538
+ stored.expiresAt !== undefined &&
539
+ stored.expiresAt - now < REFRESH_BUFFER_MS
540
+ ) {
541
+ let refreshed;
542
+ try {
543
+ refreshed = await refreshUserAccessToken({
544
+ clientIdEnv: options.clientIdEnv,
545
+ clientSecretEnv: options.clientSecretEnv,
546
+ refreshToken: stored.refreshToken,
547
+ requestedScope: stored.scope ?? scope,
548
+ });
549
+ } catch (error) {
550
+ if (!(error instanceof GitHubUserRefreshRejectedError)) {
551
+ throw error;
552
+ }
553
+ return credentialNeeded("Your GitHub authorization has expired.", scope);
554
+ }
555
+ if (!hasRequiredOAuthScope(refreshed.scope, scope)) {
556
+ return credentialNeeded(
557
+ "Your GitHub authorization needs to be refreshed.",
558
+ scope,
559
+ );
560
+ }
561
+ const refreshedTokens = {
562
+ ...refreshed,
563
+ ...(stored.account ? { account: stored.account } : {}),
564
+ };
565
+ await tokenSlot.set(refreshedTokens);
566
+ const withAccount = await tokensWithAccount(
567
+ tokenSlot,
568
+ refreshedTokens,
569
+ scope,
570
+ );
571
+ if (!withAccount.ok) {
572
+ return withAccount.result;
573
+ }
574
+ return createCredentialLease({
575
+ account: withAccount.tokens.account,
576
+ token: withAccount.tokens.accessToken,
577
+ expiresAtMs: leaseExpiry(withAccount.tokens.expiresAt),
578
+ authorization: githubUserAuthorization(scope),
579
+ });
580
+ }
581
+
582
+ if (stored.expiresAt === undefined || stored.expiresAt > Date.now()) {
583
+ const withAccount = await tokensWithAccount(tokenSlot, stored, scope);
584
+ if (!withAccount.ok) {
585
+ return withAccount.result;
586
+ }
587
+ return createCredentialLease({
588
+ account: withAccount.tokens.account,
589
+ token: withAccount.tokens.accessToken,
590
+ expiresAtMs: leaseExpiry(withAccount.tokens.expiresAt),
591
+ authorization: githubUserAuthorization(scope),
592
+ });
593
+ }
594
+
595
+ return credentialNeeded("Your GitHub authorization has expired.", scope);
596
+ }
597
+
598
+ async function issueInstallationCredential(options) {
599
+ const appId = requireEnv(options.appIdEnv);
600
+ const installationIdRaw = requireEnv(options.installationIdEnv);
601
+ const installationId = Number(installationIdRaw);
602
+ if (!Number.isSafeInteger(installationId) || installationId <= 0) {
603
+ throw new GitHubPluginSetupError(`Invalid ${options.installationIdEnv}`);
604
+ }
605
+
606
+ const appJwt = createAppJwt(appId, options.privateKeyEnv);
607
+ let tokenPermissions = options.readPermissions;
608
+ if (!tokenPermissions) {
609
+ tokenPermissions = await options.loadReadPermissions({
610
+ appJwt,
611
+ installationId,
612
+ });
613
+ }
614
+
615
+ const accessTokenResponse = await githubRequest(
616
+ "https://api.github.com",
617
+ `/app/installations/${installationId}/access_tokens`,
618
+ {
619
+ method: "POST",
620
+ token: appJwt,
621
+ body: { permissions: tokenPermissions },
622
+ },
623
+ );
624
+ const parsedToken = parseInstallationTokenResponse(accessTokenResponse);
625
+ const expiresAtMs = Math.min(
626
+ parsedToken.expiresAtMs,
627
+ Date.now() + MAX_LEASE_MS,
628
+ );
629
+ return createCredentialLease({
630
+ token: parsedToken.token,
631
+ expiresAtMs,
632
+ });
633
+ }
634
+
635
+ function createPermissionCache() {
636
+ let cached;
637
+ let pending;
638
+ return async ({ appJwt, installationId }) => {
639
+ if (cached && cached.expiresAtMs > Date.now()) {
640
+ return cached.permissions;
641
+ }
642
+ pending ??= githubRequest(
643
+ "https://api.github.com",
644
+ `/app/installations/${installationId}`,
645
+ { token: appJwt },
646
+ )
647
+ .then((installation) => {
648
+ const permissions = readInstallationPermissions(installation);
649
+ cached = {
650
+ expiresAtMs: Date.now() + MAX_LEASE_MS,
651
+ permissions,
652
+ };
653
+ return permissions;
654
+ })
655
+ .finally(() => {
656
+ pending = undefined;
657
+ });
658
+ return await pending;
659
+ };
660
+ }
661
+
662
+ function githubSmartHttpAccess(upstreamUrl) {
663
+ const pathname = upstreamUrl.pathname.toLowerCase();
664
+ const service = upstreamUrl.searchParams.get("service")?.toLowerCase();
665
+ const isSmartHttpPath =
666
+ pathname.endsWith("/info/refs") ||
667
+ pathname.endsWith("/git-receive-pack") ||
668
+ pathname.endsWith("/git-upload-pack");
669
+ if (!isSmartHttpPath) {
670
+ return undefined;
671
+ }
672
+ if (
673
+ pathname.endsWith("/git-receive-pack") ||
674
+ service === "git-receive-pack"
675
+ ) {
676
+ return "write";
677
+ }
678
+ if (pathname.endsWith("/git-upload-pack") || service === "git-upload-pack") {
679
+ return "read";
680
+ }
681
+ return undefined;
682
+ }
683
+
684
+ function isGitHubGraphqlUrl(upstreamUrl) {
685
+ return (
686
+ upstreamUrl.hostname.toLowerCase() === "api.github.com" &&
687
+ upstreamUrl.pathname.toLowerCase().endsWith("/graphql")
688
+ );
689
+ }
690
+
691
+ function isGitHubApiUrl(upstreamUrl) {
692
+ return upstreamUrl.hostname.toLowerCase() === "api.github.com";
693
+ }
694
+
695
+ function githubUserReadReason(method, upstreamUrl) {
696
+ if (method !== "GET" || !isGitHubApiUrl(upstreamUrl)) {
697
+ return undefined;
698
+ }
699
+ return upstreamUrl.pathname.toLowerCase() === "/user"
700
+ ? "github.user-read"
701
+ : undefined;
702
+ }
703
+
704
+ function githubGraphqlAccess(method, upstreamUrl) {
705
+ if (!isGitHubGraphqlUrl(upstreamUrl)) {
706
+ return undefined;
707
+ }
708
+ if (HTTP_READ_METHODS.has(method)) {
709
+ return "read";
710
+ }
711
+ // GitHub GraphQL POST bodies can be read queries or write mutations. The
712
+ // egress hook intentionally does not read sandbox request bodies, so non-read
713
+ // methods require user-write attribution rather than risking an unattributed
714
+ // mutation through an installation-read token.
715
+ return "write";
716
+ }
717
+
718
+ function githubApiWriteReason(method, upstreamUrl) {
719
+ const pathname = upstreamUrl.pathname.toLowerCase();
720
+ if (!isGitHubApiUrl(upstreamUrl)) {
721
+ return undefined;
722
+ }
723
+ if (method === "POST" && /^\/repos\/[^/]+\/[^/]+\/issues$/.test(pathname)) {
724
+ return "github.issue-create";
725
+ }
726
+ if (
727
+ method === "POST" &&
728
+ /^\/repos\/[^/]+\/[^/]+\/issues\/[^/]+\/comments$/.test(pathname)
729
+ ) {
730
+ return "github.issues-write";
731
+ }
732
+ if (method === "POST" && /^\/repos\/[^/]+\/[^/]+\/pulls$/.test(pathname)) {
733
+ return "github.pull-create";
734
+ }
735
+ if (
736
+ method === "PATCH" &&
737
+ /^\/repos\/[^/]+\/[^/]+\/pulls\/[^/]+$/.test(pathname)
738
+ ) {
739
+ return "github.pull-requests-write";
740
+ }
741
+ if (method === "POST" && /^\/repos\/[^/]+\/[^/]+\/forks$/.test(pathname)) {
742
+ return "github.fork-create";
743
+ }
744
+ if (
745
+ /^\/repos\/[^/]+\/[^/]+\/contents(?:\/|$)/.test(pathname) &&
746
+ (method === "PUT" || method === "DELETE")
747
+ ) {
748
+ return pathname.includes("/.github/workflows/")
749
+ ? "github.workflows-write"
750
+ : "github.contents-write";
751
+ }
752
+ if (
753
+ method === "POST" &&
754
+ /^\/repos\/[^/]+\/[^/]+\/git\/(blobs|trees|commits)$/.test(pathname)
755
+ ) {
756
+ return "github.contents-write";
757
+ }
758
+ if (
759
+ method === "POST" &&
760
+ /^\/repos\/[^/]+\/[^/]+\/git\/refs$/.test(pathname)
761
+ ) {
762
+ return "github.contents-write";
763
+ }
764
+ if (
765
+ (method === "PATCH" || method === "DELETE") &&
766
+ /^\/repos\/[^/]+\/[^/]+\/git\/refs\/.+/.test(pathname)
767
+ ) {
768
+ return "github.contents-write";
769
+ }
770
+ if (
771
+ method === "PUT" &&
772
+ /^\/repos\/[^/]+\/[^/]+\/pulls\/[^/]+\/merge$/.test(pathname)
773
+ ) {
774
+ return "github.contents-write";
775
+ }
776
+ return undefined;
777
+ }
778
+
779
+ function grantRequirements(reason) {
780
+ if (reason === "github.git-write" || reason === "github.contents-write") {
781
+ return CONTENTS_WRITE_REQUIREMENTS;
782
+ }
783
+ if (reason === "github.workflows-write") {
784
+ return WORKFLOWS_WRITE_REQUIREMENTS;
785
+ }
786
+ if (reason === "github.issue-create" || reason === "github.issues-write") {
787
+ return ISSUES_WRITE_REQUIREMENTS;
788
+ }
789
+ if (
790
+ reason === "github.pull-create" ||
791
+ reason === "github.pull-requests-write"
792
+ ) {
793
+ return PULL_REQUESTS_WRITE_REQUIREMENTS;
794
+ }
795
+ if (reason === "github.fork-create") {
796
+ return FORK_CREATE_REQUIREMENTS;
797
+ }
798
+ return undefined;
799
+ }
800
+
801
+ function grantForAccess(access, reason, name) {
802
+ const requirements = grantRequirements(reason);
803
+ return {
804
+ name,
805
+ access,
806
+ reason,
807
+ ...(requirements ? { requirements } : {}),
808
+ };
809
+ }
810
+
811
+ async function githubGrantForEgress(ctx) {
812
+ const method = ctx.request.method.toUpperCase();
813
+ const upstreamUrl = new URL(ctx.request.url);
814
+ const smartHttpAccess = githubSmartHttpAccess(upstreamUrl);
815
+ if (smartHttpAccess) {
816
+ return grantForAccess(
817
+ smartHttpAccess,
818
+ smartHttpAccess === "write" ? "github.git-write" : "github.git-read",
819
+ smartHttpAccess === "write" ? "user-write" : "installation-read",
820
+ );
821
+ }
822
+
823
+ const userReadReason = githubUserReadReason(method, upstreamUrl);
824
+ if (userReadReason) {
825
+ return grantForAccess("read", userReadReason, "user-read");
826
+ }
827
+
828
+ const writeReason = githubApiWriteReason(method, upstreamUrl);
829
+ if (writeReason) {
830
+ return grantForAccess("write", writeReason, "user-write");
831
+ }
832
+
833
+ const graphqlAccess = githubGraphqlAccess(method, upstreamUrl);
834
+ if (graphqlAccess) {
835
+ return grantForAccess(
836
+ graphqlAccess,
837
+ graphqlAccess === "write"
838
+ ? "github.graphql-write"
839
+ : "github.graphql-read",
840
+ graphqlAccess === "write" ? "user-write" : "installation-read",
841
+ );
842
+ }
843
+
844
+ const access = HTTP_READ_METHODS.has(method) ? "read" : "write";
845
+ return grantForAccess(
846
+ access,
847
+ access === "write" ? "github.api-write" : "github.api-read",
848
+ access === "write" ? "user-write" : "installation-read",
849
+ );
850
+ }
851
+
852
+ /** Register GitHub runtime hooks for repository workflows. */
97
853
  export function githubPlugin(options = {}) {
98
854
  const botNameEnv = options.botNameEnv ?? "GITHUB_APP_BOT_NAME";
99
855
  const botEmailEnv = options.botEmailEnv ?? "GITHUB_APP_BOT_EMAIL";
856
+ const clientIdEnv = options.clientIdEnv ?? "GITHUB_APP_CLIENT_ID";
857
+ const clientSecretEnv = options.clientSecretEnv ?? "GITHUB_APP_CLIENT_SECRET";
858
+ const appIdEnv = options.appIdEnv ?? GITHUB_APP_ID_ENV;
859
+ const privateKeyEnv = options.privateKeyEnv ?? GITHUB_APP_PRIVATE_KEY_ENV;
860
+ const installationIdEnv =
861
+ options.installationIdEnv ?? GITHUB_INSTALLATION_ID_ENV;
862
+ const appPermissions = normalizePermissions(options.appPermissions);
863
+ const appReadPermissions = appPermissions
864
+ ? readGrantPermissions(appPermissions)
865
+ : undefined;
866
+ const loadReadPermissions = createPermissionCache();
867
+ const appCapabilities = permissionCapabilities(appPermissions);
868
+ const userScopes = normalizeScopeList(options.additionalUserScopes);
869
+ const userScope = userScopes.length ? userScopes.join(" ") : undefined;
100
870
 
101
871
  return defineJuniorPlugin({
102
872
  packageName: "@sentry/junior-github",
@@ -104,25 +874,32 @@ export function githubPlugin(options = {}) {
104
874
  name: "github",
105
875
  description:
106
876
  "GitHub issue, pull request, and repository workflows via GitHub App",
877
+ ...(appCapabilities ? { capabilities: appCapabilities } : {}),
107
878
  configKeys: ["org", "repo"],
879
+ domains: ["api.github.com", "github.com"],
108
880
  envVars: {
109
- GITHUB_APP_BOT_NAME: { exposeToCommandEnv: true },
110
- GITHUB_APP_BOT_EMAIL: { exposeToCommandEnv: true },
881
+ [appIdEnv]: {},
882
+ [privateKeyEnv]: {},
883
+ [installationIdEnv]: {},
884
+ [clientIdEnv]: {},
885
+ [clientSecretEnv]: {},
886
+ [botNameEnv]: { exposeToCommandEnv: true },
887
+ [botEmailEnv]: { exposeToCommandEnv: true },
111
888
  },
112
- credentials: {
113
- type: "github-app",
114
- domains: ["api.github.com", "github.com"],
115
- authTokenEnv: "GITHUB_TOKEN",
116
- authTokenPlaceholder: "ghp_host_managed_credential",
117
- appIdEnv: "GITHUB_APP_ID",
118
- privateKeyEnv: "GITHUB_APP_PRIVATE_KEY",
119
- installationIdEnv: "GITHUB_INSTALLATION_ID",
889
+ oauth: {
890
+ clientIdEnv,
891
+ clientSecretEnv,
892
+ authorizeEndpoint: "https://github.com/login/oauth/authorize",
893
+ tokenEndpoint: "https://github.com/login/oauth/access_token",
894
+ // GitHub App user-to-server tokens always return scope: "" regardless
895
+ // of what was requested; treat empty response scope as unreported.
896
+ treatEmptyScopeAsUnreported: true,
897
+ ...(userScope ? { scope: userScope } : {}),
120
898
  },
121
899
  commandEnv: {
122
- GIT_AUTHOR_NAME: "${GITHUB_APP_BOT_NAME}",
123
- GIT_AUTHOR_EMAIL: "${GITHUB_APP_BOT_EMAIL}",
124
- GIT_COMMITTER_NAME: "${GITHUB_APP_BOT_NAME}",
125
- GIT_COMMITTER_EMAIL: "${GITHUB_APP_BOT_EMAIL}",
900
+ [GITHUB_AUTH_TOKEN_ENV]: GITHUB_AUTH_TOKEN_PLACEHOLDER,
901
+ GIT_COMMITTER_NAME: `\${${botNameEnv}}`,
902
+ GIT_COMMITTER_EMAIL: `\${${botEmailEnv}}`,
126
903
  },
127
904
  target: {
128
905
  type: "repo",
@@ -170,24 +947,58 @@ export function githubPlugin(options = {}) {
170
947
  if (!botName || !botEmail) {
171
948
  return;
172
949
  }
173
- const coauthorName = requesterName(ctx.requester);
174
- const coauthorEmail = requesterEmail(ctx.requester);
175
- if ((!coauthorName || !coauthorEmail) && isGitCommitCommand(command)) {
950
+ const authorName = requesterName(ctx.requester);
951
+ const authorEmail = requesterEmail(ctx.requester);
952
+ if ((!authorName || !authorEmail) && isGitCommitCommand(command)) {
176
953
  ctx.decision.deny(
177
- "Junior GitHub plugin could not determine a resolved requester name and email for commit attribution. This is an internal request-context error; do not set coauthor env vars manually.",
954
+ "Junior GitHub plugin could not determine a resolved requester name and email for commit attribution. This is an internal request-context error; do not set author env vars manually.",
178
955
  );
179
956
  return;
180
957
  }
181
- ctx.env.set("GIT_AUTHOR_NAME", botName);
182
- ctx.env.set("GIT_AUTHOR_EMAIL", botEmail);
958
+ if (authorName && authorEmail) {
959
+ ctx.env.set("GIT_AUTHOR_NAME", authorName);
960
+ ctx.env.set("GIT_AUTHOR_EMAIL", authorEmail);
961
+ ctx.env.set("JUNIOR_GIT_AUTHOR_NAME", authorName);
962
+ ctx.env.set("JUNIOR_GIT_AUTHOR_EMAIL", authorEmail);
963
+ }
183
964
  ctx.env.set("GIT_COMMITTER_NAME", botName);
184
965
  ctx.env.set("GIT_COMMITTER_EMAIL", botEmail);
185
- ctx.env.set("JUNIOR_GIT_AUTHOR_NAME", botName);
186
- ctx.env.set("JUNIOR_GIT_AUTHOR_EMAIL", botEmail);
187
- if (coauthorName && coauthorEmail) {
188
- ctx.env.set("JUNIOR_GIT_COAUTHOR_NAME", coauthorName);
189
- ctx.env.set("JUNIOR_GIT_COAUTHOR_EMAIL", coauthorEmail);
966
+ ctx.env.set("JUNIOR_GIT_COAUTHOR_NAME", botName);
967
+ ctx.env.set("JUNIOR_GIT_COAUTHOR_EMAIL", botEmail);
968
+ },
969
+ grantForEgress(ctx) {
970
+ return githubGrantForEgress(ctx);
971
+ },
972
+ async resolveOAuthAccount(ctx) {
973
+ return await resolveUserAccount(ctx.tokens);
974
+ },
975
+ async issueCredential(ctx) {
976
+ try {
977
+ if (ctx.grant.name === "installation-read") {
978
+ return await issueInstallationCredential({
979
+ appIdEnv,
980
+ privateKeyEnv,
981
+ installationIdEnv,
982
+ readPermissions: appReadPermissions,
983
+ loadReadPermissions,
984
+ });
985
+ }
986
+ if (USER_TOKEN_GRANTS.has(ctx.grant.name)) {
987
+ return await issueUserCredential(ctx, {
988
+ clientIdEnv,
989
+ clientSecretEnv,
990
+ userScope,
991
+ });
992
+ }
993
+ } catch (error) {
994
+ if (error instanceof GitHubPluginSetupError) {
995
+ return credentialUnavailable(error.message);
996
+ }
997
+ throw error;
190
998
  }
999
+ throw new Error(
1000
+ `GitHub plugin cannot issue unknown grant "${ctx.grant.name}".`,
1001
+ );
191
1002
  },
192
1003
  },
193
1004
  });