gcb-host 0.0.0 β†’ 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/bin/gcb.js +5 -0
  3. package/lib/audit.d.ts +25 -0
  4. package/lib/audit.d.ts.map +1 -0
  5. package/lib/audit.js +73 -0
  6. package/lib/audit.js.map +1 -0
  7. package/lib/broker.d.ts +61 -0
  8. package/lib/broker.d.ts.map +1 -0
  9. package/lib/broker.js +512 -0
  10. package/lib/broker.js.map +1 -0
  11. package/lib/commands/authorize.d.ts +38 -0
  12. package/lib/commands/authorize.d.ts.map +1 -0
  13. package/lib/commands/authorize.js +134 -0
  14. package/lib/commands/authorize.js.map +1 -0
  15. package/lib/commands/compose.d.ts +42 -0
  16. package/lib/commands/compose.d.ts.map +1 -0
  17. package/lib/commands/compose.js +208 -0
  18. package/lib/commands/compose.js.map +1 -0
  19. package/lib/commands/deployment.d.ts +49 -0
  20. package/lib/commands/deployment.d.ts.map +1 -0
  21. package/lib/commands/deployment.js +72 -0
  22. package/lib/commands/deployment.js.map +1 -0
  23. package/lib/commands/diagnose.d.ts +4 -0
  24. package/lib/commands/diagnose.d.ts.map +1 -0
  25. package/lib/commands/diagnose.js +213 -0
  26. package/lib/commands/diagnose.js.map +1 -0
  27. package/lib/commands/host-opener.d.ts +23 -0
  28. package/lib/commands/host-opener.d.ts.map +1 -0
  29. package/lib/commands/host-opener.js +244 -0
  30. package/lib/commands/host-opener.js.map +1 -0
  31. package/lib/commands/init.d.ts +153 -0
  32. package/lib/commands/init.d.ts.map +1 -0
  33. package/lib/commands/init.js +510 -0
  34. package/lib/commands/init.js.map +1 -0
  35. package/lib/commands/stage.d.ts +44 -0
  36. package/lib/commands/stage.d.ts.map +1 -0
  37. package/lib/commands/stage.js +165 -0
  38. package/lib/commands/stage.js.map +1 -0
  39. package/lib/config.d.ts +110 -0
  40. package/lib/config.d.ts.map +1 -0
  41. package/lib/config.js +408 -0
  42. package/lib/config.js.map +1 -0
  43. package/lib/daemon.d.ts +45 -0
  44. package/lib/daemon.d.ts.map +1 -0
  45. package/lib/daemon.js +123 -0
  46. package/lib/daemon.js.map +1 -0
  47. package/lib/device-flow.d.ts +80 -0
  48. package/lib/device-flow.d.ts.map +1 -0
  49. package/lib/device-flow.js +148 -0
  50. package/lib/device-flow.js.map +1 -0
  51. package/lib/gh.d.ts +37 -0
  52. package/lib/gh.d.ts.map +1 -0
  53. package/lib/gh.js +181 -0
  54. package/lib/gh.js.map +1 -0
  55. package/lib/http.d.ts +20 -0
  56. package/lib/http.d.ts.map +1 -0
  57. package/lib/http.js +133 -0
  58. package/lib/http.js.map +1 -0
  59. package/lib/index.d.ts +10 -0
  60. package/lib/index.d.ts.map +1 -0
  61. package/lib/index.js +52 -0
  62. package/lib/index.js.map +1 -0
  63. package/lib/policy.d.ts +60 -0
  64. package/lib/policy.d.ts.map +1 -0
  65. package/lib/policy.js +118 -0
  66. package/lib/policy.js.map +1 -0
  67. package/lib/providers/github-app.d.ts +56 -0
  68. package/lib/providers/github-app.d.ts.map +1 -0
  69. package/lib/providers/github-app.js +901 -0
  70. package/lib/providers/github-app.js.map +1 -0
  71. package/lib/providers/index.d.ts +29 -0
  72. package/lib/providers/index.d.ts.map +1 -0
  73. package/lib/providers/index.js +32 -0
  74. package/lib/providers/index.js.map +1 -0
  75. package/lib/providers/static.d.ts +18 -0
  76. package/lib/providers/static.d.ts.map +1 -0
  77. package/lib/providers/static.js +41 -0
  78. package/lib/providers/static.js.map +1 -0
  79. package/lib/proxy.d.ts +23 -0
  80. package/lib/proxy.d.ts.map +1 -0
  81. package/lib/proxy.js +57 -0
  82. package/lib/proxy.js.map +1 -0
  83. package/package.json +36 -2
  84. package/README.md +0 -1
