gcb-host 0.7.1 → 0.8.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.
- package/CHANGELOG.md +22 -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 +8 -10
- package/lib/commands/compose.d.ts.map +1 -1
- package/lib/commands/compose.js +33 -44
- 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
package/lib/commands/host.js
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
* `host` — the host broker for typed person actions: create, review, reply, edit, run catalogued
|
|
3
3
|
* actions, and fork with the gh account each request names.
|
|
4
4
|
*
|
|
5
|
-
* This runs on the host, beside the checkout the container also sees, and it does exactly one
|
|
6
|
-
* it turns a request file into a pull request, review, reply, pull request edit, catalogued
|
|
7
|
-
* or fork, using the gh account the request names. That is the whole point — content
|
|
8
|
-
* actually review, and the app cannot be that
|
|
5
|
+
* This runs on the host, beside the checkout the container also sees, and it does exactly one
|
|
6
|
+
* thing: it turns a request file into a pull request, review, reply, pull request edit, catalogued
|
|
7
|
+
* action or fork, using the gh account the request names. That is the whole point — content
|
|
8
|
+
* authored by a person is what automated reviewers will actually review, and the app cannot be that
|
|
9
|
+
* person.
|
|
9
10
|
*
|
|
10
11
|
* It has no other power than the catalogued actions: it cannot push or merge, and it never runs a
|
|
11
12
|
* shell; the program it calls is configuration, and it is called with an argument list built here.
|
|
@@ -13,13 +14,12 @@
|
|
|
13
14
|
import { execFile, spawn } from 'node:child_process';
|
|
14
15
|
import { existsSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
15
16
|
import { homedir, tmpdir } from 'node:os';
|
|
16
|
-
import { StringDecoder } from 'node:string_decoder';
|
|
17
17
|
import { join, resolve } from 'node:path';
|
|
18
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
18
19
|
import { promisify } from 'node:util';
|
|
19
|
-
import { channelDir, clearHostBroker, discoverCheckouts, findGitDir, listRequests, reclaimAbandoned, readHostBroker, claimRequest, removeRequest, requestProblems, writeHostBroker, writeJsonAtomic, readJson, writeResult, PACKAGE_VERSION, canonicalPath, apiBodyJson, HOST, REPOSITORY, ANSWER_TIMEOUT_MS, buildGhArgv, getGhAction, } from 'gcb-core';
|
|
20
|
-
import { fail, listFlag, parseArgs, say, validateArgs, warn } from 'gcb-core';
|
|
21
|
-
import { applyProxyChoice, fetcherFor, parseProxy } from '../http.js';
|
|
20
|
+
import { channelDir, clearHostBroker, discoverCheckouts, findGitDir, listRequests, reclaimAbandoned, readHostBroker, claimRequest, removeRequest, requestProblems, writeHostBroker, writeJsonAtomic, readJson, writeResult, PACKAGE_VERSION, canonicalPath, apiBodyJson, HOST, REPOSITORY, ANSWER_TIMEOUT_MS, buildGhArgv, getGhAction, fail, listFlag, parseArgs, say, validateArgs, warn, } from 'gcb-core';
|
|
22
21
|
import { loadConfig, resolveConfigPath } from '../config.js';
|
|
22
|
+
import { applyProxyChoice, fetcherFor, parseProxy } from '../http.js';
|
|
23
23
|
import { hostConfig, parseAllowEntry, repoAllowed } from '../policy.js';
|
|
24
24
|
import { isForkOfRepository, isPublicRepository } from '../public-repo.js';
|
|
25
25
|
const run = promisify(execFile);
|
|
@@ -29,17 +29,20 @@ const CREATE_TIMEOUT_MS = 120_000;
|
|
|
29
29
|
export function hostStatusPath() {
|
|
30
30
|
return join(homedir(), '.gcb-host', 'status.json');
|
|
31
31
|
}
|
|
32
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Per-stream byte budget for a host action, small enough that JSON escaping stays under
|
|
34
|
+
* readResult's cap.
|
|
35
|
+
*/
|
|
33
36
|
const HOST_ACTION_OUTPUT_BYTES = 48_000;
|
|
34
37
|
/**
|
|
35
38
|
* Run an action with streaming output caps.
|
|
36
39
|
*
|
|
37
|
-
*
|
|
40
|
+
* ExecFile's maxBuffer kills the child once a fixed buffer fills; a large diff or log would then be
|
|
38
41
|
* reported as a gh failure even though only the excess output is unusable. Read both streams to the
|
|
39
42
|
* end, keep the first bytes of each, and tell the caller where the cut is.
|
|
40
43
|
*/
|
|
41
44
|
export function runBounded(command, args, options) {
|
|
42
|
-
return new Promise(
|
|
45
|
+
return new Promise(resolve => {
|
|
43
46
|
let child;
|
|
44
47
|
try {
|
|
45
48
|
child = spawn(command, [...args], {
|
|
@@ -74,10 +77,12 @@ export function runBounded(command, args, options) {
|
|
|
74
77
|
const limit = stream === 'out' ? outKeep : errKeep;
|
|
75
78
|
const used = stream === 'out' ? outBytes : errBytes;
|
|
76
79
|
if (used >= limit) {
|
|
77
|
-
if (stream === 'out')
|
|
80
|
+
if (stream === 'out') {
|
|
78
81
|
outCut = true;
|
|
79
|
-
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
80
84
|
errCut = true;
|
|
85
|
+
}
|
|
81
86
|
return;
|
|
82
87
|
}
|
|
83
88
|
const piece = chunk.subarray(0, limit - used);
|
|
@@ -85,19 +90,21 @@ export function runBounded(command, args, options) {
|
|
|
85
90
|
if (stream === 'out') {
|
|
86
91
|
out += text;
|
|
87
92
|
outBytes += piece.length;
|
|
88
|
-
if (piece.length < chunk.length)
|
|
93
|
+
if (piece.length < chunk.length) {
|
|
89
94
|
outCut = true;
|
|
95
|
+
}
|
|
90
96
|
}
|
|
91
97
|
else {
|
|
92
98
|
err += text;
|
|
93
99
|
errBytes += piece.length;
|
|
94
|
-
if (piece.length < chunk.length)
|
|
100
|
+
if (piece.length < chunk.length) {
|
|
95
101
|
errCut = true;
|
|
102
|
+
}
|
|
96
103
|
}
|
|
97
104
|
};
|
|
98
105
|
child.stdout?.on('data', (chunk) => append(stdout, chunk, 'out'));
|
|
99
106
|
child.stderr?.on('data', (chunk) => append(stderr, chunk, 'err'));
|
|
100
|
-
child.on('error',
|
|
107
|
+
child.on('error', error => finish({ stdout: out, stderr: err || error.message, code: 1 }));
|
|
101
108
|
const timer = setTimeout(() => {
|
|
102
109
|
timedOut = true;
|
|
103
110
|
if (child.pid !== undefined) {
|
|
@@ -110,21 +117,26 @@ export function runBounded(command, args, options) {
|
|
|
110
117
|
}
|
|
111
118
|
}, options.timeoutMs);
|
|
112
119
|
function finish(result) {
|
|
113
|
-
if (settled)
|
|
120
|
+
if (settled) {
|
|
114
121
|
return;
|
|
122
|
+
}
|
|
115
123
|
settled = true;
|
|
116
124
|
clearTimeout(timer);
|
|
117
125
|
out += stdout.end();
|
|
118
126
|
err += stderr.end();
|
|
119
|
-
if (outCut)
|
|
127
|
+
if (outCut) {
|
|
120
128
|
out += suffix;
|
|
121
|
-
|
|
129
|
+
}
|
|
130
|
+
if (errCut) {
|
|
122
131
|
err += suffix;
|
|
123
|
-
|
|
132
|
+
}
|
|
133
|
+
if (err === '' && result.stderr !== '') {
|
|
124
134
|
err = result.stderr;
|
|
135
|
+
}
|
|
125
136
|
if (timedOut) {
|
|
126
|
-
if (err !== '' && !err.endsWith('\n'))
|
|
137
|
+
if (err !== '' && !err.endsWith('\n')) {
|
|
127
138
|
err += '\n';
|
|
139
|
+
}
|
|
128
140
|
err += timeoutNote;
|
|
129
141
|
}
|
|
130
142
|
resolve({ ...result, stdout: out, stderr: err });
|
|
@@ -163,24 +175,25 @@ directory, and only the roots this process scans are host-side paths.
|
|
|
163
175
|
/**
|
|
164
176
|
* Read a pull request's number and URL out of a program's output.
|
|
165
177
|
*
|
|
166
|
-
* `gh pr create` prints the URL, and so does anything that behaves like it. Nothing else is
|
|
167
|
-
* without a URL in the output the host broker reports a failure rather than guessing.
|
|
178
|
+
* `gh pr create` prints the URL, and so does anything that behaves like it. Nothing else is
|
|
179
|
+
* trusted: without a URL in the output the host broker reports a failure rather than guessing.
|
|
168
180
|
*
|
|
169
|
-
* @param output -
|
|
170
|
-
* @returns
|
|
181
|
+
* @param output - The program's standard output.
|
|
182
|
+
* @returns The number and URL, or `undefined`.
|
|
171
183
|
*/
|
|
172
184
|
export function pullRequestUrl(output) {
|
|
173
185
|
const match = /https:\/\/[^\s]+\/pull\/(\d+)/.exec(output);
|
|
174
186
|
const digits = match?.[1];
|
|
175
|
-
if (!match || digits === undefined)
|
|
187
|
+
if (!match || digits === undefined) {
|
|
176
188
|
return undefined;
|
|
189
|
+
}
|
|
177
190
|
return { number: Number(digits), url: match[0] };
|
|
178
191
|
}
|
|
179
192
|
/**
|
|
180
193
|
* Run the `host` command.
|
|
181
194
|
*
|
|
182
|
-
* @param argv -
|
|
183
|
-
* @returns
|
|
195
|
+
* @param argv - Arguments after the command name.
|
|
196
|
+
* @returns The process exit code.
|
|
184
197
|
*/
|
|
185
198
|
export async function runHostBroker(argv) {
|
|
186
199
|
const statusMode = argv[0] === 'status';
|
|
@@ -193,7 +206,7 @@ export async function runHostBroker(argv) {
|
|
|
193
206
|
const explicit = args.value('repo');
|
|
194
207
|
const single = explicit === undefined ? undefined : resolve(explicit);
|
|
195
208
|
const roots = listFlag(args, 'root');
|
|
196
|
-
const scanned = roots.length > 0 ? roots.map(
|
|
209
|
+
const scanned = roots.length > 0 ? roots.map(root => resolve(root)) : [homedir()];
|
|
197
210
|
const depth = Number(args.value('depth') ?? '4');
|
|
198
211
|
const command = args.value('command') ?? process.env['GCB_HOST_COMMAND'] ?? 'gh';
|
|
199
212
|
const configPath = args.value('config');
|
|
@@ -201,9 +214,7 @@ export async function runHostBroker(argv) {
|
|
|
201
214
|
const statusFile = hostStatusPath();
|
|
202
215
|
const startedAt = new Date().toISOString();
|
|
203
216
|
// The selected path is what a fork or explicit repository would load; it may not exist yet.
|
|
204
|
-
const configSelected = Boolean(configPath) ||
|
|
205
|
-
Boolean(process.env['GIT_BROKER_CONFIG']) ||
|
|
206
|
-
existsSync(resolvedConfig);
|
|
217
|
+
const configSelected = Boolean(configPath) || Boolean(process.env['GIT_BROKER_CONFIG']) || existsSync(resolvedConfig);
|
|
207
218
|
let loadedConfig = configSelected ? resolvedConfig : null;
|
|
208
219
|
/** Remove the status file only when this process owns it. */
|
|
209
220
|
function removeOwnStatus() {
|
|
@@ -222,8 +233,9 @@ export async function runHostBroker(argv) {
|
|
|
222
233
|
}
|
|
223
234
|
/** Rewrite the host-private status file; the channel marker is not trusted for this. */
|
|
224
235
|
function writeStatus() {
|
|
225
|
-
if (once)
|
|
236
|
+
if (once) {
|
|
226
237
|
return;
|
|
238
|
+
}
|
|
227
239
|
let collision;
|
|
228
240
|
try {
|
|
229
241
|
collision = canonicalPath(resolvedConfig) === canonicalPath(statusFile);
|
|
@@ -232,8 +244,9 @@ export async function runHostBroker(argv) {
|
|
|
232
244
|
warn(`could not compare the config and status paths: ${error.message}`);
|
|
233
245
|
return;
|
|
234
246
|
}
|
|
235
|
-
if (collision)
|
|
247
|
+
if (collision) {
|
|
236
248
|
fail('the config cannot be the host status file');
|
|
249
|
+
}
|
|
237
250
|
try {
|
|
238
251
|
writeJsonAtomic(statusFile, {
|
|
239
252
|
pid: process.pid,
|
|
@@ -257,12 +270,15 @@ export async function runHostBroker(argv) {
|
|
|
257
270
|
writeStatus();
|
|
258
271
|
}
|
|
259
272
|
catch (error) {
|
|
260
|
-
throw new Error(`a fork, reply or explicit repository needs the broker config allowlist: ${error.message}; pass --config or set GIT_BROKER_CONFIG
|
|
273
|
+
throw new Error(`a fork, reply or explicit repository needs the broker config allowlist: ${error.message}; pass --config or set GIT_BROKER_CONFIG`, { cause: error });
|
|
261
274
|
}
|
|
262
275
|
}
|
|
263
276
|
return hostConfig(config, host);
|
|
264
277
|
};
|
|
265
|
-
/**
|
|
278
|
+
/**
|
|
279
|
+
* Whether the broker config allowlist permits this host/repository for a fork, reply or explicit
|
|
280
|
+
* repository.
|
|
281
|
+
*/
|
|
266
282
|
const allowed = (host, repo) => {
|
|
267
283
|
const block = blockFor(host);
|
|
268
284
|
return block !== null && repoAllowed(block.allow, repo);
|
|
@@ -277,17 +293,20 @@ export async function runHostBroker(argv) {
|
|
|
277
293
|
}
|
|
278
294
|
/** The checkouts to serve: one named checkout, or everything under the roots. */
|
|
279
295
|
const targets = () => {
|
|
280
|
-
if (single === undefined)
|
|
281
|
-
return scanned.flatMap(
|
|
296
|
+
if (single === undefined) {
|
|
297
|
+
return scanned.flatMap(root => discoverCheckouts(root, depth));
|
|
298
|
+
}
|
|
282
299
|
const gitDir = findGitDir(single);
|
|
283
|
-
if (gitDir === undefined)
|
|
300
|
+
if (gitDir === undefined) {
|
|
284
301
|
fail(`no git repository at or above ${single}`);
|
|
302
|
+
}
|
|
285
303
|
return [{ repo: single, dir: channelDir(gitDir) }];
|
|
286
304
|
};
|
|
287
305
|
if (statusMode) {
|
|
288
306
|
validateArgs(args, argv.slice(1), new Set(), 'status');
|
|
289
|
-
if (args.positionals.length > 0)
|
|
307
|
+
if (args.positionals.length > 0) {
|
|
290
308
|
fail('status takes no arguments');
|
|
309
|
+
}
|
|
291
310
|
const parsed = readJson(statusFile);
|
|
292
311
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
293
312
|
fail('no host broker is running');
|
|
@@ -302,8 +321,9 @@ export async function runHostBroker(argv) {
|
|
|
302
321
|
process.kill(pid, 0);
|
|
303
322
|
}
|
|
304
323
|
catch (error) {
|
|
305
|
-
if (error.code === 'ESRCH')
|
|
324
|
+
if (error.code === 'ESRCH') {
|
|
306
325
|
fail('no host broker is running');
|
|
326
|
+
}
|
|
307
327
|
}
|
|
308
328
|
const output = {
|
|
309
329
|
pid,
|
|
@@ -336,8 +356,9 @@ export async function runHostBroker(argv) {
|
|
|
336
356
|
removeOwnStatus();
|
|
337
357
|
for (const dir of advertised) {
|
|
338
358
|
try {
|
|
339
|
-
if (readHostBroker(dir)?.pid === process.pid)
|
|
359
|
+
if (readHostBroker(dir)?.pid === process.pid) {
|
|
340
360
|
clearHostBroker(dir);
|
|
361
|
+
}
|
|
341
362
|
}
|
|
342
363
|
catch {
|
|
343
364
|
// Nothing useful is left to do while exiting.
|
|
@@ -347,7 +368,9 @@ export async function runHostBroker(argv) {
|
|
|
347
368
|
const stop = () => process.exit(0);
|
|
348
369
|
process.on('SIGINT', stop);
|
|
349
370
|
process.on('SIGTERM', stop);
|
|
350
|
-
say(single === undefined
|
|
371
|
+
say(single === undefined
|
|
372
|
+
? `serving the checkouts under ${scanned.join(', ')}`
|
|
373
|
+
: `serving ${single}`);
|
|
351
374
|
say(`creating pull requests, issues, reviews, replies and forks with ${command}${extra.length > 0 ? ` ${extra.join(' ')}` : ''}, as the request's account or this user`);
|
|
352
375
|
for (;;) {
|
|
353
376
|
for (const { repo, dir } of targets()) {
|
|
@@ -361,19 +384,20 @@ export async function runHostBroker(argv) {
|
|
|
361
384
|
warn(`could not serve ${repo}: ${error.message}`);
|
|
362
385
|
}
|
|
363
386
|
}
|
|
364
|
-
if (once)
|
|
387
|
+
if (once) {
|
|
365
388
|
return 0;
|
|
366
|
-
|
|
389
|
+
}
|
|
390
|
+
await new Promise(done => setTimeout(done, Math.max(interval, 0) * 1000));
|
|
367
391
|
}
|
|
368
392
|
}
|
|
369
393
|
/**
|
|
370
394
|
* Answer everything waiting in the channel.
|
|
371
395
|
*
|
|
372
|
-
* @param dir -
|
|
373
|
-
* @param repo -
|
|
374
|
-
* @param command -
|
|
375
|
-
* @param extra -
|
|
376
|
-
* @returns
|
|
396
|
+
* @param dir - The channel directory.
|
|
397
|
+
* @param repo - The checkout to create pull requests or issues in.
|
|
398
|
+
* @param command - The program to call.
|
|
399
|
+
* @param extra - Arguments to pass before the subcommand.
|
|
400
|
+
* @returns How many requests were answered.
|
|
377
401
|
*/
|
|
378
402
|
async function sweep(dir, repo, command, extra, allowed, blockFor) {
|
|
379
403
|
let answered = 0;
|
|
@@ -426,20 +450,7 @@ async function sweep(dir, repo, command, extra, allowed, blockFor) {
|
|
|
426
450
|
const result = await openOne(repo, command, extra, { ...request, proxy }, allowed, blockFor);
|
|
427
451
|
writeResult(dir, id, result);
|
|
428
452
|
removeRequest(dir, id);
|
|
429
|
-
if (result.error
|
|
430
|
-
warn(`failed to open ${request.kind === 'fork'
|
|
431
|
-
? 'fork'
|
|
432
|
-
: request.kind === 'reply'
|
|
433
|
-
? `reply on #${request.number}`
|
|
434
|
-
: request.kind === 'review'
|
|
435
|
-
? `review on #${request.number}`
|
|
436
|
-
: request.kind === 'edit'
|
|
437
|
-
? `edit on #${request.number}`
|
|
438
|
-
: request.kind === 'action'
|
|
439
|
-
? request.action
|
|
440
|
-
: request.head}: ${result.error}`);
|
|
441
|
-
}
|
|
442
|
-
else {
|
|
453
|
+
if (result.error === undefined) {
|
|
443
454
|
say(request.kind === 'fork'
|
|
444
455
|
? `forked ${request.repo} as ${request.account ?? 'this user'}: ${result.url}`
|
|
445
456
|
: request.kind === 'reply'
|
|
@@ -452,6 +463,19 @@ async function sweep(dir, repo, command, extra, allowed, blockFor) {
|
|
|
452
463
|
? `ran ${request.action} as ${request.account ?? 'this user'}`
|
|
453
464
|
: `opened #${result.number} as ${request.account ?? 'this user'}: ${result.url}`);
|
|
454
465
|
}
|
|
466
|
+
else {
|
|
467
|
+
warn(`failed to open ${request.kind === 'fork'
|
|
468
|
+
? 'fork'
|
|
469
|
+
: request.kind === 'reply'
|
|
470
|
+
? `reply on #${request.number}`
|
|
471
|
+
: request.kind === 'review'
|
|
472
|
+
? `review on #${request.number}`
|
|
473
|
+
: request.kind === 'edit'
|
|
474
|
+
? `edit on #${request.number}`
|
|
475
|
+
: request.kind === 'action'
|
|
476
|
+
? request.action
|
|
477
|
+
: request.head}: ${result.error}`);
|
|
478
|
+
}
|
|
455
479
|
answered += 1;
|
|
456
480
|
}
|
|
457
481
|
return answered;
|
|
@@ -484,32 +508,37 @@ function hostEnv(proxy, account) {
|
|
|
484
508
|
}
|
|
485
509
|
/** Unauthenticated public-repository check for the host route. */
|
|
486
510
|
async function repoIsPublic(host, repository, proxy, apiBaseUrl, apiVersion = '2022-11-28') {
|
|
487
|
-
const base = apiBaseUrl ??
|
|
511
|
+
const base = apiBaseUrl ??
|
|
512
|
+
(host.toLowerCase() === 'github.com' ? 'https://api.github.com' : `https://${host}/api/v3`);
|
|
488
513
|
return isPublicRepository(fetcherFor(proxy), base, repository, apiVersion);
|
|
489
514
|
}
|
|
490
515
|
/**
|
|
491
516
|
* Whether an allowlisted fork's parent is the requested repository.
|
|
492
517
|
*
|
|
493
|
-
* @param host -
|
|
494
|
-
* @param allow -
|
|
495
|
-
* @param repository -
|
|
496
|
-
* @param proxy -
|
|
497
|
-
* @param apiBaseUrl -
|
|
498
|
-
* @param apiVersion -
|
|
499
|
-
* @returns
|
|
518
|
+
* @param host - The forge host.
|
|
519
|
+
* @param allow - The configured allowlist entries.
|
|
520
|
+
* @param repository - The requested parent repository.
|
|
521
|
+
* @param proxy - The proxy choice for the check.
|
|
522
|
+
* @param apiBaseUrl - An optional API base.
|
|
523
|
+
* @param apiVersion - The API version header.
|
|
524
|
+
* @returns True when one allowlisted fork names the repository as its parent.
|
|
500
525
|
*/
|
|
501
526
|
async function forkAllows(host, allow, repository, proxy, apiBaseUrl, apiVersion = '2022-11-28') {
|
|
502
|
-
const base = apiBaseUrl ??
|
|
527
|
+
const base = apiBaseUrl ??
|
|
528
|
+
(host.toLowerCase() === 'github.com' ? 'https://api.github.com' : `https://${host}/api/v3`);
|
|
503
529
|
for (const raw of allow) {
|
|
504
530
|
const entry = parseAllowEntry(raw);
|
|
505
|
-
if (!entry || entry.owner === '*' || entry.repo === '*')
|
|
531
|
+
if (!entry || entry.owner === '*' || entry.repo === '*') {
|
|
506
532
|
continue;
|
|
533
|
+
}
|
|
507
534
|
const fork = `${entry.owner}/${entry.repo}`;
|
|
508
|
-
if (fork === repository.toLowerCase())
|
|
535
|
+
if (fork === repository.toLowerCase()) {
|
|
509
536
|
continue;
|
|
537
|
+
}
|
|
510
538
|
try {
|
|
511
|
-
if (await isForkOfRepository(fetcherFor(proxy), base, fork, repository, apiVersion))
|
|
539
|
+
if (await isForkOfRepository(fetcherFor(proxy), base, fork, repository, apiVersion)) {
|
|
512
540
|
return true;
|
|
541
|
+
}
|
|
513
542
|
}
|
|
514
543
|
catch {
|
|
515
544
|
// A failed check is not evidence of a relationship; keep looking, then fail closed.
|
|
@@ -518,14 +547,16 @@ async function forkAllows(host, allow, repository, proxy, apiBaseUrl, apiVersion
|
|
|
518
547
|
return false;
|
|
519
548
|
}
|
|
520
549
|
/**
|
|
521
|
-
* Run one person operation: check the allowlist, then call the program and turn its failure into a
|
|
550
|
+
* Run one person operation: check the allowlist, then call the program and turn its failure into a
|
|
551
|
+
* result.
|
|
522
552
|
*
|
|
523
553
|
* The typed routes differ only in the argv they build, so the check and the catch live here once
|
|
524
554
|
* instead of being copied per route.
|
|
525
555
|
*/
|
|
526
556
|
async function personRun(host, repository, allowed, work) {
|
|
527
|
-
if (!HOST.test(host))
|
|
557
|
+
if (!HOST.test(host)) {
|
|
528
558
|
return { error: `not a host: ${JSON.stringify(host)}` };
|
|
559
|
+
}
|
|
529
560
|
try {
|
|
530
561
|
if (!allowed(host, repository)) {
|
|
531
562
|
return { error: `repository ${repository} is not in the allowlist for ${host}` };
|
|
@@ -558,11 +589,11 @@ async function withBodyFile(body, work, name = 'body.md') {
|
|
|
558
589
|
/**
|
|
559
590
|
* Create one pull request, issue, review, reply, edit or comment.
|
|
560
591
|
*
|
|
561
|
-
* @param repo -
|
|
562
|
-
* @param command -
|
|
563
|
-
* @param extra -
|
|
564
|
-
* @param request -
|
|
565
|
-
* @returns
|
|
592
|
+
* @param repo - The checkout to run it in.
|
|
593
|
+
* @param command - The program to call.
|
|
594
|
+
* @param extra - Arguments to pass before the subcommand.
|
|
595
|
+
* @param request - What to do.
|
|
596
|
+
* @returns The number and URL, or the reason it did not work.
|
|
566
597
|
*/
|
|
567
598
|
async function openOne(repo, command, extra, request, allowed, blockFor) {
|
|
568
599
|
if (request.kind === 'fork') {
|
|
@@ -586,15 +617,18 @@ async function openOne(repo, command, extra, request, allowed, blockFor) {
|
|
|
586
617
|
env: forkEnv(request.proxy, request.account, host),
|
|
587
618
|
});
|
|
588
619
|
const match = /https:\/\/[^\s]+\/[^\s/]+\/[^\s/]+/.exec(stdout);
|
|
589
|
-
if (match?.[0] !== undefined)
|
|
620
|
+
if (match?.[0] !== undefined) {
|
|
590
621
|
return { url: match[0] };
|
|
622
|
+
}
|
|
591
623
|
// With no TTY, gh prints the URL only for a fresh fork; an existing fork older than a minute
|
|
592
624
|
// is `owner/name already exists` on stderr with empty stdout and exit 0.
|
|
593
625
|
const existing = /^([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+) already exists$/m.exec(stderr);
|
|
594
626
|
if (existing?.[1] !== undefined) {
|
|
595
627
|
return { url: `https://${host}/${existing[1]}` };
|
|
596
628
|
}
|
|
597
|
-
return {
|
|
629
|
+
return {
|
|
630
|
+
error: `no fork URL in the output: ${(stdout.trim() || stderr.trim()).slice(0, 300)}`,
|
|
631
|
+
};
|
|
598
632
|
}
|
|
599
633
|
catch (error) {
|
|
600
634
|
const failure = error;
|
|
@@ -630,7 +664,13 @@ async function openOne(repo, command, extra, request, allowed, blockFor) {
|
|
|
630
664
|
if (!Number.isSafeInteger(number) || number <= 0) {
|
|
631
665
|
return { error: `${String(number)} is not an issue number` };
|
|
632
666
|
}
|
|
633
|
-
const probe = await runBounded(command, [
|
|
667
|
+
const probe = await runBounded(command, [
|
|
668
|
+
...extra,
|
|
669
|
+
'api',
|
|
670
|
+
`repos/${request.repo}/issues/${String(number)}`,
|
|
671
|
+
'--jq',
|
|
672
|
+
'.pull_request != null',
|
|
673
|
+
], {
|
|
634
674
|
cwd: repo,
|
|
635
675
|
env,
|
|
636
676
|
timeoutMs: Math.max(1, deadline - Date.now()),
|
|
@@ -649,12 +689,13 @@ async function openOne(repo, command, extra, request, allowed, blockFor) {
|
|
|
649
689
|
cwd: repo,
|
|
650
690
|
env,
|
|
651
691
|
timeoutMs: Math.max(1, deadline - Date.now()),
|
|
652
|
-
writeCaveat: Object.values(action.permissions).some(
|
|
692
|
+
writeCaveat: Object.values(action.permissions).some(permission => permission === 'write'),
|
|
653
693
|
});
|
|
654
694
|
return result;
|
|
655
695
|
};
|
|
656
|
-
if (body === undefined)
|
|
696
|
+
if (body === undefined) {
|
|
657
697
|
return invoke();
|
|
698
|
+
}
|
|
658
699
|
if (action.api?.jsonBody === true) {
|
|
659
700
|
return withBodyFile(apiBodyJson(body), invoke, 'body.json');
|
|
660
701
|
}
|
|
@@ -726,7 +767,9 @@ async function openOne(repo, command, extra, request, allowed, blockFor) {
|
|
|
726
767
|
}
|
|
727
768
|
const reply = (answer ?? {});
|
|
728
769
|
if (typeof reply.html_url !== 'string' || reply.html_url === '') {
|
|
729
|
-
return {
|
|
770
|
+
return {
|
|
771
|
+
error: `no reply URL in the output: ${(stdout.trim() || stderr.trim()).slice(0, 300)}`,
|
|
772
|
+
};
|
|
730
773
|
}
|
|
731
774
|
return { number: request.number, url: reply.html_url };
|
|
732
775
|
});
|
|
@@ -777,7 +820,9 @@ async function openOne(repo, command, extra, request, allowed, blockFor) {
|
|
|
777
820
|
env: forkEnv(request.proxy, request.account, host),
|
|
778
821
|
}).then(({ stdout }) => {
|
|
779
822
|
const match = /https:\/\/[^\s]+\/pull\/\d+(?:#pullrequestreview-\d+)?/.exec(stdout);
|
|
780
|
-
return match?.[0] === undefined
|
|
823
|
+
return match?.[0] === undefined
|
|
824
|
+
? { number: request.number }
|
|
825
|
+
: { number: request.number, url: match[0] };
|
|
781
826
|
});
|
|
782
827
|
return request.body.trim() === '' ? invoke() : withBodyFile(request.body, invoke);
|
|
783
828
|
});
|
|
@@ -786,8 +831,9 @@ async function openOne(repo, command, extra, request, allowed, blockFor) {
|
|
|
786
831
|
// repository this machine has no checkout of. Without one, gh still resolves the checkout.
|
|
787
832
|
const target = request.repo;
|
|
788
833
|
if (target !== undefined) {
|
|
789
|
-
if (!REPOSITORY.test(target))
|
|
834
|
+
if (!REPOSITORY.test(target)) {
|
|
790
835
|
return { error: `not a repository: ${JSON.stringify(target)}` };
|
|
836
|
+
}
|
|
791
837
|
try {
|
|
792
838
|
if (!allowed('github.com', target)) {
|
|
793
839
|
return { error: `repository ${target} is not in the allowlist for github.com` };
|
|
@@ -808,7 +854,10 @@ async function openOne(repo, command, extra, request, allowed, blockFor) {
|
|
|
808
854
|
// above is the boundary, and no checkout of the target is required.
|
|
809
855
|
...(request.draft === true ? ['--draft'] : []),
|
|
810
856
|
...(target === undefined ? [] : ['--repo', target]),
|
|
811
|
-
'--base',
|
|
857
|
+
'--base',
|
|
858
|
+
request.base,
|
|
859
|
+
'--head',
|
|
860
|
+
request.head,
|
|
812
861
|
`--title=${request.title}`,
|
|
813
862
|
'--body-file',
|
|
814
863
|
bodyFile,
|
|
@@ -822,7 +871,9 @@ async function openOne(repo, command, extra, request, allowed, blockFor) {
|
|
|
822
871
|
});
|
|
823
872
|
const found = pullRequestUrl(stdout);
|
|
824
873
|
if (!found) {
|
|
825
|
-
return {
|
|
874
|
+
return {
|
|
875
|
+
error: `no pull request URL in the output: ${(stdout.trim() || stderr.trim()).slice(0, 300)}`,
|
|
876
|
+
};
|
|
826
877
|
}
|
|
827
878
|
return found;
|
|
828
879
|
});
|