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.
- package/CHANGELOG.md +23 -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 +147 -92
- 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 +101 -85
- 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,14 +206,16 @@ 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');
|
|
200
213
|
const resolvedConfig = resolveConfigPath(configPath);
|
|
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.
|
|
217
|
+
const configSelected = Boolean(configPath) || Boolean(process.env['GIT_BROKER_CONFIG']) || existsSync(resolvedConfig);
|
|
218
|
+
let loadedConfig = configSelected ? resolvedConfig : null;
|
|
204
219
|
/** Remove the status file only when this process owns it. */
|
|
205
220
|
function removeOwnStatus() {
|
|
206
221
|
try {
|
|
@@ -218,8 +233,9 @@ export async function runHostBroker(argv) {
|
|
|
218
233
|
}
|
|
219
234
|
/** Rewrite the host-private status file; the channel marker is not trusted for this. */
|
|
220
235
|
function writeStatus() {
|
|
221
|
-
if (once)
|
|
236
|
+
if (once) {
|
|
222
237
|
return;
|
|
238
|
+
}
|
|
223
239
|
let collision;
|
|
224
240
|
try {
|
|
225
241
|
collision = canonicalPath(resolvedConfig) === canonicalPath(statusFile);
|
|
@@ -228,8 +244,9 @@ export async function runHostBroker(argv) {
|
|
|
228
244
|
warn(`could not compare the config and status paths: ${error.message}`);
|
|
229
245
|
return;
|
|
230
246
|
}
|
|
231
|
-
if (collision)
|
|
247
|
+
if (collision) {
|
|
232
248
|
fail('the config cannot be the host status file');
|
|
249
|
+
}
|
|
233
250
|
try {
|
|
234
251
|
writeJsonAtomic(statusFile, {
|
|
235
252
|
pid: process.pid,
|
|
@@ -248,17 +265,20 @@ export async function runHostBroker(argv) {
|
|
|
248
265
|
const blockFor = (host) => {
|
|
249
266
|
if (config === undefined) {
|
|
250
267
|
try {
|
|
251
|
-
config = loadConfig(
|
|
268
|
+
config = loadConfig(resolvedConfig);
|
|
252
269
|
loadedConfig = resolvedConfig;
|
|
253
270
|
writeStatus();
|
|
254
271
|
}
|
|
255
272
|
catch (error) {
|
|
256
|
-
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 });
|
|
257
274
|
}
|
|
258
275
|
}
|
|
259
276
|
return hostConfig(config, host);
|
|
260
277
|
};
|
|
261
|
-
/**
|
|
278
|
+
/**
|
|
279
|
+
* Whether the broker config allowlist permits this host/repository for a fork, reply or explicit
|
|
280
|
+
* repository.
|
|
281
|
+
*/
|
|
262
282
|
const allowed = (host, repo) => {
|
|
263
283
|
const block = blockFor(host);
|
|
264
284
|
return block !== null && repoAllowed(block.allow, repo);
|
|
@@ -273,17 +293,20 @@ export async function runHostBroker(argv) {
|
|
|
273
293
|
}
|
|
274
294
|
/** The checkouts to serve: one named checkout, or everything under the roots. */
|
|
275
295
|
const targets = () => {
|
|
276
|
-
if (single === undefined)
|
|
277
|
-
return scanned.flatMap(
|
|
296
|
+
if (single === undefined) {
|
|
297
|
+
return scanned.flatMap(root => discoverCheckouts(root, depth));
|
|
298
|
+
}
|
|
278
299
|
const gitDir = findGitDir(single);
|
|
279
|
-
if (gitDir === undefined)
|
|
300
|
+
if (gitDir === undefined) {
|
|
280
301
|
fail(`no git repository at or above ${single}`);
|
|
302
|
+
}
|
|
281
303
|
return [{ repo: single, dir: channelDir(gitDir) }];
|
|
282
304
|
};
|
|
283
305
|
if (statusMode) {
|
|
284
306
|
validateArgs(args, argv.slice(1), new Set(), 'status');
|
|
285
|
-
if (args.positionals.length > 0)
|
|
307
|
+
if (args.positionals.length > 0) {
|
|
286
308
|
fail('status takes no arguments');
|
|
309
|
+
}
|
|
287
310
|
const parsed = readJson(statusFile);
|
|
288
311
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
289
312
|
fail('no host broker is running');
|
|
@@ -298,8 +321,9 @@ export async function runHostBroker(argv) {
|
|
|
298
321
|
process.kill(pid, 0);
|
|
299
322
|
}
|
|
300
323
|
catch (error) {
|
|
301
|
-
if (error.code === 'ESRCH')
|
|
324
|
+
if (error.code === 'ESRCH') {
|
|
302
325
|
fail('no host broker is running');
|
|
326
|
+
}
|
|
303
327
|
}
|
|
304
328
|
const output = {
|
|
305
329
|
pid,
|
|
@@ -332,8 +356,9 @@ export async function runHostBroker(argv) {
|
|
|
332
356
|
removeOwnStatus();
|
|
333
357
|
for (const dir of advertised) {
|
|
334
358
|
try {
|
|
335
|
-
if (readHostBroker(dir)?.pid === process.pid)
|
|
359
|
+
if (readHostBroker(dir)?.pid === process.pid) {
|
|
336
360
|
clearHostBroker(dir);
|
|
361
|
+
}
|
|
337
362
|
}
|
|
338
363
|
catch {
|
|
339
364
|
// Nothing useful is left to do while exiting.
|
|
@@ -343,7 +368,9 @@ export async function runHostBroker(argv) {
|
|
|
343
368
|
const stop = () => process.exit(0);
|
|
344
369
|
process.on('SIGINT', stop);
|
|
345
370
|
process.on('SIGTERM', stop);
|
|
346
|
-
say(single === undefined
|
|
371
|
+
say(single === undefined
|
|
372
|
+
? `serving the checkouts under ${scanned.join(', ')}`
|
|
373
|
+
: `serving ${single}`);
|
|
347
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`);
|
|
348
375
|
for (;;) {
|
|
349
376
|
for (const { repo, dir } of targets()) {
|
|
@@ -357,19 +384,20 @@ export async function runHostBroker(argv) {
|
|
|
357
384
|
warn(`could not serve ${repo}: ${error.message}`);
|
|
358
385
|
}
|
|
359
386
|
}
|
|
360
|
-
if (once)
|
|
387
|
+
if (once) {
|
|
361
388
|
return 0;
|
|
362
|
-
|
|
389
|
+
}
|
|
390
|
+
await new Promise(done => setTimeout(done, Math.max(interval, 0) * 1000));
|
|
363
391
|
}
|
|
364
392
|
}
|
|
365
393
|
/**
|
|
366
394
|
* Answer everything waiting in the channel.
|
|
367
395
|
*
|
|
368
|
-
* @param dir -
|
|
369
|
-
* @param repo -
|
|
370
|
-
* @param command -
|
|
371
|
-
* @param extra -
|
|
372
|
-
* @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.
|
|
373
401
|
*/
|
|
374
402
|
async function sweep(dir, repo, command, extra, allowed, blockFor) {
|
|
375
403
|
let answered = 0;
|
|
@@ -422,20 +450,7 @@ async function sweep(dir, repo, command, extra, allowed, blockFor) {
|
|
|
422
450
|
const result = await openOne(repo, command, extra, { ...request, proxy }, allowed, blockFor);
|
|
423
451
|
writeResult(dir, id, result);
|
|
424
452
|
removeRequest(dir, id);
|
|
425
|
-
if (result.error
|
|
426
|
-
warn(`failed to open ${request.kind === 'fork'
|
|
427
|
-
? 'fork'
|
|
428
|
-
: request.kind === 'reply'
|
|
429
|
-
? `reply on #${request.number}`
|
|
430
|
-
: request.kind === 'review'
|
|
431
|
-
? `review on #${request.number}`
|
|
432
|
-
: request.kind === 'edit'
|
|
433
|
-
? `edit on #${request.number}`
|
|
434
|
-
: request.kind === 'action'
|
|
435
|
-
? request.action
|
|
436
|
-
: request.head}: ${result.error}`);
|
|
437
|
-
}
|
|
438
|
-
else {
|
|
453
|
+
if (result.error === undefined) {
|
|
439
454
|
say(request.kind === 'fork'
|
|
440
455
|
? `forked ${request.repo} as ${request.account ?? 'this user'}: ${result.url}`
|
|
441
456
|
: request.kind === 'reply'
|
|
@@ -448,6 +463,19 @@ async function sweep(dir, repo, command, extra, allowed, blockFor) {
|
|
|
448
463
|
? `ran ${request.action} as ${request.account ?? 'this user'}`
|
|
449
464
|
: `opened #${result.number} as ${request.account ?? 'this user'}: ${result.url}`);
|
|
450
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
|
+
}
|
|
451
479
|
answered += 1;
|
|
452
480
|
}
|
|
453
481
|
return answered;
|
|
@@ -480,32 +508,37 @@ function hostEnv(proxy, account) {
|
|
|
480
508
|
}
|
|
481
509
|
/** Unauthenticated public-repository check for the host route. */
|
|
482
510
|
async function repoIsPublic(host, repository, proxy, apiBaseUrl, apiVersion = '2022-11-28') {
|
|
483
|
-
const base = apiBaseUrl ??
|
|
511
|
+
const base = apiBaseUrl ??
|
|
512
|
+
(host.toLowerCase() === 'github.com' ? 'https://api.github.com' : `https://${host}/api/v3`);
|
|
484
513
|
return isPublicRepository(fetcherFor(proxy), base, repository, apiVersion);
|
|
485
514
|
}
|
|
486
515
|
/**
|
|
487
516
|
* Whether an allowlisted fork's parent is the requested repository.
|
|
488
517
|
*
|
|
489
|
-
* @param host -
|
|
490
|
-
* @param allow -
|
|
491
|
-
* @param repository -
|
|
492
|
-
* @param proxy -
|
|
493
|
-
* @param apiBaseUrl -
|
|
494
|
-
* @param apiVersion -
|
|
495
|
-
* @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.
|
|
496
525
|
*/
|
|
497
526
|
async function forkAllows(host, allow, repository, proxy, apiBaseUrl, apiVersion = '2022-11-28') {
|
|
498
|
-
const base = apiBaseUrl ??
|
|
527
|
+
const base = apiBaseUrl ??
|
|
528
|
+
(host.toLowerCase() === 'github.com' ? 'https://api.github.com' : `https://${host}/api/v3`);
|
|
499
529
|
for (const raw of allow) {
|
|
500
530
|
const entry = parseAllowEntry(raw);
|
|
501
|
-
if (!entry || entry.owner === '*' || entry.repo === '*')
|
|
531
|
+
if (!entry || entry.owner === '*' || entry.repo === '*') {
|
|
502
532
|
continue;
|
|
533
|
+
}
|
|
503
534
|
const fork = `${entry.owner}/${entry.repo}`;
|
|
504
|
-
if (fork === repository.toLowerCase())
|
|
535
|
+
if (fork === repository.toLowerCase()) {
|
|
505
536
|
continue;
|
|
537
|
+
}
|
|
506
538
|
try {
|
|
507
|
-
if (await isForkOfRepository(fetcherFor(proxy), base, fork, repository, apiVersion))
|
|
539
|
+
if (await isForkOfRepository(fetcherFor(proxy), base, fork, repository, apiVersion)) {
|
|
508
540
|
return true;
|
|
541
|
+
}
|
|
509
542
|
}
|
|
510
543
|
catch {
|
|
511
544
|
// A failed check is not evidence of a relationship; keep looking, then fail closed.
|
|
@@ -514,14 +547,16 @@ async function forkAllows(host, allow, repository, proxy, apiBaseUrl, apiVersion
|
|
|
514
547
|
return false;
|
|
515
548
|
}
|
|
516
549
|
/**
|
|
517
|
-
* 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.
|
|
518
552
|
*
|
|
519
553
|
* The typed routes differ only in the argv they build, so the check and the catch live here once
|
|
520
554
|
* instead of being copied per route.
|
|
521
555
|
*/
|
|
522
556
|
async function personRun(host, repository, allowed, work) {
|
|
523
|
-
if (!HOST.test(host))
|
|
557
|
+
if (!HOST.test(host)) {
|
|
524
558
|
return { error: `not a host: ${JSON.stringify(host)}` };
|
|
559
|
+
}
|
|
525
560
|
try {
|
|
526
561
|
if (!allowed(host, repository)) {
|
|
527
562
|
return { error: `repository ${repository} is not in the allowlist for ${host}` };
|
|
@@ -554,11 +589,11 @@ async function withBodyFile(body, work, name = 'body.md') {
|
|
|
554
589
|
/**
|
|
555
590
|
* Create one pull request, issue, review, reply, edit or comment.
|
|
556
591
|
*
|
|
557
|
-
* @param repo -
|
|
558
|
-
* @param command -
|
|
559
|
-
* @param extra -
|
|
560
|
-
* @param request -
|
|
561
|
-
* @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.
|
|
562
597
|
*/
|
|
563
598
|
async function openOne(repo, command, extra, request, allowed, blockFor) {
|
|
564
599
|
if (request.kind === 'fork') {
|
|
@@ -582,15 +617,18 @@ async function openOne(repo, command, extra, request, allowed, blockFor) {
|
|
|
582
617
|
env: forkEnv(request.proxy, request.account, host),
|
|
583
618
|
});
|
|
584
619
|
const match = /https:\/\/[^\s]+\/[^\s/]+\/[^\s/]+/.exec(stdout);
|
|
585
|
-
if (match?.[0] !== undefined)
|
|
620
|
+
if (match?.[0] !== undefined) {
|
|
586
621
|
return { url: match[0] };
|
|
622
|
+
}
|
|
587
623
|
// With no TTY, gh prints the URL only for a fresh fork; an existing fork older than a minute
|
|
588
624
|
// is `owner/name already exists` on stderr with empty stdout and exit 0.
|
|
589
625
|
const existing = /^([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+) already exists$/m.exec(stderr);
|
|
590
626
|
if (existing?.[1] !== undefined) {
|
|
591
627
|
return { url: `https://${host}/${existing[1]}` };
|
|
592
628
|
}
|
|
593
|
-
return {
|
|
629
|
+
return {
|
|
630
|
+
error: `no fork URL in the output: ${(stdout.trim() || stderr.trim()).slice(0, 300)}`,
|
|
631
|
+
};
|
|
594
632
|
}
|
|
595
633
|
catch (error) {
|
|
596
634
|
const failure = error;
|
|
@@ -626,7 +664,13 @@ async function openOne(repo, command, extra, request, allowed, blockFor) {
|
|
|
626
664
|
if (!Number.isSafeInteger(number) || number <= 0) {
|
|
627
665
|
return { error: `${String(number)} is not an issue number` };
|
|
628
666
|
}
|
|
629
|
-
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
|
+
], {
|
|
630
674
|
cwd: repo,
|
|
631
675
|
env,
|
|
632
676
|
timeoutMs: Math.max(1, deadline - Date.now()),
|
|
@@ -645,12 +689,13 @@ async function openOne(repo, command, extra, request, allowed, blockFor) {
|
|
|
645
689
|
cwd: repo,
|
|
646
690
|
env,
|
|
647
691
|
timeoutMs: Math.max(1, deadline - Date.now()),
|
|
648
|
-
writeCaveat: Object.values(action.permissions).some(
|
|
692
|
+
writeCaveat: Object.values(action.permissions).some(permission => permission === 'write'),
|
|
649
693
|
});
|
|
650
694
|
return result;
|
|
651
695
|
};
|
|
652
|
-
if (body === undefined)
|
|
696
|
+
if (body === undefined) {
|
|
653
697
|
return invoke();
|
|
698
|
+
}
|
|
654
699
|
if (action.api?.jsonBody === true) {
|
|
655
700
|
return withBodyFile(apiBodyJson(body), invoke, 'body.json');
|
|
656
701
|
}
|
|
@@ -722,7 +767,9 @@ async function openOne(repo, command, extra, request, allowed, blockFor) {
|
|
|
722
767
|
}
|
|
723
768
|
const reply = (answer ?? {});
|
|
724
769
|
if (typeof reply.html_url !== 'string' || reply.html_url === '') {
|
|
725
|
-
return {
|
|
770
|
+
return {
|
|
771
|
+
error: `no reply URL in the output: ${(stdout.trim() || stderr.trim()).slice(0, 300)}`,
|
|
772
|
+
};
|
|
726
773
|
}
|
|
727
774
|
return { number: request.number, url: reply.html_url };
|
|
728
775
|
});
|
|
@@ -773,7 +820,9 @@ async function openOne(repo, command, extra, request, allowed, blockFor) {
|
|
|
773
820
|
env: forkEnv(request.proxy, request.account, host),
|
|
774
821
|
}).then(({ stdout }) => {
|
|
775
822
|
const match = /https:\/\/[^\s]+\/pull\/\d+(?:#pullrequestreview-\d+)?/.exec(stdout);
|
|
776
|
-
return match?.[0] === undefined
|
|
823
|
+
return match?.[0] === undefined
|
|
824
|
+
? { number: request.number }
|
|
825
|
+
: { number: request.number, url: match[0] };
|
|
777
826
|
});
|
|
778
827
|
return request.body.trim() === '' ? invoke() : withBodyFile(request.body, invoke);
|
|
779
828
|
});
|
|
@@ -782,8 +831,9 @@ async function openOne(repo, command, extra, request, allowed, blockFor) {
|
|
|
782
831
|
// repository this machine has no checkout of. Without one, gh still resolves the checkout.
|
|
783
832
|
const target = request.repo;
|
|
784
833
|
if (target !== undefined) {
|
|
785
|
-
if (!REPOSITORY.test(target))
|
|
834
|
+
if (!REPOSITORY.test(target)) {
|
|
786
835
|
return { error: `not a repository: ${JSON.stringify(target)}` };
|
|
836
|
+
}
|
|
787
837
|
try {
|
|
788
838
|
if (!allowed('github.com', target)) {
|
|
789
839
|
return { error: `repository ${target} is not in the allowlist for github.com` };
|
|
@@ -804,7 +854,10 @@ async function openOne(repo, command, extra, request, allowed, blockFor) {
|
|
|
804
854
|
// above is the boundary, and no checkout of the target is required.
|
|
805
855
|
...(request.draft === true ? ['--draft'] : []),
|
|
806
856
|
...(target === undefined ? [] : ['--repo', target]),
|
|
807
|
-
'--base',
|
|
857
|
+
'--base',
|
|
858
|
+
request.base,
|
|
859
|
+
'--head',
|
|
860
|
+
request.head,
|
|
808
861
|
`--title=${request.title}`,
|
|
809
862
|
'--body-file',
|
|
810
863
|
bodyFile,
|
|
@@ -818,7 +871,9 @@ async function openOne(repo, command, extra, request, allowed, blockFor) {
|
|
|
818
871
|
});
|
|
819
872
|
const found = pullRequestUrl(stdout);
|
|
820
873
|
if (!found) {
|
|
821
|
-
return {
|
|
874
|
+
return {
|
|
875
|
+
error: `no pull request URL in the output: ${(stdout.trim() || stderr.trim()).slice(0, 300)}`,
|
|
876
|
+
};
|
|
822
877
|
}
|
|
823
878
|
return found;
|
|
824
879
|
});
|