gcb-host 0.7.0 → 0.8.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.
Files changed (94) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/bin/gcb.js +3 -3
  3. package/lib/audit.d.ts +4 -4
  4. package/lib/audit.d.ts.map +1 -1
  5. package/lib/audit.js +15 -14
  6. package/lib/audit.js.map +1 -1
  7. package/lib/broker.d.ts +12 -13
  8. package/lib/broker.d.ts.map +1 -1
  9. package/lib/broker.js +77 -53
  10. package/lib/broker.js.map +1 -1
  11. package/lib/commands/authorize.d.ts +43 -13
  12. package/lib/commands/authorize.d.ts.map +1 -1
  13. package/lib/commands/authorize.js +146 -53
  14. package/lib/commands/authorize.js.map +1 -1
  15. package/lib/commands/compose.d.ts +7 -7
  16. package/lib/commands/compose.d.ts.map +1 -1
  17. package/lib/commands/compose.js +21 -22
  18. package/lib/commands/compose.js.map +1 -1
  19. package/lib/commands/config.d.ts.map +1 -1
  20. package/lib/commands/config.js +59 -41
  21. package/lib/commands/config.js.map +1 -1
  22. package/lib/commands/deployment.d.ts +11 -11
  23. package/lib/commands/deployment.d.ts.map +1 -1
  24. package/lib/commands/deployment.js +19 -18
  25. package/lib/commands/deployment.js.map +1 -1
  26. package/lib/commands/diagnose.d.ts.map +1 -1
  27. package/lib/commands/diagnose.js +51 -33
  28. package/lib/commands/diagnose.js.map +1 -1
  29. package/lib/commands/doctor.d.ts +5 -5
  30. package/lib/commands/doctor.d.ts.map +1 -1
  31. package/lib/commands/doctor.js +53 -32
  32. package/lib/commands/doctor.js.map +1 -1
  33. package/lib/commands/host.d.ts +7 -7
  34. package/lib/commands/host.d.ts.map +1 -1
  35. package/lib/commands/host.js +147 -92
  36. package/lib/commands/host.js.map +1 -1
  37. package/lib/commands/init.d.ts +33 -33
  38. package/lib/commands/init.d.ts.map +1 -1
  39. package/lib/commands/init.js +93 -75
  40. package/lib/commands/init.js.map +1 -1
  41. package/lib/commands/stage.d.ts +6 -6
  42. package/lib/commands/stage.d.ts.map +1 -1
  43. package/lib/commands/stage.js +21 -15
  44. package/lib/commands/stage.js.map +1 -1
  45. package/lib/config.d.ts +29 -28
  46. package/lib/config.d.ts.map +1 -1
  47. package/lib/config.js +101 -85
  48. package/lib/config.js.map +1 -1
  49. package/lib/daemon.d.ts +9 -9
  50. package/lib/daemon.d.ts.map +1 -1
  51. package/lib/daemon.js +21 -18
  52. package/lib/daemon.js.map +1 -1
  53. package/lib/device-flow.d.ts +23 -22
  54. package/lib/device-flow.d.ts.map +1 -1
  55. package/lib/device-flow.js +33 -28
  56. package/lib/device-flow.js.map +1 -1
  57. package/lib/gh.d.ts +2 -2
  58. package/lib/gh.d.ts.map +1 -1
  59. package/lib/gh.js +74 -46
  60. package/lib/gh.js.map +1 -1
  61. package/lib/http.d.ts.map +1 -1
  62. package/lib/http.js +35 -18
  63. package/lib/http.js.map +1 -1
  64. package/lib/index.d.ts.map +1 -1
  65. package/lib/index.js.map +1 -1
  66. package/lib/policy.d.ts +25 -25
  67. package/lib/policy.d.ts.map +1 -1
  68. package/lib/policy.js +57 -36
  69. package/lib/policy.js.map +1 -1
  70. package/lib/providers/github-app.d.ts +12 -12
  71. package/lib/providers/github-app.d.ts.map +1 -1
  72. package/lib/providers/github-app.js +194 -125
  73. package/lib/providers/github-app.js.map +1 -1
  74. package/lib/providers/index.d.ts +8 -9
  75. package/lib/providers/index.d.ts.map +1 -1
  76. package/lib/providers/index.js +5 -5
  77. package/lib/providers/index.js.map +1 -1
  78. package/lib/providers/static.d.ts +3 -3
  79. package/lib/providers/static.d.ts.map +1 -1
  80. package/lib/providers/static.js +14 -12
  81. package/lib/providers/static.js.map +1 -1
  82. package/lib/proxy.d.ts +10 -10
  83. package/lib/proxy.d.ts.map +1 -1
  84. package/lib/proxy.js +18 -16
  85. package/lib/proxy.js.map +1 -1
  86. package/lib/public-repo.d.ts +10 -10
  87. package/lib/public-repo.d.ts.map +1 -1
  88. package/lib/public-repo.js +17 -14
  89. package/lib/public-repo.js.map +1 -1
  90. package/lib/reload.d.ts +4 -4
  91. package/lib/reload.d.ts.map +1 -1
  92. package/lib/reload.js +24 -14
  93. package/lib/reload.js.map +1 -1
  94. package/package.json +10 -10