@@ -0,0 +1,901 @@
1
+ /**
2
+ * GitHub App provider: the broker mints a short-lived installation access token with
3
+ * `node:crypto` only, so the host needs no `openssl`, no SDK and no runtime dependency.
4
+ *
5
+ * Everything GitHub documents as a prerequisite for a successful mint is implemented
6
+ * explicitly, because each omission fails in a way that is painful to diagnose:
7
+ * - `RS256` signature;
8
+ * - `iat` one minute in the past (clock drift);
9
+ * - `exp` at most ten minutes after `iat`;
10
+ * - `iss` set to the client ID when configured, otherwise the app ID;
11
+ * - the account's installation is resolved by matching `account.login` case-insensitively,
12
+ * which is what makes multiple owners work;
13
+ * - the token is scoped to exactly one repository by name (`repositories` takes the
14
+ * repository name, not `owner/name`) and to explicitly narrowed permissions;
15
+ * - a minted token is cached and reused until shortly before it expires, instead of being
16
+ * re-minted on every helper invocation.
17
+ *
18
+ * Reference: https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app
19
+ */
20
+ import { userTokenPath } from '../config.js';
21
+ import { currentProxyKey, proxyFetch } from '../http.js';
22
+ import { createSign } from 'node:crypto';
23
+ import { getGhAction, ProviderConfigError, tokenFingerprint } from 'gcb-core';
24
+ import { runGhAction } from '../gh.js';
25
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
26
+ import { refreshUserToken } from '../device-flow.js';
27
+ /** REST API origin. Overridable for GitHub Enterprise. */
28
+ const DEFAULT_API = 'https://api.github.com';
29
+ /** GitHub refuses a JWT whose `exp` is more than this many seconds after `iat`. */
30
+ const MAX_JWT_LIFETIME_SECONDS = 600;
31
+ /** Access levels, ordered, so a requested level can be compared against the granted one. */
32
+ const ACCESS_LEVELS = { read: 1, write: 2, admin: 3 };
33
+ /**
34
+ * Base64url-encode a string or buffer, without padding, as JWS requires.
35
+ *
36
+ * @param input - the value to encode.
37
+ * @returns the base64url encoding.
38
+ */
39
+ export function base64url(input) {
40
+ return Buffer.from(input).toString('base64url');
41
+ }
42
+ /**
43
+ * Sign a GitHub App JWT.
44
+ *
45
+ * @param options - signing options.
46
+ * @returns the compact JWS.
47
+ * @throws {Error} when required inputs are missing or the lifetime exceeds GitHub's limit.
48
+ */
49
+ export function signAppJwt(options) {
50
+ const { privateKeyPem, iss, nowMs, lifetimeSeconds = MAX_JWT_LIFETIME_SECONDS, skewSeconds = 60 } = options;
51
+ if (!privateKeyPem)
52
+ throw new Error('signAppJwt: privateKeyPem is required');
53
+ if (iss === undefined || iss === null || iss === '') {
54
+ throw new Error('signAppJwt: iss (client ID or app ID) is required');
55
+ }
56
+ if (lifetimeSeconds > MAX_JWT_LIFETIME_SECONDS) {
57
+ throw new Error(`signAppJwt: exp must be at most ${MAX_JWT_LIFETIME_SECONDS} seconds after iat (GitHub rejects anything longer)`);
58
+ }
59
+ const nowSeconds = Math.floor(nowMs / 1000);
60
+ const header = { typ: 'JWT', alg: 'RS256' };
61
+ const payload = {
62
+ iat: nowSeconds - skewSeconds,
63
+ exp: nowSeconds + lifetimeSeconds,
64
+ iss: String(iss),
65
+ };
66
+ const signingInput = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(payload))}`;
67
+ const signature = createSign('RSA-SHA256').update(signingInput).sign(privateKeyPem);
68
+ return `${signingInput}.${base64url(signature)}`;
69
+ }
70
+ /**
71
+ * Extract the `rel="next"` URL from a `Link` header.
72
+ *
73
+ * @param linkHeader - the raw header value.
74
+ * @returns the next page URL, or null when there is none.
75
+ */
76
+ export function nextLink(linkHeader) {
77
+ if (typeof linkHeader !== 'string')
78
+ return null;
79
+ for (const part of linkHeader.split(',')) {
80
+ const match = /<([^>]+)>\s*;\s*rel="next"/.exec(part);
81
+ const url = match?.[1];
82
+ if (url)
83
+ return url;
84
+ }
85
+ return null;
86
+ }
87
+ /**
88
+ * Build a GitHub App provider for one host.
89
+ *
90
+ * @param options - provider options.
91
+ * @returns the provider.
92
+ * @throws {Error} when the private key or app identity is missing (fail fast).
93
+ */
94
+ export function createGithubAppProvider(options) {
95
+ const { host, cfg, skewSeconds = 300, fetchImpl = proxyFetch, now = () => Date.now(), ghPath, log = () => { }, } = options;
96
+ const privateKeyPem = cfg.privateKeyPem
97
+ ? cfg.privateKeyPem
98
+ : cfg.privateKeyPath
99
+ ? readFileSync(cfg.privateKeyPath, 'utf8')
100
+ : null;
101
+ if (!privateKeyPem) {
102
+ throw new Error(`github-app provider for ${host}: privateKeyPath (or privateKeyPem) is required`);
103
+ }
104
+ const keyPem = privateKeyPem;
105
+ // Captured as an explicitly-typed local so the narrowing survives into `call`, which is
106
+ // invoked later: `cfg.clientId ?? cfg.appId` is `string | number | undefined`.
107
+ const issuer = cfg.clientId ?? cfg.appId;
108
+ if (issuer === undefined || issuer === null || issuer === '') {
109
+ throw new Error(`github-app provider for ${host}: clientId (preferred) or appId is required`);
110
+ }
111
+ const iss = issuer;
112
+ const api = cfg.apiBaseUrl ?? DEFAULT_API;
113
+ const apiVersion = cfg.apiVersion ?? '2022-11-28';
114
+ const permissions = cfg.permissions ?? { contents: 'write' };
115
+ const checkPermissions = cfg.verifyAppPermissions !== false;
116
+ const installationTtlMs = (typeof cfg.installationCacheSeconds === 'number' ? cfg.installationCacheSeconds : 600) * 1000;
117
+ /**
118
+ * What a log read asks for: nothing but the log.
119
+ *
120
+ * Deliberately not the configured `permissions`, which describe the credential git needs. A token
121
+ * minted for those should not also be able to read CI logs, so a log read gets its own token,
122
+ * narrowed to `actions: read`. Whether the agent may read logs at all is then a question about the
123
+ * app's grant, not about anything running in the container.
124
+ */
125
+ const LOG_PERMISSIONS = { actions: 'read' };
126
+ /** How much of one log to return: enough to diagnose, bounded so one response stays sane. */
127
+ const LOG_LIMIT_CHARS = 200_000;
128
+ /**
129
+ * What opening a pull request asks for: the permission the operation needs, plus read access to
130
+ * the branches it names. Not the configured `permissions`, for the same reason a log read is not:
131
+ * a token minted for git should not also be able to open a pull request.
132
+ */
133
+ const PULL_REQUEST_PERMISSIONS = { pull_requests: 'write', contents: 'read' };
134
+ /** The result of an action that never reached the executor. */
135
+ const NO_RUN = { stdout: '', stderr: '', code: null, signal: null, truncated: false, timedOut: false };
136
+ const REVOKE_WARNING = 'the action token could not be revoked; it expires on its own';
137
+ /** owner login (lowercased) -> installation */
138
+ const installations = new Map();
139
+ /** owner/repo -> minted credential */
140
+ const tokens = new Map();
141
+ /** Cached pre-flight result, per owner: installations differ, so the answer does too. */
142
+ const permissionsChecked = new Map();
143
+ /**
144
+ * Name every configured permission a grant does not cover.
145
+ *
146
+ * @param granted - the permission map to check against.
147
+ * @param from - how to refer to that map in the message.
148
+ * @param absent - how to describe a permission that is missing entirely.
149
+ * @returns one message per problem; empty when the grant covers the configuration.
150
+ */
151
+ function missingPermissions(wantedPermissions, granted, from, absent) {
152
+ const problems = [];
153
+ for (const [name, wanted] of Object.entries(wantedPermissions)) {
154
+ const have = granted[name];
155
+ if (!have) {
156
+ problems.push(`${name}: ${absent}`);
157
+ continue;
158
+ }
159
+ if ((ACCESS_LEVELS[wanted] ?? 0) > (ACCESS_LEVELS[have] ?? 0)) {
160
+ problems.push(`${name}: requested "${wanted}" but ${from} only has "${have}"`);
161
+ }
162
+ }
163
+ return problems;
164
+ }
165
+ /**
166
+ * Call the REST API with a freshly signed app JWT.
167
+ *
168
+ * @param method - HTTP method.
169
+ * @param url - absolute URL.
170
+ * @param body - optional JSON body.
171
+ * @returns the parsed body and the Link header.
172
+ * @throws {Error} for a non-2xx response.
173
+ */
174
+ async function call(method, url, body) {
175
+ const jwt = signAppJwt({ privateKeyPem: keyPem, iss, nowMs: now() });
176
+ const headers = {
177
+ accept: 'application/vnd.github+json',
178
+ authorization: `Bearer ${jwt}`,
179
+ 'x-github-api-version': apiVersion,
180
+ 'user-agent': 'gcb',
181
+ };
182
+ if (body)
183
+ headers['content-type'] = 'application/json';
184
+ const response = await fetchImpl(url, {
185
+ method,
186
+ headers,
187
+ ...(body ? { body: JSON.stringify(body) } : {}),
188
+ });
189
+ const text = await response.text();
190
+ let json = null;
191
+ try {
192
+ json = JSON.parse(text);
193
+ }
194
+ catch {
195
+ json = null;
196
+ }
197
+ if (!response.ok) {
198
+ const message = json !== null && typeof json === 'object' && 'message' in json
199
+ ? String(json.message)
200
+ : text.slice(0, 200);
201
+ // The URL is safe to log (no secret); neither the JWT nor any token is included.
202
+ throw new Error(`GitHub API ${method} ${url.replace(api, '')} failed with ${response.status}: ${message}`);
203
+ }
204
+ return { json, link: response.headers.get('link') };
205
+ }
206
+ /**
207
+ * Call the REST API with one installation token, returning the body as text.
208
+ *
209
+ * Used for job logs, whose response is plain text behind a redirect to a signed URL that needs no
210
+ * authorization β€” fetch drops the header across origins, which is what GitHub intends.
211
+ *
212
+ * @param method - HTTP method.
213
+ * @param url - absolute URL.
214
+ * @param token - the installation token to authenticate with.
215
+ * @returns the status and the body text.
216
+ * @throws {Error} for a non-2xx response.
217
+ */
218
+ async function callWithToken(method, url, token, body) {
219
+ const headers = {
220
+ accept: 'application/vnd.github+json',
221
+ authorization: `Bearer ${token}`,
222
+ 'x-github-api-version': apiVersion,
223
+ 'user-agent': 'gcb',
224
+ };
225
+ if (body)
226
+ headers['content-type'] = 'application/json';
227
+ const response = await fetchImpl(url, {
228
+ method,
229
+ headers,
230
+ ...(body ? { body: JSON.stringify(body) } : {}),
231
+ });
232
+ const text = await response.text();
233
+ if (!response.ok) {
234
+ // The URL is safe to name β€” the token is a header, not part of it β€” but the body is not: it can quote
235
+ // the request, and it must never reach the container. So the message carries the endpoint and the
236
+ // status, which is what a pusher may see, and the body rides beside it where only the host log looks.
237
+ // The decision to write it there belongs to the broker, which owns that boundary; an error class
238
+ // cannot make it, because a caller that re-wraps this to name a permission wraps it in the same class.
239
+ const detail = text.replace(/\s+/g, ' ').trim().slice(0, 400);
240
+ const failure = new ProviderConfigError(`GitHub API ${method} ${url.replace(api, '')} answered ${response.status} for that request`);
241
+ if (detail !== '')
242
+ Object.assign(failure, { hostDetail: detail });
243
+ throw failure;
244
+ }
245
+ return { status: response.status, text };
246
+ }
247
+ /**
248
+ * Resolve the installation id for an account, cached per owner.
249
+ *
250
+ * @param owner - the repository owner as it appeared in the path.
251
+ * @returns the installation id.
252
+ * @throws {Error} when this app is not installed for that account.
253
+ */
254
+ async function resolveInstallationId(owner) {
255
+ const key = owner.toLowerCase();
256
+ const cached = installations.get(key);
257
+ if (cached && cached.expiresAt > now())
258
+ return cached.id;
259
+ let url = `${api}/app/installations?per_page=100`;
260
+ for (let page = 0; page < 10 && url; page += 1) {
261
+ const { json, link } = await call('GET', url);
262
+ if (Array.isArray(json)) {
263
+ for (const entry of json) {
264
+ const installation = entry;
265
+ const login = installation.account?.login;
266
+ if (typeof login === 'string' && login.toLowerCase() === key && typeof installation.id === 'number') {
267
+ installations.set(key, { id: installation.id, expiresAt: now() + installationTtlMs });
268
+ return installation.id;
269
+ }
270
+ }
271
+ }
272
+ url = nextLink(link);
273
+ }
274
+ throw new Error(`no installation of this app has access to account "${owner}"`);
275
+ }
276
+ /**
277
+ * Confirm both grants cover every permission the token request will ask for.
278
+ *
279
+ * Two checks, because there are two ceilings. The app's permissions are what the app may ask for at
280
+ * all; the **installation's** are what it actually has, and a permission can be granted on the app
281
+ * while an installation has not approved the update. Checking only the app passes in that case and
282
+ * then fails at the token request, where the message is GitHub's and so cannot be handed to the
283
+ * container β€” which is how a permission problem became "see the broker log".
284
+ *
285
+ * Cached per owner, since installations differ; a failed check is not cached, so fixing the
286
+ * settings does not require restarting the broker.
287
+ *
288
+ * @param owner - the account whose installation will be asked for a token.
289
+ * @returns resolves when the configured permissions are a subset of both grants.
290
+ * @throws {ProviderConfigError} naming each permission that is missing or granted at a lower level.
291
+ */
292
+ function ensurePermissions(owner, wantedPermissions = permissions) {
293
+ if (!checkPermissions)
294
+ return Promise.resolve();
295
+ // Keyed by both, because a log read checks a different set than a credential does.
296
+ const key = `${owner.toLowerCase()}|${currentProxyKey()}|${Object.entries(wantedPermissions)
297
+ .sort(([left], [right]) => left.localeCompare(right))
298
+ .map(([name, level]) => `${name}=${level}`)
299
+ .join(',')}`;
300
+ const cached = permissionsChecked.get(key);
301
+ if (cached)
302
+ return cached;
303
+ const pending = (async () => {
304
+ const { json } = await call('GET', `${api}/app`);
305
+ const appGranted = json?.permissions ?? {};
306
+ const problems = missingPermissions(wantedPermissions, appGranted, 'the app', 'the app is not granted this permission at all');
307
+ const installationId = await resolveInstallationId(owner);
308
+ const { json: installation } = await call('GET', `${api}/app/installations/${installationId}`);
309
+ const installationGranted = installation?.permissions ?? {};
310
+ problems.push(...missingPermissions(wantedPermissions, installationGranted, 'this installation', 'this installation has not approved it'));
311
+ if (problems.length > 0) {
312
+ throw new ProviderConfigError(`the configured permissions for ${host} are not available (${problems.join('; ')}); ` +
313
+ 'grant them on the app, approve the update on the installation, or lower `permissions` ' +
314
+ 'in the broker config, or set "verifyAppPermissions": false to skip this check');
315
+ }
316
+ })().catch((error) => {
317
+ permissionsChecked.delete(key);
318
+ throw error;
319
+ });
320
+ permissionsChecked.set(key, pending);
321
+ return pending;
322
+ }
323
+ /**
324
+ * Report what a pull request is, and how its commit is doing.
325
+ *
326
+ * Two reads: the pull request itself, which carries `mergeable_state`, and the workflow runs for
327
+ * its head commit β€” the second is what separates "the checks are red" from "the ruleset wants a
328
+ * review", which `mergeable_state: blocked` alone cannot. Both are repository-scoped, so the
329
+ * allowlist binds them.
330
+ *
331
+ * @param request - which pull request.
332
+ * @param token - the installation token.
333
+ * @param collection - the repository's pull request route.
334
+ * @returns the number, URL, state and a short report.
335
+ */
336
+ async function readPullRequestStatus(request, token, collection) {
337
+ const { text } = await callWithToken('GET', `${collection}/${request.number}`, token);
338
+ let parsed = null;
339
+ try {
340
+ parsed = JSON.parse(text);
341
+ }
342
+ catch {
343
+ parsed = null;
344
+ }
345
+ const payload = (parsed ?? {});
346
+ if (typeof payload.number !== 'number' || typeof payload.html_url !== 'string') {
347
+ throw new ProviderConfigError('GitHub did not answer with a pull request');
348
+ }
349
+ const lines = [
350
+ `#${payload.number} ${String(payload.state ?? '?')}${payload.merged === true ? ' (merged)' : ''}${payload.draft === true ? ' (draft)' : ''}`,
351
+ payload.html_url,
352
+ ];
353
+ if (typeof payload.mergeable_state === 'string') {
354
+ lines.push(`mergeable: ${payload.mergeable_state}`);
355
+ }
356
+ const sha = (payload.head ?? {}).sha;
357
+ if (typeof sha === 'string' && sha !== '') {
358
+ const { text: listed } = await callWithToken('GET', `${api}/repos/${request.owner}/${request.repo}/actions/runs?head_sha=${sha}&per_page=20`, token);
359
+ let runs = null;
360
+ try {
361
+ runs = JSON.parse(listed);
362
+ }
363
+ catch {
364
+ runs = null;
365
+ }
366
+ const batch = (runs ?? {}).workflow_runs;
367
+ if (Array.isArray(batch) && batch.length > 0) {
368
+ for (const run of batch) {
369
+ lines.push(`run ${String(run.name ?? '?')}: ${String(run.conclusion ?? run.status ?? '?')}`);
370
+ }
371
+ }
372
+ else {
373
+ lines.push('no workflow run for that commit yet');
374
+ }
375
+ }
376
+ return {
377
+ number: payload.number,
378
+ url: payload.html_url,
379
+ state: typeof payload.state === 'string' ? payload.state : undefined,
380
+ merged: payload.merged === true,
381
+ status: lines.join('\n'),
382
+ };
383
+ }
384
+ let cachedPersonToken;
385
+ // One renewal at a time. GitHub documents neither what happens to a refresh token used twice nor a
386
+ // grace period for it, so the honest reason is the failure that is certain rather than the one that is
387
+ // documented: two exchanges racing means one of them renews from a token the other has already
388
+ // replaced, and whichever write-back runs last decides what is on disk for the next renewal.
389
+ // The refresh token on disk is one shared value; two renewals racing can invalidate it. Serialize
390
+ // the exchange even when the commands selected different proxy routes, then let the waiter re-read
391
+ // the cache the winner wrote. Each exchange still uses its own command's fetch.
392
+ let renewingPersonToken;
393
+ /**
394
+ * One more sentence about a failure, when there is one worth having.
395
+ *
396
+ * `fetch failed` is the whole message undici gives for a connection problem and it names nothing useful;
397
+ * the reason is on `cause`. This is for the host log, so it is allowed to carry detail.
398
+ *
399
+ * @param error - whatever was thrown.
400
+ * @returns an empty string, or ` (the reason)`.
401
+ */
402
+ function describeCause(error) {
403
+ const cause = error?.cause?.message;
404
+ return typeof cause === 'string' && cause !== '' ? ` (${cause})` : '';
405
+ }
406
+ /**
407
+ * The pull request number in the URL GitHub returns for a pull request.
408
+ *
409
+ * A review comment carries the pull request it belongs to as `pull_request_url`, which is how a comment
410
+ * id β€” one for the whole repository, not one per pull request β€” is tied back to the pull request named
411
+ * on the command line.
412
+ *
413
+ * @param value - an `.../pulls/{number}` URL, as GitHub returns it.
414
+ * @returns the number, or undefined when the value names none.
415
+ */
416
+ function pullRequestNumber(value) {
417
+ if (typeof value !== 'string')
418
+ return undefined;
419
+ const match = /\/pulls\/(\d+)\/?$/.exec(value);
420
+ if (match?.[1] === undefined)
421
+ return undefined;
422
+ const number = Number(match[1]);
423
+ return Number.isInteger(number) && number > 0 ? number : undefined;
424
+ }
425
+ return {
426
+ name: 'github-app',
427
+ async runAction(request) {
428
+ const spec = getGhAction(request.action);
429
+ const wanted = { ...spec.permissions };
430
+ await ensurePermissions(request.owner, wanted);
431
+ const installationId = await resolveInstallationId(request.owner);
432
+ const { json } = await call('POST', `${api}/app/installations/${installationId}/access_tokens`, {
433
+ repositories: [request.repo],
434
+ permissions: wanted,
435
+ });
436
+ const token = json?.token;
437
+ if (typeof token !== 'string' || !token) {
438
+ throw new ProviderConfigError('GitHub returned no installation token for the action');
439
+ }
440
+ let result;
441
+ let warning;
442
+ try {
443
+ result = await runGhAction({
444
+ action: request.action,
445
+ values: request.values,
446
+ repo: request.full,
447
+ token,
448
+ host: request.host,
449
+ ghPath,
450
+ env: process.env,
451
+ proxy: request.proxy,
452
+ });
453
+ }
454
+ catch (error) {
455
+ result = { ...NO_RUN, error: error instanceof Error ? error.message : String(error) };
456
+ }
457
+ finally {
458
+ try {
459
+ const response = await fetchImpl(`${api}/installation/token`, {
460
+ method: 'DELETE',
461
+ headers: {
462
+ accept: 'application/vnd.github+json',
463
+ authorization: `Bearer ${token}`,
464
+ 'x-github-api-version': apiVersion,
465
+ 'user-agent': 'gcb',
466
+ },
467
+ });
468
+ if (!response.ok) {
469
+ let detail = '';
470
+ try {
471
+ detail = (await response.text()).replace(/\s+/g, ' ').trim().slice(0, 200);
472
+ }
473
+ catch {
474
+ // The status is enough; a body that cannot be read is still not a successful revoke.
475
+ }
476
+ log(`could not revoke the action token: GitHub answered ${response.status}${detail ? ` ${detail}` : ''}`);
477
+ warning = REVOKE_WARNING;
478
+ }
479
+ }
480
+ catch (error) {
481
+ log(`could not revoke the action token: ${String(error.message ?? error)}`);
482
+ warning = REVOKE_WARNING;
483
+ }
484
+ }
485
+ return { ...result, tokenFingerprint: tokenFingerprint(token), ...(warning === undefined ? {} : { warning }) };
486
+ },
487
+ async getCredential(request) {
488
+ const cached = tokens.get(request.full);
489
+ if (cached && cached.expiresAt - now() > skewSeconds * 1000) {
490
+ return { ...cached, cached: true };
491
+ }
492
+ await ensurePermissions(request.owner);
493
+ const installationId = await resolveInstallationId(request.owner);
494
+ const { json } = await call('POST', `${api}/app/installations/${installationId}/access_tokens`, {
495
+ repositories: [request.repo],
496
+ permissions,
497
+ });
498
+ const payload = (json ?? {});
499
+ if (typeof payload.token !== 'string' || !payload.token) {
500
+ throw new Error('GitHub API returned no token for the installation access token request');
501
+ }
502
+ const expiresAt = typeof payload.expires_at === 'string' ? Date.parse(payload.expires_at) : now() + 3_600_000;
503
+ const credential = {
504
+ username: 'x-access-token',
505
+ password: payload.token,
506
+ expiresAt,
507
+ };
508
+ tokens.set(request.full, credential);
509
+ return { ...credential, cached: false };
510
+ },
511
+ /**
512
+ * Read one workflow job's log.
513
+ *
514
+ * One call, to the repository-scoped route: the repository is part of the URL, so the allowlist
515
+ * binds the job id by construction. The global route is keyed by job id alone β€” an allowlisted
516
+ * repository could name any job this app can see β€” and it is the one that answers "Must have
517
+ * admin rights", which is a permission this broker should never want.
518
+ *
519
+ * @param request - the job to read.
520
+ * @returns the log text, bounded.
521
+ */
522
+ async getJobLog(request) {
523
+ await ensurePermissions(request.owner, LOG_PERMISSIONS);
524
+ const installationId = await resolveInstallationId(request.owner);
525
+ const { json } = await call('POST', `${api}/app/installations/${installationId}/access_tokens`, {
526
+ repositories: [request.repo],
527
+ permissions: LOG_PERMISSIONS,
528
+ });
529
+ const token = json?.token;
530
+ if (typeof token !== 'string' || !token) {
531
+ throw new ProviderConfigError('GitHub returned no installation token for the log request');
532
+ }
533
+ let text;
534
+ try {
535
+ const body = await callWithToken('GET', `${api}/repos/${request.owner}/${request.repo}/actions/jobs/${request.jobId}/logs`, token);
536
+ text = body.text;
537
+ }
538
+ catch (error) {
539
+ // A 404 here has two ordinary causes, and neither is a broker fault: the job is not in that
540
+ // repository, or the run it belongs to has not finished β€” the endpoint serves no log at all
541
+ // while a job is still running.
542
+ throw new ProviderConfigError(`${error.message}; the job has to exist in that repository, and a run that is still in progress has no log yet`);
543
+ }
544
+ if (text.length <= LOG_LIMIT_CHARS)
545
+ return { text, truncated: false };
546
+ return {
547
+ text: `${text.slice(0, LOG_LIMIT_CHARS)}\n… log truncated at ${LOG_LIMIT_CHARS} characters\n`,
548
+ truncated: true,
549
+ };
550
+ },
551
+ /**
552
+ * Open one pull request.
553
+ *
554
+ * The same shape as a log read: its own token, narrowed to what the operation needs, and the
555
+ * repository-scoped route, so the allowlist binds it by construction.
556
+ *
557
+ * @param request - the branches and the text.
558
+ * @returns the number and the URL GitHub answered with.
559
+ */
560
+ async pullRequest(request) {
561
+ // The two actions a person may perform are decided first: when somebody has authorized this
562
+ // deployment, their token is the whole authority for them, and requiring an installation as well
563
+ // would refuse a repository the app was never installed on β€” the fork-to-upstream case the allowlist
564
+ // exists to permit. Everything else still acts as the app, and only ever could.
565
+ const personalForThis = request.action === 'open' || request.action === 'resolve';
566
+ const personToken = personalForThis ? await personalToken() : undefined;
567
+ // `--status` also reads /actions/runs, which is checked against actions: read. Pre-flighting
568
+ // pull_requests alone passed a grant that could not make that call, which is the opposite of what a
569
+ // pre-flight is for.
570
+ const wanted = request.action === 'status' ? { ...PULL_REQUEST_PERMISSIONS, actions: 'read' } : PULL_REQUEST_PERMISSIONS;
571
+ // One variable, so every branch below holds a token without having to ask which kind it is.
572
+ let token;
573
+ // A token per owner is the other way a person acts. It counts exactly as the authorized token does:
574
+ // whoever is acting, requiring an installation as well would refuse a repository the app was never
575
+ // installed on β€” the fork-to-upstream case the allowlist exists to permit.
576
+ const fileToken = request.action === 'open' || request.action === 'resolve'
577
+ ? cfg.userTokens?.[request.owner]
578
+ : undefined;
579
+ const actingToken = personToken ?? (fileToken === undefined ? undefined : readFileSync(fileToken, 'utf8').trim());
580
+ if (actingToken === undefined) {
581
+ await ensurePermissions(request.owner, wanted);
582
+ const installationId = await resolveInstallationId(request.owner);
583
+ const { json: minted } = await call('POST', `${api}/app/installations/${installationId}/access_tokens`, {
584
+ repositories: [request.repo],
585
+ permissions: wanted,
586
+ });
587
+ const mintedToken = minted?.token;
588
+ if (typeof mintedToken !== 'string' || !mintedToken) {
589
+ throw new ProviderConfigError('GitHub returned no installation token for the pull request');
590
+ }
591
+ token = mintedToken;
592
+ }
593
+ else {
594
+ token = actingToken;
595
+ }
596
+ const collection = `${api}/repos/${request.owner}/${request.repo}/pulls`;
597
+ /**
598
+ * The token of the person this deployment authorized, when there is one.
599
+
600
+ * Only two actions may use it, and both because an installation token cannot: GitHub will not
601
+ * author a pull request as a person for an app, and refuses to resolve a review thread for one.
602
+ * The refresh token lives beside the private key, and GitHub rotates it on every use, so the new
603
+ * one is written back β€” keeping the old would work once and then stop.
604
+ *
605
+ * @returns the user token, or `undefined` when nobody has authorized one.
606
+ */
607
+ async function personalToken() {
608
+ while (renewingPersonToken !== undefined) {
609
+ try {
610
+ await renewingPersonToken;
611
+ }
612
+ catch {
613
+ // The winner failed; this command retries after the lock is released.
614
+ }
615
+ }
616
+ const pending = renewPersonToken();
617
+ renewingPersonToken = pending;
618
+ try {
619
+ return await pending;
620
+ }
621
+ finally {
622
+ if (renewingPersonToken === pending)
623
+ renewingPersonToken = undefined;
624
+ }
625
+ }
626
+ async function renewPersonToken() {
627
+ if (cfg.clientId === undefined || cfg.privateKeyPath === undefined)
628
+ return undefined;
629
+ const path = userTokenPath(cfg.privateKeyPath);
630
+ if (!existsSync(path))
631
+ return undefined;
632
+ const now = Date.now();
633
+ // The file is the only thing that says who the broker may act as, so it is read before the cache is
634
+ // trusted: running `authorize` again β€” the documented way to change that person, and the only one β€”
635
+ // has to take effect now rather than when the cached token happens to expire.
636
+ const held = readFileSync(path, 'utf8').trim();
637
+ if (cachedPersonToken !== undefined &&
638
+ cachedPersonToken.until > now + 60_000 &&
639
+ cachedPersonToken.source === held) {
640
+ return cachedPersonToken.token;
641
+ }
642
+ let next;
643
+ try {
644
+ next = await refreshUserToken(cfg.clientId, held, (url, init) => fetchImpl(url, init));
645
+ }
646
+ catch (error) {
647
+ // This is the one place a renewal failure is explained: the caller is an action that failed,
648
+ // and "fetch failed" on its own names neither the host nor the reason.
649
+ throw new Error(`could not renew the authorized token from ${path}: ${error.message}${describeCause(error)}`);
650
+ }
651
+ if (next.refreshToken !== undefined) {
652
+ try {
653
+ writeFileSync(path, `${next.refreshToken}\n`, { mode: 0o600 });
654
+ }
655
+ catch (error) {
656
+ // GitHub invalidated the token on disk the moment it answered, so this is not transient and not
657
+ // recoverable: the next renewal reads a dead token. Nothing else in the log would say so.
658
+ throw new Error(`could not store the new refresh token in ${path}: ${error.message}; the previous ` +
659
+ 'one is no longer valid, so run authorize again');
660
+ }
661
+ }
662
+ cachedPersonToken = {
663
+ token: next.token,
664
+ until: now + (next.expiresInSeconds ?? 28_800) * 1_000,
665
+ source: next.refreshToken ?? held,
666
+ };
667
+ return cachedPersonToken.token;
668
+ }
669
+ if (request.action === 'resolve') {
670
+ // Resolving has no REST endpoint either. The thread id is the one `--threads` prints.
671
+ // The thread id is a global node id, and the person token is not narrowed to a repository the way
672
+ // an installation token is. Ask whose thread it is before changing it: the allowlist decided which
673
+ // repositories this broker may touch, and a mutation is the wrong place to discover it did not.
674
+ const { text: scoped } = await callWithToken('POST', `${api}/graphql`, personToken ?? token, {
675
+ query: 'query($threadId:ID!){node(id:$threadId){... on PullRequestReviewThread{pullRequest{repository{nameWithOwner}}}}}',
676
+ variables: { threadId: request.threadId },
677
+ });
678
+ let belongsTo;
679
+ try {
680
+ const parsed = JSON.parse(scoped);
681
+ belongsTo = parsed.data?.node?.pullRequest?.repository?.nameWithOwner;
682
+ }
683
+ catch {
684
+ belongsTo = undefined;
685
+ }
686
+ if (typeof belongsTo !== 'string' ||
687
+ belongsTo.toLowerCase() !== `${request.owner}/${request.repo}`.toLowerCase()) {
688
+ // A plain Error on purpose: this is the host log's business, not the container's.
689
+ throw new Error(`refusing to resolve ${request.threadId}: it is not a review thread of ${request.owner}/${request.repo}` +
690
+ (typeof belongsTo === 'string' ? ` (it belongs to ${belongsTo})` : ' (or it could not be read)'));
691
+ }
692
+ const { text: raw } = await callWithToken('POST', `${api}/graphql`, personToken ?? token, {
693
+ query: 'mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{isResolved}}}',
694
+ variables: { threadId: request.threadId ?? '' },
695
+ });
696
+ let answer = null;
697
+ try {
698
+ answer = JSON.parse(raw);
699
+ }
700
+ catch {
701
+ answer = null;
702
+ }
703
+ const state = (answer ?? {});
704
+ // A plain Error, not a ProviderConfigError: GraphQL answers in a response body, and those
705
+ // are not passed to the container.
706
+ if (Array.isArray(state.errors) && state.errors.length > 0) {
707
+ // The reason belongs in the host log, which is where a plain Error goes: the broker answers
708
+ // the container generically for one, so nothing from a response body crosses the socket.
709
+ throw new Error(`GitHub refused to resolve that review thread: ${JSON.stringify(state.errors).slice(0, 300)}`);
710
+ }
711
+ return {
712
+ number: request.number ?? 0,
713
+ url: '',
714
+ status: state.data?.resolveReviewThread?.thread?.isResolved === true ? 'resolved' : 'still open',
715
+ };
716
+ }
717
+ if (request.action === 'reply') {
718
+ // A reply belongs in the thread it answers. The issues API would need `issues: write` and
719
+ // would start a new conversation instead of joining one.
720
+ await callWithToken('POST', `${collection}/${request.number}/comments/${String(request.commentId ?? 0)}/replies`, token, { body: request.body ?? '' });
721
+ return { number: request.number ?? 0, url: '' };
722
+ }
723
+ if (request.action === 'edit') {
724
+ // A review comment is addressed by its own id, and that id is one for the whole repository, not
725
+ // one per pull request. The route therefore carries no pull request number and the CLI asks for
726
+ // none: the number to report is the one the comment itself names, read back from the answer
727
+ // rather than echoed from the request. The repository in the path is what the allowlist binds.
728
+ const endpoint = `${api}/repos/${request.owner}/${request.repo}/pulls/comments/${String(request.commentId ?? 0)}`;
729
+ const { text: edited } = await callWithToken('PATCH', endpoint, token, { body: request.body ?? '' });
730
+ let changed = null;
731
+ try {
732
+ changed = JSON.parse(edited);
733
+ }
734
+ catch {
735
+ changed = null;
736
+ }
737
+ const html = (changed ?? {});
738
+ return {
739
+ number: pullRequestNumber(html.pull_request_url) ?? request.number ?? 0,
740
+ url: typeof html.html_url === 'string' ? html.html_url : '',
741
+ };
742
+ }
743
+ if (request.action === 'threads') {
744
+ // GraphQL, because review threads have no REST list. The repository and the number are
745
+ // named here, so the allowlist still decides what can be reached.
746
+ const { text: raw } = await callWithToken('POST', `${api}/graphql`, token, {
747
+ query: 'query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:50){totalCount nodes{id isResolved path line comments(first:10){nodes{databaseId author{login} body}}}}}}}',
748
+ variables: { owner: request.owner, name: request.repo, number: request.number },
749
+ });
750
+ let listed = null;
751
+ try {
752
+ listed = JSON.parse(raw);
753
+ }
754
+ catch {
755
+ listed = null;
756
+ }
757
+ // Nothing from a response body reaches the container β€” the broker answers with a generic
758
+ // message for an ordinary Error β€” but the host log is where a reason belongs, and a refusal
759
+ // that reads as "no threads" is worse than one that reads as a refusal.
760
+ const refused = (listed ?? {});
761
+ if (Array.isArray(refused.errors) && refused.errors.length > 0) {
762
+ throw new Error(`GitHub refused to list the review threads: ${JSON.stringify(refused.errors).slice(0, 300)}`);
763
+ }
764
+ const data = (listed ?? {});
765
+ const nodes = data.data?.repository?.pullRequest?.reviewThreads?.nodes;
766
+ const report = [];
767
+ if (Array.isArray(nodes)) {
768
+ for (const node of nodes) {
769
+ report.push(`${node.isResolved === true ? 'resolved' : 'open '} ${String(node.id ?? '')} ${String(node.path ?? '')}:${String(node.line ?? '')}`);
770
+ const comments = node.comments?.nodes;
771
+ if (!Array.isArray(comments))
772
+ continue;
773
+ for (const comment of comments) {
774
+ // The whole comment, not its first line and not 120 characters of it: a review bot puts the finding
775
+ // in the lines below the header, and a report that drops them sends the reader to the web UI for the
776
+ // one thing this command exists to show.
777
+ const body = String(comment.body ?? '');
778
+ // The prose, not the first twelve lines of the comment: a review bot prefixes its finding with the
779
+ // commands it ran and a fence of their output, so counting raw lines reads the preamble and never
780
+ // reaches the claim. Fences and the bot's own metadata lines are dropped, and the cap then applies to
781
+ // what is left, which is what the reader wants and what the writer meant.
782
+ const bodyLines = body.split('\n');
783
+ const kept = [];
784
+ let inFence = false;
785
+ for (const raw of bodyLines) {
786
+ const line = raw.trim();
787
+ if (line.startsWith('```')) {
788
+ inFence = !inFence;
789
+ continue;
790
+ }
791
+ if (inFence)
792
+ continue;
793
+ if (/^(🏁|πŸ”Ž|βš™οΈ|🌐|πŸ’‘|πŸ’¬|πŸ”—|Repository:|Length of output:|Proposed fix$|---$|_?\s*[πŸ”’πŸ“πŸŽ―πŸ©ΊπŸŸ‘πŸŸ ])/u.test(line))
794
+ continue;
795
+ kept.push(line.replace(/<\/?[a-z][^>]*>/gi, '').trim());
796
+ }
797
+ const shown = kept.filter((line) => line !== '').slice(0, 14);
798
+ const more = kept.length > shown.length ? ` (+${kept.length - shown.length} more lines)` : '';
799
+ report.push(` ${String(comment.databaseId ?? '')} @${String(comment.author?.login ?? '?')}:${more}`);
800
+ for (const line of shown)
801
+ report.push(` ${line.slice(0, 300)}`);
802
+ }
803
+ }
804
+ }
805
+ // A page is not the whole list. Saying which it is is the difference between a report and a guess.
806
+ const shown = Array.isArray(nodes) ? nodes.length : 0;
807
+ const counted = data.data?.repository?.pullRequest?.reviewThreads
808
+ ?.totalCount;
809
+ const total = typeof counted === 'number' ? counted : shown;
810
+ if (total > shown) {
811
+ report.push(`... and ${total - shown} more: this asks for the first 50 threads, and the first 10 comments of each`);
812
+ }
813
+ return {
814
+ number: request.number ?? 0,
815
+ url: '',
816
+ status: report.length > 0 ? report.join('\n') : 'no review threads on this pull request',
817
+ };
818
+ }
819
+ if (request.action === 'comment') {
820
+ // A review with no verdict: GitHub shows it as a comment on the pull request, and it needs
821
+ // only the pull request permission the app already has. A *conversation* comment would be the
822
+ // issues API, which needs `issues: write` β€” see the README.
823
+ await callWithToken('POST', `${collection}/${request.number}/reviews`, token, {
824
+ body: request.body ?? '',
825
+ event: 'COMMENT',
826
+ // An inline comment rides on the same review, so a body-only comment stays one code path.
827
+ ...(request.filePath === undefined || request.line === undefined
828
+ ? {}
829
+ : {
830
+ comments: [
831
+ {
832
+ path: request.filePath,
833
+ line: request.line,
834
+ // GitHub spells the side in capitals on this endpoint; ours is the readable one.
835
+ side: (request.side ?? 'right').toUpperCase(),
836
+ body: request.body ?? '',
837
+ },
838
+ ],
839
+ }),
840
+ });
841
+ const { text: after } = await callWithToken('GET', `${collection}/${request.number}`, token);
842
+ let read = null;
843
+ try {
844
+ read = JSON.parse(after);
845
+ }
846
+ catch {
847
+ read = null;
848
+ }
849
+ const html = (read ?? {});
850
+ return {
851
+ number: request.number ?? 0,
852
+ url: typeof html.html_url === 'string' ? html.html_url : '',
853
+ };
854
+ }
855
+ if (request.action === 'status') {
856
+ return readPullRequestStatus(request, token, collection);
857
+ }
858
+ let text;
859
+ const method = 'POST';
860
+ const url = collection;
861
+ const body = { title: request.title, body: request.body, head: request.head, base: request.base, draft: request.draft === true };
862
+ try {
863
+ // as theirs, which is what automated reviewers recognise. The token itself is as wide as that person β€”
864
+ // it carries the app permissions and could push and merge β€” and what is narrow is this code: it is
865
+ // used for creating and resolving, and every other action stays on the app's installation token.
866
+ // Creating a pull request as a person, and resolving a review thread: the two things an
867
+ // installation token is refused for. A token per owner is the older, narrower way and is still
868
+ // used when there is one; the authorized token answers for every owner at once.
869
+ // Resolved above, where it also decided whether the app had to be asked at all.
870
+ ({ text } = await callWithToken(method, url, actingToken ?? token, body));
871
+ }
872
+ catch (error) {
873
+ // A permission sentence is composed here and is worth passing through; the host-only detail, if
874
+ // the failure carried any, is carried across rather than put in the message the pusher sees.
875
+ const wrapped = new ProviderConfigError(`${error.message}${describeCause(error)}; this needs the pull_requests: write permission on both the app and this installation`);
876
+ const detail = error.hostDetail;
877
+ if (detail !== undefined)
878
+ Object.assign(wrapped, { hostDetail: detail });
879
+ throw wrapped;
880
+ }
881
+ let parsed = null;
882
+ try {
883
+ parsed = JSON.parse(text);
884
+ }
885
+ catch {
886
+ parsed = null;
887
+ }
888
+ const payload = (parsed ?? {});
889
+ if (typeof payload.number !== 'number' || typeof payload.html_url !== 'string') {
890
+ throw new ProviderConfigError('GitHub did not answer with a pull request');
891
+ }
892
+ return {
893
+ number: payload.number,
894
+ url: payload.html_url,
895
+ state: typeof payload.state === 'string' ? payload.state : undefined,
896
+ merged: payload.merged === true,
897
+ };
898
+ },
899
+ };
900
+ }
901
+ //# sourceMappingURL=github-app.js.map