@sentry/junior-github 0.181.1 → 0.183.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,728 +1,56 @@
1
1
  import {
2
+ CREATE_TOOL_ROUTING_GUIDANCE,
3
+ GITHUB_APP_ID_ENV,
4
+ GITHUB_APP_PRIVATE_KEY_ENV,
5
+ GITHUB_AUTH_TOKEN_ENV,
6
+ GITHUB_AUTH_TOKEN_PLACEHOLDER,
2
7
  GITHUB_DEPLOYMENT_EVENTS,
3
8
  GITHUB_DEPLOYMENT_SUGGESTED_EVENTS,
9
+ GITHUB_GRAPHQL_RESPONSE_BODY_LIMIT_BYTES,
10
+ GITHUB_INSTALLATION_ID_ENV,
4
11
  GITHUB_ISSUE_EVENTS,
5
12
  GITHUB_ISSUE_SUGGESTED_EVENTS,
6
13
  GITHUB_PULL_REQUEST_EVENTS,
14
+ GITHUB_PULL_REQUEST_MATCH_FIELDS,
7
15
  GITHUB_PULL_REQUEST_SUGGESTED_EVENTS,
8
16
  GITHUB_RELEASE_EVENTS,
9
17
  GITHUB_RELEASE_SUGGESTED_EVENTS,
18
+ GitHubPluginSetupError,
19
+ HTTP_READ_METHODS,
20
+ USER_TOKEN_GRANTS,
21
+ USER_WRITE_REQUIREMENTS,
22
+ createPermissionCache,
23
+ credentialUnavailable,
10
24
  gitHubDeploymentSourceSubscribable,
11
25
  gitHubIssueSubscribable,
12
26
  gitHubPullRequestSubscribable,
13
27
  gitHubReleaseSourceSubscribable,
14
28
  gitHubRepositorySubscribable,
29
+ githubRepositoryFromLeaseScope,
30
+ githubRepositoryFromUrl,
31
+ githubRepositoryLeaseScope,
32
+ githubRequest,
33
+ isRecord,
34
+ issueInstallationCredential,
35
+ issueInstallationToken,
36
+ issueUserCredential,
37
+ loadCheckSuiteFacts,
38
+ needsCheckSuitePullRequestFacts,
15
39
  normalizeGitHubResourceEvents,
16
- parseCheckSuiteEnrichmentTarget,
17
- selectFailingChecks
18
- } from "./chunk-GXZVNEIB.js";
40
+ normalizePermissions,
41
+ normalizeScopeList,
42
+ parseCheckSuitePublishTargets,
43
+ readEnv,
44
+ readGrantPermissions,
45
+ requireEnv,
46
+ resolveUserAccount
47
+ } from "./chunk-KXAUXJ3Q.js";
19
48
 
20
49
  // src/plugin.ts
21
50
  import {
22
- defineJuniorPlugin,
23
- EgressPolicyDenied as EgressPolicyDenied2,
24
- enforceEgressPolicy
51
+ defineJuniorPlugin
25
52
  } from "@sentry/junior-plugin-api";
26
53
 