@@ -1,31 +1,33 @@
1
+ import { createSign } from 'node:crypto';
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { getGhAction, ProviderConfigError, tokenFingerprint, } from 'gcb-core';
1
4
  /**
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.
5
+ * GitHub App provider: the broker mints a short-lived installation access token with `node:crypto`
6
+ * only, so the host needs no `openssl`, no SDK and no runtime dependency.
7
+ *
8
+ * Everything GitHub documents as a prerequisite for a successful mint is implemented explicitly,
9
+ * because each omission fails in a way that is painful to diagnose:
4
10
  *
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.
11
+ * - `RS256` signature;
12
+ * - `iat` one minute in the past (clock drift);
13
+ * - `exp` at most ten minutes after `iat`;
14
+ * - `iss` set to the client ID when configured, otherwise the app ID;
15
+ * - The account's installation is resolved by matching `account.login` case-insensitively, which is
16
+ * what makes multiple owners work;
17
+ * - The token is scoped to exactly one repository by name (`repositories` takes the repository name,
18
+ * not `owner/name`) and to explicitly narrowed permissions;
19
+ * - A minted token is cached and reused until shortly before it expires, instead of being re-minted
20
+ * on every helper invocation.
17
21
  *
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
22
+ * Reference:
23
+ * 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
24
  */
20
25
  import { userTokenPath } from '../config.js';
21
- import { repoAllowed } from '../policy.js';
22
- import { currentProxyKey, proxyFetch } from '../http.js';
23
- import { createSign } from 'node:crypto';
24
- import { getGhAction, ProviderConfigError, tokenFingerprint } from 'gcb-core';
25
- import { runGhAction } from '../gh.js';
26
- import { isPublicRepository as checkPublicRepository, withRepositoryResponse } from '../public-repo.js';
27
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
28
26
  import { refreshUserToken } from '../device-flow.js';
27
+ import { runGhAction } from '../gh.js';
28
+ import { currentProxyKey, proxyFetch } from '../http.js';
29
+ import { repoAllowed } from '../policy.js';
30
+ import { isPublicRepository as checkPublicRepository, withRepositoryResponse, } from '../public-repo.js';
29
31
  /** REST API origin. Overridable for GitHub Enterprise. */
30
32
  const DEFAULT_API = 'https://api.github.com';
31
33
  /** GitHub refuses a JWT whose `exp` is more than this many seconds after `iat`. */
@@ -35,8 +37,8 @@ const ACCESS_LEVELS = { read: 1, write: 2, admin: 3 };
35
37
  /**
36
38
  * Base64url-encode a string or buffer, without padding, as JWS requires.
37
39
  *
38
- * @param input - the value to encode.
39
- * @returns the base64url encoding.
40
+ * @param input - The value to encode.
41
+ * @returns The base64url encoding.
40
42
  */
