@link-assistant/hive-mind 2.13.4 → 2.14.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 +29 -0
- package/README.hi.md +2 -0
- package/README.md +2 -0
- package/README.ru.md +2 -0
- package/README.zh.md +2 -0
- package/package.json +1 -1
- package/src/agent.lib.mjs +5 -1
- package/src/claude.lib.mjs +5 -158
- package/src/claude.session-tokens.lib.mjs +180 -0
- package/src/codex.diagnostics.lib.mjs +135 -0
- package/src/codex.lib.mjs +8 -121
- package/src/config.lib.mjs +9 -0
- package/src/docker-sidecar.lib.mjs +276 -0
- package/src/formal-ai-maintenance.lib.mjs +2 -14
- package/src/formal-ai-sidecar.lib.mjs +17 -137
- package/src/gemini.lib.mjs +5 -1
- package/src/git-push-guard.lib.mjs +230 -0
- package/src/git-retry.lib.mjs +97 -0
- package/src/github-pr-idempotency.lib.mjs +83 -0
- package/src/github-rate-limit.lib.mjs +44 -41
- package/src/hive.mjs +8 -150
- package/src/hive.repository-fallback.lib.mjs +125 -0
- package/src/hive.startup-checks.lib.mjs +57 -0
- package/src/isolation-runner.lib.mjs +94 -287
- package/src/isolation-runner.parsers.lib.mjs +292 -0
- package/src/lib.mjs +79 -18
- package/src/opencode.lib.mjs +5 -1
- package/src/qwen.lib.mjs +5 -1
- package/src/router-isolation.lib.mjs +496 -0
- package/src/router-logs.lib.mjs +143 -0
- package/src/router-maintenance.lib.mjs +77 -0
- package/src/router-session-drain.lib.mjs +153 -0
- package/src/router-sidecar.lib.mjs +516 -0
- package/src/router-task-isolation.lib.mjs +121 -0
- package/src/session-monitor.lib.mjs +12 -272
- package/src/session-monitor.queries.lib.mjs +304 -0
- package/src/solve.auto-pr-push-sync.lib.mjs +176 -0
- package/src/solve.auto-pr.lib.mjs +40 -154
- package/src/solve.config.lib.mjs +11 -0
- package/src/solve.mjs +8 -158
- package/src/solve.mode.lib.mjs +191 -0
- package/src/task.config.lib.mjs +5 -0
- package/src/task.mjs +1 -0
- package/src/telegram-bot.mjs +18 -0
- package/src/telegram-solve-queue.lib.mjs +19 -272
- package/src/telegram-solve-queue.throttling.lib.mjs +323 -0
- package/src/transient-errors.lib.mjs +238 -0
|
@@ -43,18 +43,23 @@
|
|
|
43
43
|
|
|
44
44
|
import { execFile } from 'node:child_process';
|
|
45
45
|
import fs from 'node:fs';
|
|
46
|
-
import path from 'node:path';
|
|
47
46
|
import { promisify } from 'node:util';
|
|
48
47
|
|
|
49
48
|
import { FORMAL_AI_MINIMUM_VERSION, isFormalAiVersionAtLeast } from './formal-ai-version.lib.mjs';
|
|
50
49
|
import { ensureFormalAiSidecarImage, resolveFormalAiSidecarImage } from './formal-ai-image.lib.mjs';
|
|
51
50
|
import { isFormalAiModel } from './formal-ai-model.lib.mjs';
|
|
52
51
|
import { getModelFromArgs } from './model-args.lib.mjs';
|
|
53
|
-
import { resolveBotStateDir } from './session-store.lib.mjs';
|
|
54
52
|
import { withStateLock } from './state-lock.lib.mjs';
|
|
53
|
+
import { attachDockerNetwork, DEFAULT_IMAGE_TIMEOUT_MS, dockerOk, dockerText, ensureDockerVolume, ensureInternalDockerNetwork, inspectDockerContainer, readDockerContainerAddress, readDockerImageDigest, readSidecarState, reconcileSidecarLeases, resolveSidecarStatePath, sleep, writeSidecarState } from './docker-sidecar.lib.mjs';
|
|
55
54
|
|
|
56
55
|
const execFileAsync = promisify(execFile);
|
|
57
56
|
|
|
57
|
+
// Re-exported because callers of this module have always imported them from
|
|
58
|
+
// here; the implementations now live in the shared sidecar module.
|
|
59
|
+
export { inspectDockerContainer, readDockerImageDigest };
|
|
60
|
+
|
|
61
|
+
const LOG_PREFIX = 'formal-ai-sidecar';
|
|
62
|
+
|
|
58
63
|
/** Container, network, volume and alias names. Stable so reconciliation works across restarts. */
|
|
59
64
|
export const FORMAL_AI_SIDECAR_CONTAINER_NAME = 'hive-mind-formal-ai';
|
|
60
65
|
export const FORMAL_AI_SIDECAR_NETWORK_NAME = 'hive-mind-formal-ai';
|
|
@@ -92,17 +97,13 @@ export const FORMAL_AI_SIDECAR_LABEL = 'com.link-assistant.hive-mind.formal-ai';
|
|
|
92
97
|
|
|
93
98
|
const STATE_FILE_NAME = 'formal-ai-sidecar.json';
|
|
94
99
|
const SIDECAR_LOCK_NAME = 'formal-ai-sidecar';
|
|
95
|
-
const DEFAULT_DOCKER_TIMEOUT_MS = 120_000;
|
|
96
100
|
// Pulling a sidecar image is the one Docker call that legitimately takes many
|
|
97
101
|
// minutes, so it gets its own budget instead of the general command timeout.
|
|
98
|
-
const DEFAULT_IMAGE_TIMEOUT_MS = 600_000;
|
|
99
102
|
const DEFAULT_HEALTH_ATTEMPTS = 60;
|
|
100
103
|
const DEFAULT_HEALTH_DELAY_MS = 1000;
|
|
101
104
|
|
|
102
105
|
const EMPTY_STATE = Object.freeze({ version: 1, image: null, imageDigest: null, startedAt: null, leases: [], lastUpdate: null });
|
|
103
106
|
|
|
104
|
-
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
105
|
-
|
|
106
107
|
/**
|
|
107
108
|
* True when a task will be driven by Formal AI.
|
|
108
109
|
*
|
|
@@ -149,27 +150,13 @@ export const isFormalAiSidecarEnabled = (env = process.env) => {
|
|
|
149
150
|
return !['0', 'false', 'no', 'off'].includes(raw);
|
|
150
151
|
};
|
|
151
152
|
|
|
152
|
-
export const resolveFormalAiSidecarStatePath = (env = process.env) =>
|
|
153
|
+
export const resolveFormalAiSidecarStatePath = (env = process.env) => resolveSidecarStatePath(STATE_FILE_NAME, env);
|
|
153
154
|
|
|
154
155
|
/** Read the durable sidecar record. A missing or corrupt file is an empty record, never a throw. */
|
|
155
|
-
export const readFormalAiSidecarState = ({ env = process.env, fsImpl = fs } = {}) => {
|
|
156
|
-
try {
|
|
157
|
-
const parsed = JSON.parse(fsImpl.readFileSync(resolveFormalAiSidecarStatePath(env), 'utf8'));
|
|
158
|
-
return { ...EMPTY_STATE, ...parsed, leases: Array.isArray(parsed?.leases) ? parsed.leases : [] };
|
|
159
|
-
} catch {
|
|
160
|
-
return { ...EMPTY_STATE, leases: [] };
|
|
161
|
-
}
|
|
162
|
-
};
|
|
156
|
+
export const readFormalAiSidecarState = ({ env = process.env, fsImpl = fs } = {}) => readSidecarState({ fileName: STATE_FILE_NAME, emptyState: EMPTY_STATE, env, fsImpl });
|
|
163
157
|
|
|
164
158
|
/** Persist the sidecar record atomically so a crash mid-write cannot corrupt it. */
|
|
165
|
-
export const writeFormalAiSidecarState = (state, { env = process.env, fsImpl = fs } = {}) => {
|
|
166
|
-
const target = resolveFormalAiSidecarStatePath(env);
|
|
167
|
-
fsImpl.mkdirSync(path.dirname(target), { recursive: true });
|
|
168
|
-
const temporary = `${target}.tmp`;
|
|
169
|
-
fsImpl.writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
|
170
|
-
fsImpl.renameSync(temporary, target);
|
|
171
|
-
return state;
|
|
172
|
-
};
|
|
159
|
+
export const writeFormalAiSidecarState = (state, { env = process.env, fsImpl = fs } = {}) => writeSidecarState(state, { fileName: STATE_FILE_NAME, env, fsImpl });
|
|
173
160
|
|
|
174
161
|
/**
|
|
175
162
|
* Serialize every sidecar mutation — task launches, task releases and image
|
|
@@ -180,35 +167,6 @@ export const writeFormalAiSidecarState = (state, { env = process.env, fsImpl = f
|
|
|
180
167
|
*/
|
|
181
168
|
export const withFormalAiSidecarLock = (fn, options = {}) => withStateLock(SIDECAR_LOCK_NAME, fn, options);
|
|
182
169
|
|
|
183
|
-
const dockerText = async (run, args, { timeoutMs = DEFAULT_DOCKER_TIMEOUT_MS } = {}) => {
|
|
184
|
-
const result = await run('docker', args, { encoding: 'utf8', timeout: timeoutMs });
|
|
185
|
-
return String(result?.stdout ?? '').trim();
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
const dockerOk = async (run, args, options) => {
|
|
189
|
-
try {
|
|
190
|
-
await dockerText(run, args, options);
|
|
191
|
-
return true;
|
|
192
|
-
} catch {
|
|
193
|
-
return false;
|
|
194
|
-
}
|
|
195
|
-
};
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Inspect a container without treating "absent" as an error.
|
|
199
|
-
*
|
|
200
|
-
* @returns {Promise<{exists: boolean, running: boolean, image: string|null, imageDigest: string|null}>}
|
|
201
|
-
*/
|
|
202
|
-
export const inspectDockerContainer = async (name, { run = execFileAsync, timeoutMs } = {}) => {
|
|
203
|
-
try {
|
|
204
|
-
const raw = await dockerText(run, ['inspect', name, '--format', '{{.State.Running}}|{{.Config.Image}}|{{.Image}}'], { timeoutMs });
|
|
205
|
-
const [running, image, imageDigest] = raw.split('|');
|
|
206
|
-
return { exists: true, running: running === 'true', image: image || null, imageDigest: imageDigest || null };
|
|
207
|
-
} catch {
|
|
208
|
-
return { exists: false, running: false, image: null, imageDigest: null };
|
|
209
|
-
}
|
|
210
|
-
};
|
|
211
|
-
|
|
212
170
|
/**
|
|
213
171
|
* The sidecar's IPv4 address on the internal network.
|
|
214
172
|
*
|
|
@@ -218,22 +176,7 @@ export const inspectDockerContainer = async (name, { run = execFileAsync, timeou
|
|
|
218
176
|
* post-attachment would be a needless gamble; the address cannot change during
|
|
219
177
|
* a lease, because an image replacement requires zero leases.
|
|
220
178
|
*/
|
|
221
|
-
export const readFormalAiSidecarAddress = async ({ containerName = FORMAL_AI_SIDECAR_CONTAINER_NAME, network = FORMAL_AI_SIDECAR_NETWORK_NAME, run = execFileAsync, timeoutMs } = {}) => {
|
|
222
|
-
try {
|
|
223
|
-
return (await dockerText(run, ['inspect', containerName, '--format', `{{with index .NetworkSettings.Networks "${network}"}}{{.IPAddress}}{{end}}`], { timeoutMs })) || null;
|
|
224
|
-
} catch {
|
|
225
|
-
return null;
|
|
226
|
-
}
|
|
227
|
-
};
|
|
228
|
-
|
|
229
|
-
/** Resolve the local content digest of an image reference, or null when it is absent. */
|
|
230
|
-
export const readDockerImageDigest = async (image, { run = execFileAsync, timeoutMs } = {}) => {
|
|
231
|
-
try {
|
|
232
|
-
return (await dockerText(run, ['image', 'inspect', image, '--format', '{{.Id}}'], { timeoutMs })) || null;
|
|
233
|
-
} catch {
|
|
234
|
-
return null;
|
|
235
|
-
}
|
|
236
|
-
};
|
|
179
|
+
export const readFormalAiSidecarAddress = async ({ containerName = FORMAL_AI_SIDECAR_CONTAINER_NAME, network = FORMAL_AI_SIDECAR_NETWORK_NAME, run = execFileAsync, timeoutMs } = {}) => readDockerContainerAddress(containerName, network, { run, timeoutMs });
|
|
237
180
|
|
|
238
181
|
/**
|
|
239
182
|
* Create the private network the sidecar and its tasks share.
|
|
@@ -244,34 +187,7 @@ export const readDockerImageDigest = async (image, { run = execFileAsync, timeou
|
|
|
244
187
|
* artifact from the Compose deployment and is replaced when nothing is
|
|
245
188
|
* attached to it.
|
|
246
189
|
*/
|
|
247
|
-
export const ensureFormalAiNetwork = async ({ run = execFileAsync, timeoutMs, log = null, verbose = false } = {}) => {
|
|
248
|
-
// `null` means "absent", which is different from "present but not internal".
|
|
249
|
-
let internal = null;
|
|
250
|
-
let containers = 0;
|
|
251
|
-
try {
|
|
252
|
-
const raw = await dockerText(run, ['network', 'inspect', FORMAL_AI_SIDECAR_NETWORK_NAME, '--format', '{{.Internal}}|{{len .Containers}}'], { timeoutMs });
|
|
253
|
-
const [internalFlag, containerCount] = raw.split('|');
|
|
254
|
-
internal = internalFlag === 'true';
|
|
255
|
-
containers = Number(containerCount) || 0;
|
|
256
|
-
} catch {
|
|
257
|
-
// Absent; fall through to creation.
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
if (internal === true) return { created: false, internal: true };
|
|
261
|
-
|
|
262
|
-
if (internal === false) {
|
|
263
|
-
if (containers > 0) {
|
|
264
|
-
if (log) await log(`⚠️ Formal AI network '${FORMAL_AI_SIDECAR_NETWORK_NAME}' is not internal but still has ${containers} attached container(s); leaving it in place`);
|
|
265
|
-
return { created: false, internal: false };
|
|
266
|
-
}
|
|
267
|
-
if (verbose && log) await log(`[VERBOSE] formal-ai-sidecar: replacing non-internal network '${FORMAL_AI_SIDECAR_NETWORK_NAME}'`);
|
|
268
|
-
await dockerOk(run, ['network', 'rm', FORMAL_AI_SIDECAR_NETWORK_NAME], { timeoutMs });
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
await dockerText(run, ['network', 'create', '--internal', '--label', `${FORMAL_AI_SIDECAR_LABEL}=network`, FORMAL_AI_SIDECAR_NETWORK_NAME], { timeoutMs });
|
|
272
|
-
if (verbose && log) await log(`[VERBOSE] formal-ai-sidecar: created internal network '${FORMAL_AI_SIDECAR_NETWORK_NAME}'`);
|
|
273
|
-
return { created: true, internal: true };
|
|
274
|
-
};
|
|
190
|
+
export const ensureFormalAiNetwork = async ({ run = execFileAsync, timeoutMs, log = null, verbose = false } = {}) => ensureInternalDockerNetwork({ name: FORMAL_AI_SIDECAR_NETWORK_NAME, label: FORMAL_AI_SIDECAR_LABEL, run, timeoutMs, log, verbose, logPrefix: LOG_PREFIX });
|
|
275
191
|
|
|
276
192
|
/**
|
|
277
193
|
* Create the persisted-memory volume if it is missing and hand it to the
|
|
@@ -281,16 +197,12 @@ export const ensureFormalAiNetwork = async ({ run = execFileAsync, timeoutMs, lo
|
|
|
281
197
|
* task boundaries, sidecar stops, image replacement and rollback.
|
|
282
198
|
*/
|
|
283
199
|
export const ensureFormalAiMemoryVolume = async ({ image, run = execFileAsync, timeoutMs, log = null, verbose = false } = {}) => {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
await dockerText(run, ['volume', 'create', '--label', `${FORMAL_AI_SIDECAR_LABEL}=memory`, FORMAL_AI_MEMORY_VOLUME_NAME], { timeoutMs });
|
|
200
|
+
const result = await ensureDockerVolume({ name: FORMAL_AI_MEMORY_VOLUME_NAME, label: FORMAL_AI_SIDECAR_LABEL, role: 'memory', run, timeoutMs, log, verbose, logPrefix: LOG_PREFIX });
|
|
201
|
+
if (!result.created) return result;
|
|
289
202
|
// A fresh named volume is root-owned; the image runs application commands as
|
|
290
203
|
// `box`, so seed the ownership exactly as upstream's own upgrade fixture does.
|
|
291
204
|
await dockerOk(run, ['run', '--rm', '--volume', `${FORMAL_AI_MEMORY_VOLUME_NAME}:${FORMAL_AI_MEMORY_MOUNT}`, '--entrypoint', 'chown', image, '-R', 'box:box', FORMAL_AI_MEMORY_MOUNT], { timeoutMs });
|
|
292
|
-
|
|
293
|
-
return { created: true };
|
|
205
|
+
return result;
|
|
294
206
|
};
|
|
295
207
|
|
|
296
208
|
/** Build the `docker run` argv for the sidecar. Exported so tests can assert the contract. */
|
|
@@ -369,29 +281,7 @@ export const waitForFormalAiSidecarHealth = async ({ containerName = FORMAL_AI_S
|
|
|
369
281
|
* `LEASE_START_GRACE_MS` elapses; afterwards, and always once the container has
|
|
370
282
|
* been observed, liveness is Docker's answer alone.
|
|
371
283
|
*/
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
const reconcileLeases = async (leases, { run, timeoutMs, log, verbose, now = () => Date.now() }) => {
|
|
375
|
-
const live = [];
|
|
376
|
-
for (const lease of leases) {
|
|
377
|
-
if (!lease?.sessionId) continue;
|
|
378
|
-
const container = await inspectDockerContainer(lease.sessionId, { run, timeoutMs });
|
|
379
|
-
if (container.exists && container.running) {
|
|
380
|
-
live.push(lease.containerSeen ? lease : { ...lease, containerSeen: true });
|
|
381
|
-
continue;
|
|
382
|
-
}
|
|
383
|
-
if (!lease.containerSeen) {
|
|
384
|
-
const age = now() - (Date.parse(lease.acquiredAt ?? '') || 0);
|
|
385
|
-
if (age < LEASE_START_GRACE_MS) {
|
|
386
|
-
if (verbose && log) await log(`[VERBOSE] formal-ai-sidecar: keeping lease '${lease.sessionId}' whose container has not appeared yet (${Math.round(age / 1000)}s into the ${Math.round(LEASE_START_GRACE_MS / 1000)}s launch grace)`);
|
|
387
|
-
live.push(lease);
|
|
388
|
-
continue;
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
if (verbose && log) await log(`[VERBOSE] formal-ai-sidecar: dropping stale lease '${lease.sessionId}' (container exists=${container.exists} running=${container.running})`);
|
|
392
|
-
}
|
|
393
|
-
return live;
|
|
394
|
-
};
|
|
284
|
+
const reconcileLeases = async (leases, options) => reconcileSidecarLeases(leases, { ...options, logPrefix: LOG_PREFIX });
|
|
395
285
|
|
|
396
286
|
/**
|
|
397
287
|
* Re-derive the sidecar record from Docker.
|
|
@@ -522,17 +412,7 @@ export const acquireFormalAiSidecar = async ({ sessionId, tool = null, model = n
|
|
|
522
412
|
*/
|
|
523
413
|
export const attachTaskToFormalAiNetwork = async ({ sessionId, run = execFileAsync, timeoutMs, log = null, verbose = false } = {}) => {
|
|
524
414
|
if (!sessionId) return { attached: false, error: 'no sessionId' };
|
|
525
|
-
|
|
526
|
-
await dockerText(run, ['network', 'connect', FORMAL_AI_SIDECAR_NETWORK_NAME, sessionId], { timeoutMs });
|
|
527
|
-
if (verbose && log) await log(`[VERBOSE] formal-ai-sidecar: attached task container '${sessionId}' to '${FORMAL_AI_SIDECAR_NETWORK_NAME}'`);
|
|
528
|
-
return { attached: true, error: null };
|
|
529
|
-
} catch (error) {
|
|
530
|
-
const message = error?.stderr?.toString?.().trim() || error?.message || String(error);
|
|
531
|
-
// Docker reports an already-attached container as an error; that is success.
|
|
532
|
-
if (/already exists in network/i.test(message)) return { attached: true, error: null };
|
|
533
|
-
if (log) await log(`⚠️ Could not attach task container '${sessionId}' to the Formal AI network: ${message}`);
|
|
534
|
-
return { attached: false, error: message };
|
|
535
|
-
}
|
|
415
|
+
return attachDockerNetwork({ network: FORMAL_AI_SIDECAR_NETWORK_NAME, container: sessionId, run, timeoutMs, log, verbose, logPrefix: LOG_PREFIX });
|
|
536
416
|
};
|
|
537
417
|
|
|
538
418
|
/**
|
package/src/gemini.lib.mjs
CHANGED
|
@@ -8,7 +8,11 @@ if (typeof globalThis.use === 'undefined') {
|
|
|
8
8
|
await ensureUseM();
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
const { $ } = await use('command-stream');
|
|
11
|
+
const { $: __rawDollar$ } = await use('command-stream');
|
|
12
|
+
// Issue #2168: retry transient git network failures (push/fetch/pull) the same
|
|
13
|
+
// way `gh` calls are retried, for every command run through this module's `$`.
|
|
14
|
+
const { wrapDollarWithGitRetry } = await import('./git-retry.lib.mjs');
|
|
15
|
+
const $ = wrapDollarWithGitRetry(__rawDollar$);
|
|
12
16
|
|
|
13
17
|
import { log, buildToolErrorMessage } from './lib.mjs';
|
|
14
18
|
import { reportError } from './sentry.lib.mjs';
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blocking destructive git pushes from inside a routed task (issue #2164, R13).
|
|
3
|
+
*
|
|
4
|
+
* The issue asks that agents lose the *physical ability* to destroy data:
|
|
5
|
+
* "immediately apply block of all delete operations or history changes like git
|
|
6
|
+
* reset and so on detected up on git push". A `git reset` is harmless on its
|
|
7
|
+
* own — nothing is lost until the rewritten history reaches the remote — so the
|
|
8
|
+
* point where the damage becomes real, and therefore the point worth guarding,
|
|
9
|
+
* is the push.
|
|
10
|
+
*
|
|
11
|
+
* Three layers were planned for R13. This module is layer 2:
|
|
12
|
+
*
|
|
13
|
+
* 1. Branch protection on the remote (`src/protect-branch.mjs`) — server-side
|
|
14
|
+
* and unbypassable, but only covers branches somebody protected.
|
|
15
|
+
* 2. This `pre-push` hook — covers every branch and every remote in the task
|
|
16
|
+
* container, costs nothing, and is defeated by `git push --no-verify`.
|
|
17
|
+
* 3. The router's git transport (`/git/…`), which routed tasks now push
|
|
18
|
+
* through. It is unbypassable for the same reason the model traffic is —
|
|
19
|
+
* the task holds no other credential — and it refuses branch deletions
|
|
20
|
+
* outright (measured: `git push origin :refs/heads/x` → HTTP 403).
|
|
21
|
+
*
|
|
22
|
+
* Layer 3 originally covered deletions but not force pushes: the router decided
|
|
23
|
+
* by looking for a `force-ref-updates` capability that git never sends, so a
|
|
24
|
+
* non-fast-forward push was relayed unchanged (measured in
|
|
25
|
+
* `experiments/issue-2164/probe-git-transport.sh`, reported upstream as
|
|
26
|
+
* link-assistant/router#272). That was fixed in link-assistant/router#273 and
|
|
27
|
+
* ships from 0.110.0, which is at or below the pin — the router now asks
|
|
28
|
+
* GitHub's compare API whether the proposed tip is ahead of the current one and
|
|
29
|
+
* fails closed when it cannot tell.
|
|
30
|
+
*
|
|
31
|
+
* So this hook is no longer the only thing standing between an agent and a
|
|
32
|
+
* rewritten branch. It is still worth keeping: it is the layer that applies to
|
|
33
|
+
* every remote rather than only the routed one, it costs nothing, and it stops
|
|
34
|
+
* the push locally instead of after a round trip. It remains defeated by
|
|
35
|
+
* `--no-verify`, so it is a speed bump and the docs say so.
|
|
36
|
+
*
|
|
37
|
+
* The hook is delivered by mounting a host directory read-only into the task
|
|
38
|
+
* container and pointing git at it with `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_0`
|
|
39
|
+
* (git >= 2.31). Env vars rather than `git config --global` because the task's
|
|
40
|
+
* `~/.gitconfig` is bind-mounted from the *host*: writing to it would reconfigure
|
|
41
|
+
* the operator's own machine.
|
|
42
|
+
*
|
|
43
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2164
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import fs from 'node:fs';
|
|
47
|
+
import os from 'node:os';
|
|
48
|
+
import path from 'node:path';
|
|
49
|
+
|
|
50
|
+
/** Only `pre-push` is provided; git silently skips hook names that do not exist. */
|
|
51
|
+
export const GIT_PUSH_GUARD_HOOK_NAME = 'pre-push';
|
|
52
|
+
|
|
53
|
+
/** Operator escape hatch, propagated from an explicit `solve` opt-in (see `hasForcePushOptIn`). */
|
|
54
|
+
export const GIT_PUSH_GUARD_ESCAPE_ENV = 'HIVE_MIND_ALLOW_DESTRUCTIVE_PUSH';
|
|
55
|
+
|
|
56
|
+
/** Where the hook directory is mounted inside a Docker-isolated task. */
|
|
57
|
+
export const GIT_PUSH_GUARD_CONTAINER_DIR = '/home/box/.hive-mind/git-hooks';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The hook itself.
|
|
61
|
+
*
|
|
62
|
+
* git feeds `<local ref> <local sha> <remote ref> <remote sha>` on stdin, one
|
|
63
|
+
* line per ref being updated, and a non-zero exit aborts the whole push. Two
|
|
64
|
+
* things are refused:
|
|
65
|
+
*
|
|
66
|
+
* - an all-zero *local* sha, which is how `git push --delete`, `git push
|
|
67
|
+
* :branch` and `--mirror`/`--prune` deletions present themselves;
|
|
68
|
+
* - an update where the remote's current commit is not an ancestor of what we
|
|
69
|
+
* are about to send, i.e. a force push discarding commits that exist only on
|
|
70
|
+
* the remote — the shape a `git reset --hard` + `push --force` takes.
|
|
71
|
+
*
|
|
72
|
+
* A remote sha we do not have locally is refused too: it cannot be proven to be
|
|
73
|
+
* an ancestor, and the honest answer to "would this destroy something?" is
|
|
74
|
+
* "unknown". Everything else — ordinary fast-forward pushes, new branches, new
|
|
75
|
+
* tags — passes untouched.
|
|
76
|
+
*
|
|
77
|
+
* POSIX sh, no bashisms: the isolation image's /bin/sh is dash.
|
|
78
|
+
*/
|
|
79
|
+
export const PRE_PUSH_GUARD_SCRIPT = `#!/bin/sh
|
|
80
|
+
# Hive Mind push guard (issue #2164). Refuses branch/tag deletions and
|
|
81
|
+
# history-rewriting (non-fast-forward) pushes from inside an isolated task.
|
|
82
|
+
# Generated file - edit src/git-push-guard.lib.mjs instead.
|
|
83
|
+
set -u
|
|
84
|
+
|
|
85
|
+
remote_name="\${1:-}"
|
|
86
|
+
remote_url="\${2:-}"
|
|
87
|
+
allow="\${${GIT_PUSH_GUARD_ESCAPE_ENV}:-}"
|
|
88
|
+
status=0
|
|
89
|
+
|
|
90
|
+
is_zero_sha() {
|
|
91
|
+
[ -n "$1" ] || return 0
|
|
92
|
+
case "$1" in
|
|
93
|
+
*[!0]*) return 1 ;;
|
|
94
|
+
*) return 0 ;;
|
|
95
|
+
esac
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
refuse() {
|
|
99
|
+
status=1
|
|
100
|
+
echo "🛑 Hive Mind push guard: refused to $1" >&2
|
|
101
|
+
echo " remote: \${remote_name} \${remote_url}" >&2
|
|
102
|
+
echo " ref: $2" >&2
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
while read -r local_ref local_sha remote_ref remote_sha; do
|
|
106
|
+
[ -n "\${remote_ref:-}" ] || continue
|
|
107
|
+
if is_zero_sha "\${local_sha:-}"; then
|
|
108
|
+
refuse "delete a remote ref" "\${remote_ref}"
|
|
109
|
+
continue
|
|
110
|
+
fi
|
|
111
|
+
is_zero_sha "\${remote_sha:-}" && continue
|
|
112
|
+
if ! git cat-file -e "\${remote_sha}^{commit}" 2>/dev/null; then
|
|
113
|
+
refuse "overwrite a remote commit this clone does not have (\${remote_sha})" "\${remote_ref}"
|
|
114
|
+
continue
|
|
115
|
+
fi
|
|
116
|
+
if ! git merge-base --is-ancestor "\${remote_sha}" "\${local_sha}" 2>/dev/null; then
|
|
117
|
+
refuse "rewrite history (the remote's \${remote_sha} is not an ancestor of \${local_sha})" "\${remote_ref}"
|
|
118
|
+
fi
|
|
119
|
+
done
|
|
120
|
+
|
|
121
|
+
if [ "\${status}" -ne 0 ]; then
|
|
122
|
+
case "\${allow}" in
|
|
123
|
+
1 | true | TRUE | yes | YES)
|
|
124
|
+
echo "⚠️ ${GIT_PUSH_GUARD_ESCAPE_ENV} is set, so the push is allowed anyway." >&2
|
|
125
|
+
exit 0
|
|
126
|
+
;;
|
|
127
|
+
esac
|
|
128
|
+
echo "" >&2
|
|
129
|
+
echo "Destructive pushes are blocked for routed tasks (--use-router, issue #2164)." >&2
|
|
130
|
+
echo "Nothing was sent. Push a new commit instead, or ask a human operator to run it." >&2
|
|
131
|
+
fi
|
|
132
|
+
|
|
133
|
+
exit "\${status}"
|
|
134
|
+
`;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Host directory holding the generated hook.
|
|
138
|
+
*
|
|
139
|
+
* Deliberately NOT the bot state directory: that holds the router's signing
|
|
140
|
+
* secret, and this directory is mounted into every routed task.
|
|
141
|
+
*/
|
|
142
|
+
export function resolveGitPushGuardHostDir({ env = process.env, homeDir = os.homedir() } = {}) {
|
|
143
|
+
const explicit = String(env.HIVE_MIND_GIT_HOOKS_DIR || '').trim();
|
|
144
|
+
return explicit || path.join(homeDir, '.hive-mind', 'git-hooks');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Write the hook to the host, ready to be mounted.
|
|
149
|
+
*
|
|
150
|
+
* Rewritten on every launch so an upgrade cannot leave a stale hook behind.
|
|
151
|
+
* Never throws: a task that cannot get its guard is still a task worth running
|
|
152
|
+
* (the caller warns), because the remaining layers — branch protection — are
|
|
153
|
+
* the ones that were never bypassable anyway.
|
|
154
|
+
*
|
|
155
|
+
* @returns {{installed: boolean, dir: string, hookPath: string, error: string|null}}
|
|
156
|
+
*/
|
|
157
|
+
export function installGitPushGuard({ env = process.env, homeDir = os.homedir(), fsImpl = fs } = {}) {
|
|
158
|
+
const dir = resolveGitPushGuardHostDir({ env, homeDir });
|
|
159
|
+
const hookPath = path.join(dir, GIT_PUSH_GUARD_HOOK_NAME);
|
|
160
|
+
try {
|
|
161
|
+
fsImpl.mkdirSync(dir, { recursive: true });
|
|
162
|
+
fsImpl.writeFileSync(hookPath, PRE_PUSH_GUARD_SCRIPT, { mode: 0o755 });
|
|
163
|
+
// writeFileSync only applies `mode` when it creates the file, so an existing
|
|
164
|
+
// hook keeps whatever permissions it had — including non-executable ones,
|
|
165
|
+
// which git ignores silently.
|
|
166
|
+
fsImpl.chmodSync(hookPath, 0o755);
|
|
167
|
+
return { installed: true, dir, hookPath, error: null };
|
|
168
|
+
} catch (error) {
|
|
169
|
+
return { installed: false, dir, hookPath, error: error?.message || String(error) };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Turn `[key, value]` pairs into git's `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_n`
|
|
175
|
+
* environment form (git >= 2.31).
|
|
176
|
+
*
|
|
177
|
+
* A routed task needs several such settings — the hook path here, plus the URL
|
|
178
|
+
* rewrite and CA that send git through the router (issue #2164) — and they share
|
|
179
|
+
* one counter, so building them separately would have each overwrite the other.
|
|
180
|
+
* The order is preserved because git applies these last-to-win, which is what
|
|
181
|
+
* lets `credential.helper=` clear an inherited helper list.
|
|
182
|
+
*
|
|
183
|
+
* @param {Array<[string, string]>} entries
|
|
184
|
+
* @returns {Record<string,string>}
|
|
185
|
+
*/
|
|
186
|
+
export function buildGitConfigEnv(entries = []) {
|
|
187
|
+
const usable = entries.filter(entry => Array.isArray(entry) && entry[0]);
|
|
188
|
+
if (usable.length === 0) return {};
|
|
189
|
+
const taskEnv = { GIT_CONFIG_COUNT: String(usable.length) };
|
|
190
|
+
usable.forEach(([key, value], index) => {
|
|
191
|
+
taskEnv[`GIT_CONFIG_KEY_${index}`] = key;
|
|
192
|
+
taskEnv[`GIT_CONFIG_VALUE_${index}`] = value ?? '';
|
|
193
|
+
});
|
|
194
|
+
return taskEnv;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Environment that points git at the mounted hook for every repository in the
|
|
199
|
+
* container, without writing to the bind-mounted `~/.gitconfig`.
|
|
200
|
+
*
|
|
201
|
+
* `extraConfig` carries any other settings the same task needs, so all of them
|
|
202
|
+
* end up under one `GIT_CONFIG_COUNT`.
|
|
203
|
+
*/
|
|
204
|
+
export function buildGitPushGuardEnv({ hooksPath = GIT_PUSH_GUARD_CONTAINER_DIR, allowDestructive = false, extraConfig = [] } = {}) {
|
|
205
|
+
const entries = [...(hooksPath ? [['core.hooksPath', hooksPath]] : []), ...extraConfig];
|
|
206
|
+
const taskEnv = buildGitConfigEnv(entries);
|
|
207
|
+
if (Object.keys(taskEnv).length === 0) return {};
|
|
208
|
+
if (allowDestructive) taskEnv[GIT_PUSH_GUARD_ESCAPE_ENV] = '1';
|
|
209
|
+
return taskEnv;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Read the existing fork-divergence opt-in out of a raw argument vector.
|
|
214
|
+
*
|
|
215
|
+
* `--allow-fork-divergence-resolution-using-force-push-with-lease` already means
|
|
216
|
+
* "this operator accepts a force push", and Hive Mind performs one itself in
|
|
217
|
+
* `solve.fork-sync.lib.mjs`. Blocking that would break a documented workflow, so
|
|
218
|
+
* the opt-in is propagated into the container rather than overridden.
|
|
219
|
+
*
|
|
220
|
+
* @param {string[]} args
|
|
221
|
+
*/
|
|
222
|
+
export function hasForcePushOptIn(args) {
|
|
223
|
+
const list = Array.isArray(args) ? args : [];
|
|
224
|
+
return list.some(arg => {
|
|
225
|
+
const value = String(arg ?? '');
|
|
226
|
+
return value === '--allow-fork-divergence-resolution-using-force-push-with-lease' || value === '--allow-fork-divergence-resolution-using-force-push-with-lease=true';
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export default { buildGitConfigEnv, buildGitPushGuardEnv, hasForcePushOptIn, installGitPushGuard, resolveGitPushGuardHostDir, GIT_PUSH_GUARD_CONTAINER_DIR, PRE_PUSH_GUARD_SCRIPT };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Retry wrapper for network-facing *git* commands.
|
|
5
|
+
*
|
|
6
|
+
* Issue #2168 asked for retry on "any git/github operation". The `gh` half was
|
|
7
|
+
* already covered by src/github-rate-limit.lib.mjs (`wrapDollarWithGhRetry`),
|
|
8
|
+
* but the ~36 `git push` / `git fetch` / `git pull` call sites scattered across
|
|
9
|
+
* src/*.mjs had no retry at all: a single `fatal: unable to access ...` or
|
|
10
|
+
* `RPC failed; curl 56 ... connection reset` aborted the whole session.
|
|
11
|
+
*
|
|
12
|
+
* Wrapping every call site by hand would have to be repeated for every new call
|
|
13
|
+
* site, so the retry is installed on command-stream's `$` tag instead - exactly
|
|
14
|
+
* the shape already used for `gh`. Anything that runs through a wrapped `$`
|
|
15
|
+
* gets the retry for free, and the existing ESLint rules that push code towards
|
|
16
|
+
* the wrapped `$` keep new call sites covered.
|
|
17
|
+
*
|
|
18
|
+
* Only *network* subcommands are retried. Local plumbing (`git commit`,
|
|
19
|
+
* `git checkout`, ...) is deterministic: re-running it cannot turn a failure
|
|
20
|
+
* into a success, and a blind second attempt could mask the real error.
|
|
21
|
+
*
|
|
22
|
+
* `git clone` is intentionally NOT in the list: a partially-written destination
|
|
23
|
+
* directory makes the second attempt fail with "already exists and is not an
|
|
24
|
+
* empty directory", which would replace the real (transient) diagnosis with a
|
|
25
|
+
* confusing one. Repository cloning in this codebase goes through
|
|
26
|
+
* `gh repo clone`, which is already covered by the gh retry wrapper.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
// Subcommands that talk to the remote and are safe to re-run verbatim.
|
|
30
|
+
const GIT_NETWORK_SUBCOMMANDS = Object.freeze(['push', 'fetch', 'pull', 'ls-remote']);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Decide whether `command` is a git command that reaches the network.
|
|
34
|
+
*
|
|
35
|
+
* Handles the `git -C <dir> push ...` and `git --no-pager fetch ...` forms by
|
|
36
|
+
* skipping leading option tokens (and their argument, for the options that take
|
|
37
|
+
* one) before looking at the subcommand.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} command - the reconstructed shell command line.
|
|
40
|
+
* @returns {string|null} the matched subcommand, or null when not a git network command.
|
|
41
|
+
*/
|
|
42
|
+
export const matchGitNetworkCommand = command => {
|
|
43
|
+
const text = String(command ?? '').trim();
|
|
44
|
+
if (!/^git(?:\s|$)/.test(text)) return null;
|
|
45
|
+
const tokens = text.split(/\s+/).slice(1);
|
|
46
|
+
const optionsWithValue = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path']);
|
|
47
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
48
|
+
const token = tokens[i];
|
|
49
|
+
if (!token.startsWith('-')) {
|
|
50
|
+
// `git clone` is excluded on purpose - see the module header.
|
|
51
|
+
return GIT_NETWORK_SUBCOMMANDS.includes(token) ? token : null;
|
|
52
|
+
}
|
|
53
|
+
if (optionsWithValue.has(token)) i++;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Wrap command-stream's `$` so that git network commands are retried on
|
|
60
|
+
* transient failures. Non-git commands (and local git plumbing) are passed
|
|
61
|
+
* straight through, so the wrapper is safe to install globally.
|
|
62
|
+
*
|
|
63
|
+
* @template T
|
|
64
|
+
* @param {(strings: TemplateStringsArray, ...values: unknown[]) => Promise<T>} dollar
|
|
65
|
+
* @param {object} [options] - forwarded to gitCmdRetry per call.
|
|
66
|
+
* @returns {(strings: TemplateStringsArray, ...values: unknown[]) => Promise<T>}
|
|
67
|
+
*/
|
|
68
|
+
export const wrapDollarWithGitRetry = (dollar, options = {}) => {
|
|
69
|
+
if (typeof dollar !== 'function') {
|
|
70
|
+
throw new TypeError(`Expected command-stream's $ export to be a function, received ${typeof dollar}.`);
|
|
71
|
+
}
|
|
72
|
+
const wrapped = (strings, ...values) => {
|
|
73
|
+
// Options-call form: `$({ cwd })` returns a new tag bound to those options.
|
|
74
|
+
if (strings && !Array.isArray(strings) && typeof strings === 'object') {
|
|
75
|
+
return wrapDollarWithGitRetry(dollar(strings), options);
|
|
76
|
+
}
|
|
77
|
+
let preview = '';
|
|
78
|
+
for (let i = 0; i < strings.length; i++) {
|
|
79
|
+
preview += strings[i];
|
|
80
|
+
if (i < values.length) preview += String(values[i] ?? '');
|
|
81
|
+
}
|
|
82
|
+
const subcommand = matchGitNetworkCommand(preview);
|
|
83
|
+
if (!subcommand) return dollar(strings, ...values);
|
|
84
|
+
// Lazy import keeps this module free of a static cycle with lib.mjs.
|
|
85
|
+
return (async () => {
|
|
86
|
+
const { gitCmdRetry } = await import('./lib.mjs');
|
|
87
|
+
return gitCmdRetry(() => dollar(strings, ...values), {
|
|
88
|
+
label: `git ${subcommand}`,
|
|
89
|
+
...options,
|
|
90
|
+
});
|
|
91
|
+
})();
|
|
92
|
+
};
|
|
93
|
+
wrapped.raw = typeof dollar.raw === 'function' ? dollar.raw : dollar;
|
|
94
|
+
return wrapped;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export default { matchGitNetworkCommand, wrapDollarWithGitRetry };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Make `gh pr create` safe to retry.
|
|
5
|
+
*
|
|
6
|
+
* Issue #2168: `gh pr create` aborted a solve run on GitHub's transient
|
|
7
|
+
* GraphQL internal error. The fix is to retry — but a mutation that failed
|
|
8
|
+
* *after* GitHub committed it (a 5xx on the response path, not the request
|
|
9
|
+
* path) would make the retry fail again with
|
|
10
|
+
*
|
|
11
|
+
* GraphQL: A pull request already exists for konard:issue-4804-203a323f30b1.
|
|
12
|
+
*
|
|
13
|
+
* which is not transient and would abort the run just the same, only one step
|
|
14
|
+
* later. Retrying a write is only correct when the write is idempotent, so
|
|
15
|
+
* this module turns "already exists" into "here is the pull request you were
|
|
16
|
+
* trying to create".
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Combined text of an error-ish value, mirroring the collector used by the
|
|
21
|
+
* transient classifier.
|
|
22
|
+
*/
|
|
23
|
+
const errorText = error => {
|
|
24
|
+
if (!error) return '';
|
|
25
|
+
if (typeof error === 'string') return error;
|
|
26
|
+
const parts = [];
|
|
27
|
+
if (typeof error.message === 'string') parts.push(error.message);
|
|
28
|
+
if (error.stderr) parts.push(error.stderr.toString());
|
|
29
|
+
if (error.stdout) parts.push(error.stdout.toString());
|
|
30
|
+
if (error.cause) parts.push(errorText(error.cause));
|
|
31
|
+
return parts.join('\n');
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Detect `gh pr create`'s "a pull request already exists" rejection.
|
|
36
|
+
*
|
|
37
|
+
* @param {unknown} error
|
|
38
|
+
* @returns {boolean}
|
|
39
|
+
*/
|
|
40
|
+
export const isPullRequestAlreadyExistsError = error => {
|
|
41
|
+
const text = errorText(error).toLowerCase();
|
|
42
|
+
if (!text) return false;
|
|
43
|
+
return text.includes('a pull request already exists') || (text.includes('pull request') && text.includes('already exists'));
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Look up the pull request that already exists for `headRef`.
|
|
48
|
+
*
|
|
49
|
+
* `gh pr list --head` matches on the branch name; for a fork the head is
|
|
50
|
+
* `owner:branch`, and GitHub still indexes it under the bare branch name, so
|
|
51
|
+
* the bare name is what we query with.
|
|
52
|
+
*
|
|
53
|
+
* @param {object} params
|
|
54
|
+
* @param {string} params.owner
|
|
55
|
+
* @param {string} params.repo
|
|
56
|
+
* @param {string} params.headRef - branch name, with or without an `owner:` prefix.
|
|
57
|
+
* @param {(command: string, options?: object) => Promise<{stdout: string}>} params.execGh - retry-wrapped exec (e.g. `execGhWithRetry`).
|
|
58
|
+
* @param {(msg: string, options?: object) => Promise<void>|void} [params.log]
|
|
59
|
+
* @returns {Promise<string|null>} PR URL, or null when none could be resolved.
|
|
60
|
+
*/
|
|
61
|
+
export const findExistingPullRequestUrl = async ({ owner, repo, headRef, execGh, log = null }) => {
|
|
62
|
+
const branch = String(headRef || '').includes(':') ? String(headRef).split(':').pop() : String(headRef || '');
|
|
63
|
+
if (!branch) return null;
|
|
64
|
+
try {
|
|
65
|
+
const { stdout } = await execGh(`gh pr list --repo ${owner}/${repo} --head ${branch} --state all --limit 1 --json url,number,state`, {
|
|
66
|
+
label: 'gh pr list (existing PR lookup)',
|
|
67
|
+
});
|
|
68
|
+
const parsed = JSON.parse((stdout || '[]').toString().trim() || '[]');
|
|
69
|
+
const first = Array.isArray(parsed) ? parsed[0] : null;
|
|
70
|
+
if (first?.url) {
|
|
71
|
+
if (log) await Promise.resolve(log(` Recovered existing PR #${first.number} (${first.state}) for head ${branch}: ${first.url}`));
|
|
72
|
+
return first.url;
|
|
73
|
+
}
|
|
74
|
+
} catch (lookupError) {
|
|
75
|
+
if (log) await Promise.resolve(log(` Could not look up the existing PR for head ${branch}: ${lookupError.message}`, { level: 'warn' }));
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export default {
|
|
81
|
+
isPullRequestAlreadyExistsError,
|
|
82
|
+
findExistingPullRequestUrl,
|
|
83
|
+
};
|