27
- // src/permissions.ts
28
- var LEVELS = /* @__PURE__ */ new Set(["read", "write", "admin"]);
29
- var WRITE_ONLY_PERMISSIONS = /* @__PURE__ */ new Set(["profile", "workflows"]);
30
- function isLevel(value) {
31
- return LEVELS.has(value);
32
- }
33
- function normalizeScope(rawScope) {
34
- return String(rawScope).trim().replace(/-/g, "_");
35
- }
36
- function normalizePermissions(permissions) {
37
- if (permissions === void 0) {
38
- return void 0;
39
- }
40
- const entries = Object.entries(permissions);
41
- if (entries.length === 0) {
42
- throw new Error(
43
- "githubPlugin appPermissions must contain at least one permission when provided."
44
- );
45
- }
46
- const request = {};
47
- for (const [rawScope, rawLevel] of entries) {
48
- const normalizedScope = normalizeScope(rawScope);
49
- if (!normalizedScope) {
50
- throw new Error(
51
- "githubPlugin appPermissions contains an empty permission name."
52
- );
53
- }
54
- if (!/^[a-z][a-z0-9_]*$/.test(normalizedScope)) {
55
- throw new Error(
56
- `githubPlugin appPermissions contains invalid permission "${rawScope}".`
57
- );
58
- }
59
- if (!isLevel(rawLevel)) {
60
- throw new Error(
61
- `githubPlugin appPermissions.${rawScope} must be "read", "write", or "admin".`
62
- );
63
- }
64
- request[normalizedScope] = rawLevel;
65
- }
66
- return request;
67
- }
68
- function readGrantPermissions(permissions) {
69
- const readOnly = { metadata: "read" };
70
- for (const [scope, level] of Object.entries(permissions ?? {})) {
71
- if (!isLevel(level)) {
72
- throw new Error(
73
- `GitHub permission "${scope}" returned invalid level "${String(level)}".`
74
- );
75
- }
76
- if (!WRITE_ONLY_PERMISSIONS.has(scope)) {
77
- readOnly[scope] = "read";
78
- }
79
- }
80
- return readOnly;
81
- }
82
-
83
- // src/pull-request-review-policy.ts
84
- import { EgressPolicyDenied } from "@sentry/junior-plugin-api";
85
-
86
- // src/credential-support.ts
87
- import { createPrivateKey, createSign } from "crypto";
88
- var GITHUB_APP_ID_ENV = "GITHUB_APP_ID";
89
- var GITHUB_APP_PRIVATE_KEY_ENV = "GITHUB_APP_PRIVATE_KEY";
90
- var GITHUB_INSTALLATION_ID_ENV = "GITHUB_INSTALLATION_ID";
91
- var GITHUB_AUTH_TOKEN_ENV = "GITHUB_TOKEN";
92
- var GITHUB_AUTH_TOKEN_PLACEHOLDER = "ghp_host_managed_credential";
93
- var MAX_LEASE_MS = 60 * 60 * 1e3;
94
- var REFRESH_BUFFER_MS = 5 * 60 * 1e3;
95
- var USER_REFRESH_TIMEOUT_MS = 2e4;
96
- var GITHUB_GRAPHQL_RESPONSE_BODY_LIMIT_BYTES = 64 * 1024;
97
- var HTTP_READ_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
98
- var USER_TOKEN_GRANTS = /* @__PURE__ */ new Set(["user-read", "user-write"]);
99
- var CREATE_TOOL_ROUTING_GUIDANCE = "This is a Junior tool-routing denial, not a GitHub permission failure. Do not ask the user for GitHub permissions; retry with the required Junior tool.";
100
- var USER_WRITE_REQUIREMENTS = [
101
- "requesting GitHub user permission to perform this operation"
102
- ];
103
- var GITHUB_CREDENTIAL_DOMAINS = ["api.github.com", "github.com"];
104
- var GITHUB_ASSET_UPLOAD_CREDENTIAL_DOMAINS = [
105
- ...GITHUB_CREDENTIAL_DOMAINS,
106
- "uploads.github.com"
107
- ];
108
- var GitHubUserRefreshRejectedError = class extends Error {
109
- constructor(message) {
110
- super(message);
111
- this.name = "GitHubUserRefreshRejectedError";
112
- }
113
- };
114
- var GitHubRequestError = class extends Error {
115
- status;
116
- constructor(message, status) {
117
- super(message);
118
- this.name = "GitHubRequestError";
119
- this.status = status;
120
- }
121
- };
122
- var GitHubPluginSetupError = class extends Error {
123
- constructor(message) {
124
- super(message);
125
- this.name = "GitHubPluginSetupError";
126
- }
127
- };
128
- function isRecord(value) {
129
- return Boolean(value && typeof value === "object" && !Array.isArray(value));
130
- }
131
- function readEnv(name) {
132
- const value = process.env[name];
133
- if (typeof value !== "string") {
134
- return void 0;
135
- }
136
- const trimmed = value.trim();
137
- return trimmed ? trimmed : void 0;
138
- }
139
- function requireEnv(name) {
140
- const value = readEnv(name);
141
- if (!value) {
142
- throw new GitHubPluginSetupError(`Missing ${name}`);
143
- }
144
- return value;
145
- }
146
- function normalizeScopeList(scopes) {
147
- return [
148
- ...new Set(
149
- (scopes ?? []).flatMap((scope) => String(scope).split(/\s+/)).map((scope) => scope.trim()).filter(Boolean)
150
- )
151
- ].sort();
152
- }
153
- function normalizeOAuthScope(scope) {
154
- const normalized = normalizeScopeList(scope ? [scope] : []);
155
- return normalized.length ? normalized.join(" ") : void 0;
156
- }
157
- function hasRequiredOAuthScope(storedScope, requiredScope) {
158
- const required = normalizeScopeList(requiredScope ? [requiredScope] : []);
159
- if (required.length === 0) {
160
- return true;
161
- }
162
- const stored = new Set(normalizeScopeList(storedScope ? [storedScope] : []));
163
- if (stored.size === 0) {
164
- return false;
165
- }
166
- return required.every((scope) => stored.has(scope));
167
- }
168
- function isGitHubApiUrl(upstreamUrl) {
169
- return upstreamUrl.hostname.toLowerCase() === "api.github.com";
170
- }
171
- function base64Url(input) {
172
- return Buffer.from(input).toString("base64").replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
173
- }
174
- function getPrivateKey(envName) {
175
- const raw = requireEnv(envName);
176
- let key;
177
- try {
178
- key = createPrivateKey({ key: raw, format: "pem" });
179
- } catch {
180
- throw new GitHubPluginSetupError(
181
- `Invalid ${envName}: expected a PEM-encoded RSA private key`
182
- );
183
- }
184
- if (key.asymmetricKeyType !== "rsa") {
185
- throw new GitHubPluginSetupError(
186
- `Invalid ${envName}: GitHub App signing requires an RSA private key`
187
- );
188
- }
189
- return key;
190
- }
191
- function createAppJwt(appId, privateKeyEnv) {
192
- const now = Math.floor(Date.now() / 1e3);
193
- const header = { alg: "RS256", typ: "JWT" };
194
- const payload = { iat: now - 60, exp: now + 9 * 60, iss: appId };
195
- const encodedHeader = base64Url(JSON.stringify(header));
196
- const encodedPayload = base64Url(JSON.stringify(payload));
197
- const signingInput = `${encodedHeader}.${encodedPayload}`;
198
- const signer = createSign("RSA-SHA256");
199
- signer.update(signingInput);
200
- signer.end();
201
- const signature = signer.sign(getPrivateKey(privateKeyEnv)).toString("base64").replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
202
- return `${signingInput}.${signature}`;
203
- }
204
- async function githubRequest(apiBase, path, params) {
205
- const response = await fetch(`${apiBase}${path}`, {
206
- method: params.method ?? "GET",
207
- headers: {
208
- Accept: "application/vnd.github+json",
209
- Authorization: `Bearer ${params.token}`,
210
- "X-GitHub-Api-Version": "2022-11-28",
211
- ...params.body ? { "Content-Type": "application/json" } : void 0
212
- },
213
- ...params.body ? { body: JSON.stringify(params.body) } : void 0
214
- });
215
- const text2 = await response.text();
216
- let parsed;
217
- if (text2) {
218
- try {
219
- parsed = JSON.parse(text2);
220
- } catch {
221
- parsed = void 0;
222
- }
223
- }
224
- if (!response.ok) {
225
- const message = isRecord(parsed) && typeof parsed.message === "string" ? parsed.message : `GitHub API error ${response.status}`;
226
- throw new GitHubRequestError(message, response.status);
227
- }
228
- return parsed;
229
- }
230
- function buildOAuthTokenRequest(input) {
231
- const payload = {
232
- ...input.payload,
233
- client_id: input.clientId,
234
- client_secret: input.clientSecret
235
- };
236
- return {
237
- headers: {
238
- Accept: "application/json",
239
- "Content-Type": "application/x-www-form-urlencoded"
240
- },
241
- body: new URLSearchParams(payload)
242
- };
243
- }
244
- function parseOAuthResponseJson(responseText) {
245
- if (!responseText.trim()) {
246
- return void 0;
247
- }
248
- try {
249
- return JSON.parse(responseText);
250
- } catch {
251
- return void 0;
252
- }
253
- }
254
- function oauthErrorCode(data) {
255
- return isRecord(data) && typeof data.error === "string" ? data.error : void 0;
256
- }
257
- function isRejectedRefreshError(errorCode) {
258
- return errorCode === "bad_refresh_token" || errorCode === "invalid_grant";
259
- }
260
- function parseOAuthTokenResponse(data, requestedScope) {
261
- if (!isRecord(data)) {
262
- throw new Error("OAuth token response is invalid");
263
- }
264
- if (typeof data.access_token !== "string" || !data.access_token.trim()) {
265
- throw new Error("OAuth token response missing access_token");
266
- }
267
- if (typeof data.refresh_token !== "string" || !data.refresh_token.trim()) {
268
- throw new Error("OAuth token response missing refresh_token");
269
- }
270
- let scope = normalizeOAuthScope(requestedScope);
271
- if (data.scope !== void 0) {
272
- if (typeof data.scope !== "string") {
273
- throw new Error("OAuth token response returned invalid scope");
274
- }
275
- scope = normalizeOAuthScope(data.scope) ?? scope;
276
- }
277
- const result = {
278
- accessToken: data.access_token,
279
- refreshToken: data.refresh_token,
280
- ...scope ? { scope } : void 0
281
- };
282
- if (data.expires_in !== void 0) {
283
- if (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0) {
284
- throw new Error("OAuth token response returned invalid expires_in");
285
- }
286
- result.expiresAt = Date.now() + data.expires_in * 1e3;
287
- }
288
- if (data.refresh_token_expires_in !== void 0) {
289
- if (typeof data.refresh_token_expires_in !== "number" || !Number.isFinite(data.refresh_token_expires_in) || data.refresh_token_expires_in <= 0) {
290
- throw new Error(
291
- "OAuth token response returned invalid refresh_token_expires_in"
292
- );
293
- }
294
- result.refreshTokenExpiresAt = Date.now() + data.refresh_token_expires_in * 1e3;
295
- }
296
- return result;
297
- }
298
- async function refreshUserAccessToken(input) {
299
- const clientId = requireEnv(input.clientIdEnv);
300
- const clientSecret = requireEnv(input.clientSecretEnv);
301
- const request = buildOAuthTokenRequest({
302
- clientId,
303
- clientSecret,
304
- payload: {
305
- grant_type: "refresh_token",
306
- refresh_token: input.refreshToken
307
- }
308
- });
309
- const response = await fetch("https://github.com/login/oauth/access_token", {
310
- method: "POST",
311
- headers: request.headers,
312
- body: request.body,
313
- signal: AbortSignal.timeout(USER_REFRESH_TIMEOUT_MS)
314
- });
315
- const responseText = await response.text();
316
- const responseData = parseOAuthResponseJson(responseText);
317
- const errorCode = oauthErrorCode(responseData);
318
- if (isRejectedRefreshError(errorCode)) {
319
- throw new GitHubUserRefreshRejectedError(
320
- `GitHub user token refresh rejected: ${errorCode}`
321
- );
322
- }
323
- if (!response.ok || errorCode) {
324
- throw new Error(
325
- `GitHub user token refresh failed: ${response.status}${errorCode ? ` ${errorCode}` : ""}`
326
- );
327
- }
328
- try {
329
- return parseOAuthTokenResponse(responseData, input.requestedScope);
330
- } catch (error) {
331
- if (error instanceof Error && error.message === "OAuth token response missing access_token") {
332
- throw new GitHubUserRefreshRejectedError(error.message);
333
- }
334
- throw error;
335
- }
336
- }
337
- function leaseExpiry(expiresAt) {
338
- return expiresAt ? Math.min(expiresAt, Date.now() + MAX_LEASE_MS) : Date.now() + MAX_LEASE_MS;
339
- }
340
- function isGitSmartHttpDomain(domain) {
341
- return domain.toLowerCase() === "github.com";
342
- }
343
- function authorizationFor(domain, token) {
344
- if (isGitSmartHttpDomain(domain)) {
345
- return `Basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`;
346
- }
347
- return `Bearer ${token}`;
348
- }
349
- function createCredentialLease(input) {
350
- return {
351
- type: "lease",
352
- lease: {
353
- ...input.account ? { account: input.account } : void 0,
354
- ...input.authorization ? { authorization: input.authorization } : void 0,
355
- expiresAt: new Date(input.expiresAtMs).toISOString(),
356
- headerTransforms: (input.domains ?? (input.authorization ? GITHUB_ASSET_UPLOAD_CREDENTIAL_DOMAINS : GITHUB_CREDENTIAL_DOMAINS)).map((domain) => ({
357
- domain,
358
- headers: {
359
- Authorization: authorizationFor(domain, input.token)
360
- }
361
- }))
362
- }
363
- };
364
- }
365
- function githubUserAuthorization(scope) {
366
- return {
367
- type: "oauth",
368
- provider: "github",
369
- ...scope ? { scope } : void 0
370
- };
371
- }
372
- function credentialNeeded(message, scope, allowAuthorization = true) {
373
- return {
374
- type: "needed",
375
- message,
376
- ...allowAuthorization ? { authorization: githubUserAuthorization(scope) } : void 0
377
- };
378
- }
379
- function credentialUnavailable(message) {
380
- return {
381
- type: "unavailable",
382
- message
383
- };
384
- }
385
- function parseInstallationTokenResponse(data) {
386
- if (!isRecord(data)) {
387
- throw new Error("GitHub installation token response is invalid");
388
- }
389
- const token = data.token;
390
- if (typeof token !== "string" || !token.trim()) {
391
- throw new Error("GitHub installation token response missing token");
392
- }
393
- const expiresAt = data.expires_at;
394
- const expiresAtMs = typeof expiresAt === "string" ? Date.parse(expiresAt) : Number.NaN;
395
- if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
396
- throw new Error(
397
- "GitHub installation token response returned invalid expires_at"
398
- );
399
- }
400
- return { token, expiresAtMs };
401
- }
402
- function readInstallationPermissions(installation) {
403
- if (!isRecord(installation) || !isRecord(installation.permissions)) {
404
- throw new Error("GitHub installation response missing permissions");
405
- }
406
- return readGrantPermissions(installation.permissions);
407
- }
408
- function decodeGitHubPathSegment(value) {
409
- try {
410
- const decoded = decodeURIComponent(value).trim();
411
- return decoded && !decoded.includes("/") ? decoded : void 0;
412
- } catch {
413
- return void 0;
414
- }
415
- }
416
- function githubRepositoryFromUrl(upstreamUrl) {
417
- const segments = upstreamUrl.pathname.split("/").filter(Boolean);
418
- if (isGitHubApiUrl(upstreamUrl) && segments[0]?.toLowerCase() === "repos") {
419
- const owner2 = segments[1] ? decodeGitHubPathSegment(segments[1]) : void 0;
420
- const name2 = segments[2] ? decodeGitHubPathSegment(segments[2]) : void 0;
421
- return owner2 && name2 ? { owner: owner2, name: name2 } : void 0;
422
- }
423
- if (upstreamUrl.hostname.toLowerCase() !== "github.com") {
424
- return void 0;
425
- }
426
- const owner = segments[0] ? decodeGitHubPathSegment(segments[0]) : void 0;
427
- const rawName = segments[1]?.replace(/\.git$/i, "");
428
- const name = rawName ? decodeGitHubPathSegment(rawName) : void 0;
429
- return owner && name ? { owner, name } : void 0;
430
- }
431
- function githubRepositoryLeaseScope(repository) {
432
- return `repository:${repository.owner.toLowerCase()}/${repository.name.toLowerCase()}`;
433
- }
434
- function githubRepositoryFromLeaseScope(leaseScope) {
435
- const match = /^repository:([^/]+)\/([^/]+)$/.exec(leaseScope ?? "");
436
- if (!match?.[1] || !match[2]) {
437
- throw new GitHubPluginSetupError(
438
- "GitHub installation write grant is missing a repository lease scope."
439
- );
440
- }
441
- return { owner: match[1], name: match[2] };
442
- }
443
- async function resolveUserAccount(tokens) {
444
- const account = await githubRequest("https://api.github.com", "/user", {
445
- token: tokens.accessToken
446
- });
447
- if (!isRecord(account)) {
448
- throw new Error("GitHub user response is invalid");
449
- }
450
- const id = account.id;
451
- const login = account.login;
452
- if (typeof id !== "number" && typeof id !== "string" || typeof login !== "string" || !login.trim()) {
453
- throw new Error("GitHub user response missing id or login");
454
- }
455
- const url = typeof account.html_url === "string" ? account.html_url : void 0;
456
- return {
457
- handle: login.trim(),
458
- id: String(id),
459
- label: login.trim(),
460
- ...url ? { url } : void 0
461
- };
462
- }
463
- async function tokensWithAccount(tokenSlot, stored, scope) {
464
- if (stored.account) {
465
- return { ok: true, tokens: stored };
466
- }
467
- let account;
468
- try {
469
- account = await resolveUserAccount(stored);
470
- } catch (error) {
471
- if (error instanceof GitHubRequestError && (error.status === 401 || error.status === 403)) {
472
- return {
473
- ok: false,
474
- result: credentialNeeded(
475
- "Your GitHub authorization needs to be refreshed.",
476
- scope
477
- )
478
- };
479
- }
480
- throw error;
481
- }
482
- const updated = { ...stored, account };
483
- await tokenSlot.set(updated);
484
- return { ok: true, tokens: updated };
485
- }
486
- function shouldRefreshUserToken(stored, now = Date.now()) {
487
- return stored.expiresAt !== void 0 && stored.expiresAt - now < REFRESH_BUFFER_MS;
488
- }
489
- function canUseStoredUserToken(stored) {
490
- return stored.expiresAt === void 0 || stored.expiresAt > Date.now() && !shouldRefreshUserToken(stored);
491
- }
492
- async function refreshUserTokensWithLock(tokenSlot, scope, options) {
493
- return await tokenSlot.withRefresh(async () => {
494
- const latest = await tokenSlot.get();
495
- if (!latest) {
496
- return {
497
- ok: false,
498
- result: credentialNeeded("Connect your GitHub account.", scope)
499
- };
500
- }
501
- if (!hasRequiredOAuthScope(latest.scope, scope)) {
502
- return {
503
- ok: false,
504
- result: credentialNeeded(
505
- "Your GitHub authorization needs to be refreshed.",
506
- scope
507
- )
508
- };
509
- }
510
- if (canUseStoredUserToken(latest)) {
511
- return { ok: true, tokens: latest };
512
- }
513
- let refreshed;
514
- try {
515
- refreshed = await refreshUserAccessToken({
516
- clientIdEnv: options.clientIdEnv,
517
- clientSecretEnv: options.clientSecretEnv,
518
- refreshToken: latest.refreshToken,
519
- requestedScope: latest.scope ?? scope
520
- });
521
- } catch (error) {
522
- if (!(error instanceof GitHubUserRefreshRejectedError)) {
523
- throw error;
524
- }
525
- return {
526
- ok: false,
527
- result: credentialNeeded(
528
- "Your GitHub authorization has expired.",
529
- scope
530
- )
531
- };
532
- }
533
- if (!hasRequiredOAuthScope(refreshed.scope, scope)) {
534
- return {
535
- ok: false,
536
- result: credentialNeeded(
537
- "Your GitHub authorization needs to be refreshed.",
538
- scope
539
- )
540
- };
541
- }
542
- const refreshedTokens = {
543
- ...latest.refreshTokenExpiresAt ? { refreshTokenExpiresAt: latest.refreshTokenExpiresAt } : void 0,
544
- ...refreshed,
545
- ...latest.account ? { account: latest.account } : void 0
546
- };
547
- await tokenSlot.set(refreshedTokens);
548
- return { ok: true, tokens: refreshedTokens };
549
- });
550
- }
551
- async function issueUserCredential(ctx, options) {
552
- const scope = options.userScope;
553
- const tokenSlot = ctx.tokens.currentUser ?? ctx.tokens.credentialSubject;
554
- if (!tokenSlot) {
555
- return credentialNeeded(
556
- "GitHub write access requires a current user or delegated user credential subject.",
557
- scope,
558
- false
559
- );
560
- }
561
- const stored = await tokenSlot.get();
562
- if (!stored) {
563
- return credentialNeeded(
564
- "GitHub write access requires user authorization.",
565
- scope
566
- );
567
- }
568
- if (!hasRequiredOAuthScope(stored.scope, scope)) {
569
- return credentialNeeded(
570
- "Your GitHub authorization needs to be refreshed.",
571
- scope
572
- );
573
- }
574
- const now = Date.now();
575
- if (stored.expiresAt !== void 0 && stored.expiresAt - now < REFRESH_BUFFER_MS) {
576
- const refreshResult = await refreshUserTokensWithLock(
577
- tokenSlot,
578
- scope,
579
- options
580
- );
581
- if (!refreshResult.ok) {
582
- return refreshResult.result;
583
- }
584
- const withAccount = await tokensWithAccount(
585
- tokenSlot,
586
- refreshResult.tokens,
587
- scope
588
- );
589
- if (!withAccount.ok) {
590
- return withAccount.result;
591
- }
592
- return createCredentialLease({
593
- account: withAccount.tokens.account,
594
- token: withAccount.tokens.accessToken,
595
- expiresAtMs: leaseExpiry(withAccount.tokens.expiresAt),
596
- authorization: githubUserAuthorization(scope)
597
- });
598
- }
599
- if (stored.expiresAt === void 0 || stored.expiresAt > Date.now()) {
600
- const withAccount = await tokensWithAccount(tokenSlot, stored, scope);
601
- if (!withAccount.ok) {
602
- return withAccount.result;
603
- }
604
- return createCredentialLease({
605
- account: withAccount.tokens.account,
606
- token: withAccount.tokens.accessToken,
607
- expiresAtMs: leaseExpiry(withAccount.tokens.expiresAt),
608
- authorization: githubUserAuthorization(scope)
609
- });
610
- }
611
- return credentialNeeded("Your GitHub authorization has expired.", scope);
612
- }
613
- async function issueInstallationToken(options) {
614
- const appId = requireEnv(options.appIdEnv);
615
- const installationIdRaw = requireEnv(options.installationIdEnv);
616
- const installationId = Number(installationIdRaw);
617
- if (!Number.isSafeInteger(installationId) || installationId <= 0) {
618
- throw new GitHubPluginSetupError(`Invalid ${options.installationIdEnv}`);
619
- }
620
- const appJwt = createAppJwt(appId, options.privateKeyEnv);
621
- const permissions = "permissions" in options ? options.permissions : typeof options.loadPermissions === "function" ? await options.loadPermissions({ appJwt, installationId }) : void 0;
622
- const body = {
623
- ...permissions ? { permissions } : void 0,
624
- ..."repositories" in options ? { repositories: options.repositories } : void 0
625
- };
626
- const accessTokenResponse = await githubRequest(
627
- "https://api.github.com",
628
- `/app/installations/${installationId}/access_tokens`,
629
- {
630
- method: "POST",
631
- token: appJwt,
632
- body
633
- }
634
- );
635
- const parsedToken = parseInstallationTokenResponse(accessTokenResponse);
636
- return {
637
- expiresAtMs: Math.min(parsedToken.expiresAtMs, Date.now() + MAX_LEASE_MS),
638
- token: parsedToken.token
639
- };
640
- }
641
- async function issueInstallationCredential(options) {
642
- const token = await issueInstallationToken(options);
643
- return createCredentialLease({
644
- token: token.token,
645
- expiresAtMs: token.expiresAtMs
646
- });
647
- }
648
- function createPermissionCache() {
649
- let cached;
650
- let pending;
651
- return async ({ appJwt, installationId }) => {
652
- if (cached && cached.expiresAtMs > Date.now()) {
653
- return cached.permissions;
654
- }
655
- pending ??= githubRequest(
656
- "https://api.github.com",
657
- `/app/installations/${installationId}`,
658
- { token: appJwt }
659
- ).then((installation) => {
660
- const permissions = readInstallationPermissions(installation);
661
- cached = {
662
- expiresAtMs: Date.now() + MAX_LEASE_MS,
663
- permissions
664
- };
665
- return permissions;
666
- }).finally(() => {
667
- pending = void 0;
668
- });
669
- return await pending;
670
- };
671
- }
672
-
673
- // src/pull-request-review-policy.ts
674
- function assertGitHubPullRequestApprovalDenied(input) {
675
- if (input.method !== "POST" || input.upstreamUrl.hostname.toLowerCase() !== "api.github.com") {
676
- return;
677
- }
678
- const match = input.upstreamUrl.pathname.toLowerCase().match(
679
- /^\/repos\/[^/]+\/[^/]+\/pulls\/[^/]+\/reviews(?:\/[^/]+\/(events))?$/
680
- );
681
- if (!match) return;
682
- const isEventsPath = match[1] === "events";
683
- const bodyText = input.bodyText?.trim() ?? "";
684
- if (!bodyText) {
685
- if (isEventsPath) {
686
- throw new EgressPolicyDenied(
687
- "GitHub pull request review submissions must include a parseable non-APPROVE event so Junior can enforce the no-approve policy."
688
- );
689
- }
690
- return;
691
- }
692
- let body;
693
- try {
694
- body = JSON.parse(bodyText);
695
- } catch {
696
- throw new EgressPolicyDenied(
697
- "GitHub pull request review requests must use JSON bodies so Junior can enforce the no-approve policy."
698
- );
699
- }
700
- if (!isRecord(body)) {
701
- throw new EgressPolicyDenied(
702
- "GitHub pull request review requests must use JSON object bodies so Junior can enforce the no-approve policy."
703
- );
704
- }
705
- let event;
706
- if ("event" in body) {
707
- if (typeof body.event !== "string" || body.event.trim().length === 0) {
708
- throw new EgressPolicyDenied(
709
- "GitHub pull request review submissions must include a parseable non-APPROVE event so Junior can enforce the no-approve policy."
710
- );
711
- }
712
- event = body.event.trim().toUpperCase();
713
- }
714
- if (event === "APPROVE") {
715
- throw new EgressPolicyDenied(
716
- "Junior cannot approve GitHub pull requests. Request changes, leave a comment review, or dismiss Junior's own review instead."
717
- );
718
- }
719
- if (isEventsPath && event === void 0) {
720
- throw new EgressPolicyDenied(
721
- "GitHub pull request review submissions must include a parseable non-APPROVE event so Junior can enforce the no-approve policy."
722
- );
723
- }
724
- }
725
-
726
54
  // src/tools/clone-repository.ts
