gcb-host 0.7.1 → 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.
- package/CHANGELOG.md +12 -1
- package/bin/gcb.js +3 -3
- package/lib/audit.d.ts +4 -4
- package/lib/audit.d.ts.map +1 -1
- package/lib/audit.js +15 -14
- package/lib/audit.js.map +1 -1
- package/lib/broker.d.ts +12 -13
- package/lib/broker.d.ts.map +1 -1
- package/lib/broker.js +77 -53
- package/lib/broker.js.map +1 -1
- package/lib/commands/authorize.d.ts +43 -13
- package/lib/commands/authorize.d.ts.map +1 -1
- package/lib/commands/authorize.js +146 -53
- package/lib/commands/authorize.js.map +1 -1
- package/lib/commands/compose.d.ts +7 -7
- package/lib/commands/compose.d.ts.map +1 -1
- package/lib/commands/compose.js +21 -22
- package/lib/commands/compose.js.map +1 -1
- package/lib/commands/config.d.ts.map +1 -1
- package/lib/commands/config.js +59 -41
- package/lib/commands/config.js.map +1 -1
- package/lib/commands/deployment.d.ts +11 -11
- package/lib/commands/deployment.d.ts.map +1 -1
- package/lib/commands/deployment.js +19 -18
- package/lib/commands/deployment.js.map +1 -1
- package/lib/commands/diagnose.d.ts.map +1 -1
- package/lib/commands/diagnose.js +51 -33
- package/lib/commands/diagnose.js.map +1 -1
- package/lib/commands/doctor.d.ts +5 -5
- package/lib/commands/doctor.d.ts.map +1 -1
- package/lib/commands/doctor.js +53 -32
- package/lib/commands/doctor.js.map +1 -1
- package/lib/commands/host.d.ts +7 -7
- package/lib/commands/host.d.ts.map +1 -1
- package/lib/commands/host.js +144 -93
- package/lib/commands/host.js.map +1 -1
- package/lib/commands/init.d.ts +33 -33
- package/lib/commands/init.d.ts.map +1 -1
- package/lib/commands/init.js +93 -75
- package/lib/commands/init.js.map +1 -1
- package/lib/commands/stage.d.ts +6 -6
- package/lib/commands/stage.d.ts.map +1 -1
- package/lib/commands/stage.js +21 -15
- package/lib/commands/stage.js.map +1 -1
- package/lib/config.d.ts +29 -28
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js +100 -84
- package/lib/config.js.map +1 -1
- package/lib/daemon.d.ts +9 -9
- package/lib/daemon.d.ts.map +1 -1
- package/lib/daemon.js +21 -18
- package/lib/daemon.js.map +1 -1
- package/lib/device-flow.d.ts +23 -22
- package/lib/device-flow.d.ts.map +1 -1
- package/lib/device-flow.js +33 -28
- package/lib/device-flow.js.map +1 -1
- package/lib/gh.d.ts +2 -2
- package/lib/gh.d.ts.map +1 -1
- package/lib/gh.js +74 -46
- package/lib/gh.js.map +1 -1
- package/lib/http.d.ts.map +1 -1
- package/lib/http.js +35 -18
- package/lib/http.js.map +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js.map +1 -1
- package/lib/policy.d.ts +25 -25
- package/lib/policy.d.ts.map +1 -1
- package/lib/policy.js +57 -36
- package/lib/policy.js.map +1 -1
- package/lib/providers/github-app.d.ts +12 -12
- package/lib/providers/github-app.d.ts.map +1 -1
- package/lib/providers/github-app.js +194 -125
- package/lib/providers/github-app.js.map +1 -1
- package/lib/providers/index.d.ts +8 -9
- package/lib/providers/index.d.ts.map +1 -1
- package/lib/providers/index.js +5 -5
- package/lib/providers/index.js.map +1 -1
- package/lib/providers/static.d.ts +3 -3
- package/lib/providers/static.d.ts.map +1 -1
- package/lib/providers/static.js +14 -12
- package/lib/providers/static.js.map +1 -1
- package/lib/proxy.d.ts +10 -10
- package/lib/proxy.d.ts.map +1 -1
- package/lib/proxy.js +18 -16
- package/lib/proxy.js.map +1 -1
- package/lib/public-repo.d.ts +10 -10
- package/lib/public-repo.d.ts.map +1 -1
- package/lib/public-repo.js +17 -14
- package/lib/public-repo.js.map +1 -1
- package/lib/reload.d.ts +4 -4
- package/lib/reload.d.ts.map +1 -1
- package/lib/reload.js +24 -14
- package/lib/reload.js.map +1 -1
- 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
|
-
*
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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:
|
|
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 -
|
|
39
|
-
* @returns
|
|
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 -
|
|
48
|
-
* @returns
|
|
49
|
-
* @throws {Error}
|
|
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 -
|
|
76
|
-
* @returns
|
|
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 -
|
|
93
|
-
* @returns
|
|
94
|
-
* @throws {Error}
|
|
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
|
|
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 = {
|
|
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 = {
|
|
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
|
-
/**
|
|
154
|
+
/** Owner login (lowercased) -> installation */
|
|
140
155
|
const installations = new Map();
|
|
141
|
-
/**
|
|
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 -
|
|
149
|
-
* @param from -
|
|
150
|
-
* @param absent -
|
|
151
|
-
* @returns
|
|
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 -
|
|
172
|
-
* @param body -
|
|
173
|
-
* @returns
|
|
174
|
-
* @throws {Error}
|
|
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 -
|
|
216
|
-
* @param token -
|
|
217
|
-
* @returns
|
|
218
|
-
* @throws {Error}
|
|
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 -
|
|
253
|
-
* @returns
|
|
254
|
-
* @throws {Error}
|
|
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' &&
|
|
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
|
|
282
|
-
* all; the **installation's** are what it actually has, and a permission can be granted on the
|
|
283
|
-
* while an installation has not approved the update. Checking only the app passes in that
|
|
284
|
-
* then fails at the token request, where the message is GitHub's and so cannot be handed
|
|
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 -
|
|
291
|
-
* @returns
|
|
292
|
-
* @throws {ProviderConfigError}
|
|
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 -
|
|
334
|
-
* @param token -
|
|
335
|
-
* @param collection -
|
|
336
|
-
* @returns
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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 -
|
|
486
|
-
* @returns
|
|
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
|
|
496
|
-
* id — one for the whole repository, not one per pull request — is tied back to the pull
|
|
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 -
|
|
500
|
-
* @returns
|
|
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 -
|
|
515
|
-
* @param repo -
|
|
516
|
-
* @returns
|
|
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
|
|
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 {
|
|
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 -
|
|
717
|
-
* @returns
|
|
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 -
|
|
755
|
-
* @returns
|
|
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'
|
|
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'
|
|
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
|
|
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, {
|
|
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' ||
|
|
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
|
-
|
|
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(
|
|
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 = {
|
|
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;
|