@sentry/junior-github 0.80.1 → 0.82.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/SETUP.md +15 -5
- package/dist/index.d.ts +39 -0
- package/dist/index.js +1814 -0
- package/dist/permissions.d.ts +8 -0
- package/dist/tools/create-issue.d.ts +12 -0
- package/dist/tools/create-pull-request.d.ts +14 -0
- package/dist/tools.d.ts +3 -0
- package/package.json +20 -7
- package/skills/github-code/SKILL.md +3 -4
- package/skills/github-code/references/api-surface.md +30 -30
- package/skills/github-code/references/troubleshooting-workarounds.md +16 -16
- package/skills/github-issues/SKILL.md +4 -5
- package/skills/github-issues/references/api-surface.md +8 -8
- package/index.d.ts +0 -49
- package/index.js +0 -1230
- package/permissions.js +0 -77
package/dist/index.js
ADDED
|
@@ -0,0 +1,1814 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { createPrivateKey, createSign } from "crypto";
|
|
3
|
+
import {
|
|
4
|
+
defineJuniorPlugin,
|
|
5
|
+
EgressPolicyDenied
|
|
6
|
+
} from "@sentry/junior-plugin-api";
|
|
7
|
+
|
|
8
|
+
// src/permissions.ts
|
|
9
|
+
var LEVELS = /* @__PURE__ */ new Set(["read", "write", "admin"]);
|
|
10
|
+
var WRITE_ONLY_PERMISSIONS = /* @__PURE__ */ new Set(["profile", "workflows"]);
|
|
11
|
+
function isLevel(value) {
|
|
12
|
+
return LEVELS.has(value);
|
|
13
|
+
}
|
|
14
|
+
function normalizeScope(rawScope) {
|
|
15
|
+
return String(rawScope).trim().replace(/-/g, "_");
|
|
16
|
+
}
|
|
17
|
+
function normalizePermissions(permissions) {
|
|
18
|
+
if (permissions === void 0) {
|
|
19
|
+
return void 0;
|
|
20
|
+
}
|
|
21
|
+
const entries = Object.entries(permissions);
|
|
22
|
+
if (entries.length === 0) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
"githubPlugin appPermissions must contain at least one permission when provided."
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
const request = {};
|
|
28
|
+
for (const [rawScope, rawLevel] of entries) {
|
|
29
|
+
const normalizedScope = normalizeScope(rawScope);
|
|
30
|
+
if (!normalizedScope) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
"githubPlugin appPermissions contains an empty permission name."
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
if (!/^[a-z][a-z0-9_]*$/.test(normalizedScope)) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`githubPlugin appPermissions contains invalid permission "${rawScope}".`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
if (!isLevel(rawLevel)) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`githubPlugin appPermissions.${rawScope} must be "read", "write", or "admin".`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
request[normalizedScope] = rawLevel;
|
|
46
|
+
}
|
|
47
|
+
return request;
|
|
48
|
+
}
|
|
49
|
+
function readGrantPermissions(permissions) {
|
|
50
|
+
const readOnly = { metadata: "read" };
|
|
51
|
+
for (const [scope, level] of Object.entries(permissions ?? {})) {
|
|
52
|
+
if (!isLevel(level)) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`GitHub permission "${scope}" returned invalid level "${String(level)}".`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (!WRITE_ONLY_PERMISSIONS.has(scope)) {
|
|
58
|
+
readOnly[scope] = "read";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return readOnly;
|
|
62
|
+
}
|
|
63
|
+
function permissionCapabilities(permissions) {
|
|
64
|
+
if (permissions === void 0) {
|
|
65
|
+
return void 0;
|
|
66
|
+
}
|
|
67
|
+
return Object.entries(permissions).map(([normalizedScope, rawLevel]) => {
|
|
68
|
+
const scope = normalizedScope.replace(/_/g, "-");
|
|
69
|
+
return `github.${scope}.${rawLevel}`;
|
|
70
|
+
}).sort();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// src/tools/create-issue.ts
|
|
74
|
+
import {
|
|
75
|
+
EgressAuthRequired,
|
|
76
|
+
PluginToolInputError
|
|
77
|
+
} from "@sentry/junior-plugin-api";
|
|
78
|
+
import { Type } from "@sinclair/typebox";
|
|
79
|
+
import { Value } from "@sinclair/typebox/value";
|
|
80
|
+
var GITHUB_ISSUE_FOOTER_START = "<!-- junior-session-footer:start -->";
|
|
81
|
+
var GITHUB_ISSUE_FOOTER_END = "<!-- junior-session-footer:end -->";
|
|
82
|
+
var GITHUB_ISSUE_CREATE_IDEMPOTENCY_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
83
|
+
var GITHUB_ISSUE_CREATE_LOCK_TTL_MS = 6e4;
|
|
84
|
+
var GitHubIssueCreateRejectedError = class extends Error {
|
|
85
|
+
status;
|
|
86
|
+
constructor(message, status) {
|
|
87
|
+
super(message);
|
|
88
|
+
this.name = "GitHubIssueCreateRejectedError";
|
|
89
|
+
this.status = status;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
var createIssueInputSchema = Type.Object(
|
|
93
|
+
{
|
|
94
|
+
repo: Type.String({
|
|
95
|
+
description: 'Repository in "owner/name" format.'
|
|
96
|
+
}),
|
|
97
|
+
title: Type.String({
|
|
98
|
+
description: "Issue title."
|
|
99
|
+
}),
|
|
100
|
+
body: Type.Optional(
|
|
101
|
+
Type.String({
|
|
102
|
+
description: "Issue body. Junior appends the conversation footer."
|
|
103
|
+
})
|
|
104
|
+
),
|
|
105
|
+
labels: Type.Optional(
|
|
106
|
+
Type.Array(Type.String(), {
|
|
107
|
+
description: "Labels to apply to the issue."
|
|
108
|
+
})
|
|
109
|
+
)
|
|
110
|
+
},
|
|
111
|
+
{ additionalProperties: false }
|
|
112
|
+
);
|
|
113
|
+
var createIssueStateSchema = Type.Union([
|
|
114
|
+
Type.Object(
|
|
115
|
+
{
|
|
116
|
+
createdAtMs: Type.Number(),
|
|
117
|
+
number: Type.Number(),
|
|
118
|
+
status: Type.Literal("completed"),
|
|
119
|
+
url: Type.String()
|
|
120
|
+
},
|
|
121
|
+
{ additionalProperties: false }
|
|
122
|
+
),
|
|
123
|
+
Type.Object(
|
|
124
|
+
{
|
|
125
|
+
createdAtMs: Type.Number(),
|
|
126
|
+
status: Type.Literal("pending")
|
|
127
|
+
},
|
|
128
|
+
{ additionalProperties: false }
|
|
129
|
+
)
|
|
130
|
+
]);
|
|
131
|
+
function parseCreateIssueInput(input) {
|
|
132
|
+
try {
|
|
133
|
+
return Value.Parse(createIssueInputSchema, input);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
throw new PluginToolInputError("Invalid GitHub createIssue input.", {
|
|
136
|
+
cause: error
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function nonEmptyString(value, name) {
|
|
141
|
+
if (!value?.trim()) {
|
|
142
|
+
throw new PluginToolInputError(`${name} is required`);
|
|
143
|
+
}
|
|
144
|
+
return value.trim();
|
|
145
|
+
}
|
|
146
|
+
function parseRepo(value) {
|
|
147
|
+
const repo = nonEmptyString(value, "repo");
|
|
148
|
+
const parts = repo.split("/");
|
|
149
|
+
if (parts.length !== 2 || !parts[0]?.trim() || !parts[1]?.trim()) {
|
|
150
|
+
throw new PluginToolInputError('repo must use "owner/name" format');
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
owner: parts[0].trim(),
|
|
154
|
+
name: parts[1].trim()
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function escapeRegExp(value) {
|
|
158
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
159
|
+
}
|
|
160
|
+
function sentryConversationUrl(conversationId) {
|
|
161
|
+
const dsn = process.env.SENTRY_DSN?.trim();
|
|
162
|
+
const orgSlug = process.env.SENTRY_ORG_SLUG?.trim();
|
|
163
|
+
if (!dsn || !orgSlug) {
|
|
164
|
+
return void 0;
|
|
165
|
+
}
|
|
166
|
+
let parsed;
|
|
167
|
+
try {
|
|
168
|
+
parsed = new URL(dsn);
|
|
169
|
+
} catch {
|
|
170
|
+
return void 0;
|
|
171
|
+
}
|
|
172
|
+
const projectId = parsed.pathname.split("/").filter(Boolean).at(-1);
|
|
173
|
+
if (!parsed.hostname || !projectId) {
|
|
174
|
+
return void 0;
|
|
175
|
+
}
|
|
176
|
+
const encodedConversationId = encodeURIComponent(conversationId);
|
|
177
|
+
const params = new URLSearchParams({ project: projectId });
|
|
178
|
+
const path = `explore/conversations/${encodedConversationId}/?${params.toString()}`;
|
|
179
|
+
if (parsed.hostname === "sentry.io" || parsed.hostname.endsWith(".sentry.io")) {
|
|
180
|
+
return `https://${orgSlug}.sentry.io/${path}`;
|
|
181
|
+
}
|
|
182
|
+
const port = parsed.port ? `:${parsed.port}` : "";
|
|
183
|
+
return `${parsed.protocol}//${parsed.hostname}${port}/organizations/${orgSlug}/${path}`;
|
|
184
|
+
}
|
|
185
|
+
function githubIssueConversationFooter(conversationId) {
|
|
186
|
+
const id = nonEmptyString(conversationId, "conversationId");
|
|
187
|
+
const sessionUrl = sentryConversationUrl(id);
|
|
188
|
+
if (!sessionUrl) {
|
|
189
|
+
return void 0;
|
|
190
|
+
}
|
|
191
|
+
return `${GITHUB_ISSUE_FOOTER_START}
|
|
192
|
+
|
|
193
|
+
[View Session in Sentry](${sessionUrl})
|
|
194
|
+
|
|
195
|
+
${GITHUB_ISSUE_FOOTER_END}`;
|
|
196
|
+
}
|
|
197
|
+
function appendGitHubIssueFooter(body, conversationId) {
|
|
198
|
+
const footer = githubIssueConversationFooter(conversationId);
|
|
199
|
+
const normalizedBody = body.trimEnd();
|
|
200
|
+
const existingFooter = new RegExp(
|
|
201
|
+
`${escapeRegExp(GITHUB_ISSUE_FOOTER_START)}[\\s\\S]*?${escapeRegExp(
|
|
202
|
+
GITHUB_ISSUE_FOOTER_END
|
|
203
|
+
)}`
|
|
204
|
+
);
|
|
205
|
+
if (existingFooter.test(normalizedBody)) {
|
|
206
|
+
return footer ? normalizedBody.replace(existingFooter, footer) : normalizedBody.replace(existingFooter, "").trimEnd();
|
|
207
|
+
}
|
|
208
|
+
if (!footer) {
|
|
209
|
+
return normalizedBody;
|
|
210
|
+
}
|
|
211
|
+
return normalizedBody ? `${normalizedBody}
|
|
212
|
+
|
|
213
|
+
${footer}` : footer;
|
|
214
|
+
}
|
|
215
|
+
async function readJsonResponse(response) {
|
|
216
|
+
const text = await response.text();
|
|
217
|
+
if (!text) {
|
|
218
|
+
return void 0;
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
return JSON.parse(text);
|
|
222
|
+
} catch {
|
|
223
|
+
return text;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function githubApiErrorMessage(payload) {
|
|
227
|
+
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
|
|
228
|
+
const message = payload.message;
|
|
229
|
+
if (typeof message === "string") {
|
|
230
|
+
return message;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (typeof payload === "string" && payload.trim()) {
|
|
234
|
+
return payload.trim();
|
|
235
|
+
}
|
|
236
|
+
return "GitHub request failed";
|
|
237
|
+
}
|
|
238
|
+
function createIssueState(value) {
|
|
239
|
+
if (value === void 0) {
|
|
240
|
+
return void 0;
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
return Value.Parse(createIssueStateSchema, value);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
throw new Error("Invalid GitHub createIssue idempotency state.", {
|
|
246
|
+
cause: error
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function isEgressAuthRequired(error) {
|
|
251
|
+
return error instanceof EgressAuthRequired || error instanceof Error && error.name === "EgressAuthRequired";
|
|
252
|
+
}
|
|
253
|
+
function isDefinitiveGitHubIssueCreateRejection(error) {
|
|
254
|
+
if (!(error instanceof GitHubIssueCreateRejectedError)) {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
return [400, 401, 404, 410, 422].includes(error.status);
|
|
258
|
+
}
|
|
259
|
+
function createGitHubIssueRequest(conversationId, input) {
|
|
260
|
+
const repo = parseRepo(input.repo);
|
|
261
|
+
const labels = input.labels?.map(
|
|
262
|
+
(label) => nonEmptyString(label, "labels entry")
|
|
263
|
+
);
|
|
264
|
+
const payload = {
|
|
265
|
+
title: nonEmptyString(input.title, "title"),
|
|
266
|
+
body: appendGitHubIssueFooter(input.body ?? "", conversationId),
|
|
267
|
+
...labels?.length ? { labels } : {}
|
|
268
|
+
};
|
|
269
|
+
return new Request(
|
|
270
|
+
`https://api.github.com/repos/${encodeURIComponent(
|
|
271
|
+
repo.owner
|
|
272
|
+
)}/${encodeURIComponent(repo.name)}/issues`,
|
|
273
|
+
{
|
|
274
|
+
method: "POST",
|
|
275
|
+
headers: {
|
|
276
|
+
Accept: "application/vnd.github+json",
|
|
277
|
+
"Content-Type": "application/json",
|
|
278
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
279
|
+
},
|
|
280
|
+
body: JSON.stringify(payload)
|
|
281
|
+
}
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
async function createGitHubIssue(ctx, request) {
|
|
285
|
+
const response = await ctx.egress.fetch({
|
|
286
|
+
provider: "github",
|
|
287
|
+
operation: "github.issue.create",
|
|
288
|
+
request
|
|
289
|
+
});
|
|
290
|
+
const parsed = await readJsonResponse(response);
|
|
291
|
+
if (!response.ok) {
|
|
292
|
+
throw new GitHubIssueCreateRejectedError(
|
|
293
|
+
`GitHub issue creation failed with HTTP ${response.status}: ${githubApiErrorMessage(
|
|
294
|
+
parsed
|
|
295
|
+
)}`,
|
|
296
|
+
response.status
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
300
|
+
throw new Error("GitHub issue creation returned an invalid response.");
|
|
301
|
+
}
|
|
302
|
+
const issue = parsed;
|
|
303
|
+
if (typeof issue.number !== "number" || typeof issue.html_url !== "string") {
|
|
304
|
+
throw new Error("GitHub issue creation returned an invalid response.");
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
number: issue.number,
|
|
308
|
+
url: issue.html_url
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
function createGitHubIssueTool(ctx) {
|
|
312
|
+
return {
|
|
313
|
+
description: "Create a GitHub issue with a runtime-owned Junior conversation footer. Use this instead of shelling out to gh issue create when creating issues.",
|
|
314
|
+
inputSchema: createIssueInputSchema,
|
|
315
|
+
async execute(input, options) {
|
|
316
|
+
const parsedInput = parseCreateIssueInput(input);
|
|
317
|
+
const conversationId = nonEmptyString(
|
|
318
|
+
ctx.conversationId,
|
|
319
|
+
"conversationId"
|
|
320
|
+
);
|
|
321
|
+
const toolCallId = nonEmptyString(options?.toolCallId, "toolCallId");
|
|
322
|
+
const key = `createIssue:${conversationId}:${toolCallId}`;
|
|
323
|
+
return await ctx.state.withLock(
|
|
324
|
+
`${key}:lock`,
|
|
325
|
+
GITHUB_ISSUE_CREATE_LOCK_TTL_MS,
|
|
326
|
+
async () => {
|
|
327
|
+
const state = createIssueState(await ctx.state.get(key));
|
|
328
|
+
if (state?.status === "completed") {
|
|
329
|
+
return {
|
|
330
|
+
number: state.number,
|
|
331
|
+
url: state.url
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
if (state?.status === "pending") {
|
|
335
|
+
throw new Error(
|
|
336
|
+
"GitHub issue creation for this tool call has an uncertain pending result; refusing to create a duplicate issue."
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
const request = createGitHubIssueRequest(conversationId, parsedInput);
|
|
340
|
+
const pendingState = {
|
|
341
|
+
status: "pending",
|
|
342
|
+
createdAtMs: Date.now()
|
|
343
|
+
};
|
|
344
|
+
await ctx.state.set(
|
|
345
|
+
key,
|
|
346
|
+
pendingState,
|
|
347
|
+
GITHUB_ISSUE_CREATE_IDEMPOTENCY_TTL_MS
|
|
348
|
+
);
|
|
349
|
+
try {
|
|
350
|
+
const result = await createGitHubIssue(ctx, request);
|
|
351
|
+
Object.assign(pendingState, { status: "completed", ...result });
|
|
352
|
+
try {
|
|
353
|
+
await ctx.state.set(
|
|
354
|
+
key,
|
|
355
|
+
pendingState,
|
|
356
|
+
GITHUB_ISSUE_CREATE_IDEMPOTENCY_TTL_MS
|
|
357
|
+
);
|
|
358
|
+
} catch (error) {
|
|
359
|
+
throw new Error(
|
|
360
|
+
"GitHub issue was created, but Junior could not persist the completed issue state.",
|
|
361
|
+
{ cause: error }
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
return result;
|
|
365
|
+
} catch (error) {
|
|
366
|
+
if (isEgressAuthRequired(error) || isDefinitiveGitHubIssueCreateRejection(error)) {
|
|
367
|
+
await ctx.state.delete(key);
|
|
368
|
+
}
|
|
369
|
+
throw error;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// src/tools/create-pull-request.ts
|
|
378
|
+
import {
|
|
379
|
+
EgressAuthRequired as EgressAuthRequired2,
|
|
380
|
+
PluginToolInputError as PluginToolInputError2
|
|
381
|
+
} from "@sentry/junior-plugin-api";
|
|
382
|
+
import { Type as Type2 } from "@sinclair/typebox";
|
|
383
|
+
import { Value as Value2 } from "@sinclair/typebox/value";
|
|
384
|
+
var GITHUB_PULL_REQUEST_FOOTER_START = "<!-- junior-session-footer:start -->";
|
|
385
|
+
var GITHUB_PULL_REQUEST_FOOTER_END = "<!-- junior-session-footer:end -->";
|
|
386
|
+
var GITHUB_PULL_REQUEST_CREATE_IDEMPOTENCY_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
387
|
+
var GITHUB_PULL_REQUEST_CREATE_LOCK_TTL_MS = 6e4;
|
|
388
|
+
var GitHubPullRequestCreateRejectedError = class extends Error {
|
|
389
|
+
status;
|
|
390
|
+
constructor(message, status) {
|
|
391
|
+
super(message);
|
|
392
|
+
this.name = "GitHubPullRequestCreateRejectedError";
|
|
393
|
+
this.status = status;
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
var createPullRequestInputSchema = Type2.Object(
|
|
397
|
+
{
|
|
398
|
+
repo: Type2.String({
|
|
399
|
+
description: 'Repository in "owner/name" format.'
|
|
400
|
+
}),
|
|
401
|
+
title: Type2.String({
|
|
402
|
+
description: "Pull request title."
|
|
403
|
+
}),
|
|
404
|
+
head: Type2.String({
|
|
405
|
+
description: "Head branch or owner:branch ref."
|
|
406
|
+
}),
|
|
407
|
+
base: Type2.String({
|
|
408
|
+
description: "Base branch."
|
|
409
|
+
}),
|
|
410
|
+
body: Type2.Optional(
|
|
411
|
+
Type2.String({
|
|
412
|
+
description: "Pull request body. Junior appends the conversation footer."
|
|
413
|
+
})
|
|
414
|
+
),
|
|
415
|
+
draft: Type2.Optional(
|
|
416
|
+
Type2.Boolean({
|
|
417
|
+
description: "Whether to open the pull request as a draft."
|
|
418
|
+
})
|
|
419
|
+
)
|
|
420
|
+
},
|
|
421
|
+
{ additionalProperties: false }
|
|
422
|
+
);
|
|
423
|
+
var createPullRequestStateSchema = Type2.Union([
|
|
424
|
+
Type2.Object(
|
|
425
|
+
{
|
|
426
|
+
createdAtMs: Type2.Number(),
|
|
427
|
+
number: Type2.Number(),
|
|
428
|
+
status: Type2.Literal("completed"),
|
|
429
|
+
url: Type2.String()
|
|
430
|
+
},
|
|
431
|
+
{ additionalProperties: false }
|
|
432
|
+
),
|
|
433
|
+
Type2.Object(
|
|
434
|
+
{
|
|
435
|
+
createdAtMs: Type2.Number(),
|
|
436
|
+
status: Type2.Literal("pending")
|
|
437
|
+
},
|
|
438
|
+
{ additionalProperties: false }
|
|
439
|
+
)
|
|
440
|
+
]);
|
|
441
|
+
function parseCreatePullRequestInput(input) {
|
|
442
|
+
try {
|
|
443
|
+
return Value2.Parse(createPullRequestInputSchema, input);
|
|
444
|
+
} catch (error) {
|
|
445
|
+
throw new PluginToolInputError2("Invalid GitHub createPullRequest input.", {
|
|
446
|
+
cause: error
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
function nonEmptyString2(value, name) {
|
|
451
|
+
if (!value?.trim()) {
|
|
452
|
+
throw new PluginToolInputError2(`${name} is required`);
|
|
453
|
+
}
|
|
454
|
+
return value.trim();
|
|
455
|
+
}
|
|
456
|
+
function parseRepo2(value) {
|
|
457
|
+
const repo = nonEmptyString2(value, "repo");
|
|
458
|
+
const parts = repo.split("/");
|
|
459
|
+
if (parts.length !== 2 || !parts[0]?.trim() || !parts[1]?.trim()) {
|
|
460
|
+
throw new PluginToolInputError2('repo must use "owner/name" format');
|
|
461
|
+
}
|
|
462
|
+
return {
|
|
463
|
+
owner: parts[0].trim(),
|
|
464
|
+
name: parts[1].trim()
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
function escapeRegExp2(value) {
|
|
468
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
469
|
+
}
|
|
470
|
+
function sentryConversationUrl2(conversationId) {
|
|
471
|
+
const dsn = process.env.SENTRY_DSN?.trim();
|
|
472
|
+
const orgSlug = process.env.SENTRY_ORG_SLUG?.trim();
|
|
473
|
+
if (!dsn || !orgSlug) {
|
|
474
|
+
return void 0;
|
|
475
|
+
}
|
|
476
|
+
let parsed;
|
|
477
|
+
try {
|
|
478
|
+
parsed = new URL(dsn);
|
|
479
|
+
} catch {
|
|
480
|
+
return void 0;
|
|
481
|
+
}
|
|
482
|
+
const projectId = parsed.pathname.split("/").filter(Boolean).at(-1);
|
|
483
|
+
if (!parsed.hostname || !projectId) {
|
|
484
|
+
return void 0;
|
|
485
|
+
}
|
|
486
|
+
const encodedConversationId = encodeURIComponent(conversationId);
|
|
487
|
+
const params = new URLSearchParams({ project: projectId });
|
|
488
|
+
const path = `explore/conversations/${encodedConversationId}/?${params.toString()}`;
|
|
489
|
+
if (parsed.hostname === "sentry.io" || parsed.hostname.endsWith(".sentry.io")) {
|
|
490
|
+
return `https://${orgSlug}.sentry.io/${path}`;
|
|
491
|
+
}
|
|
492
|
+
const port = parsed.port ? `:${parsed.port}` : "";
|
|
493
|
+
return `${parsed.protocol}//${parsed.hostname}${port}/organizations/${orgSlug}/${path}`;
|
|
494
|
+
}
|
|
495
|
+
function githubPullRequestConversationFooter(conversationId) {
|
|
496
|
+
const id = nonEmptyString2(conversationId, "conversationId");
|
|
497
|
+
const sessionUrl = sentryConversationUrl2(id);
|
|
498
|
+
if (!sessionUrl) {
|
|
499
|
+
return void 0;
|
|
500
|
+
}
|
|
501
|
+
return `${GITHUB_PULL_REQUEST_FOOTER_START}
|
|
502
|
+
|
|
503
|
+
[View Session in Sentry](${sessionUrl})
|
|
504
|
+
|
|
505
|
+
${GITHUB_PULL_REQUEST_FOOTER_END}`;
|
|
506
|
+
}
|
|
507
|
+
function appendGitHubPullRequestFooter(body, conversationId) {
|
|
508
|
+
const footer = githubPullRequestConversationFooter(conversationId);
|
|
509
|
+
const normalizedBody = body.trimEnd();
|
|
510
|
+
const existingFooter = new RegExp(
|
|
511
|
+
`${escapeRegExp2(GITHUB_PULL_REQUEST_FOOTER_START)}[\\s\\S]*?${escapeRegExp2(
|
|
512
|
+
GITHUB_PULL_REQUEST_FOOTER_END
|
|
513
|
+
)}`
|
|
514
|
+
);
|
|
515
|
+
if (existingFooter.test(normalizedBody)) {
|
|
516
|
+
return footer ? normalizedBody.replace(existingFooter, footer) : normalizedBody.replace(existingFooter, "").trimEnd();
|
|
517
|
+
}
|
|
518
|
+
if (!footer) {
|
|
519
|
+
return normalizedBody;
|
|
520
|
+
}
|
|
521
|
+
return normalizedBody ? `${normalizedBody}
|
|
522
|
+
|
|
523
|
+
${footer}` : footer;
|
|
524
|
+
}
|
|
525
|
+
async function readJsonResponse2(response) {
|
|
526
|
+
const text = await response.text();
|
|
527
|
+
if (!text) {
|
|
528
|
+
return void 0;
|
|
529
|
+
}
|
|
530
|
+
try {
|
|
531
|
+
return JSON.parse(text);
|
|
532
|
+
} catch {
|
|
533
|
+
return text;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
function githubApiErrorMessage2(payload) {
|
|
537
|
+
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
|
|
538
|
+
const message = payload.message;
|
|
539
|
+
if (typeof message === "string") {
|
|
540
|
+
return message;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (typeof payload === "string" && payload.trim()) {
|
|
544
|
+
return payload.trim();
|
|
545
|
+
}
|
|
546
|
+
return "GitHub request failed";
|
|
547
|
+
}
|
|
548
|
+
function createPullRequestState(value) {
|
|
549
|
+
if (value === void 0) {
|
|
550
|
+
return void 0;
|
|
551
|
+
}
|
|
552
|
+
try {
|
|
553
|
+
return Value2.Parse(createPullRequestStateSchema, value);
|
|
554
|
+
} catch (error) {
|
|
555
|
+
throw new Error("Invalid GitHub createPullRequest idempotency state.", {
|
|
556
|
+
cause: error
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
function isEgressAuthRequired2(error) {
|
|
561
|
+
return error instanceof EgressAuthRequired2 || error instanceof Error && error.name === "EgressAuthRequired";
|
|
562
|
+
}
|
|
563
|
+
function isDefinitiveGitHubPullRequestCreateRejection(error) {
|
|
564
|
+
if (!(error instanceof GitHubPullRequestCreateRejectedError)) {
|
|
565
|
+
return false;
|
|
566
|
+
}
|
|
567
|
+
return [400, 401, 404, 410, 422].includes(error.status);
|
|
568
|
+
}
|
|
569
|
+
function createGitHubPullRequestRequest(conversationId, input) {
|
|
570
|
+
const repo = parseRepo2(input.repo);
|
|
571
|
+
const payload = {
|
|
572
|
+
title: nonEmptyString2(input.title, "title"),
|
|
573
|
+
head: nonEmptyString2(input.head, "head"),
|
|
574
|
+
base: nonEmptyString2(input.base, "base"),
|
|
575
|
+
body: appendGitHubPullRequestFooter(input.body ?? "", conversationId),
|
|
576
|
+
...input.draft !== void 0 ? { draft: input.draft } : {}
|
|
577
|
+
};
|
|
578
|
+
return new Request(
|
|
579
|
+
`https://api.github.com/repos/${encodeURIComponent(
|
|
580
|
+
repo.owner
|
|
581
|
+
)}/${encodeURIComponent(repo.name)}/pulls`,
|
|
582
|
+
{
|
|
583
|
+
method: "POST",
|
|
584
|
+
headers: {
|
|
585
|
+
Accept: "application/vnd.github+json",
|
|
586
|
+
"Content-Type": "application/json",
|
|
587
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
588
|
+
},
|
|
589
|
+
body: JSON.stringify(payload)
|
|
590
|
+
}
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
async function createGitHubPullRequest(ctx, request) {
|
|
594
|
+
const response = await ctx.egress.fetch({
|
|
595
|
+
provider: "github",
|
|
596
|
+
operation: "github.pull.create",
|
|
597
|
+
request
|
|
598
|
+
});
|
|
599
|
+
const parsed = await readJsonResponse2(response);
|
|
600
|
+
if (!response.ok) {
|
|
601
|
+
throw new GitHubPullRequestCreateRejectedError(
|
|
602
|
+
`GitHub pull request creation failed with HTTP ${response.status}: ${githubApiErrorMessage2(
|
|
603
|
+
parsed
|
|
604
|
+
)}`,
|
|
605
|
+
response.status
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
609
|
+
throw new Error(
|
|
610
|
+
"GitHub pull request creation returned an invalid response."
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
const pullRequest = parsed;
|
|
614
|
+
if (typeof pullRequest.number !== "number" || typeof pullRequest.html_url !== "string") {
|
|
615
|
+
throw new Error(
|
|
616
|
+
"GitHub pull request creation returned an invalid response."
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
return {
|
|
620
|
+
number: pullRequest.number,
|
|
621
|
+
url: pullRequest.html_url
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
function createGitHubPullRequestTool(ctx) {
|
|
625
|
+
return {
|
|
626
|
+
description: "Create a GitHub pull request with a runtime-owned Junior conversation footer. Use this instead of shelling out to gh pr create when creating pull requests.",
|
|
627
|
+
inputSchema: createPullRequestInputSchema,
|
|
628
|
+
async execute(input, options) {
|
|
629
|
+
const parsedInput = parseCreatePullRequestInput(input);
|
|
630
|
+
const conversationId = nonEmptyString2(
|
|
631
|
+
ctx.conversationId,
|
|
632
|
+
"conversationId"
|
|
633
|
+
);
|
|
634
|
+
const toolCallId = nonEmptyString2(options?.toolCallId, "toolCallId");
|
|
635
|
+
const key = `createPullRequest:${conversationId}:${toolCallId}`;
|
|
636
|
+
return await ctx.state.withLock(
|
|
637
|
+
`${key}:lock`,
|
|
638
|
+
GITHUB_PULL_REQUEST_CREATE_LOCK_TTL_MS,
|
|
639
|
+
async () => {
|
|
640
|
+
const state = createPullRequestState(await ctx.state.get(key));
|
|
641
|
+
if (state?.status === "completed") {
|
|
642
|
+
return {
|
|
643
|
+
number: state.number,
|
|
644
|
+
url: state.url
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
if (state?.status === "pending") {
|
|
648
|
+
throw new Error(
|
|
649
|
+
"GitHub pull request creation for this tool call has an uncertain pending result; refusing to create a duplicate pull request."
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
const request = createGitHubPullRequestRequest(
|
|
653
|
+
conversationId,
|
|
654
|
+
parsedInput
|
|
655
|
+
);
|
|
656
|
+
const pendingState = {
|
|
657
|
+
status: "pending",
|
|
658
|
+
createdAtMs: Date.now()
|
|
659
|
+
};
|
|
660
|
+
await ctx.state.set(
|
|
661
|
+
key,
|
|
662
|
+
pendingState,
|
|
663
|
+
GITHUB_PULL_REQUEST_CREATE_IDEMPOTENCY_TTL_MS
|
|
664
|
+
);
|
|
665
|
+
try {
|
|
666
|
+
const result = await createGitHubPullRequest(ctx, request);
|
|
667
|
+
Object.assign(pendingState, { status: "completed", ...result });
|
|
668
|
+
try {
|
|
669
|
+
await ctx.state.set(
|
|
670
|
+
key,
|
|
671
|
+
pendingState,
|
|
672
|
+
GITHUB_PULL_REQUEST_CREATE_IDEMPOTENCY_TTL_MS
|
|
673
|
+
);
|
|
674
|
+
} catch (error) {
|
|
675
|
+
throw new Error(
|
|
676
|
+
"GitHub pull request was created, but Junior could not persist the completed pull request state.",
|
|
677
|
+
{ cause: error }
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
return result;
|
|
681
|
+
} catch (error) {
|
|
682
|
+
if (isEgressAuthRequired2(error) || isDefinitiveGitHubPullRequestCreateRejection(error)) {
|
|
683
|
+
await ctx.state.delete(key);
|
|
684
|
+
}
|
|
685
|
+
throw error;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// src/tools.ts
|
|
694
|
+
function createGitHubTools(ctx) {
|
|
695
|
+
return {
|
|
696
|
+
createIssue: createGitHubIssueTool(ctx),
|
|
697
|
+
createPullRequest: createGitHubPullRequestTool(ctx)
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// src/index.ts
|
|
702
|
+
var GITHUB_APP_ID_ENV = "GITHUB_APP_ID";
|
|
703
|
+
var GITHUB_APP_PRIVATE_KEY_ENV = "GITHUB_APP_PRIVATE_KEY";
|
|
704
|
+
var GITHUB_INSTALLATION_ID_ENV = "GITHUB_INSTALLATION_ID";
|
|
705
|
+
var GITHUB_AUTH_TOKEN_ENV = "GITHUB_TOKEN";
|
|
706
|
+
var GITHUB_AUTH_TOKEN_PLACEHOLDER = "ghp_host_managed_credential";
|
|
707
|
+
var MAX_LEASE_MS = 60 * 60 * 1e3;
|
|
708
|
+
var REFRESH_BUFFER_MS = 5 * 60 * 1e3;
|
|
709
|
+
var USER_REFRESH_TIMEOUT_MS = 2e4;
|
|
710
|
+
var GITHUB_GRAPHQL_RESPONSE_BODY_LIMIT_BYTES = 64 * 1024;
|
|
711
|
+
var HTTP_READ_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
|
|
712
|
+
var USER_TOKEN_GRANTS = /* @__PURE__ */ new Set(["user-read", "user-write"]);
|
|
713
|
+
var CONTENTS_WRITE_REQUIREMENTS = [
|
|
714
|
+
"GitHub App Contents: write on the target repository",
|
|
715
|
+
"requesting GitHub user write access to the repository"
|
|
716
|
+
];
|
|
717
|
+
var WORKFLOWS_WRITE_REQUIREMENTS = [
|
|
718
|
+
"GitHub App Contents: write and Workflows: write on the target repository",
|
|
719
|
+
"requesting GitHub user write access to the repository"
|
|
720
|
+
];
|
|
721
|
+
var ISSUES_WRITE_REQUIREMENTS = [
|
|
722
|
+
"GitHub App Issues: write on the target repository",
|
|
723
|
+
"requesting GitHub user issue access to the repository"
|
|
724
|
+
];
|
|
725
|
+
var PULL_REQUESTS_WRITE_REQUIREMENTS = [
|
|
726
|
+
"GitHub App Pull requests: write on the target repository",
|
|
727
|
+
"requesting GitHub user write access to the repository"
|
|
728
|
+
];
|
|
729
|
+
var FORK_CREATE_REQUIREMENTS = [
|
|
730
|
+
"GitHub App Administration: write and Contents: read",
|
|
731
|
+
"app installation access on the source and destination accounts",
|
|
732
|
+
"requesting GitHub user permission to fork the repository"
|
|
733
|
+
];
|
|
734
|
+
var GitHubUserRefreshRejectedError = class extends Error {
|
|
735
|
+
constructor(message) {
|
|
736
|
+
super(message);
|
|
737
|
+
this.name = "GitHubUserRefreshRejectedError";
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
var GitHubRequestError = class extends Error {
|
|
741
|
+
status;
|
|
742
|
+
constructor(message, status) {
|
|
743
|
+
super(message);
|
|
744
|
+
this.name = "GitHubRequestError";
|
|
745
|
+
this.status = status;
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
var GitHubPluginSetupError = class extends Error {
|
|
749
|
+
constructor(message) {
|
|
750
|
+
super(message);
|
|
751
|
+
this.name = "GitHubPluginSetupError";
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
function isRecord(value) {
|
|
755
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
756
|
+
}
|
|
757
|
+
function readEnv(name) {
|
|
758
|
+
const value = process.env[name];
|
|
759
|
+
if (typeof value !== "string") {
|
|
760
|
+
return void 0;
|
|
761
|
+
}
|
|
762
|
+
const trimmed = value.trim();
|
|
763
|
+
return trimmed ? trimmed : void 0;
|
|
764
|
+
}
|
|
765
|
+
function requireEnv(name) {
|
|
766
|
+
const value = readEnv(name);
|
|
767
|
+
if (!value) {
|
|
768
|
+
throw new GitHubPluginSetupError(`Missing ${name}`);
|
|
769
|
+
}
|
|
770
|
+
return value;
|
|
771
|
+
}
|
|
772
|
+
function normalizeScopeList(scopes) {
|
|
773
|
+
return [
|
|
774
|
+
...new Set(
|
|
775
|
+
(scopes ?? []).flatMap((scope) => String(scope).split(/\s+/)).map((scope) => scope.trim()).filter(Boolean)
|
|
776
|
+
)
|
|
777
|
+
].sort();
|
|
778
|
+
}
|
|
779
|
+
function normalizeOAuthScope(scope) {
|
|
780
|
+
const normalized = normalizeScopeList(scope ? [scope] : []);
|
|
781
|
+
return normalized.length ? normalized.join(" ") : void 0;
|
|
782
|
+
}
|
|
783
|
+
function hasRequiredOAuthScope(storedScope, requiredScope) {
|
|
784
|
+
const required = normalizeScopeList(requiredScope ? [requiredScope] : []);
|
|
785
|
+
if (required.length === 0) {
|
|
786
|
+
return true;
|
|
787
|
+
}
|
|
788
|
+
const stored = new Set(normalizeScopeList(storedScope ? [storedScope] : []));
|
|
789
|
+
if (stored.size === 0) {
|
|
790
|
+
return false;
|
|
791
|
+
}
|
|
792
|
+
return required.every((scope) => stored.has(scope));
|
|
793
|
+
}
|
|
794
|
+
function cleanIdentityPart(value) {
|
|
795
|
+
return String(value ?? "").replaceAll("\n", " ").replaceAll("\r", " ").replace(/[<>]/g, "").trim();
|
|
796
|
+
}
|
|
797
|
+
function isSlackUserId(value) {
|
|
798
|
+
return /^[UW][A-Z0-9]{5,}$/.test(value);
|
|
799
|
+
}
|
|
800
|
+
function requesterDisplayName(value, requester) {
|
|
801
|
+
const name = cleanIdentityPart(value);
|
|
802
|
+
if (!name || name.toLowerCase() === "unknown" || name === cleanIdentityPart(requester?.userId)) {
|
|
803
|
+
return void 0;
|
|
804
|
+
}
|
|
805
|
+
return isSlackUserId(name) ? void 0 : name;
|
|
806
|
+
}
|
|
807
|
+
function requesterName(requester) {
|
|
808
|
+
return requesterDisplayName(requester?.fullName, requester) || requesterDisplayName(requester?.userName, requester) || void 0;
|
|
809
|
+
}
|
|
810
|
+
function requesterEmail(requester) {
|
|
811
|
+
const email = cleanIdentityPart(requester?.email);
|
|
812
|
+
return /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(email) ? email : void 0;
|
|
813
|
+
}
|
|
814
|
+
function isGitCommitCommand(command) {
|
|
815
|
+
return /(?:^|[\s;|&])git(?:\s+(?:-C\s+\S+|-c\s+\S+|--git-dir(?:=\S+|\s+\S+)|--work-tree(?:=\S+|\s+\S+)|--namespace(?:=\S+|\s+\S+)))*\s+commit(?:\s|$)/.test(
|
|
816
|
+
command
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
function prepareCommitMsgHook() {
|
|
820
|
+
return `#!/usr/bin/env bash
|
|
821
|
+
set -eu
|
|
822
|
+
|
|
823
|
+
message_file="\${1:-}"
|
|
824
|
+
if [ -z "$message_file" ]; then
|
|
825
|
+
exit 1
|
|
826
|
+
fi
|
|
827
|
+
|
|
828
|
+
if [ -z "\${JUNIOR_GIT_AUTHOR_NAME:-}" ] || [ -z "\${JUNIOR_GIT_AUTHOR_EMAIL:-}" ]; then
|
|
829
|
+
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
|
|
830
|
+
exit 1
|
|
831
|
+
fi
|
|
832
|
+
|
|
833
|
+
if [ "\${GIT_AUTHOR_NAME:-}" != "$JUNIOR_GIT_AUTHOR_NAME" ] || [ "\${GIT_AUTHOR_EMAIL:-}" != "$JUNIOR_GIT_AUTHOR_EMAIL" ]; then
|
|
834
|
+
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
|
|
835
|
+
exit 1
|
|
836
|
+
fi
|
|
837
|
+
|
|
838
|
+
if [ -z "\${JUNIOR_GIT_COAUTHOR_NAME:-}" ] || [ -z "\${JUNIOR_GIT_COAUTHOR_EMAIL:-}" ]; then
|
|
839
|
+
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
|
|
840
|
+
exit 1
|
|
841
|
+
fi
|
|
842
|
+
|
|
843
|
+
trailer="Co-Authored-By: $JUNIOR_GIT_COAUTHOR_NAME <$JUNIOR_GIT_COAUTHOR_EMAIL>"
|
|
844
|
+
if grep -Fqx "$trailer" "$message_file"; then
|
|
845
|
+
exit 0
|
|
846
|
+
fi
|
|
847
|
+
|
|
848
|
+
printf '\\n%s\\n' "$trailer" >> "$message_file"
|
|
849
|
+
`;
|
|
850
|
+
}
|
|
851
|
+
async function configureGit(ctx, key, value) {
|
|
852
|
+
const result = await ctx.sandbox.run({
|
|
853
|
+
cmd: "git",
|
|
854
|
+
args: ["config", "--global", key, value]
|
|
855
|
+
});
|
|
856
|
+
if (result.exitCode !== 0) {
|
|
857
|
+
throw new Error(
|
|
858
|
+
`Failed to configure git ${key}: ${result.stderr || result.stdout}`
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
function base64Url(input) {
|
|
863
|
+
return Buffer.from(input).toString("base64").replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
|
864
|
+
}
|
|
865
|
+
function getPrivateKey(envName) {
|
|
866
|
+
const raw = requireEnv(envName);
|
|
867
|
+
let key;
|
|
868
|
+
try {
|
|
869
|
+
key = createPrivateKey({ key: raw, format: "pem" });
|
|
870
|
+
} catch {
|
|
871
|
+
throw new GitHubPluginSetupError(
|
|
872
|
+
`Invalid ${envName}: expected a PEM-encoded RSA private key`
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
if (key.asymmetricKeyType !== "rsa") {
|
|
876
|
+
throw new GitHubPluginSetupError(
|
|
877
|
+
`Invalid ${envName}: GitHub App signing requires an RSA private key`
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
return key;
|
|
881
|
+
}
|
|
882
|
+
function createAppJwt(appId, privateKeyEnv) {
|
|
883
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
884
|
+
const header = { alg: "RS256", typ: "JWT" };
|
|
885
|
+
const payload = { iat: now - 60, exp: now + 9 * 60, iss: appId };
|
|
886
|
+
const encodedHeader = base64Url(JSON.stringify(header));
|
|
887
|
+
const encodedPayload = base64Url(JSON.stringify(payload));
|
|
888
|
+
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
|
889
|
+
const signer = createSign("RSA-SHA256");
|
|
890
|
+
signer.update(signingInput);
|
|
891
|
+
signer.end();
|
|
892
|
+
const signature = signer.sign(getPrivateKey(privateKeyEnv)).toString("base64").replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
|
893
|
+
return `${signingInput}.${signature}`;
|
|
894
|
+
}
|
|
895
|
+
async function githubRequest(apiBase, path, params) {
|
|
896
|
+
const response = await fetch(`${apiBase}${path}`, {
|
|
897
|
+
method: params.method ?? "GET",
|
|
898
|
+
headers: {
|
|
899
|
+
Accept: "application/vnd.github+json",
|
|
900
|
+
Authorization: `Bearer ${params.token}`,
|
|
901
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
902
|
+
...params.body ? { "Content-Type": "application/json" } : {}
|
|
903
|
+
},
|
|
904
|
+
...params.body ? { body: JSON.stringify(params.body) } : {}
|
|
905
|
+
});
|
|
906
|
+
const text = await response.text();
|
|
907
|
+
let parsed;
|
|
908
|
+
if (text) {
|
|
909
|
+
try {
|
|
910
|
+
parsed = JSON.parse(text);
|
|
911
|
+
} catch {
|
|
912
|
+
parsed = void 0;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
if (!response.ok) {
|
|
916
|
+
const message = isRecord(parsed) && typeof parsed.message === "string" ? parsed.message : `GitHub API error ${response.status}`;
|
|
917
|
+
throw new GitHubRequestError(message, response.status);
|
|
918
|
+
}
|
|
919
|
+
return parsed;
|
|
920
|
+
}
|
|
921
|
+
function buildOAuthTokenRequest(input) {
|
|
922
|
+
const payload = {
|
|
923
|
+
...input.payload,
|
|
924
|
+
client_id: input.clientId,
|
|
925
|
+
client_secret: input.clientSecret
|
|
926
|
+
};
|
|
927
|
+
return {
|
|
928
|
+
headers: {
|
|
929
|
+
Accept: "application/json",
|
|
930
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
931
|
+
},
|
|
932
|
+
body: new URLSearchParams(payload)
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
function parseOAuthResponseJson(responseText) {
|
|
936
|
+
if (!responseText.trim()) {
|
|
937
|
+
return void 0;
|
|
938
|
+
}
|
|
939
|
+
try {
|
|
940
|
+
return JSON.parse(responseText);
|
|
941
|
+
} catch {
|
|
942
|
+
return void 0;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
function oauthErrorCode(data) {
|
|
946
|
+
return isRecord(data) && typeof data.error === "string" ? data.error : void 0;
|
|
947
|
+
}
|
|
948
|
+
function isRejectedRefreshError(errorCode) {
|
|
949
|
+
return errorCode === "bad_refresh_token" || errorCode === "invalid_grant";
|
|
950
|
+
}
|
|
951
|
+
function parseOAuthTokenResponse(data, requestedScope) {
|
|
952
|
+
if (!isRecord(data)) {
|
|
953
|
+
throw new Error("OAuth token response is invalid");
|
|
954
|
+
}
|
|
955
|
+
if (typeof data.access_token !== "string" || !data.access_token.trim()) {
|
|
956
|
+
throw new Error("OAuth token response missing access_token");
|
|
957
|
+
}
|
|
958
|
+
if (typeof data.refresh_token !== "string" || !data.refresh_token.trim()) {
|
|
959
|
+
throw new Error("OAuth token response missing refresh_token");
|
|
960
|
+
}
|
|
961
|
+
let scope = normalizeOAuthScope(requestedScope);
|
|
962
|
+
if (data.scope !== void 0) {
|
|
963
|
+
if (typeof data.scope !== "string") {
|
|
964
|
+
throw new Error("OAuth token response returned invalid scope");
|
|
965
|
+
}
|
|
966
|
+
scope = normalizeOAuthScope(data.scope) ?? scope;
|
|
967
|
+
}
|
|
968
|
+
const result = {
|
|
969
|
+
accessToken: data.access_token,
|
|
970
|
+
refreshToken: data.refresh_token,
|
|
971
|
+
...scope ? { scope } : {}
|
|
972
|
+
};
|
|
973
|
+
if (data.expires_in !== void 0) {
|
|
974
|
+
if (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0) {
|
|
975
|
+
throw new Error("OAuth token response returned invalid expires_in");
|
|
976
|
+
}
|
|
977
|
+
result.expiresAt = Date.now() + data.expires_in * 1e3;
|
|
978
|
+
}
|
|
979
|
+
if (data.refresh_token_expires_in !== void 0) {
|
|
980
|
+
if (typeof data.refresh_token_expires_in !== "number" || !Number.isFinite(data.refresh_token_expires_in) || data.refresh_token_expires_in <= 0) {
|
|
981
|
+
throw new Error(
|
|
982
|
+
"OAuth token response returned invalid refresh_token_expires_in"
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
result.refreshTokenExpiresAt = Date.now() + data.refresh_token_expires_in * 1e3;
|
|
986
|
+
}
|
|
987
|
+
return result;
|
|
988
|
+
}
|
|
989
|
+
async function refreshUserAccessToken(input) {
|
|
990
|
+
const clientId = requireEnv(input.clientIdEnv);
|
|
991
|
+
const clientSecret = requireEnv(input.clientSecretEnv);
|
|
992
|
+
const request = buildOAuthTokenRequest({
|
|
993
|
+
clientId,
|
|
994
|
+
clientSecret,
|
|
995
|
+
payload: {
|
|
996
|
+
grant_type: "refresh_token",
|
|
997
|
+
refresh_token: input.refreshToken
|
|
998
|
+
}
|
|
999
|
+
});
|
|
1000
|
+
const response = await fetch("https://github.com/login/oauth/access_token", {
|
|
1001
|
+
method: "POST",
|
|
1002
|
+
headers: request.headers,
|
|
1003
|
+
body: request.body,
|
|
1004
|
+
signal: AbortSignal.timeout(USER_REFRESH_TIMEOUT_MS)
|
|
1005
|
+
});
|
|
1006
|
+
const responseText = await response.text();
|
|
1007
|
+
const responseData = parseOAuthResponseJson(responseText);
|
|
1008
|
+
const errorCode = oauthErrorCode(responseData);
|
|
1009
|
+
if (isRejectedRefreshError(errorCode)) {
|
|
1010
|
+
throw new GitHubUserRefreshRejectedError(
|
|
1011
|
+
`GitHub user token refresh rejected: ${errorCode}`
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
if (!response.ok || errorCode) {
|
|
1015
|
+
throw new Error(
|
|
1016
|
+
`GitHub user token refresh failed: ${response.status}${errorCode ? ` ${errorCode}` : ""}`
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
try {
|
|
1020
|
+
return parseOAuthTokenResponse(responseData, input.requestedScope);
|
|
1021
|
+
} catch (error) {
|
|
1022
|
+
if (error instanceof Error && error.message === "OAuth token response missing access_token") {
|
|
1023
|
+
throw new GitHubUserRefreshRejectedError(error.message);
|
|
1024
|
+
}
|
|
1025
|
+
throw error;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
function leaseExpiry(expiresAt) {
|
|
1029
|
+
return expiresAt ? Math.min(expiresAt, Date.now() + MAX_LEASE_MS) : Date.now() + MAX_LEASE_MS;
|
|
1030
|
+
}
|
|
1031
|
+
function isGitSmartHttpDomain(domain) {
|
|
1032
|
+
return domain.toLowerCase() === "github.com";
|
|
1033
|
+
}
|
|
1034
|
+
function authorizationFor(domain, token) {
|
|
1035
|
+
if (isGitSmartHttpDomain(domain)) {
|
|
1036
|
+
return `Basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`;
|
|
1037
|
+
}
|
|
1038
|
+
return `Bearer ${token}`;
|
|
1039
|
+
}
|
|
1040
|
+
function createCredentialLease(input) {
|
|
1041
|
+
return {
|
|
1042
|
+
type: "lease",
|
|
1043
|
+
lease: {
|
|
1044
|
+
...input.account ? { account: input.account } : {},
|
|
1045
|
+
...input.authorization ? { authorization: input.authorization } : {},
|
|
1046
|
+
expiresAt: new Date(input.expiresAtMs).toISOString(),
|
|
1047
|
+
headerTransforms: ["api.github.com", "github.com"].map((domain) => ({
|
|
1048
|
+
domain,
|
|
1049
|
+
headers: {
|
|
1050
|
+
Authorization: authorizationFor(domain, input.token)
|
|
1051
|
+
}
|
|
1052
|
+
}))
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
function githubUserAuthorization(scope) {
|
|
1057
|
+
return {
|
|
1058
|
+
type: "oauth",
|
|
1059
|
+
provider: "github",
|
|
1060
|
+
...scope ? { scope } : {}
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
function credentialNeeded(message, scope, allowAuthorization = true) {
|
|
1064
|
+
return {
|
|
1065
|
+
type: "needed",
|
|
1066
|
+
message,
|
|
1067
|
+
...allowAuthorization ? { authorization: githubUserAuthorization(scope) } : {}
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
function credentialUnavailable(message) {
|
|
1071
|
+
return {
|
|
1072
|
+
type: "unavailable",
|
|
1073
|
+
message
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
function parseInstallationTokenResponse(data) {
|
|
1077
|
+
if (!isRecord(data)) {
|
|
1078
|
+
throw new Error("GitHub installation token response is invalid");
|
|
1079
|
+
}
|
|
1080
|
+
const token = data.token;
|
|
1081
|
+
if (typeof token !== "string" || !token.trim()) {
|
|
1082
|
+
throw new Error("GitHub installation token response missing token");
|
|
1083
|
+
}
|
|
1084
|
+
const expiresAt = data.expires_at;
|
|
1085
|
+
const expiresAtMs = typeof expiresAt === "string" ? Date.parse(expiresAt) : Number.NaN;
|
|
1086
|
+
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
|
|
1087
|
+
throw new Error(
|
|
1088
|
+
"GitHub installation token response returned invalid expires_at"
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
return { token, expiresAtMs };
|
|
1092
|
+
}
|
|
1093
|
+
function readInstallationPermissions(installation) {
|
|
1094
|
+
if (!isRecord(installation) || !isRecord(installation.permissions)) {
|
|
1095
|
+
throw new Error("GitHub installation response missing permissions");
|
|
1096
|
+
}
|
|
1097
|
+
return readGrantPermissions(installation.permissions);
|
|
1098
|
+
}
|
|
1099
|
+
async function resolveUserAccount(tokens) {
|
|
1100
|
+
const account = await githubRequest("https://api.github.com", "/user", {
|
|
1101
|
+
token: tokens.accessToken
|
|
1102
|
+
});
|
|
1103
|
+
if (!isRecord(account)) {
|
|
1104
|
+
throw new Error("GitHub user response is invalid");
|
|
1105
|
+
}
|
|
1106
|
+
const id = account.id;
|
|
1107
|
+
const login = account.login;
|
|
1108
|
+
if (typeof id !== "number" && typeof id !== "string" || typeof login !== "string" || !login.trim()) {
|
|
1109
|
+
throw new Error("GitHub user response missing id or login");
|
|
1110
|
+
}
|
|
1111
|
+
const url = typeof account.html_url === "string" ? account.html_url : void 0;
|
|
1112
|
+
return {
|
|
1113
|
+
id: String(id),
|
|
1114
|
+
label: login.trim(),
|
|
1115
|
+
...url ? { url } : {}
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
async function tokensWithAccount(tokenSlot, stored, scope) {
|
|
1119
|
+
if (stored.account) {
|
|
1120
|
+
return { ok: true, tokens: stored };
|
|
1121
|
+
}
|
|
1122
|
+
let account;
|
|
1123
|
+
try {
|
|
1124
|
+
account = await resolveUserAccount(stored);
|
|
1125
|
+
} catch (error) {
|
|
1126
|
+
if (error instanceof GitHubRequestError && (error.status === 401 || error.status === 403)) {
|
|
1127
|
+
return {
|
|
1128
|
+
ok: false,
|
|
1129
|
+
result: credentialNeeded(
|
|
1130
|
+
"Your GitHub authorization needs to be refreshed.",
|
|
1131
|
+
scope
|
|
1132
|
+
)
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
throw error;
|
|
1136
|
+
}
|
|
1137
|
+
const updated = { ...stored, account };
|
|
1138
|
+
await tokenSlot.set(updated);
|
|
1139
|
+
return { ok: true, tokens: updated };
|
|
1140
|
+
}
|
|
1141
|
+
function shouldRefreshUserToken(stored, now = Date.now()) {
|
|
1142
|
+
return stored.expiresAt !== void 0 && stored.expiresAt - now < REFRESH_BUFFER_MS;
|
|
1143
|
+
}
|
|
1144
|
+
function canUseStoredUserToken(stored) {
|
|
1145
|
+
return stored.expiresAt === void 0 || stored.expiresAt > Date.now() && !shouldRefreshUserToken(stored);
|
|
1146
|
+
}
|
|
1147
|
+
async function refreshUserTokensWithLock(tokenSlot, scope, options) {
|
|
1148
|
+
return await tokenSlot.withRefresh(async () => {
|
|
1149
|
+
const latest = await tokenSlot.get();
|
|
1150
|
+
if (!latest) {
|
|
1151
|
+
return {
|
|
1152
|
+
ok: false,
|
|
1153
|
+
result: credentialNeeded("Connect your GitHub account.", scope)
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
if (!hasRequiredOAuthScope(latest.scope, scope)) {
|
|
1157
|
+
return {
|
|
1158
|
+
ok: false,
|
|
1159
|
+
result: credentialNeeded(
|
|
1160
|
+
"Your GitHub authorization needs to be refreshed.",
|
|
1161
|
+
scope
|
|
1162
|
+
)
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
if (canUseStoredUserToken(latest)) {
|
|
1166
|
+
return { ok: true, tokens: latest };
|
|
1167
|
+
}
|
|
1168
|
+
let refreshed;
|
|
1169
|
+
try {
|
|
1170
|
+
refreshed = await refreshUserAccessToken({
|
|
1171
|
+
clientIdEnv: options.clientIdEnv,
|
|
1172
|
+
clientSecretEnv: options.clientSecretEnv,
|
|
1173
|
+
refreshToken: latest.refreshToken,
|
|
1174
|
+
requestedScope: latest.scope ?? scope
|
|
1175
|
+
});
|
|
1176
|
+
} catch (error) {
|
|
1177
|
+
if (!(error instanceof GitHubUserRefreshRejectedError)) {
|
|
1178
|
+
throw error;
|
|
1179
|
+
}
|
|
1180
|
+
return {
|
|
1181
|
+
ok: false,
|
|
1182
|
+
result: credentialNeeded(
|
|
1183
|
+
"Your GitHub authorization has expired.",
|
|
1184
|
+
scope
|
|
1185
|
+
)
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
if (!hasRequiredOAuthScope(refreshed.scope, scope)) {
|
|
1189
|
+
return {
|
|
1190
|
+
ok: false,
|
|
1191
|
+
result: credentialNeeded(
|
|
1192
|
+
"Your GitHub authorization needs to be refreshed.",
|
|
1193
|
+
scope
|
|
1194
|
+
)
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
const refreshedTokens = {
|
|
1198
|
+
...latest.refreshTokenExpiresAt ? { refreshTokenExpiresAt: latest.refreshTokenExpiresAt } : {},
|
|
1199
|
+
...refreshed,
|
|
1200
|
+
...latest.account ? { account: latest.account } : {}
|
|
1201
|
+
};
|
|
1202
|
+
await tokenSlot.set(refreshedTokens);
|
|
1203
|
+
return { ok: true, tokens: refreshedTokens };
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
async function issueUserCredential(ctx, options) {
|
|
1207
|
+
const scope = options.userScope;
|
|
1208
|
+
const tokenSlot = ctx.tokens.currentUser ?? ctx.tokens.credentialSubject;
|
|
1209
|
+
if (!tokenSlot) {
|
|
1210
|
+
return credentialNeeded(
|
|
1211
|
+
"GitHub write access requires a current user or delegated user credential subject.",
|
|
1212
|
+
scope,
|
|
1213
|
+
false
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
const stored = await tokenSlot.get();
|
|
1217
|
+
if (!stored) {
|
|
1218
|
+
return credentialNeeded(
|
|
1219
|
+
"GitHub write access requires user authorization.",
|
|
1220
|
+
scope
|
|
1221
|
+
);
|
|
1222
|
+
}
|
|
1223
|
+
if (!hasRequiredOAuthScope(stored.scope, scope)) {
|
|
1224
|
+
return credentialNeeded(
|
|
1225
|
+
"Your GitHub authorization needs to be refreshed.",
|
|
1226
|
+
scope
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
const now = Date.now();
|
|
1230
|
+
if (stored.expiresAt !== void 0 && stored.expiresAt - now < REFRESH_BUFFER_MS) {
|
|
1231
|
+
const refreshResult = await refreshUserTokensWithLock(
|
|
1232
|
+
tokenSlot,
|
|
1233
|
+
scope,
|
|
1234
|
+
options
|
|
1235
|
+
);
|
|
1236
|
+
if (!refreshResult.ok) {
|
|
1237
|
+
return refreshResult.result;
|
|
1238
|
+
}
|
|
1239
|
+
const withAccount = await tokensWithAccount(
|
|
1240
|
+
tokenSlot,
|
|
1241
|
+
refreshResult.tokens,
|
|
1242
|
+
scope
|
|
1243
|
+
);
|
|
1244
|
+
if (!withAccount.ok) {
|
|
1245
|
+
return withAccount.result;
|
|
1246
|
+
}
|
|
1247
|
+
return createCredentialLease({
|
|
1248
|
+
account: withAccount.tokens.account,
|
|
1249
|
+
token: withAccount.tokens.accessToken,
|
|
1250
|
+
expiresAtMs: leaseExpiry(withAccount.tokens.expiresAt),
|
|
1251
|
+
authorization: githubUserAuthorization(scope)
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
if (stored.expiresAt === void 0 || stored.expiresAt > Date.now()) {
|
|
1255
|
+
const withAccount = await tokensWithAccount(tokenSlot, stored, scope);
|
|
1256
|
+
if (!withAccount.ok) {
|
|
1257
|
+
return withAccount.result;
|
|
1258
|
+
}
|
|
1259
|
+
return createCredentialLease({
|
|
1260
|
+
account: withAccount.tokens.account,
|
|
1261
|
+
token: withAccount.tokens.accessToken,
|
|
1262
|
+
expiresAtMs: leaseExpiry(withAccount.tokens.expiresAt),
|
|
1263
|
+
authorization: githubUserAuthorization(scope)
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
return credentialNeeded("Your GitHub authorization has expired.", scope);
|
|
1267
|
+
}
|
|
1268
|
+
async function issueInstallationCredential(options) {
|
|
1269
|
+
const appId = requireEnv(options.appIdEnv);
|
|
1270
|
+
const installationIdRaw = requireEnv(options.installationIdEnv);
|
|
1271
|
+
const installationId = Number(installationIdRaw);
|
|
1272
|
+
if (!Number.isSafeInteger(installationId) || installationId <= 0) {
|
|
1273
|
+
throw new GitHubPluginSetupError(`Invalid ${options.installationIdEnv}`);
|
|
1274
|
+
}
|
|
1275
|
+
const appJwt = createAppJwt(appId, options.privateKeyEnv);
|
|
1276
|
+
let tokenPermissions = options.readPermissions;
|
|
1277
|
+
if (!tokenPermissions) {
|
|
1278
|
+
tokenPermissions = await options.loadReadPermissions({
|
|
1279
|
+
appJwt,
|
|
1280
|
+
installationId
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
const accessTokenResponse = await githubRequest(
|
|
1284
|
+
"https://api.github.com",
|
|
1285
|
+
`/app/installations/${installationId}/access_tokens`,
|
|
1286
|
+
{
|
|
1287
|
+
method: "POST",
|
|
1288
|
+
token: appJwt,
|
|
1289
|
+
body: { permissions: tokenPermissions }
|
|
1290
|
+
}
|
|
1291
|
+
);
|
|
1292
|
+
const parsedToken = parseInstallationTokenResponse(accessTokenResponse);
|
|
1293
|
+
const expiresAtMs = Math.min(
|
|
1294
|
+
parsedToken.expiresAtMs,
|
|
1295
|
+
Date.now() + MAX_LEASE_MS
|
|
1296
|
+
);
|
|
1297
|
+
return createCredentialLease({
|
|
1298
|
+
token: parsedToken.token,
|
|
1299
|
+
expiresAtMs
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
function createPermissionCache() {
|
|
1303
|
+
let cached;
|
|
1304
|
+
let pending;
|
|
1305
|
+
return async ({ appJwt, installationId }) => {
|
|
1306
|
+
if (cached && cached.expiresAtMs > Date.now()) {
|
|
1307
|
+
return cached.permissions;
|
|
1308
|
+
}
|
|
1309
|
+
pending ??= githubRequest(
|
|
1310
|
+
"https://api.github.com",
|
|
1311
|
+
`/app/installations/${installationId}`,
|
|
1312
|
+
{ token: appJwt }
|
|
1313
|
+
).then((installation) => {
|
|
1314
|
+
const permissions = readInstallationPermissions(installation);
|
|
1315
|
+
cached = {
|
|
1316
|
+
expiresAtMs: Date.now() + MAX_LEASE_MS,
|
|
1317
|
+
permissions
|
|
1318
|
+
};
|
|
1319
|
+
return permissions;
|
|
1320
|
+
}).finally(() => {
|
|
1321
|
+
pending = void 0;
|
|
1322
|
+
});
|
|
1323
|
+
return await pending;
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
function githubSmartHttpAccess(upstreamUrl) {
|
|
1327
|
+
const pathname = upstreamUrl.pathname.toLowerCase();
|
|
1328
|
+
const service = upstreamUrl.searchParams.get("service")?.toLowerCase();
|
|
1329
|
+
const isSmartHttpPath = pathname.endsWith("/info/refs") || pathname.endsWith("/git-receive-pack") || pathname.endsWith("/git-upload-pack");
|
|
1330
|
+
if (!isSmartHttpPath) {
|
|
1331
|
+
return void 0;
|
|
1332
|
+
}
|
|
1333
|
+
if (pathname.endsWith("/git-receive-pack") || service === "git-receive-pack") {
|
|
1334
|
+
return "write";
|
|
1335
|
+
}
|
|
1336
|
+
if (pathname.endsWith("/git-upload-pack") || service === "git-upload-pack") {
|
|
1337
|
+
return "read";
|
|
1338
|
+
}
|
|
1339
|
+
return void 0;
|
|
1340
|
+
}
|
|
1341
|
+
function isGitHubGraphqlUrl(upstreamUrl) {
|
|
1342
|
+
return upstreamUrl.hostname.toLowerCase() === "api.github.com" && upstreamUrl.pathname.toLowerCase().endsWith("/graphql");
|
|
1343
|
+
}
|
|
1344
|
+
function isGitHubApiUrl(upstreamUrl) {
|
|
1345
|
+
return upstreamUrl.hostname.toLowerCase() === "api.github.com";
|
|
1346
|
+
}
|
|
1347
|
+
function githubUserReadReason(method, upstreamUrl) {
|
|
1348
|
+
if (method !== "GET" || !isGitHubApiUrl(upstreamUrl)) {
|
|
1349
|
+
return void 0;
|
|
1350
|
+
}
|
|
1351
|
+
return upstreamUrl.pathname.toLowerCase() === "/user" ? "github.user-read" : void 0;
|
|
1352
|
+
}
|
|
1353
|
+
function parseGitHubGraphqlOperation(bodyText) {
|
|
1354
|
+
const parsed = parseGitHubGraphqlRequest(bodyText);
|
|
1355
|
+
if (!parsed) {
|
|
1356
|
+
return void 0;
|
|
1357
|
+
}
|
|
1358
|
+
const { normalized, operationName } = parsed;
|
|
1359
|
+
if (operationName) {
|
|
1360
|
+
const namedOperation = normalized.match(
|
|
1361
|
+
new RegExp(
|
|
1362
|
+
`\\b(query|mutation|subscription)\\s+${escapeRegExp3(operationName)}\\b`
|
|
1363
|
+
)
|
|
1364
|
+
)?.[1];
|
|
1365
|
+
return namedOperation ? graphqlOperationAccess(namedOperation) : void 0;
|
|
1366
|
+
}
|
|
1367
|
+
const operation = normalized.match(/\b(query|mutation|subscription)\b/)?.[1];
|
|
1368
|
+
const operationAccess = graphqlOperationAccess(operation);
|
|
1369
|
+
if (operationAccess) {
|
|
1370
|
+
return operationAccess;
|
|
1371
|
+
}
|
|
1372
|
+
if (normalized.startsWith("{")) {
|
|
1373
|
+
return "read";
|
|
1374
|
+
}
|
|
1375
|
+
return void 0;
|
|
1376
|
+
}
|
|
1377
|
+
function parseGitHubGraphqlRequest(bodyText) {
|
|
1378
|
+
if (typeof bodyText !== "string" || bodyText.trim().length === 0) {
|
|
1379
|
+
return void 0;
|
|
1380
|
+
}
|
|
1381
|
+
let parsed;
|
|
1382
|
+
try {
|
|
1383
|
+
parsed = JSON.parse(bodyText);
|
|
1384
|
+
} catch {
|
|
1385
|
+
return void 0;
|
|
1386
|
+
}
|
|
1387
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1388
|
+
return void 0;
|
|
1389
|
+
}
|
|
1390
|
+
const query = parsed.query;
|
|
1391
|
+
if (typeof query !== "string") {
|
|
1392
|
+
return void 0;
|
|
1393
|
+
}
|
|
1394
|
+
const operationName = typeof parsed.operationName === "string" ? parsed.operationName.trim() : void 0;
|
|
1395
|
+
const normalized = maskGraphqlStringLiterals(
|
|
1396
|
+
query.replace(/^\s*#[^\n\r]*(?:\r?\n|$)/gm, "")
|
|
1397
|
+
).trim();
|
|
1398
|
+
return {
|
|
1399
|
+
normalized,
|
|
1400
|
+
...operationName ? { operationName } : {}
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
function escapeRegExp3(value) {
|
|
1404
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1405
|
+
}
|
|
1406
|
+
function graphqlOperationAccess(operation) {
|
|
1407
|
+
if (operation === "mutation" || operation === "subscription") {
|
|
1408
|
+
return "write";
|
|
1409
|
+
}
|
|
1410
|
+
if (operation === "query") {
|
|
1411
|
+
return "read";
|
|
1412
|
+
}
|
|
1413
|
+
return void 0;
|
|
1414
|
+
}
|
|
1415
|
+
function maskGraphqlStringLiterals(query) {
|
|
1416
|
+
return query.replace(
|
|
1417
|
+
/"""[\s\S]*?"""|"(?:\\.|[^"\\])*"/g,
|
|
1418
|
+
(match) => " ".repeat(match.length)
|
|
1419
|
+
);
|
|
1420
|
+
}
|
|
1421
|
+
function githubGraphqlAccess(method, upstreamUrl, bodyText) {
|
|
1422
|
+
if (!isGitHubGraphqlUrl(upstreamUrl)) {
|
|
1423
|
+
return void 0;
|
|
1424
|
+
}
|
|
1425
|
+
if (HTTP_READ_METHODS.has(method)) {
|
|
1426
|
+
return "read";
|
|
1427
|
+
}
|
|
1428
|
+
const operation = parseGitHubGraphqlOperation(bodyText);
|
|
1429
|
+
if (operation) {
|
|
1430
|
+
return operation;
|
|
1431
|
+
}
|
|
1432
|
+
return "write";
|
|
1433
|
+
}
|
|
1434
|
+
function githubGraphqlPermissionDeniedMessage(bodyText) {
|
|
1435
|
+
let parsed;
|
|
1436
|
+
try {
|
|
1437
|
+
parsed = JSON.parse(bodyText);
|
|
1438
|
+
} catch {
|
|
1439
|
+
return void 0;
|
|
1440
|
+
}
|
|
1441
|
+
if (!isRecord(parsed) || !Array.isArray(parsed.errors)) {
|
|
1442
|
+
return void 0;
|
|
1443
|
+
}
|
|
1444
|
+
for (const error of parsed.errors) {
|
|
1445
|
+
if (!isRecord(error) || typeof error.message !== "string") {
|
|
1446
|
+
continue;
|
|
1447
|
+
}
|
|
1448
|
+
const message = error.message;
|
|
1449
|
+
if (error.type === "NOT_FOUND" && /\bCould not resolve to a Repository with the name\b/.test(message)) {
|
|
1450
|
+
return `GitHub GraphQL could not access the repository: ${message}`;
|
|
1451
|
+
}
|
|
1452
|
+
if (/\bResource not accessible by integration\b/.test(message)) {
|
|
1453
|
+
return `GitHub GraphQL denied access: ${message}`;
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
return void 0;
|
|
1457
|
+
}
|
|
1458
|
+
function shouldInspectGitHubGraphqlResponse(ctx) {
|
|
1459
|
+
if (ctx.request.method.toUpperCase() !== "POST" || ctx.response.status !== 200) {
|
|
1460
|
+
return false;
|
|
1461
|
+
}
|
|
1462
|
+
let upstreamUrl;
|
|
1463
|
+
try {
|
|
1464
|
+
upstreamUrl = new URL(ctx.request.url);
|
|
1465
|
+
} catch {
|
|
1466
|
+
return false;
|
|
1467
|
+
}
|
|
1468
|
+
if (!isGitHubGraphqlUrl(upstreamUrl)) {
|
|
1469
|
+
return false;
|
|
1470
|
+
}
|
|
1471
|
+
const contentType = ctx.response.headers.get("content-type");
|
|
1472
|
+
return contentType ? /\bjson\b/i.test(contentType) : false;
|
|
1473
|
+
}
|
|
1474
|
+
function githubApiWriteReason(method, upstreamUrl) {
|
|
1475
|
+
const pathname = upstreamUrl.pathname.toLowerCase();
|
|
1476
|
+
if (!isGitHubApiUrl(upstreamUrl)) {
|
|
1477
|
+
return void 0;
|
|
1478
|
+
}
|
|
1479
|
+
if (method === "POST" && /^\/repos\/[^/]+\/[^/]+\/issues$/.test(pathname)) {
|
|
1480
|
+
return "github.issue-create";
|
|
1481
|
+
}
|
|
1482
|
+
if (method === "POST" && /^\/repos\/[^/]+\/[^/]+\/issues\/[^/]+\/comments$/.test(pathname)) {
|
|
1483
|
+
return "github.issues-write";
|
|
1484
|
+
}
|
|
1485
|
+
if (method === "POST" && /^\/repos\/[^/]+\/[^/]+\/pulls$/.test(pathname)) {
|
|
1486
|
+
return "github.pull-create";
|
|
1487
|
+
}
|
|
1488
|
+
if (method === "PATCH" && /^\/repos\/[^/]+\/[^/]+\/pulls\/[^/]+$/.test(pathname)) {
|
|
1489
|
+
return "github.pull-requests-write";
|
|
1490
|
+
}
|
|
1491
|
+
if (method === "POST" && /^\/repos\/[^/]+\/[^/]+\/forks$/.test(pathname)) {
|
|
1492
|
+
return "github.fork-create";
|
|
1493
|
+
}
|
|
1494
|
+
if (/^\/repos\/[^/]+\/[^/]+\/contents(?:\/|$)/.test(pathname) && (method === "PUT" || method === "DELETE")) {
|
|
1495
|
+
return pathname.includes("/.github/workflows/") ? "github.workflows-write" : "github.contents-write";
|
|
1496
|
+
}
|
|
1497
|
+
if (method === "POST" && /^\/repos\/[^/]+\/[^/]+\/git\/(blobs|trees|commits)$/.test(pathname)) {
|
|
1498
|
+
return "github.contents-write";
|
|
1499
|
+
}
|
|
1500
|
+
if (method === "POST" && /^\/repos\/[^/]+\/[^/]+\/git\/refs$/.test(pathname)) {
|
|
1501
|
+
return "github.contents-write";
|
|
1502
|
+
}
|
|
1503
|
+
if ((method === "PATCH" || method === "DELETE") && /^\/repos\/[^/]+\/[^/]+\/git\/refs\/.+/.test(pathname)) {
|
|
1504
|
+
return "github.contents-write";
|
|
1505
|
+
}
|
|
1506
|
+
if (method === "PUT" && /^\/repos\/[^/]+\/[^/]+\/pulls\/[^/]+\/merge$/.test(pathname)) {
|
|
1507
|
+
return "github.contents-write";
|
|
1508
|
+
}
|
|
1509
|
+
return void 0;
|
|
1510
|
+
}
|
|
1511
|
+
function isGitHubIssueCreateRestRequest(method, upstreamUrl) {
|
|
1512
|
+
return method === "POST" && isGitHubApiUrl(upstreamUrl) && /^\/repos\/[^/]+\/[^/]+\/issues$/.test(upstreamUrl.pathname.toLowerCase());
|
|
1513
|
+
}
|
|
1514
|
+
function isGitHubPullCreateRestRequest(method, upstreamUrl) {
|
|
1515
|
+
return method === "POST" && isGitHubApiUrl(upstreamUrl) && /^\/repos\/[^/]+\/[^/]+\/pulls$/.test(upstreamUrl.pathname.toLowerCase());
|
|
1516
|
+
}
|
|
1517
|
+
function isGitHubIssueCreateGraphqlMutation(method, upstreamUrl, bodyText) {
|
|
1518
|
+
if (method !== "POST" || !isGitHubGraphqlUrl(upstreamUrl)) {
|
|
1519
|
+
return false;
|
|
1520
|
+
}
|
|
1521
|
+
const parsed = parseGitHubGraphqlRequest(bodyText);
|
|
1522
|
+
if (!parsed) {
|
|
1523
|
+
return false;
|
|
1524
|
+
}
|
|
1525
|
+
if (!/\bcreateIssue\b/.test(parsed.normalized)) {
|
|
1526
|
+
return false;
|
|
1527
|
+
}
|
|
1528
|
+
if (!parsed.operationName) {
|
|
1529
|
+
return /\bmutation\b/.test(parsed.normalized);
|
|
1530
|
+
}
|
|
1531
|
+
return new RegExp(
|
|
1532
|
+
`\\bmutation\\s+${escapeRegExp3(parsed.operationName)}\\b`
|
|
1533
|
+
).test(parsed.normalized);
|
|
1534
|
+
}
|
|
1535
|
+
function isGitHubPullCreateGraphqlMutation(method, upstreamUrl, bodyText) {
|
|
1536
|
+
if (method !== "POST" || !isGitHubGraphqlUrl(upstreamUrl)) {
|
|
1537
|
+
return false;
|
|
1538
|
+
}
|
|
1539
|
+
const parsed = parseGitHubGraphqlRequest(bodyText);
|
|
1540
|
+
if (!parsed) {
|
|
1541
|
+
return false;
|
|
1542
|
+
}
|
|
1543
|
+
if (!/\bcreatePullRequest\b/.test(parsed.normalized)) {
|
|
1544
|
+
return false;
|
|
1545
|
+
}
|
|
1546
|
+
if (!parsed.operationName) {
|
|
1547
|
+
return /\bmutation\b/.test(parsed.normalized);
|
|
1548
|
+
}
|
|
1549
|
+
return new RegExp(
|
|
1550
|
+
`\\bmutation\\s+${escapeRegExp3(parsed.operationName)}\\b`
|
|
1551
|
+
).test(parsed.normalized);
|
|
1552
|
+
}
|
|
1553
|
+
function assertGitHubWriteAllowed(input) {
|
|
1554
|
+
if (input.operation === "github.issue.create") {
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
if (input.operation === "github.pull.create") {
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
if (isGitHubIssueCreateRestRequest(input.method, input.upstreamUrl) || isGitHubIssueCreateGraphqlMutation(
|
|
1561
|
+
input.method,
|
|
1562
|
+
input.upstreamUrl,
|
|
1563
|
+
input.bodyText
|
|
1564
|
+
)) {
|
|
1565
|
+
throw new EgressPolicyDenied(
|
|
1566
|
+
"GitHub issue creation must use the github_createIssue tool so Junior can own idempotency and the conversation footer."
|
|
1567
|
+
);
|
|
1568
|
+
}
|
|
1569
|
+
if (isGitHubPullCreateRestRequest(input.method, input.upstreamUrl) || isGitHubPullCreateGraphqlMutation(
|
|
1570
|
+
input.method,
|
|
1571
|
+
input.upstreamUrl,
|
|
1572
|
+
input.bodyText
|
|
1573
|
+
)) {
|
|
1574
|
+
throw new EgressPolicyDenied(
|
|
1575
|
+
"GitHub pull request creation must use the github_createPullRequest tool so Junior can own idempotency and the conversation footer."
|
|
1576
|
+
);
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
function grantRequirements(reason) {
|
|
1580
|
+
if (reason === "github.git-write" || reason === "github.contents-write") {
|
|
1581
|
+
return CONTENTS_WRITE_REQUIREMENTS;
|
|
1582
|
+
}
|
|
1583
|
+
if (reason === "github.workflows-write") {
|
|
1584
|
+
return WORKFLOWS_WRITE_REQUIREMENTS;
|
|
1585
|
+
}
|
|
1586
|
+
if (reason === "github.issue-create" || reason === "github.issues-write") {
|
|
1587
|
+
return ISSUES_WRITE_REQUIREMENTS;
|
|
1588
|
+
}
|
|
1589
|
+
if (reason === "github.pull-create" || reason === "github.pull-requests-write") {
|
|
1590
|
+
return PULL_REQUESTS_WRITE_REQUIREMENTS;
|
|
1591
|
+
}
|
|
1592
|
+
if (reason === "github.fork-create") {
|
|
1593
|
+
return FORK_CREATE_REQUIREMENTS;
|
|
1594
|
+
}
|
|
1595
|
+
return void 0;
|
|
1596
|
+
}
|
|
1597
|
+
function grantForAccess(access, reason, name) {
|
|
1598
|
+
const requirements = grantRequirements(reason);
|
|
1599
|
+
return {
|
|
1600
|
+
name,
|
|
1601
|
+
access,
|
|
1602
|
+
reason,
|
|
1603
|
+
...requirements ? { requirements } : {}
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
async function githubGrantForEgress(ctx) {
|
|
1607
|
+
const method = ctx.request.method.toUpperCase();
|
|
1608
|
+
const upstreamUrl = new URL(ctx.request.url);
|
|
1609
|
+
assertGitHubWriteAllowed({
|
|
1610
|
+
...ctx.request.bodyText !== void 0 ? { bodyText: ctx.request.bodyText } : {},
|
|
1611
|
+
method,
|
|
1612
|
+
...ctx.request.operation ? { operation: ctx.request.operation } : {},
|
|
1613
|
+
upstreamUrl
|
|
1614
|
+
});
|
|
1615
|
+
const smartHttpAccess = githubSmartHttpAccess(upstreamUrl);
|
|
1616
|
+
if (smartHttpAccess) {
|
|
1617
|
+
return grantForAccess(
|
|
1618
|
+
smartHttpAccess,
|
|
1619
|
+
smartHttpAccess === "write" ? "github.git-write" : "github.git-read",
|
|
1620
|
+
smartHttpAccess === "write" ? "user-write" : "installation-read"
|
|
1621
|
+
);
|
|
1622
|
+
}
|
|
1623
|
+
const userReadReason = githubUserReadReason(method, upstreamUrl);
|
|
1624
|
+
if (userReadReason) {
|
|
1625
|
+
return grantForAccess("read", userReadReason, "user-read");
|
|
1626
|
+
}
|
|
1627
|
+
const writeReason = githubApiWriteReason(method, upstreamUrl);
|
|
1628
|
+
if (writeReason) {
|
|
1629
|
+
return grantForAccess("write", writeReason, "user-write");
|
|
1630
|
+
}
|
|
1631
|
+
const graphqlAccess = githubGraphqlAccess(
|
|
1632
|
+
method,
|
|
1633
|
+
upstreamUrl,
|
|
1634
|
+
ctx.request.bodyText
|
|
1635
|
+
);
|
|
1636
|
+
if (graphqlAccess) {
|
|
1637
|
+
return grantForAccess(
|
|
1638
|
+
graphqlAccess,
|
|
1639
|
+
graphqlAccess === "write" ? "github.graphql-write" : "github.graphql-read",
|
|
1640
|
+
graphqlAccess === "write" ? "user-write" : "installation-read"
|
|
1641
|
+
);
|
|
1642
|
+
}
|
|
1643
|
+
const access = HTTP_READ_METHODS.has(method) ? "read" : "write";
|
|
1644
|
+
return grantForAccess(
|
|
1645
|
+
access,
|
|
1646
|
+
access === "write" ? "github.api-write" : "github.api-read",
|
|
1647
|
+
access === "write" ? "user-write" : "installation-read"
|
|
1648
|
+
);
|
|
1649
|
+
}
|
|
1650
|
+
function githubPlugin(options = {}) {
|
|
1651
|
+
const botNameEnv = options.botNameEnv ?? "GITHUB_APP_BOT_NAME";
|
|
1652
|
+
const botEmailEnv = options.botEmailEnv ?? "GITHUB_APP_BOT_EMAIL";
|
|
1653
|
+
const clientIdEnv = options.clientIdEnv ?? "GITHUB_APP_CLIENT_ID";
|
|
1654
|
+
const clientSecretEnv = options.clientSecretEnv ?? "GITHUB_APP_CLIENT_SECRET";
|
|
1655
|
+
const appIdEnv = options.appIdEnv ?? GITHUB_APP_ID_ENV;
|
|
1656
|
+
const privateKeyEnv = options.privateKeyEnv ?? GITHUB_APP_PRIVATE_KEY_ENV;
|
|
1657
|
+
const installationIdEnv = options.installationIdEnv ?? GITHUB_INSTALLATION_ID_ENV;
|
|
1658
|
+
const appPermissions = normalizePermissions(options.appPermissions);
|
|
1659
|
+
const appReadPermissions = appPermissions ? readGrantPermissions(appPermissions) : void 0;
|
|
1660
|
+
const loadReadPermissions = createPermissionCache();
|
|
1661
|
+
const appCapabilities = permissionCapabilities(appPermissions);
|
|
1662
|
+
const userScopes = normalizeScopeList(options.additionalUserScopes);
|
|
1663
|
+
const userScope = userScopes.length ? userScopes.join(" ") : void 0;
|
|
1664
|
+
return defineJuniorPlugin({
|
|
1665
|
+
packageName: "@sentry/junior-github",
|
|
1666
|
+
manifest: {
|
|
1667
|
+
name: "github",
|
|
1668
|
+
displayName: "GitHub",
|
|
1669
|
+
description: "GitHub issue, pull request, and repository workflows via GitHub App",
|
|
1670
|
+
...appCapabilities ? { capabilities: appCapabilities } : {},
|
|
1671
|
+
configKeys: ["org", "repo"],
|
|
1672
|
+
domains: ["api.github.com", "github.com"],
|
|
1673
|
+
envVars: {
|
|
1674
|
+
[appIdEnv]: {},
|
|
1675
|
+
[privateKeyEnv]: {},
|
|
1676
|
+
[installationIdEnv]: {},
|
|
1677
|
+
[clientIdEnv]: {},
|
|
1678
|
+
[clientSecretEnv]: {},
|
|
1679
|
+
[botNameEnv]: { exposeToCommandEnv: true },
|
|
1680
|
+
[botEmailEnv]: { exposeToCommandEnv: true }
|
|
1681
|
+
},
|
|
1682
|
+
oauth: {
|
|
1683
|
+
clientIdEnv,
|
|
1684
|
+
clientSecretEnv,
|
|
1685
|
+
authorizeEndpoint: "https://github.com/login/oauth/authorize",
|
|
1686
|
+
tokenEndpoint: "https://github.com/login/oauth/access_token",
|
|
1687
|
+
// GitHub App user-to-server tokens always return scope: "" regardless
|
|
1688
|
+
// of what was requested; treat empty response scope as unreported.
|
|
1689
|
+
treatEmptyScopeAsUnreported: true,
|
|
1690
|
+
...userScope ? { scope: userScope } : {}
|
|
1691
|
+
},
|
|
1692
|
+
commandEnv: {
|
|
1693
|
+
[GITHUB_AUTH_TOKEN_ENV]: GITHUB_AUTH_TOKEN_PLACEHOLDER,
|
|
1694
|
+
GIT_COMMITTER_NAME: `\${${botNameEnv}}`,
|
|
1695
|
+
GIT_COMMITTER_EMAIL: `\${${botEmailEnv}}`
|
|
1696
|
+
},
|
|
1697
|
+
target: {
|
|
1698
|
+
type: "repo",
|
|
1699
|
+
configKey: "repo",
|
|
1700
|
+
commandFlags: ["--repo", "-R"]
|
|
1701
|
+
},
|
|
1702
|
+
runtimeDependencies: [
|
|
1703
|
+
{
|
|
1704
|
+
type: "system",
|
|
1705
|
+
package: "gh"
|
|
1706
|
+
}
|
|
1707
|
+
]
|
|
1708
|
+
},
|
|
1709
|
+
hooks: {
|
|
1710
|
+
tools(ctx) {
|
|
1711
|
+
return createGitHubTools(ctx);
|
|
1712
|
+
},
|
|
1713
|
+
async sandboxPrepare(ctx) {
|
|
1714
|
+
const hooksPath = `${ctx.sandbox.juniorRoot}/git-hooks`;
|
|
1715
|
+
await ctx.sandbox.writeFile({
|
|
1716
|
+
path: `${hooksPath}/prepare-commit-msg`,
|
|
1717
|
+
mode: 493,
|
|
1718
|
+
content: prepareCommitMsgHook()
|
|
1719
|
+
});
|
|
1720
|
+
await configureGit(ctx, "core.hooksPath", hooksPath);
|
|
1721
|
+
await configureGit(ctx, "commit.gpgsign", "false");
|
|
1722
|
+
await configureGit(ctx, "credential.helper", "");
|
|
1723
|
+
await configureGit(ctx, "http.emptyAuth", "true");
|
|
1724
|
+
},
|
|
1725
|
+
beforeToolExecute(ctx) {
|
|
1726
|
+
if (ctx.tool.name !== "bash") {
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
const command = typeof ctx.tool.input === "object" && ctx.tool.input && "command" in ctx.tool.input ? String(ctx.tool.input.command ?? "") : "";
|
|
1730
|
+
const botName = readEnv(botNameEnv);
|
|
1731
|
+
const botEmail = readEnv(botEmailEnv);
|
|
1732
|
+
if ((!botName || !botEmail) && isGitCommitCommand(command)) {
|
|
1733
|
+
ctx.decision.deny(
|
|
1734
|
+
`Junior GitHub plugin is misconfigured: host env vars ${botNameEnv} and ${botEmailEnv} are missing. This is an internal deployment configuration error; do not set them in the sandbox.`
|
|
1735
|
+
);
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
if (!botName || !botEmail) {
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
const authorName = requesterName(ctx.requester);
|
|
1742
|
+
const authorEmail = requesterEmail(ctx.requester);
|
|
1743
|
+
if ((!authorName || !authorEmail) && isGitCommitCommand(command)) {
|
|
1744
|
+
ctx.decision.deny(
|
|
1745
|
+
"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."
|
|
1746
|
+
);
|
|
1747
|
+
return;
|
|
1748
|
+
}
|
|
1749
|
+
if (authorName && authorEmail) {
|
|
1750
|
+
ctx.env.set("GIT_AUTHOR_NAME", authorName);
|
|
1751
|
+
ctx.env.set("GIT_AUTHOR_EMAIL", authorEmail);
|
|
1752
|
+
ctx.env.set("JUNIOR_GIT_AUTHOR_NAME", authorName);
|
|
1753
|
+
ctx.env.set("JUNIOR_GIT_AUTHOR_EMAIL", authorEmail);
|
|
1754
|
+
}
|
|
1755
|
+
ctx.env.set("GIT_COMMITTER_NAME", botName);
|
|
1756
|
+
ctx.env.set("GIT_COMMITTER_EMAIL", botEmail);
|
|
1757
|
+
ctx.env.set("JUNIOR_GIT_COAUTHOR_NAME", botName);
|
|
1758
|
+
ctx.env.set("JUNIOR_GIT_COAUTHOR_EMAIL", botEmail);
|
|
1759
|
+
},
|
|
1760
|
+
grantForEgress(ctx) {
|
|
1761
|
+
return githubGrantForEgress(ctx);
|
|
1762
|
+
},
|
|
1763
|
+
async onEgressResponse(ctx) {
|
|
1764
|
+
if (!shouldInspectGitHubGraphqlResponse(ctx)) {
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
const bodyText = await ctx.response.readText(
|
|
1768
|
+
GITHUB_GRAPHQL_RESPONSE_BODY_LIMIT_BYTES
|
|
1769
|
+
);
|
|
1770
|
+
if (!bodyText) {
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
const message = githubGraphqlPermissionDeniedMessage(bodyText);
|
|
1774
|
+
if (message) {
|
|
1775
|
+
ctx.permissionDenied(message);
|
|
1776
|
+
}
|
|
1777
|
+
},
|
|
1778
|
+
async resolveOAuthAccount(ctx) {
|
|
1779
|
+
return await resolveUserAccount(ctx.tokens);
|
|
1780
|
+
},
|
|
1781
|
+
async issueCredential(ctx) {
|
|
1782
|
+
try {
|
|
1783
|
+
if (ctx.grant.name === "installation-read") {
|
|
1784
|
+
return await issueInstallationCredential({
|
|
1785
|
+
appIdEnv,
|
|
1786
|
+
privateKeyEnv,
|
|
1787
|
+
installationIdEnv,
|
|
1788
|
+
readPermissions: appReadPermissions,
|
|
1789
|
+
loadReadPermissions
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1792
|
+
if (USER_TOKEN_GRANTS.has(ctx.grant.name)) {
|
|
1793
|
+
return await issueUserCredential(ctx, {
|
|
1794
|
+
clientIdEnv,
|
|
1795
|
+
clientSecretEnv,
|
|
1796
|
+
userScope
|
|
1797
|
+
});
|
|
1798
|
+
}
|
|
1799
|
+
} catch (error) {
|
|
1800
|
+
if (error instanceof GitHubPluginSetupError) {
|
|
1801
|
+
return credentialUnavailable(error.message);
|
|
1802
|
+
}
|
|
1803
|
+
throw error;
|
|
1804
|
+
}
|
|
1805
|
+
throw new Error(
|
|
1806
|
+
`GitHub plugin cannot issue unknown grant "${ctx.grant.name}".`
|
|
1807
|
+
);
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
});
|
|
1811
|
+
}
|
|
1812
|
+
export {
|
|
1813
|
+
githubPlugin
|
|
1814
|
+
};
|