727
55
  import {
728
56
  definePluginTool,
@@ -3072,6 +2400,7 @@ var githubPullRequestOutcomeInputSchema = z13.object({
3072
2400
  repositoryFullName: z13.string().min(1),
3073
2401
  repositoryId: z13.string().min(1),
3074
2402
  state: githubPullRequestStateSchema,
2403
+ title: z13.string().min(1).optional(),
3075
2404
  updatedAt: z13.date()
3076
2405
  }).strict();
3077
2406
  var githubPullRequestConversationsInputSchema = z13.object({
@@ -3401,6 +2730,7 @@ var canonicalPullRequestOutcomeSchema = z15.object({
3401
2730
  merged: z15.boolean(),
3402
2731
  merged_at: z15.string().nullable().optional(),
3403
2732
  number: z15.number().int().positive(),
2733
+ title: z15.string().min(1).optional(),
3404
2734
  updated_at: z15.string(),
3405
2735
  user: z15.object({ login: z15.string().min(1) }).strict()
3406
2736
  }).strict(),
@@ -3419,6 +2749,7 @@ var pullRequestOutcomeSchema = z15.object({
3419
2749
  merged: z15.boolean(),
3420
2750
  merged_at: z15.string().nullable().optional(),
3421
2751
  number: z15.number().int().positive(),
2752
+ title: z15.string().min(1).optional(),
3422
2753
  updated_at: z15.string(),
3423
2754
  user: z15.object({ login: z15.string().min(1) }).passthrough()
3424
2755
  }).passthrough(),
@@ -3437,6 +2768,7 @@ var pullRequestOutcomeSchema = z15.object({
3437
2768
  merged: provider.pull_request.merged,
3438
2769
  merged_at: provider.pull_request.merged_at,
3439
2770
  number: provider.pull_request.number,
2771
+ title: provider.pull_request.title,
3440
2772
  updated_at: provider.pull_request.updated_at,
3441
2773
  user: { login: provider.pull_request.user.login }
3442
2774
  },
@@ -3519,6 +2851,7 @@ function normalizeGitHubPullRequestOutcome(args) {
3519
2851
  repositoryFullName: parsed.repository.full_name,
3520
2852
  repositoryId: String(parsed.repository.id),
3521
2853
  state,
2854
+ title: pullRequest.title,
3522
2855
  updatedAt
3523
2856
  };
3524
2857
  }
@@ -3582,6 +2915,25 @@ function webhookInstallationId(body) {
3582
2915
  }
3583
2916
  return parseInstallationId(installation.id);
3584
2917
  }
2918
+ function githubCodeChange(outcome, conversationIds) {
2919
+ return {
2920
+ closedAt: outcome.closedAt,
2921
+ conversationIds,
2922
+ mergedAt: outcome.mergedAt,
2923
+ number: outcome.number,
2924
+ openedAt: outcome.openedAt,
2925
+ providerId: outcome.pullRequestId,
2926
+ repository: {
2927
+ name: outcome.repositoryFullName,
2928
+ providerId: outcome.repositoryId,
2929
+ url: `https://github.com/${outcome.repositoryFullName}`
2930
+ },
2931
+ state: outcome.state === "closed_unmerged" ? "closed" : outcome.state,
2932
+ title: outcome.title,
2933
+ updatedAt: outcome.updatedAt,
2934
+ url: `https://github.com/${outcome.repositoryFullName}/pull/${outcome.number}`
2935
+ };
2936
+ }
3585
2937
  function createGitHubWebhookRoute(args) {
3586
2938
  return {
3587
2939
  method: "POST",
@@ -3618,6 +2970,14 @@ function createGitHubWebhookRoute(args) {
3618
2970
  args.db,
3619
2971
  pullRequestOutcome
3620
2972
  );
2973
+ if (recordedOutcome.applied) {
2974
+ await args.codeChanges.record(
2975
+ githubCodeChange(
2976
+ pullRequestOutcome,
2977
+ recordedOutcome.conversationIds
2978
+ )
2979
+ );
2980
+ }
3621
2981
  if (recordedOutcome.applied && pullRequestOutcome.state !== "open") {
3622
2982
  const status = pullRequestOutcome.state === "merged" ? "merged" : "closed";
3623
2983
  await Promise.all(
@@ -3679,16 +3039,31 @@ function createGitHubWebhookRoute(args) {
3679
3039
  args.db,
3680
3040
  pullRequestConversations
3681
3041
  ) : false;
3042
+ if (recordedPullRequestConversations && pullRequestConversations) {
3043
+ await args.codeChanges.associateConversations({
3044
+ conversationIds: pullRequestConversations.conversationIds,
3045
+ providerId: pullRequestConversations.pullRequestId
3046
+ });
3047
+ }
3682
3048
  const recordedPullRequestLinkedIssues = pullRequestLinkedIssues ? await recordGitHubPullRequestLinkedIssues(
3683
3049
  args.db,
3684
3050
  pullRequestLinkedIssues
3685
3051
  ) : false;
3686
- const failingChecks = eventName === "check_suite" && args.loadFailingChecks ? await args.loadFailingChecks(body) : void 0;
3052
+ const checkSuitePublishTargets = eventName === "check_suite" ? parseCheckSuitePublishTargets(body) : void 0;
3053
+ const checkSuiteMatchKeys = checkSuitePublishTargets && args.resourceEvents.neededMatchKeys ? await args.resourceEvents.neededMatchKeys(checkSuitePublishTargets) : [];
3054
+ const checkSuiteFacts = eventName === "check_suite" ? await loadCheckSuiteFacts({
3055
+ appIdEnv: args.appIdEnv,
3056
+ body,
3057
+ installationIdEnv: args.installationIdEnv,
3058
+ loadPullRequestFacts: needsCheckSuitePullRequestFacts(checkSuiteMatchKeys),
3059
+ log: args.log,
3060
+ privateKeyEnv: args.privateKeyEnv
3061
+ }) : void 0;
3687
3062
  const resourceEvents = normalizeGitHubResourceEvents({
3688
3063
  body,
3064
+ ...checkSuiteFacts ? { checkSuiteFacts } : void 0,
3689
3065
  deliveryId,
3690
- eventName,
3691
- failingChecks
3066
+ eventName
3692
3067
  });
3693
3068
  for (const event of resourceEvents) {
3694
3069
  await args.resourceEvents.publish(event);
@@ -4852,44 +4227,6 @@ function githubSidebarAnnotations(annotations) {
4852
4227
  }).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)).map(({ annotation }) => annotation);
4853
4228
  }
4854
4229
 
4855
- // src/webhooks/check-suite-enrichment.ts
4856
- function checkRunsFromResponse(value) {
4857
- if (!value || typeof value !== "object" || Array.isArray(value)) {
4858
- return [];
4859
- }
4860
- const checkRuns = value.check_runs;
4861
- return Array.isArray(checkRuns) ? checkRuns : [];
4862
- }
4863
- async function loadFailingChecksForSuite(args) {
4864
- const target = parseCheckSuiteEnrichmentTarget(args.body);
4865
- if (!target) return void 0;
4866
- try {
4867
- const token = await issueInstallationToken({
4868
- appIdEnv: args.appIdEnv,
4869
- installationIdEnv: args.installationIdEnv,
4870
- permissions: { checks: "read" },
4871
- privateKeyEnv: args.privateKeyEnv,
4872
- repositories: [target.repoName]
4873
- });
4874
- const response = await githubRequest(
4875
- "https://api.github.com",
4876
- `/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repoName)}/check-suites/${target.checkSuiteId}/check-runs?filter=latest&per_page=100`,
4877
- { token: token.token }
4878
- );
4879
- const failing = selectFailingChecks(checkRunsFromResponse(response), {
4880
- checkSuiteId: target.checkSuiteId
4881
- });
4882
- return failing.length > 0 ? failing : void 0;
4883
- } catch (error) {
4884
- args.log?.error("GitHub check suite enrichment failed", {
4885
- checkSuiteId: target.checkSuiteId,
4886
- errorType: error instanceof Error ? error.name : "UnknownError",
4887
- repository: `${target.owner}/${target.repoName}`
4888
- });
4889
- return void 0;
4890
- }
4891
- }
4892
-
4893
4230
  // src/git-config.ts
4894
4231
  function cleanIdentityPart(value) {
4895
4232
  return String(value ?? "").replaceAll("\n", " ").replaceAll("\r", " ").replace(/[<>]/g, "").trim();
@@ -5438,7 +4775,67 @@ async function prepareWorkspace(ctx) {
5438
4775
  }
5439
4776
  }
5440
4777
 
5441
- // src/plugin.ts
4778
+ // src/egress-policy.ts
4779
+ import {
4780
+ EgressPolicyDenied as EgressPolicyDenied2,
4781
+ enforceEgressPolicy
4782
+ } from "@sentry/junior-plugin-api";
4783
+
4784
+ // src/pull-request-review-policy.ts
4785
+ import { EgressPolicyDenied } from "@sentry/junior-plugin-api";
4786
+ function assertGitHubPullRequestApprovalDenied(input) {
4787
+ if (input.method !== "POST" || input.upstreamUrl.hostname.toLowerCase() !== "api.github.com") {
4788
+ return;
4789
+ }
4790
+ const match = input.upstreamUrl.pathname.toLowerCase().match(
4791
+ /^\/repos\/[^/]+\/[^/]+\/pulls\/[^/]+\/reviews(?:\/[^/]+\/(events))?$/
4792
+ );
4793
+ if (!match) return;
4794
+ const isEventsPath = match[1] === "events";
4795
+ const bodyText = input.bodyText?.trim() ?? "";
4796
+ if (!bodyText) {
4797
+ if (isEventsPath) {
4798
+ throw new EgressPolicyDenied(
4799
+ "GitHub pull request review submissions must include a parseable non-APPROVE event so Junior can enforce the no-approve policy."
4800
+ );
4801
+ }
4802
+ return;
4803
+ }
4804
+ let body;
4805
+ try {
4806
+ body = JSON.parse(bodyText);
4807
+ } catch {
4808
+ throw new EgressPolicyDenied(
4809
+ "GitHub pull request review requests must use JSON bodies so Junior can enforce the no-approve policy."
4810
+ );
4811
+ }
4812
+ if (!isRecord(body)) {
4813
+ throw new EgressPolicyDenied(
4814
+ "GitHub pull request review requests must use JSON object bodies so Junior can enforce the no-approve policy."
4815
+ );
4816
+ }
4817
+ let event;
4818
+ if ("event" in body) {
4819
+ if (typeof body.event !== "string" || body.event.trim().length === 0) {
4820
+ throw new EgressPolicyDenied(
4821
+ "GitHub pull request review submissions must include a parseable non-APPROVE event so Junior can enforce the no-approve policy."
4822
+ );
4823
+ }
4824
+ event = body.event.trim().toUpperCase();
4825
+ }
4826
+ if (event === "APPROVE") {
4827
+ throw new EgressPolicyDenied(
4828
+ "Junior cannot approve GitHub pull requests. Request changes, leave a comment review, or dismiss Junior's own review instead."
4829
+ );
4830
+ }
4831
+ if (isEventsPath && event === void 0) {
4832
+ throw new EgressPolicyDenied(
4833
+ "GitHub pull request review submissions must include a parseable non-APPROVE event so Junior can enforce the no-approve policy."
4834
+ );
4835
+ }
4836
+ }
4837
+
4838
+ // src/egress-policy.ts
5442
4839
  function githubSmartHttpAccess(upstreamUrl) {
5443
4840
  const pathname = upstreamUrl.pathname.toLowerCase();
5444
4841
  const service = upstreamUrl.searchParams.get("service")?.toLowerCase();
@@ -5457,14 +4854,14 @@ function githubSmartHttpAccess(upstreamUrl) {
5457
4854
  function isGitHubGraphqlUrl(upstreamUrl) {
5458
4855
  return upstreamUrl.hostname.toLowerCase() === "api.github.com" && upstreamUrl.pathname.toLowerCase().endsWith("/graphql");
5459
4856
  }
5460
- function isGitHubApiUrl2(upstreamUrl) {
4857
+ function isGitHubApiUrl(upstreamUrl) {
5461
4858
  return upstreamUrl.hostname.toLowerCase() === "api.github.com";
5462
4859
  }
5463
4860
  function isGitHubAssetUploadRequest(method, upstreamUrl) {
5464
4861
  return method === "POST" && upstreamUrl.hostname.toLowerCase() === "uploads.github.com" && upstreamUrl.pathname === "/user-attachments/assets";
5465
4862
  }
5466
4863
  function githubUserReadReason(method, upstreamUrl) {
5467
- if (method !== "GET" || !isGitHubApiUrl2(upstreamUrl)) {
4864
+ if (method !== "GET" || !isGitHubApiUrl(upstreamUrl)) {
5468
4865
  return void 0;
5469
4866
  }
5470
4867
  return upstreamUrl.pathname.toLowerCase() === "/user" ? "github.user-read" : void 0;
@@ -5622,7 +5019,7 @@ var GITHUB_BODY_WRITES = [
5622
5019
  ];
5623
5020
  function githubApiWriteGrantName(method, upstreamUrl) {
5624
5021
  const pathname = upstreamUrl.pathname.toLowerCase();
5625
- if (!isGitHubApiUrl2(upstreamUrl)) {
5022
+ if (!isGitHubApiUrl(upstreamUrl)) {
5626
5023
  return void 0;
5627
5024
  }
5628
5025
  if (method === "POST" && /^\/repos\/[^/]+\/[^/]+\/actions\/workflows\/[^/]+\/dispatches$/.test(
@@ -5710,7 +5107,7 @@ function applyGitHubEgressPolicy(input) {
5710
5107
  });
5711
5108
  const pathname = input.upstreamUrl.pathname.toLowerCase();
5712
5109
  const write = GITHUB_BODY_WRITES.find(
5713
- (candidate) => candidate.method === input.method && isGitHubApiUrl2(input.upstreamUrl) && candidate.restPath.test(pathname) || isGitHubGraphqlMutation(
5110
+ (candidate) => candidate.method === input.method && isGitHubApiUrl(input.upstreamUrl) && candidate.restPath.test(pathname) || isGitHubGraphqlMutation(
5714
5111
  input.method,
5715
5112
  input.upstreamUrl,
5716
5113
  input.bodyText,
@@ -5821,6 +5218,8 @@ async function githubGrantForEgress(ctx) {
5821
5218
  }
5822
5219
  return grantForAccess(access, "github.api-read", "installation-read");
5823
5220
  }
5221
+
5222
+ // src/plugin.ts
5824
5223
  function githubPlugin(options = {}) {
5825
5224
  const botNameEnv = options.botNameEnv ?? "GITHUB_APP_BOT_NAME";
5826
5225
  const botEmailEnv = options.botEmailEnv ?? "GITHUB_APP_BOT_EMAIL";
@@ -5852,6 +5251,7 @@ function githubPlugin(options = {}) {
5852
5251
  type: "pull_request",
5853
5252
  supportedEvents: [...GITHUB_PULL_REQUEST_EVENTS],
5854
5253
  suggestedEvents: [...GITHUB_PULL_REQUEST_SUGGESTED_EVENTS],
5254
+ matchFields: GITHUB_PULL_REQUEST_MATCH_FIELDS,
5855
5255
  ...options.pullRequestEvents?.guidance ? { guidance: options.pullRequestEvents.guidance } : void 0
5856
5256
  },
5857
5257
  {
@@ -5861,16 +5261,14 @@ function githubPlugin(options = {}) {
5861
5261
  },
5862
5262
  {
5863
5263
  type: "repository",
5864
- supportedEvents: [
5865
- ...GITHUB_ISSUE_EVENTS,
5866
- ...GITHUB_PULL_REQUEST_EVENTS
5867
- ],
5264
+ supportedEvents: [...GITHUB_ISSUE_EVENTS, ...GITHUB_PULL_REQUEST_EVENTS],
5868
5265
  suggestedEvents: [
5869
5266
  "issue.opened",
5870
5267
  "pull_request.opened",
5871
5268
  ...GITHUB_ISSUE_SUGGESTED_EVENTS,
5872
5269
  ...GITHUB_PULL_REQUEST_SUGGESTED_EVENTS
5873
- ]
5270
+ ],
5271
+ matchFields: GITHUB_PULL_REQUEST_MATCH_FIELDS
5874
5272
  }
5875
5273
  ],
5876
5274
  isEnabled: () => Boolean(readEnv("GITHUB_WEBHOOK_SECRET")),
@@ -5959,6 +5357,7 @@ function githubPlugin(options = {}) {
5959
5357
  return [
5960
5358
  createGitHubWebhookRoute({
5961
5359
  annotations: ctx.annotations,
5360
+ appIdEnv,
5962
5361
  botEmail: () => readEnv(botEmailEnv),
5963
5362
  classifyPullRequestCommits: async ({
5964
5363
  number,
@@ -5986,16 +5385,12 @@ function githubPlugin(options = {}) {
5986
5385
  )
5987
5386
  });
5988
5387
  },
5388
+ codeChanges: ctx.codeChanges,
5989
5389
  db: ctx.db,
5990
5390
  installationId: () => readEnv(installationIdEnv),
5991
- loadFailingChecks: async (body) => await loadFailingChecksForSuite({
5992
- appIdEnv,
5993
- body,
5994
- installationIdEnv,
5995
- log: ctx.log,
5996
- privateKeyEnv
5997
- }),
5391
+ installationIdEnv,
5998
5392
  log: ctx.log,
5393
+ privateKeyEnv,
5999
5394
  resourceEvents: ctx.resourceEvents,
6000
5395
  webhookSecret: () => readEnv("GITHUB_WEBHOOK_SECRET")
6001
5396
  })