41
43
  export function base64url(input) {
42
44
  return Buffer.from(input).toString('base64url');
@@ -44,14 +46,15 @@ export function base64url(input) {
44
46
  /**
45
47
  * Sign a GitHub App JWT.
46
48
  *
47
- * @param options - signing options.
48
- * @returns the compact JWS.
49
- * @throws {Error} when required inputs are missing or the lifetime exceeds GitHub's limit.
49
+ * @param options - Signing options.
50
+ * @returns The compact JWS.
51
+ * @throws {Error} When required inputs are missing or the lifetime exceeds GitHub's limit.
50
52
  */
51
53
  export function signAppJwt(options) {
52
- const { privateKeyPem, iss, nowMs, lifetimeSeconds = MAX_JWT_LIFETIME_SECONDS, skewSeconds = 60 } = options;
53
- if (!privateKeyPem)
54
+ const { privateKeyPem, iss, nowMs, lifetimeSeconds = MAX_JWT_LIFETIME_SECONDS, skewSeconds = 60, } = options;
55
+ if (!privateKeyPem) {
54
56
  throw new Error('signAppJwt: privateKeyPem is required');
57
+ }
55
58
  if (iss === undefined || iss === null || iss === '') {
56
59
  throw new Error('signAppJwt: iss (client ID or app ID) is required');
57
60
  }
@@ -72,26 +75,28 @@ export function signAppJwt(options) {
72
75
  /**
73
76
  * Extract the `rel="next"` URL from a `Link` header.
74
77
  *
75
- * @param linkHeader - the raw header value.
76
- * @returns the next page URL, or null when there is none.
78
+ * @param linkHeader - The raw header value.
79
+ * @returns The next page URL, or null when there is none.
77
80
  */
78
81
  export function nextLink(linkHeader) {
79
- if (typeof linkHeader !== 'string')
82
+ if (typeof linkHeader !== 'string') {
80
83
  return null;
84
+ }
81
85
  for (const part of linkHeader.split(',')) {
82
86
  const match = /<([^>]+)>\s*;\s*rel="next"/.exec(part);
83
87
  const url = match?.[1];
84
- if (url)
88
+ if (url) {
85
89
  return url;
90
+ }
86
91
  }
87
92
  return null;
88
93
  }
89
94
  /**
90
95
  * Build a GitHub App provider for one host.
91
96
  *
92
- * @param options - provider options.
93
- * @returns the provider.
94
- * @throws {Error} when the private key or app identity is missing (fail fast).
97
+ * @param options - Provider options.
98
+ * @returns The provider.
99
+ * @throws {Error} When the private key or app identity is missing (fail fast).
95
100
  */
96
101
  export function createGithubAppProvider(options) {
97
102
  const { host, cfg, skewSeconds = 300, fetchImpl = proxyFetch, now = () => Date.now(), ghPath, log = () => { }, } = options;
@@ -121,8 +126,8 @@ export function createGithubAppProvider(options) {
121
126
  *
122
127
  * Deliberately not the configured `permissions`, which describe the credential git needs. A token
123
128
  * minted for those should not also be able to read CI logs, so a log read gets its own token,
124
- * narrowed to `actions: read`. Whether the agent may read logs at all is then a question about the
125
- * app's grant, not about anything running in the container.
129
+ * narrowed to `actions: read`. Whether the agent may read logs at all is then a question about
130
+ * the app's grant, not about anything running in the container.
126
131
  */
127
132
  const LOG_PERMISSIONS = { actions: 'read' };
128
133
  /** How much of one log to return: enough to diagnose, bounded so one response stays sane. */
@@ -132,23 +137,33 @@ export function createGithubAppProvider(options) {
132
137
  * the branches it names. Not the configured `permissions`, for the same reason a log read is not:
133
138
  * a token minted for git should not also be able to open a pull request.
134
139
  */
135
- const PULL_REQUEST_PERMISSIONS = { pull_requests: 'write', contents: 'read' };
140
+ const PULL_REQUEST_PERMISSIONS = {
141
+ pull_requests: 'write',
142
+ contents: 'read',
143
+ };
136
144
  /** The result of an action that never reached the executor. */
137
- const NO_RUN = { stdout: '', stderr: '', code: null, signal: null, truncated: false, timedOut: false };
145
+ const NO_RUN = {
146
+ stdout: '',
147
+ stderr: '',
148
+ code: null,
149
+ signal: null,
150
+ truncated: false,
151
+ timedOut: false,
152
+ };
138
153
  const REVOKE_WARNING = 'the action token could not be revoked; it expires on its own';
139
- /** owner login (lowercased) -> installation */
154
+ /** Owner login (lowercased) -> installation */
140
155
  const installations = new Map();
141
- /** owner/repo -> minted credential */
156
+ /** Owner/repo -> minted credential */
142
157
  const tokens = new Map();
143
158
  /** Cached pre-flight result, per owner: installations differ, so the answer does too. */
144
159
  const permissionsChecked = new Map();
145
160
  /**
146
161
  * Name every configured permission a grant does not cover.
147
162
  *
148
- * @param granted - the permission map to check against.
149
- * @param from - how to refer to that map in the message.
150
- * @param absent - how to describe a permission that is missing entirely.
151
- * @returns one message per problem; empty when the grant covers the configuration.
163
+ * @param granted - The permission map to check against.
164
+ * @param from - How to refer to that map in the message.
165
+ * @param absent - How to describe a permission that is missing entirely.
166
+ * @returns One message per problem; empty when the grant covers the configuration.
152
167
  */
153
168
  function missingPermissions(wantedPermissions, granted, from, absent) {
154
169
  const problems = [];
@@ -168,10 +183,10 @@ export function createGithubAppProvider(options) {
168
183
  * Call the REST API with a freshly signed app JWT.
169
184
  *
170
185
  * @param method - HTTP method.
171
- * @param url - absolute URL.
172
- * @param body - optional JSON body.
173
- * @returns the parsed body and the Link header.
174
- * @throws {Error} for a non-2xx response.
186
+ * @param url - Absolute URL.
187
+ * @param body - Optional JSON body.
188
+ * @returns The parsed body and the Link header.
189
+ * @throws {Error} For a non-2xx response.
175
190
  */
176
191
  async function call(method, url, body) {
177
192
  const jwt = signAppJwt({ privateKeyPem: keyPem, iss, nowMs: now() });
@@ -181,8 +196,9 @@ export function createGithubAppProvider(options) {
181
196
  'x-github-api-version': apiVersion,
182
197
  'user-agent': 'gcb',
183
198
  };
184
- if (body)
199
+ if (body) {
185
200
  headers['content-type'] = 'application/json';
201
+ }
186
202
  const response = await fetchImpl(url, {
187
203
  method,
188
204
  headers,
@@ -212,10 +228,10 @@ export function createGithubAppProvider(options) {
212
228
  * authorization — fetch drops the header across origins, which is what GitHub intends.
213
229
  *
214
230
  * @param method - HTTP method.
215
- * @param url - absolute URL.
216
- * @param token - the installation token to authenticate with.
217
- * @returns the status and the body text.
218
- * @throws {Error} for a non-2xx response.
231
+ * @param url - Absolute URL.
232
+ * @param token - The installation token to authenticate with.
233
+ * @returns The status and the body text.
234
+ * @throws {Error} For a non-2xx response.
219
235
  */
220
236
  async function callWithToken(method, url, token, body) {
221
237
  const headers = {
@@ -224,8 +240,9 @@ export function createGithubAppProvider(options) {
224
240
  'x-github-api-version': apiVersion,
225
241
  'user-agent': 'gcb',
226
242
  };
227
- if (body)
243
+ if (body) {
228
244
  headers['content-type'] = 'application/json';
245
+ }
229
246
  const response = await fetchImpl(url, {
230
247
  method,
231
248
  headers,
@@ -240,8 +257,9 @@ export function createGithubAppProvider(options) {
240
257
  // cannot make it, because a caller that re-wraps this to name a permission wraps it in the same class.
241
258
  const detail = text.replace(/\s+/g, ' ').trim().slice(0, 400);
242
259
  const failure = new ProviderConfigError(`GitHub API ${method} ${url.replace(api, '')} answered ${response.status} for that request`);
243
- if (detail !== '')
260
+ if (detail !== '') {
244
261
  Object.assign(failure, { hostDetail: detail });
262
+ }
245
263
  throw failure;
246
264
  }
247
265
  return { status: response.status, text };
@@ -249,15 +267,16 @@ export function createGithubAppProvider(options) {
249
267
  /**
250
268
  * Resolve the installation id for an account, cached per owner.
251
269
  *
252
- * @param owner - the repository owner as it appeared in the path.
253
- * @returns the installation id.
254
- * @throws {Error} when this app is not installed for that account.
270
+ * @param owner - The repository owner as it appeared in the path.
271
+ * @returns The installation id.
272
+ * @throws {Error} When this app is not installed for that account.
255
273
  */
256
274
  async function resolveInstallationId(owner) {
257
275
  const key = owner.toLowerCase();
258
276
  const cached = installations.get(key);
259
- if (cached && cached.expiresAt > now())
277
+ if (cached && cached.expiresAt > now()) {
260
278
  return cached.id;
279
+ }
261
280
  let url = `${api}/app/installations?per_page=100`;
262
281
  for (let page = 0; page < 10 && url; page += 1) {
263
282
  const { json, link } = await call('GET', url);
@@ -265,7 +284,9 @@ export function createGithubAppProvider(options) {
265
284
  for (const entry of json) {
266
285
  const installation = entry;
267
286
  const login = installation.account?.login;
268
- if (typeof login === 'string' && login.toLowerCase() === key && typeof installation.id === 'number') {
287
+ if (typeof login === 'string' &&
288
+ login.toLowerCase() === key &&
289
+ typeof installation.id === 'number') {
269
290
  installations.set(key, { id: installation.id, expiresAt: now() + installationTtlMs });
270
291
  return installation.id;
271
292
  }
@@ -278,30 +299,33 @@ export function createGithubAppProvider(options) {
278
299
  /**
279
300
  * Confirm both grants cover every permission the token request will ask for.
280
301
  *
281
- * Two checks, because there are two ceilings. The app's permissions are what the app may ask for at
282
- * all; the **installation's** are what it actually has, and a permission can be granted on the app
283
- * while an installation has not approved the update. Checking only the app passes in that case and
284
- * then fails at the token request, where the message is GitHub's and so cannot be handed to the
285
- * container — which is how a permission problem became "see the broker log".
302
+ * Two checks, because there are two ceilings. The app's permissions are what the app may ask for
303
+ * at all; the **installation's** are what it actually has, and a permission can be granted on the
304
+ * app while an installation has not approved the update. Checking only the app passes in that
305
+ * case and then fails at the token request, where the message is GitHub's and so cannot be handed
306
+ * to the container — which is how a permission problem became "see the broker log".
286
307
  *
287
308
  * Cached per owner, since installations differ; a failed check is not cached, so fixing the
288
309
  * settings does not require restarting the broker.
289
310
  *
290
- * @param owner - the account whose installation will be asked for a token.
291
- * @returns resolves when the configured permissions are a subset of both grants.
292
- * @throws {ProviderConfigError} naming each permission that is missing or granted at a lower level.
311
+ * @param owner - The account whose installation will be asked for a token.
312
+ * @returns Resolves when the configured permissions are a subset of both grants.
313
+ * @throws {ProviderConfigError} Naming each permission that is missing or granted at a lower
314
+ * level.
293
315
  */
294
316
  function ensurePermissions(owner, wantedPermissions = permissions) {
295
- if (!checkPermissions)
317
+ if (!checkPermissions) {
296
318
  return Promise.resolve();
319
+ }
297
320
  // Keyed by both, because a log read checks a different set than a credential does.
298
321
  const key = `${owner.toLowerCase()}|${currentProxyKey()}|${Object.entries(wantedPermissions)
299
322
  .sort(([left], [right]) => left.localeCompare(right))
300
323
  .map(([name, level]) => `${name}=${level}`)
301
324
  .join(',')}`;
302
325
  const cached = permissionsChecked.get(key);
303
- if (cached)
326
+ if (cached) {
304
327
  return cached;
328
+ }
305
329
  const pending = (async () => {
306
330
  const { json } = await call('GET', `${api}/app`);
307
331
  const appGranted = json?.permissions ?? {};
@@ -330,10 +354,10 @@ export function createGithubAppProvider(options) {
330
354
  * review", which `mergeable_state: blocked` alone cannot. Both are repository-scoped, so the
331
355
  * allowlist binds them.
332
356
  *
333
- * @param request - which pull request.
334
- * @param token - the installation token.
335
- * @param collection - the repository's pull request route.
336
- * @returns the number, URL, state and a short report.
357
+ * @param request - Which pull request.
358
+ * @param token - The installation token.
359
+ * @param collection - The repository's pull request route.
360
+ * @returns The number, URL, state and a short report.
337
361
  */
338
362
  async function readPullRequestStatus(request, token, collection) {
339
363
  const { text } = await callWithToken('GET', `${collection}/${request.number}`, token);
@@ -397,11 +421,10 @@ export function createGithubAppProvider(options) {
397
421
  *
398
422
  * Only person-relevant actions may use it; GitHub will not author a pull request as a person for
399
423
  * an app, refuses to resolve a review thread for one, and a reply must be attributable to the
400
- * person answering.
401
- * The refresh token lives beside the private key, and GitHub rotates it on every use, so the new
402
- * one is written back — keeping the old would work once and then stop.
424
+ * person answering. The refresh token lives beside the private key, and GitHub rotates it on
425
+ * every use, so the new one is written back — keeping the old would work once and then stop.
403
426
  *
404
- * @returns the user token, or `undefined` when nobody has authorized one.
427
+ * @returns The user token, or `undefined` when nobody has authorized one.
405
428
  */
406
429
  async function personalToken() {
407
430
  while (renewingPersonToken !== undefined) {
@@ -418,32 +441,42 @@ export function createGithubAppProvider(options) {
418
441
  return await pending;
419
442
  }
420
443
  finally {
421
- if (renewingPersonToken === pending)
444
+ if (renewingPersonToken === pending) {
422
445
  renewingPersonToken = undefined;
446
+ }
423
447
  }
424
448
  }
425
449
  /** The authorized person's token, falling back to the per-owner token for this owner. */
426
450
  async function personTokenFor(owner) {
427
451
  const personal = await personalToken();
428
- if (personal !== undefined)
452
+ if (personal !== undefined) {
429
453
  return personal;
454
+ }
430
455
  const file = cfg.userTokens?.[owner];
431
- if (file === undefined)
456
+ if (file === undefined) {
432
457
  return undefined;
458
+ }
433
459
  const token = readFileSync(file, 'utf8').trim();
434
460
  return token === '' ? undefined : token;
435
461
  }
436
462
  async function renewPersonToken() {
437
- if (cfg.clientId === undefined || cfg.privateKeyPath === undefined)
463
+ if (cfg.clientId === undefined || cfg.privateKeyPath === undefined) {
438
464
  return undefined;
465
+ }
439
466
  const path = userTokenPath(cfg.privateKeyPath);
440
- if (!existsSync(path))
467
+ if (!existsSync(path)) {
441
468
  return undefined;
469
+ }
442
470
  const now = Date.now();
443
471
  // The file is the only thing that says who the broker may act as, so it is read before the cache is
444
472
  // trusted: running `authorize` again — the documented way to change that person, and the only one —
445
473
  // has to take effect now rather than when the cached token happens to expire.
446
474
  const held = readFileSync(path, 'utf8').trim();
475
+ if (held === '') {
476
+ // A pre-created mount file is a placeholder until `authorize` writes it; treat it as absent
477
+ // rather than trying to renew an empty refresh token.
478
+ return undefined;
479
+ }
447
480
  if (cachedPersonToken !== undefined &&
448
481
  cachedPersonToken.until > now + 60_000 &&
449
482
  cachedPersonToken.source === held) {
@@ -456,7 +489,7 @@ export function createGithubAppProvider(options) {
456
489
  catch (error) {
457
490
  // This is the one place a renewal failure is explained: the caller is an action that failed,
458
491
  // and "fetch failed" on its own names neither the host nor the reason.
459
- throw new Error(`could not renew the authorized token from ${path}: ${error.message}${describeCause(error)}`);
492
+ throw new Error(`could not renew the authorized token from ${path}: ${error.message}${describeCause(error)}`, { cause: error });
460
493
  }
461
494
  if (next.refreshToken !== undefined) {
462
495
  try {
@@ -466,7 +499,7 @@ export function createGithubAppProvider(options) {
466
499
  // GitHub invalidated the token on disk the moment it answered, so this is not transient and not
467
500
  // recoverable: the next renewal reads a dead token. Nothing else in the log would say so.
468
501
  throw new Error(`could not store the new refresh token in ${path}: ${error.message}; the previous ` +
469
- 'one is no longer valid, so run authorize again');
502
+ 'one is no longer valid, so run authorize again', { cause: error });
470
503
  }
471
504
  }
472
505
  cachedPersonToken = {
@@ -479,11 +512,11 @@ export function createGithubAppProvider(options) {
479
512
  /**
480
513
  * One more sentence about a failure, when there is one worth having.
481
514
  *
482
- * `fetch failed` is the whole message undici gives for a connection problem and it names nothing useful;
483
- * the reason is on `cause`. This is for the host log, so it is allowed to carry detail.
515
+ * `fetch failed` is the whole message undici gives for a connection problem and it names nothing
516
+ * useful; the reason is on `cause`. This is for the host log, so it is allowed to carry detail.
484
517
  *
485
- * @param error - whatever was thrown.
486
- * @returns an empty string, or ` (the reason)`.
518
+ * @param error - Whatever was thrown.
519
+ * @returns An empty string, or ` (the reason)`.
487
520
  */
488
521
  function describeCause(error) {
489
522
  const cause = error?.cause?.message;
@@ -492,28 +525,30 @@ export function createGithubAppProvider(options) {
492
525
  /**
493
526
  * The pull request number in the URL GitHub returns for a pull request.
494
527
  *
495
- * A review comment carries the pull request it belongs to as `pull_request_url`, which is how a comment
496
- * id — one for the whole repository, not one per pull request — is tied back to the pull request named
497
- * on the command line.
528
+ * A review comment carries the pull request it belongs to as `pull_request_url`, which is how a
529
+ * comment id — one for the whole repository, not one per pull request — is tied back to the pull
530
+ * request named on the command line.
498
531
  *
499
- * @param value - an `.../pulls/{number}` URL, as GitHub returns it.
500
- * @returns the number, or undefined when the value names none.
532
+ * @param value - An `.../pulls/{number}` URL, as GitHub returns it.
533
+ * @returns The number, or undefined when the value names none.
501
534
  */
502
535
  function pullRequestNumber(value) {
503
- if (typeof value !== 'string')
536
+ if (typeof value !== 'string') {
504
537
  return undefined;
538
+ }
505
539
  const match = /\/pulls\/(\d+)\/?$/.exec(value);
506
- if (match?.[1] === undefined)
540
+ if (match?.[1] === undefined) {
507
541
  return undefined;
542
+ }
508
543
  const number = Number(match[1]);
509
544
  return Number.isInteger(number) && number > 0 ? number : undefined;
510
545
  }
511
546
  /**
512
547
  * The installation ID GitHub reports for a specific repository.
513
548
  *
514
- * @param owner - the repository owner.
515
- * @param repo - the repository name.
516
- * @returns the installation id, or undefined when the app is not installed there.
549
+ * @param owner - The repository owner.
550
+ * @param repo - The repository name.
551
+ * @returns The installation id, or undefined when the app is not installed there.
517
552
  */
518
553
  async function repositoryInstallation(owner, repo) {
519
554
  const jwt = signAppJwt({ privateKeyPem: keyPem, iss, nowMs: now() });
@@ -526,14 +561,17 @@ export function createGithubAppProvider(options) {
526
561
  'user-agent': 'gcb',
527
562
  },
528
563
  });
529
- if (response.status === 404)
564
+ if (response.status === 404) {
530
565
  return undefined;
566
+ }
531
567
  if (!response.ok) {
532
568
  const text = await response.text();
533
569
  throw new Error(`GitHub API GET /repos/${owner}/${repo}/installation failed with ${response.status}: ${text.slice(0, 200)}`);
534
570
  }
535
571
  const body = JSON.parse(await response.text());
536
- return typeof body?.id === 'number' && Number.isInteger(body.id) && body.id > 0 ? body.id : undefined;
572
+ return typeof body?.id === 'number' && Number.isInteger(body.id) && body.id > 0
573
+ ? body.id
574
+ : undefined;
537
575
  }
538
576
  return {
539
577
  name: 'github-app',
@@ -550,8 +588,9 @@ export function createGithubAppProvider(options) {
550
588
  if (cached?.id !== installationId) {
551
589
  installations.set(key, { id: installationId, expiresAt: now() + installationTtlMs });
552
590
  for (const permissionKey of permissionsChecked.keys()) {
553
- if (permissionKey.startsWith(`${key}|`))
591
+ if (permissionKey.startsWith(`${key}|`)) {
554
592
  permissionsChecked.delete(permissionKey);
593
+ }
555
594
  }
556
595
  }
557
596
  }
@@ -579,8 +618,9 @@ export function createGithubAppProvider(options) {
579
618
  minted = true;
580
619
  }
581
620
  catch (error) {
582
- if (error instanceof ProviderConfigError)
621
+ if (error instanceof ProviderConfigError) {
583
622
  throw error;
623
+ }
584
624
  throw new ProviderConfigError('the app needs an installation on this repository to read it with a metadata-only token');
585
625
  }
586
626
  }
@@ -653,7 +693,11 @@ export function createGithubAppProvider(options) {
653
693
  }
654
694
  }
655
695
  }
656
- return { ...result, tokenFingerprint: tokenFingerprint(token), ...(warning === undefined ? {} : { warning }) };
696
+ return {
697
+ ...result,
698
+ tokenFingerprint: tokenFingerprint(token),
699
+ ...(warning === undefined ? {} : { warning }),
700
+ };
657
701
  },
658
702
  async getCredential(request) {
659
703
  const cached = tokens.get(request.full);
@@ -682,8 +726,9 @@ export function createGithubAppProvider(options) {
682
726
  /** Check a public fork with no credential; only confirmed fork relationships pass. */
683
727
  async isForkOf(owner, repo, bases) {
684
728
  return withRepositoryResponse(fetchImpl, api, `${owner}/${repo}`, { apiVersion, label: 'fork' }, async (response) => {
685
- if (!response.ok)
729
+ if (!response.ok) {
686
730
  return false;
731
+ }
687
732
  const text = await response.text();
688
733
  let body;
689
734
  try {
@@ -692,11 +737,13 @@ export function createGithubAppProvider(options) {
692
737
  catch {
693
738
  return false;
694
739
  }
695
- if (body?.fork !== true)
740
+ if (body?.fork !== true) {
696
741
  return false;
742
+ }
697
743
  for (const candidate of [body.parent?.full_name, body.source?.full_name]) {
698
- if (typeof candidate === 'string' && repoAllowed(bases, candidate))
744
+ if (typeof candidate === 'string' && repoAllowed(bases, candidate)) {
699
745
  return true;
746
+ }
700
747
  }
701
748
  return false;
702
749
  });
@@ -713,8 +760,8 @@ export function createGithubAppProvider(options) {
713
760
  * repository could name any job this app can see — and it is the one that answers "Must have
714
761
  * admin rights", which is a permission this broker should never want.
715
762
  *
716
- * @param request - the job to read.
717
- * @returns the log text, bounded.
763
+ * @param request - The job to read.
764
+ * @returns The log text, bounded.
718
765
  */
719
766
  async getJobLog(request) {
720
767
  await ensurePermissions(request.owner, LOG_PERMISSIONS);
@@ -738,8 +785,9 @@ export function createGithubAppProvider(options) {
738
785
  // while a job is still running.
739
786
  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`);
740
787
  }
741
- if (text.length <= LOG_LIMIT_CHARS)
788
+ if (text.length <= LOG_LIMIT_CHARS) {
742
789
  return { text, truncated: false };
790
+ }
743
791
  return {
744
792
  text: `${text.slice(0, LOG_LIMIT_CHARS)}\n… log truncated at ${LOG_LIMIT_CHARS} characters\n`,
745
793
  truncated: true,
@@ -751,8 +799,8 @@ export function createGithubAppProvider(options) {
751
799
  * The same shape as a log read: its own token, narrowed to what the operation needs, and the
752
800
  * repository-scoped route, so the allowlist binds it by construction.
753
801
  *
754
- * @param request - the branches and the text.
755
- * @returns the number and the URL GitHub answered with.
802
+ * @param request - The branches and the text.
803
+ * @returns The number and the URL GitHub answered with.
756
804
  */
757
805
  async pullRequest(request) {
758
806
  if (request.action === 'fork') {
@@ -791,7 +839,9 @@ export function createGithubAppProvider(options) {
791
839
  // `--status` also reads /actions/runs, which is checked against actions: read. Pre-flighting
792
840
  // pull_requests alone passed a grant that could not make that call, which is the opposite of what a
793
841
  // pre-flight is for.
794
- const wanted = request.action === 'status' ? { ...PULL_REQUEST_PERMISSIONS, actions: 'read' } : PULL_REQUEST_PERMISSIONS;
842
+ const wanted = request.action === 'status'
843
+ ? { ...PULL_REQUEST_PERMISSIONS, actions: 'read' }
844
+ : PULL_REQUEST_PERMISSIONS;
795
845
  // One variable, so every branch below holds a token without having to ask which kind it is.
796
846
  let token;
797
847
  if (actingToken === undefined) {
@@ -832,7 +882,9 @@ export function createGithubAppProvider(options) {
832
882
  belongsTo.toLowerCase() !== `${request.owner}/${request.repo}`.toLowerCase()) {
833
883
  // A plain Error on purpose: this is the host log's business, not the container's.
834
884
  throw new Error(`refusing to resolve ${request.threadId}: it is not a review thread of ${request.owner}/${request.repo}` +
835
- (typeof belongsTo === 'string' ? ` (it belongs to ${belongsTo})` : ' (or it could not be read)'));
885
+ (typeof belongsTo === 'string'
886
+ ? ` (it belongs to ${belongsTo})`
887
+ : ' (or it could not be read)'));
836
888
  }
837
889
  const { text: raw } = await callWithToken('POST', `${api}/graphql`, token, {
838
890
  query: 'mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{isResolved}}}',
@@ -856,7 +908,9 @@ export function createGithubAppProvider(options) {
856
908
  return {
857
909
  number: request.number ?? 0,
858
910
  url: '',
859
- status: state.data?.resolveReviewThread?.thread?.isResolved === true ? 'resolved' : 'still open',
911
+ status: state.data?.resolveReviewThread?.thread?.isResolved === true
912
+ ? 'resolved'
913
+ : 'still open',
860
914
  };
861
915
  }
862
916
  if (request.action === 'reply') {
@@ -871,7 +925,9 @@ export function createGithubAppProvider(options) {
871
925
  // none: the number to report is the one the comment itself names, read back from the answer
872
926
  // rather than echoed from the request. The repository in the path is what the allowlist binds.
873
927
  const endpoint = `${api}/repos/${request.owner}/${request.repo}/pulls/comments/${String(request.commentId ?? 0)}`;
874
- const { text: edited } = await callWithToken('PATCH', endpoint, token, { body: request.body ?? '' });
928
+ const { text: edited } = await callWithToken('PATCH', endpoint, token, {
929
+ body: request.body ?? '',
930
+ });
875
931
  let changed = null;
876
932
  try {
877
933
  changed = JSON.parse(edited);
@@ -916,7 +972,9 @@ export function createGithubAppProvider(options) {
916
972
  if (!Array.isArray(threads.nodes)) {
917
973
  throw new Error('GitHub did not answer with reviewThreads.nodes');
918
974
  }
919
- if (typeof threads.totalCount !== 'number' || !Number.isInteger(threads.totalCount) || threads.totalCount < 0) {
975
+ if (typeof threads.totalCount !== 'number' ||
976
+ !Number.isInteger(threads.totalCount) ||
977
+ threads.totalCount < 0) {
920
978
  throw new Error('GitHub did not answer with reviewThreads.totalCount');
921
979
  }
922
980
  if (threads.totalCount > 0 && threads.nodes.length === 0) {
@@ -955,18 +1013,21 @@ export function createGithubAppProvider(options) {
955
1013
  inFence = !inFence;
956
1014
  continue;
957
1015
  }
958
- if (inFence)
1016
+ if (inFence) {
959
1017
  continue;
960
- if (/^(🏁|🔎|⚙️|🌐|💡|💬|🔗|Repository:|Length of output:|Proposed fix$|---$|_?\s*[🔒📐🎯🩺🟡🟠])/u.test(line))
1018
+ }
1019
+ if (/^(🏁|🔎|⚙️|🌐|💡|💬|🔗|Repository:|Length of output:|Proposed fix$|---$|_?\s*[🔒📐🎯🩺🟡🟠])/u.test(line)) {
961
1020
  continue;
1021
+ }
962
1022
  kept.push(line.replace(/<\/?[a-z][^>]*>/gi, '').trim());
963
1023
  }
964
- const visible = kept.filter((line) => line !== '');
1024
+ const visible = kept.filter(line => line !== '');
965
1025
  const shown = visible.slice(0, 14);
966
1026
  const more = visible.length > shown.length ? ` (+${visible.length - shown.length} more lines)` : '';
967
1027
  report.push(` ${String(comment.databaseId ?? '')} @${String(comment.author?.login ?? '?')}:${more}`);
968
- for (const line of shown)
1028
+ for (const line of shown) {
969
1029
  report.push(` ${line.slice(0, 300)}`);
1030
+ }
970
1031
  }
971
1032
  }
972
1033
  // A page is not the whole list. Saying which it is is the difference between a report and a guess.
@@ -1023,7 +1084,13 @@ export function createGithubAppProvider(options) {
1023
1084
  let text;
1024
1085
  const method = 'POST';
1025
1086
  const url = collection;
1026
- const body = { title: request.title, body: request.body, head: request.head, base: request.base, draft: request.draft === true };
1087
+ const body = {
1088
+ title: request.title,
1089
+ body: request.body,
1090
+ head: request.head,
1091
+ base: request.base,
1092
+ draft: request.draft === true,
1093
+ };
1027
1094
  try {
1028
1095
  // as theirs, which is what automated reviewers recognise. The token itself is as wide as that person —
1029
1096
  // it carries the app permissions and could push and merge — and what is narrow is this code: it is
@@ -1032,6 +1099,7 @@ export function createGithubAppProvider(options) {
1032
1099
  // installation token is refused for. A token per owner is the older, narrower way and is still
1033
1100
  // used when there is one; the authorized token answers for every owner at once.
1034
1101
  // Resolved above, where it also decided whether the app had to be asked at all.
1102
+ ;
1035
1103
  ({ text } = await callWithToken(method, url, actingToken ?? token, body));
1036
1104
  }
1037
1105
  catch (error) {
@@ -1039,8 +1107,9 @@ export function createGithubAppProvider(options) {
1039
1107
  // the failure carried any, is carried across rather than put in the message the pusher sees.
1040
1108
  const wrapped = new ProviderConfigError(`${error.message}${describeCause(error)}; this needs the pull_requests: write permission on both the app and this installation`);
1041
1109
  const detail = error.hostDetail;
1042
- if (detail !== undefined)
1110
+ if (detail !== undefined) {
1043
1111
  Object.assign(wrapped, { hostDetail: detail });
1112
+ }
1044
1113
  throw wrapped;
1045
1114
  }
1046
1115
  let parsed = null;