@yemi33/minions 0.1.2197 → 0.1.2198
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/dashboard/js/command-center.js +32 -2
- package/dashboard.js +95 -0
- package/engine/db/migrations/014-pr-fix-target-prefs.js +29 -0
- package/engine/gh-token.js +31 -0
- package/engine/pr-action.js +391 -0
- package/engine/pr-clone-keep.js +370 -0
- package/engine/pr-devbox.js +322 -0
- package/engine/pr-fix-target-store.js +123 -0
- package/engine/pr-fix-target.js +418 -0
- package/engine/pr-remote-patch.js +384 -0
- package/engine/pr-resolve.js +443 -0
- package/engine/pr-temp-clone.js +414 -0
- package/engine/pr-track.js +209 -0
- package/package.json +1 -1
- package/prompts/cc-system.md +33 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/pr-clone-keep.js — Clone & keep fix executor: auto-clone a PR's repo and
|
|
3
|
+
* register it as a PERSISTENT project so ongoing tracking / auto-fix works.
|
|
4
|
+
*
|
|
5
|
+
* Plan: projectless-pr-actions, Phase 2, item P-ppa10007. This is the second
|
|
6
|
+
* mutating executor in the chain (after the ephemeral temp-clone, P-ppa10006).
|
|
7
|
+
* The execution-target choice surface (engine/pr-fix-target.js) routes
|
|
8
|
+
* `clone-keep` here once the user has explicitly picked it — the "never clone
|
|
9
|
+
* silently" invariant lives upstream; by the time we run, the user has chosen to
|
|
10
|
+
* clone AND keep.
|
|
11
|
+
*
|
|
12
|
+
* Contract (from the choice surface's description): "Auto-clone the repo and
|
|
13
|
+
* register it as a persistent project so ongoing tracking/auto-fix works.
|
|
14
|
+
* Disk-resident."
|
|
15
|
+
*
|
|
16
|
+
* planCloneKeep(input, opts) — resolve the PR ref, confirm auth + the base
|
|
17
|
+
* repo's clone URL + default branch, and compute
|
|
18
|
+
* the PERSISTENT project dir + name. PLANNING
|
|
19
|
+
* ONLY: nothing is created on disk, nothing is
|
|
20
|
+
* cloned.
|
|
21
|
+
* executeCloneKeep(plan, opts) — clone the BASE repo into the persistent dir
|
|
22
|
+
* and register it as a project via
|
|
23
|
+
* engine/projects.js#addProject. The repo then
|
|
24
|
+
* becomes `project-ready` so the fix itself
|
|
25
|
+
* flows through the engine's normal project fix
|
|
26
|
+
* path. On registration failure the partial
|
|
27
|
+
* clone is rolled back so no orphan dir leaks.
|
|
28
|
+
*
|
|
29
|
+
* Load-bearing invariants (read before extending):
|
|
30
|
+
* - PERSISTENT (the whole point vs temp-clone): the clone dir is durable and is
|
|
31
|
+
* handed to addProject as the project's `localPath`. It is NOT discarded on
|
|
32
|
+
* success. It IS rolled back if registration fails (so a half-registered repo
|
|
33
|
+
* never leaves an orphan directory behind).
|
|
34
|
+
* - BASE REPO, NOT FORK: a persistent project tracks the canonical PR target
|
|
35
|
+
* repo (`ref.slug`), never a contributor's fork — ongoing auto-fix is about the
|
|
36
|
+
* base repo. The base clone URL is derived from the ref, independent of the
|
|
37
|
+
* PR head repo.
|
|
38
|
+
* - REGISTER-THEN-DONE: this executor sets up the ENVIRONMENT (clone + project
|
|
39
|
+
* registration). It does NOT run the fix/tests/push itself — once the project
|
|
40
|
+
* is registered the repo is `project-ready` and the fix routes through the
|
|
41
|
+
* existing project worktree/fix path. Keeping the two concerns separate is why
|
|
42
|
+
* a single dispatch can never both clone-silently and skip the choice.
|
|
43
|
+
* - IDEMPOTENT RE-CHECK: a stale clone-keep plan must never re-clone a repo that
|
|
44
|
+
* has since become a configured project. executeCloneKeep re-derives the
|
|
45
|
+
* configured-project check from current config and short-circuits to
|
|
46
|
+
* `project-ready` (no clone) when the repo is already linked.
|
|
47
|
+
* - All git/network/register steps are injectable seams so unit tests never clone
|
|
48
|
+
* a real repo; the defaults are real (shellSafeGit / projects.addProject).
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
const fs = require('fs');
|
|
52
|
+
const path = require('path');
|
|
53
|
+
|
|
54
|
+
const shared = require('./shared');
|
|
55
|
+
const prResolve = require('./pr-resolve');
|
|
56
|
+
const ghToken = require('./gh-token');
|
|
57
|
+
const prFixTarget = require('./pr-fix-target');
|
|
58
|
+
const { PrActionError } = require('./pr-action');
|
|
59
|
+
|
|
60
|
+
const CLONE_KEEP_DIR_NAME = 'cloned-repos'; // persistent root, under MINIONS_DIR
|
|
61
|
+
const CLONE_TIMEOUT_MS = 10 * 60 * 1000; // full clone can be large
|
|
62
|
+
const GIT_OP_TIMEOUT_MS = 60 * 1000;
|
|
63
|
+
|
|
64
|
+
// ── Ref resolution ────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve `{ url }` (raw PR URL / canonical id), an already-normalized ref, or a
|
|
68
|
+
* routed plan (carrying `.ref`) into a normalized PR ref. Throws
|
|
69
|
+
* `PrActionError` (400) on missing/unrecognized input — mirrors the read-only,
|
|
70
|
+
* choice-surface, and temp-clone paths so the dashboard reflects a 400, not a 500.
|
|
71
|
+
*/
|
|
72
|
+
function _resolveRef(input) {
|
|
73
|
+
if (input && typeof input === 'object') {
|
|
74
|
+
if (input.host && input.slug && input.number) return input; // already normalized
|
|
75
|
+
if (input.ref && input.ref.host) return input.ref; // plan/handle carrying a ref
|
|
76
|
+
}
|
|
77
|
+
const rawUrl = input && typeof input === 'object'
|
|
78
|
+
? (typeof input.url === 'string' ? input.url.trim() : '')
|
|
79
|
+
: (typeof input === 'string' ? input.trim() : '');
|
|
80
|
+
if (!rawUrl) throw new PrActionError('url required');
|
|
81
|
+
const ref = prResolve.normalizePrRef(rawUrl);
|
|
82
|
+
if (!ref) throw new PrActionError(`unrecognized PR reference: ${JSON.stringify(rawUrl.slice(0, 120))}`);
|
|
83
|
+
return ref;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function _prId(ref) {
|
|
87
|
+
if (!ref) return null;
|
|
88
|
+
return ref.id || (ref.host && ref.slug && ref.number ? `${ref.host}:${ref.slug}#${ref.number}` : null);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Filesystem-safe slug for the persistent project dir name (from the repo name). */
|
|
92
|
+
function _safeDirSlug(name) {
|
|
93
|
+
return String(name || 'repo').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'repo';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The persistent-clone root — durable, OUTSIDE the worktree pool + tmp roots. */
|
|
97
|
+
function _cloneRoot(opts = {}) {
|
|
98
|
+
return opts.cloneRoot || path.join(shared.MINIONS_DIR, CLONE_KEEP_DIR_NAME);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The BASE repo clone URL for a normalized ref (the PR TARGET repo, never a fork).
|
|
103
|
+
* GitHub is derived from the validated slug; ADO uses the base repo remoteUrl that
|
|
104
|
+
* fetchPrBranches already resolved (ADO PRs are same-repo, so it is the base repo).
|
|
105
|
+
*/
|
|
106
|
+
function _baseCloneUrl(ref, branches) {
|
|
107
|
+
if (ref.host === 'github') {
|
|
108
|
+
const slug = shared.validateGhSlug(ref.slug);
|
|
109
|
+
return `https://github.com/${slug}.git`;
|
|
110
|
+
}
|
|
111
|
+
if (ref.host === 'ado') {
|
|
112
|
+
return (branches && branches.cloneUrl) || null;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Planning ──────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Plan a clone-keep fix: resolve the PR ref, confirm auth + the base repo's clone
|
|
121
|
+
* URL + default branch (via fetchPrBranches, mirroring temp-clone), and compute
|
|
122
|
+
* the persistent project dir + name. NOTHING is created or cloned here.
|
|
123
|
+
*
|
|
124
|
+
* `input` — `{ url }`, a normalized ref, or a routed plan from
|
|
125
|
+
* engine/pr-fix-target.js#resolvePrFixTarget.
|
|
126
|
+
* `opts.fetchPrBranches` — override the branch fetch (test seam).
|
|
127
|
+
* `opts.prUrl` — original user URL (preferred for surfacing).
|
|
128
|
+
* `opts.cloneRoot` — override the persistent-clone root.
|
|
129
|
+
* `opts.projectName` — override the derived project name.
|
|
130
|
+
*
|
|
131
|
+
* Returns `{ status:'clone-keep-planned', target:'clone-keep', ref, prId, prUrl,
|
|
132
|
+
* cloneUrl, targetBranch, projectDir, projectName, validates:true }`.
|
|
133
|
+
* Throws `PrActionError` (400) on bad input; re-throws fetch errors.
|
|
134
|
+
*/
|
|
135
|
+
async function planCloneKeep(input = {}, opts = {}) {
|
|
136
|
+
const ref = _resolveRef(input);
|
|
137
|
+
const prUrl = opts.prUrl || (input && typeof input === 'object' ? input.url : undefined) || null;
|
|
138
|
+
const prId = _prId(ref);
|
|
139
|
+
|
|
140
|
+
// Confirm the PR exists + auth works, and learn the base (target) branch. This
|
|
141
|
+
// mirrors temp-clone — a clone-keep on a repo we cannot reach should fail in
|
|
142
|
+
// planning, before we touch the disk.
|
|
143
|
+
const fetchBranches = opts.fetchPrBranches || prResolve.fetchPrBranches;
|
|
144
|
+
const branches = await fetchBranches(ref, opts);
|
|
145
|
+
|
|
146
|
+
const cloneUrl = _baseCloneUrl(ref, branches);
|
|
147
|
+
if (!cloneUrl) throw new PrActionError(`could not resolve a base clone URL for ${prId || 'PR'}`, 502);
|
|
148
|
+
|
|
149
|
+
const projectName = _safeDirSlug(opts.projectName || ref.repo || ref.slug);
|
|
150
|
+
const projectDir = path.join(_cloneRoot(opts), projectName);
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
status: 'clone-keep-planned',
|
|
154
|
+
target: 'clone-keep',
|
|
155
|
+
ref,
|
|
156
|
+
prId,
|
|
157
|
+
prUrl,
|
|
158
|
+
cloneUrl,
|
|
159
|
+
targetBranch: (branches && branches.targetBranch) || null,
|
|
160
|
+
projectDir,
|
|
161
|
+
projectName,
|
|
162
|
+
validates: true,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Default git seam (real implementation; tests inject their own) ─────────────
|
|
167
|
+
|
|
168
|
+
/** Resolve an auth token for the PR's host (GitHub PAT per-slug / ADO bearer). */
|
|
169
|
+
async function _resolveToken(plan, opts = {}) {
|
|
170
|
+
if (opts.token) return opts.token;
|
|
171
|
+
if (plan.ref.host === 'github') {
|
|
172
|
+
const resolve = opts._resolveTokenForSlug || ghToken.resolveTokenForSlug;
|
|
173
|
+
return resolve(plan.ref.slug) || null;
|
|
174
|
+
}
|
|
175
|
+
if (plan.ref.host === 'ado') {
|
|
176
|
+
const acquire = opts._acquireAdoToken || require('./ado-token').acquireAdoToken;
|
|
177
|
+
const acquired = await acquire();
|
|
178
|
+
return (acquired && acquired.token) || null;
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Build an HTTPS clone URL with an embedded GitHub token (private base repos). */
|
|
184
|
+
function _authedGithubUrl(cloneUrl, token) {
|
|
185
|
+
if (!token) return cloneUrl;
|
|
186
|
+
try {
|
|
187
|
+
const u = new URL(cloneUrl);
|
|
188
|
+
u.username = 'x-access-token';
|
|
189
|
+
u.password = token;
|
|
190
|
+
return u.toString();
|
|
191
|
+
} catch {
|
|
192
|
+
return cloneUrl;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Per-invocation git auth args. ADO injects a bearer header; GitHub embeds in URL. */
|
|
197
|
+
function _gitAuthArgs(plan, token) {
|
|
198
|
+
if (plan.ref.host === 'ado' && token) {
|
|
199
|
+
return ['-c', `http.extraHeader=Authorization: Bearer ${token}`];
|
|
200
|
+
}
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function _defaultGitClone(plan, token, opts = {}) {
|
|
205
|
+
const runGit = opts._shellSafeGit || shared.shellSafeGit;
|
|
206
|
+
const url = plan.ref.host === 'github' ? _authedGithubUrl(plan.cloneUrl, token) : plan.cloneUrl;
|
|
207
|
+
// Full clone (NOT --depth 1 / --single-branch): a persistent project needs the
|
|
208
|
+
// whole history + every branch so the engine can later create worktrees and
|
|
209
|
+
// fetch the PR branch through the normal project fix path.
|
|
210
|
+
await runGit(
|
|
211
|
+
['clone', url, plan.projectDir],
|
|
212
|
+
{ timeout: CLONE_TIMEOUT_MS, gitExtraArgs: _gitAuthArgs(plan, token) },
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Best-effort recursive remove of a partial clone dir on rollback. NEVER throws —
|
|
218
|
+
* rollback runs after a failure and must not mask the real error.
|
|
219
|
+
*/
|
|
220
|
+
async function _rollbackClone(dir, opts = {}) {
|
|
221
|
+
if (!dir) return true;
|
|
222
|
+
const rm = opts._rm || ((d) => fs.promises.rm(d, { recursive: true, force: true, maxRetries: 6, retryDelay: 200 }));
|
|
223
|
+
try {
|
|
224
|
+
await rm(dir);
|
|
225
|
+
return true;
|
|
226
|
+
} catch (e) {
|
|
227
|
+
shared.log('warn', `pr-clone-keep: failed to roll back partial clone ${dir}: ${e?.message || e}`);
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── Execution ─────────────────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
function _record(plan, fields) {
|
|
235
|
+
return {
|
|
236
|
+
status: 'failed',
|
|
237
|
+
target: 'clone-keep',
|
|
238
|
+
ref: plan.ref,
|
|
239
|
+
prId: plan.prId,
|
|
240
|
+
prUrl: plan.prUrl,
|
|
241
|
+
cloneUrl: plan.cloneUrl,
|
|
242
|
+
projectDir: plan.projectDir,
|
|
243
|
+
projectName: plan.projectName,
|
|
244
|
+
cloned: false,
|
|
245
|
+
registered: false,
|
|
246
|
+
project: null,
|
|
247
|
+
rolledBack: false,
|
|
248
|
+
failure_class: null,
|
|
249
|
+
retryable: false,
|
|
250
|
+
error: null,
|
|
251
|
+
...fields,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Execute the clone-keep lifecycle for a planned (or raw) PR fix: clone the BASE
|
|
257
|
+
* repo into a persistent dir and register it as a project so the repo becomes
|
|
258
|
+
* `project-ready`. The fix itself is NOT run here — once registered it flows
|
|
259
|
+
* through the engine's normal project fix path.
|
|
260
|
+
*
|
|
261
|
+
* `planOrInput` — a plan from `planCloneKeep`, a routed plan from pr-fix-target.js,
|
|
262
|
+
* a normalized ref, or `{ url }`.
|
|
263
|
+
* `opts`:
|
|
264
|
+
* - `config` — full config; re-checks configured-project so a stale plan can't
|
|
265
|
+
* re-clone a repo that is already linked.
|
|
266
|
+
* - `registerOptions` — passed through to projects.addProject (name override,
|
|
267
|
+
* worktreeMode, observeAuthors, …).
|
|
268
|
+
* - test seams: `gitClone`, `addProject`, `findConfiguredProject`, `fetchPrBranches`,
|
|
269
|
+
* `token`/`_resolveTokenForSlug`/`_acquireAdoToken`, `cloneRoot`, `_rm`, `_mkdir`.
|
|
270
|
+
*
|
|
271
|
+
* Returns:
|
|
272
|
+
* - `{ status:'project-ready', alreadyConfigured:true, project, projectName,
|
|
273
|
+
* cloned:false, registered:false, ... }` — repo is already a configured
|
|
274
|
+
* project; NOTHING is cloned, route to the existing project fix path.
|
|
275
|
+
* - `{ status:'done', registered:true, project, projectDir, projectName,
|
|
276
|
+
* cloned:true, ... }` — clone + registration succeeded.
|
|
277
|
+
* - a `failed` record on any downstream failure (partial clone rolled back).
|
|
278
|
+
*
|
|
279
|
+
* Throws `PrActionError` (400) only for bad input.
|
|
280
|
+
*/
|
|
281
|
+
async function executeCloneKeep(planOrInput = {}, opts = {}) {
|
|
282
|
+
// 0) Idempotent re-check: never re-clone a repo that is ALREADY a configured
|
|
283
|
+
// project (a stale awaiting-choice plan must route to the project path).
|
|
284
|
+
const ref = _resolveRef(planOrInput); // PrActionError(400) on bad input propagates
|
|
285
|
+
const findConfigured = opts.findConfiguredProject || prFixTarget.findConfiguredProjectForRef;
|
|
286
|
+
const existing = findConfigured(ref, opts.config);
|
|
287
|
+
if (existing) {
|
|
288
|
+
return {
|
|
289
|
+
status: 'project-ready',
|
|
290
|
+
target: 'clone-keep',
|
|
291
|
+
ref,
|
|
292
|
+
prId: _prId(ref),
|
|
293
|
+
prUrl: (planOrInput && typeof planOrInput === 'object' ? (planOrInput.prUrl || planOrInput.url) : null) || null,
|
|
294
|
+
alreadyConfigured: true,
|
|
295
|
+
project: existing,
|
|
296
|
+
projectName: existing.name || null,
|
|
297
|
+
cloned: false,
|
|
298
|
+
registered: false,
|
|
299
|
+
failure_class: null,
|
|
300
|
+
retryable: false,
|
|
301
|
+
error: null,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Accept an already-built plan, else plan now (resolves clone URL + project dir).
|
|
306
|
+
let plan;
|
|
307
|
+
if (planOrInput && planOrInput.status === 'clone-keep-planned' && planOrInput.projectDir) {
|
|
308
|
+
plan = planOrInput;
|
|
309
|
+
} else {
|
|
310
|
+
plan = await planCloneKeep(planOrInput, opts); // PrActionError(400) on bad input propagates
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const gitClone = opts.gitClone || ((p, token) => _defaultGitClone(p, token, opts));
|
|
314
|
+
const addProject = opts.addProject || ((target, registerOptions) => require('./projects').addProject(target, registerOptions));
|
|
315
|
+
const mkdir = opts._mkdir || ((dir) => fs.promises.mkdir(dir, { recursive: true }));
|
|
316
|
+
|
|
317
|
+
const rec = _record(plan, {});
|
|
318
|
+
let token = null;
|
|
319
|
+
|
|
320
|
+
// 1) Persistent clone of the BASE repo.
|
|
321
|
+
try {
|
|
322
|
+
token = await _resolveToken(plan, opts);
|
|
323
|
+
await mkdir(path.dirname(plan.projectDir));
|
|
324
|
+
await gitClone(plan, token);
|
|
325
|
+
rec.cloned = true;
|
|
326
|
+
} catch (e) {
|
|
327
|
+
rec.failure_class = shared.FAILURE_CLASS.NETWORK_ERROR;
|
|
328
|
+
rec.retryable = true;
|
|
329
|
+
rec.error = `clone failed: ${e?.message || e}`;
|
|
330
|
+
// Best-effort rollback of any partial clone dir before reporting.
|
|
331
|
+
rec.rolledBack = await _rollbackClone(plan.projectDir, opts);
|
|
332
|
+
return rec;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// 2) Register the clone as a persistent project.
|
|
336
|
+
let registered;
|
|
337
|
+
try {
|
|
338
|
+
registered = await addProject(plan.projectDir, { ...(opts.registerOptions || {}) });
|
|
339
|
+
} catch (e) {
|
|
340
|
+
rec.failure_class = shared.FAILURE_CLASS.CONFIG_ERROR;
|
|
341
|
+
rec.retryable = false;
|
|
342
|
+
rec.error = `project registration failed: ${e?.message || e}`;
|
|
343
|
+
// Roll back the clone we created so a failed registration leaves no orphan.
|
|
344
|
+
rec.rolledBack = await _rollbackClone(plan.projectDir, opts);
|
|
345
|
+
rec.cloned = false;
|
|
346
|
+
return rec;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
rec.status = 'done';
|
|
350
|
+
rec.registered = true;
|
|
351
|
+
rec.project = (registered && registered.project) || registered || null;
|
|
352
|
+
rec.projectName = (rec.project && rec.project.name) || plan.projectName;
|
|
353
|
+
rec.failure_class = null;
|
|
354
|
+
rec.retryable = false;
|
|
355
|
+
rec.error = null;
|
|
356
|
+
return rec;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
module.exports = {
|
|
360
|
+
CLONE_KEEP_DIR_NAME,
|
|
361
|
+
planCloneKeep,
|
|
362
|
+
executeCloneKeep,
|
|
363
|
+
// Exported for testing.
|
|
364
|
+
_resolveRef,
|
|
365
|
+
_safeDirSlug,
|
|
366
|
+
_baseCloneUrl,
|
|
367
|
+
_authedGithubUrl,
|
|
368
|
+
_gitAuthArgs,
|
|
369
|
+
_rollbackClone,
|
|
370
|
+
};
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/pr-devbox.js — DevBox execution target (the "never touches your machine"
|
|
3
|
+
* option for a projectless one-off PR fix).
|
|
4
|
+
*
|
|
5
|
+
* Plan: projectless-pr-actions, Phase 4 (stretch), item P-ppa10011. Like
|
|
6
|
+
* engine/pr-temp-clone.js this is an EXECUTOR the choice surface
|
|
7
|
+
* (engine/pr-fix-target.js) routes to once the user has EXPLICITLY picked it —
|
|
8
|
+
* the "never clone silently" invariant lives upstream; by the time we run, the
|
|
9
|
+
* user has chosen the DevBox target.
|
|
10
|
+
*
|
|
11
|
+
* Contract (from the choice surface's description): "Clone ephemerally ON A
|
|
12
|
+
* DEVBOX, run the fix there, push from the DevBox. Zero local footprint — the
|
|
13
|
+
* answer for high PR volume."
|
|
14
|
+
*
|
|
15
|
+
* planDevBox(input, opts) — resolve the PR ref + its source branch + a
|
|
16
|
+
* clone URL and compute the REMOTE clone dir (on
|
|
17
|
+
* the DevBox). PLANNING ONLY: nothing is created
|
|
18
|
+
* on the local machine, nothing is cloned. The
|
|
19
|
+
* branch fetch is a network metadata call, not a
|
|
20
|
+
* local checkout.
|
|
21
|
+
* executeDevBox(plan, opts) — delegate the full remote lifecycle to the
|
|
22
|
+
* injected DevBox runner (clone → fix → validate
|
|
23
|
+
* → push, all ON the DevBox), then ALWAYS tear
|
|
24
|
+
* the remote clone down. The engine never writes
|
|
25
|
+
* to local disk on this path.
|
|
26
|
+
*
|
|
27
|
+
* Load-bearing invariants (read before extending):
|
|
28
|
+
* - ZERO LOCAL FOOTPRINT: this module touches NO local disk. It pulls in no local
|
|
29
|
+
* filesystem module, never runs a local clone, and every record it returns
|
|
30
|
+
* flags `localFootprint: false`. A source-inspection test guards this. The
|
|
31
|
+
* clone, build/test, and push all happen on the DevBox.
|
|
32
|
+
* - DELEGATED LIFECYCLE: the engine daemon cannot call the DevBox MCP tools
|
|
33
|
+
* directly (they are agent-side tools — see the `devbox-remote-control` skill).
|
|
34
|
+
* So the ENTIRE remote lifecycle is performed by the injected `opts.runOnDevBox`
|
|
35
|
+
* runner (the engine/agent wires the DevBox MCP `task_resource` calls). Without
|
|
36
|
+
* a runner there is nothing to execute — we fail fast (CONFIG_ERROR) BEFORE
|
|
37
|
+
* selecting/provisioning a DevBox rather than spin one up for nothing.
|
|
38
|
+
* - ALWAYS TEAR DOWN: the remote ephemeral clone is removed in a `finally` —
|
|
39
|
+
* success, failure, or noop — so nothing persists on the DevBox either.
|
|
40
|
+
* - EXPLICIT CONSENT ONLY: DevBox is never the default target and is never
|
|
41
|
+
* auto-selected; it only runs when the user picks `devbox` at the choice
|
|
42
|
+
* surface (engine/pr-fix-target.js).
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
const shared = require('./shared');
|
|
46
|
+
const prResolve = require('./pr-resolve');
|
|
47
|
+
const { PrActionError } = require('./pr-action');
|
|
48
|
+
|
|
49
|
+
const DEVBOX_TMP_PREFIX = 'pr-devbox-';
|
|
50
|
+
// DevBoxes are Windows (Windows App, WindowsTerminal, powershell — see the
|
|
51
|
+
// devbox-remote-control skill), so the remote ephemeral-clone root defaults to a
|
|
52
|
+
// Windows temp path. This is a REMOTE path string only — never created locally.
|
|
53
|
+
const DEFAULT_REMOTE_TMP_ROOT = 'C:/minions-devbox-tmp';
|
|
54
|
+
|
|
55
|
+
// ── Ref resolution ────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve `{ url }` (raw PR URL / canonical id), an already-normalized ref, or a
|
|
59
|
+
* routed/devbox plan (carrying `.ref`) into a normalized PR ref. Throws
|
|
60
|
+
* `PrActionError` (400) on missing/unrecognized input — mirrors the other
|
|
61
|
+
* projectless paths so the dashboard reflects a 400, not a 500.
|
|
62
|
+
*/
|
|
63
|
+
function _resolveRef(input) {
|
|
64
|
+
if (input && typeof input === 'object') {
|
|
65
|
+
if (input.host && input.slug && input.number) return input; // already normalized
|
|
66
|
+
if (input.ref && input.ref.host) return input.ref; // plan/handle carrying a ref
|
|
67
|
+
}
|
|
68
|
+
const rawUrl = input && typeof input === 'object'
|
|
69
|
+
? (typeof input.url === 'string' ? input.url.trim() : '')
|
|
70
|
+
: (typeof input === 'string' ? input.trim() : '');
|
|
71
|
+
if (!rawUrl) throw new PrActionError('url required');
|
|
72
|
+
const ref = prResolve.normalizePrRef(rawUrl);
|
|
73
|
+
if (!ref) throw new PrActionError(`unrecognized PR reference: ${JSON.stringify(rawUrl.slice(0, 120))}`);
|
|
74
|
+
return ref;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function _prId(ref) {
|
|
78
|
+
if (!ref) return null;
|
|
79
|
+
return ref.id || (ref.host && ref.slug && ref.number ? `${ref.host}:${ref.slug}#${ref.number}` : null);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Filesystem-safe slug for a PR id (used in the remote dir name). */
|
|
83
|
+
function _safeDirSlug(prId) {
|
|
84
|
+
return String(prId || 'pr').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'pr';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Join a remote root + child into a single forward-slashed REMOTE path. We do NOT
|
|
89
|
+
* use path.join — that would apply the LOCAL separator and risks coupling the
|
|
90
|
+
* remote path to the host OS; the runner interprets the string on the DevBox.
|
|
91
|
+
*/
|
|
92
|
+
function _remoteJoin(root, name) {
|
|
93
|
+
return `${String(root || DEFAULT_REMOTE_TMP_ROOT).replace(/[\\/]+$/, '')}/${name}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Planning ──────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Plan a DevBox fix: resolve the PR ref, fetch its source branch + clone URL
|
|
100
|
+
* (network metadata only), and compute the REMOTE ephemeral clone dir. NOTHING is
|
|
101
|
+
* created on the local machine and NOTHING is cloned here.
|
|
102
|
+
*
|
|
103
|
+
* `input` — `{ url }`, a normalized ref, or a routed plan from
|
|
104
|
+
* engine/pr-fix-target.js#resolvePrFixTarget.
|
|
105
|
+
* `opts.fetchPrBranches` — override the branch fetch (test seam).
|
|
106
|
+
* `opts.prUrl` — original user URL (preferred for surfacing).
|
|
107
|
+
* `opts.remoteTmpRoot` — override the DevBox-side ephemeral-clone root.
|
|
108
|
+
*
|
|
109
|
+
* Returns `{ status:'devbox-planned', target:'devbox', ref, prId, prUrl,
|
|
110
|
+
* sourceBranch, targetBranch, cloneUrl, sourceRepoSlug, isFork, remoteCloneDir,
|
|
111
|
+
* validates:true, localFootprint:false }`.
|
|
112
|
+
* Throws `PrActionError` (400) on bad input; re-throws fetch errors.
|
|
113
|
+
*/
|
|
114
|
+
async function planDevBox(input = {}, opts = {}) {
|
|
115
|
+
const ref = _resolveRef(input);
|
|
116
|
+
const prUrl = opts.prUrl || (input && typeof input === 'object' ? input.url : undefined) || null;
|
|
117
|
+
const prId = _prId(ref);
|
|
118
|
+
|
|
119
|
+
const fetchBranches = opts.fetchPrBranches || prResolve.fetchPrBranches;
|
|
120
|
+
const branches = await fetchBranches(ref, opts);
|
|
121
|
+
const sourceBranch = branches && branches.sourceBranch;
|
|
122
|
+
if (!sourceBranch) throw new PrActionError(`could not resolve source branch for ${prId || 'PR'}`, 502);
|
|
123
|
+
// Defensive re-validation (fetchPrBranches already validates, but a custom seam
|
|
124
|
+
// might not) — a poisoned ref name must never reach `git` on the DevBox.
|
|
125
|
+
shared.validateGitRef(sourceBranch);
|
|
126
|
+
|
|
127
|
+
const remoteCloneDir = _remoteJoin(opts.remoteTmpRoot, `${DEVBOX_TMP_PREFIX}${_safeDirSlug(prId)}-${shared.uid()}`);
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
status: 'devbox-planned',
|
|
131
|
+
target: 'devbox',
|
|
132
|
+
ref,
|
|
133
|
+
prId,
|
|
134
|
+
prUrl,
|
|
135
|
+
sourceBranch,
|
|
136
|
+
targetBranch: branches.targetBranch || null,
|
|
137
|
+
cloneUrl: branches.cloneUrl || null,
|
|
138
|
+
sourceRepoSlug: branches.sourceRepoSlug || ref.slug,
|
|
139
|
+
isFork: !!branches.isFork,
|
|
140
|
+
remoteCloneDir,
|
|
141
|
+
validates: true,
|
|
142
|
+
localFootprint: false,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Execution ─────────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
function _record(plan, fields) {
|
|
149
|
+
return {
|
|
150
|
+
status: 'failed',
|
|
151
|
+
target: 'devbox',
|
|
152
|
+
ref: plan.ref,
|
|
153
|
+
prId: plan.prId,
|
|
154
|
+
prUrl: plan.prUrl,
|
|
155
|
+
sourceBranch: plan.sourceBranch || null,
|
|
156
|
+
remoteCloneDir: plan.remoteCloneDir || null,
|
|
157
|
+
devBox: plan.devBox || null,
|
|
158
|
+
changed: false,
|
|
159
|
+
validated: false,
|
|
160
|
+
validationOk: null,
|
|
161
|
+
pushed: false,
|
|
162
|
+
remoteDiscarded: false,
|
|
163
|
+
localFootprint: false,
|
|
164
|
+
output: '',
|
|
165
|
+
failure_class: null,
|
|
166
|
+
retryable: false,
|
|
167
|
+
error: null,
|
|
168
|
+
...fields,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Execute the DevBox fix lifecycle for a planned (or raw) PR fix. The engine never
|
|
174
|
+
* touches local disk here: it selects/provisions a DevBox, hands the whole
|
|
175
|
+
* clone → fix → validate → push lifecycle to the injected `opts.runOnDevBox`
|
|
176
|
+
* runner (which drives the DevBox MCP), then ALWAYS tears the remote clone down.
|
|
177
|
+
*
|
|
178
|
+
* `planOrInput` — a plan from `planDevBox`, a routed plan from pr-fix-target.js, a
|
|
179
|
+
* normalized ref, or `{ url }`.
|
|
180
|
+
* `opts`:
|
|
181
|
+
* - `runOnDevBox({ devBox, remoteCloneDir, ref, prId, sourceBranch, targetBranch,
|
|
182
|
+
* cloneUrl, isFork })` — REQUIRED. Performs the ENTIRE remote lifecycle on the
|
|
183
|
+
* DevBox (clone the source branch, run the fix, run tests, push to the PR
|
|
184
|
+
* branch). Returns `{ changed, validated, validationOk, pushed, output,
|
|
185
|
+
* failure_class?, retryable?, error? }`. Throws to fail the run.
|
|
186
|
+
* - `selectDevBox()` — OPTIONAL. Resolves/provisions the target DevBox descriptor
|
|
187
|
+
* (`{ name, projectName, devCenterName, locationInUri, powerState }`). Defaults
|
|
188
|
+
* to the DevBox already on the plan, else null (the runner may pick its own).
|
|
189
|
+
* - `teardown(remoteCloneDir, devBox)` — OPTIONAL. Removes the remote ephemeral
|
|
190
|
+
* clone. Best-effort, never throws. Defaults to a no-op that returns true
|
|
191
|
+
* (the runner is expected to clean up its own remote dir).
|
|
192
|
+
*
|
|
193
|
+
* Returns a terminal record. Throws `PrActionError` (400) only for bad input;
|
|
194
|
+
* every downstream failure is captured in a `failed` record (remote clone still
|
|
195
|
+
* torn down).
|
|
196
|
+
*/
|
|
197
|
+
async function executeDevBox(planOrInput = {}, opts = {}) {
|
|
198
|
+
// Fail fast BEFORE selecting/provisioning a DevBox when there is no runner — the
|
|
199
|
+
// remote lifecycle is delegated; spinning up a DevBox for nothing wastes a slot.
|
|
200
|
+
const runOnDevBox = opts.runOnDevBox;
|
|
201
|
+
if (typeof runOnDevBox !== 'function') {
|
|
202
|
+
let ref = null; let prId = null; let prUrl = null;
|
|
203
|
+
try { ref = _resolveRef(planOrInput); prId = _prId(ref); } catch { /* leave null */ }
|
|
204
|
+
prUrl = (planOrInput && typeof planOrInput === 'object' ? (planOrInput.prUrl || planOrInput.url) : null) || null;
|
|
205
|
+
return _record({ ref, prId, prUrl, sourceBranch: null, remoteCloneDir: null }, {
|
|
206
|
+
status: 'failed',
|
|
207
|
+
failure_class: shared.FAILURE_CLASS.CONFIG_ERROR,
|
|
208
|
+
retryable: false,
|
|
209
|
+
error: 'devbox executor requires a DevBox runner (opts.runOnDevBox)',
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Accept an already-built plan, else plan now (resolves branch + remote dir).
|
|
214
|
+
let plan;
|
|
215
|
+
if (planOrInput && planOrInput.status === 'devbox-planned' && planOrInput.remoteCloneDir) {
|
|
216
|
+
plan = planOrInput;
|
|
217
|
+
} else {
|
|
218
|
+
plan = await planDevBox(planOrInput, opts); // PrActionError(400) on bad input propagates
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const teardown = opts.teardown || (async () => true);
|
|
222
|
+
let provisioned = false;
|
|
223
|
+
const rec = _record(plan, {});
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
// 1) Select / provision the DevBox (no local disk touched).
|
|
227
|
+
if (plan.devBox) {
|
|
228
|
+
rec.devBox = plan.devBox;
|
|
229
|
+
} else if (typeof opts.selectDevBox === 'function') {
|
|
230
|
+
try {
|
|
231
|
+
rec.devBox = await opts.selectDevBox();
|
|
232
|
+
provisioned = true;
|
|
233
|
+
} catch (e) {
|
|
234
|
+
rec.failure_class = shared.FAILURE_CLASS.CONFIG_ERROR;
|
|
235
|
+
rec.retryable = true;
|
|
236
|
+
rec.error = `devbox selection failed: ${e?.message || e}`;
|
|
237
|
+
return rec;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// 2) Delegate the ENTIRE remote lifecycle (clone → fix → validate → push).
|
|
242
|
+
let result;
|
|
243
|
+
try {
|
|
244
|
+
result = await runOnDevBox({
|
|
245
|
+
devBox: rec.devBox,
|
|
246
|
+
remoteCloneDir: plan.remoteCloneDir,
|
|
247
|
+
ref: plan.ref,
|
|
248
|
+
prId: plan.prId,
|
|
249
|
+
sourceBranch: plan.sourceBranch,
|
|
250
|
+
targetBranch: plan.targetBranch,
|
|
251
|
+
cloneUrl: plan.cloneUrl,
|
|
252
|
+
isFork: plan.isFork,
|
|
253
|
+
});
|
|
254
|
+
} catch (e) {
|
|
255
|
+
rec.failure_class = shared.FAILURE_CLASS.UNKNOWN;
|
|
256
|
+
rec.retryable = true;
|
|
257
|
+
rec.error = `devbox runner failed: ${e?.message || e}`;
|
|
258
|
+
return rec;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
result = result || {};
|
|
262
|
+
rec.changed = !!result.changed;
|
|
263
|
+
rec.validated = !!result.validated;
|
|
264
|
+
rec.validationOk = result.validationOk == null ? null : !!result.validationOk;
|
|
265
|
+
rec.pushed = !!result.pushed;
|
|
266
|
+
if (typeof result.output === 'string' && result.output) rec.output = result.output;
|
|
267
|
+
|
|
268
|
+
// 3) Classify the outcome the runner reported.
|
|
269
|
+
if (rec.pushed) {
|
|
270
|
+
rec.status = 'done';
|
|
271
|
+
rec.failure_class = null;
|
|
272
|
+
rec.retryable = false;
|
|
273
|
+
rec.error = null;
|
|
274
|
+
return rec;
|
|
275
|
+
}
|
|
276
|
+
if (!rec.changed) {
|
|
277
|
+
// A clean tree is a legit noop — nothing to push.
|
|
278
|
+
rec.status = 'done';
|
|
279
|
+
rec.noop = true;
|
|
280
|
+
rec.failure_class = null;
|
|
281
|
+
rec.retryable = false;
|
|
282
|
+
rec.error = null;
|
|
283
|
+
return rec;
|
|
284
|
+
}
|
|
285
|
+
if (rec.validated && rec.validationOk === false) {
|
|
286
|
+
// Changed but tests failed on the DevBox — not pushed (validate-then-push).
|
|
287
|
+
rec.failure_class = shared.FAILURE_CLASS.BUILD_FAILURE;
|
|
288
|
+
rec.retryable = true;
|
|
289
|
+
rec.error = result.error || 'validation failed on the DevBox — fix not pushed';
|
|
290
|
+
return rec;
|
|
291
|
+
}
|
|
292
|
+
// Changed but the runner did not push and gave no validation verdict — surface
|
|
293
|
+
// whatever it reported.
|
|
294
|
+
rec.failure_class = result.failure_class || shared.FAILURE_CLASS.UNKNOWN;
|
|
295
|
+
rec.retryable = result.retryable != null ? !!result.retryable : true;
|
|
296
|
+
rec.error = result.error || 'devbox fix did not push (no validation verdict)';
|
|
297
|
+
return rec;
|
|
298
|
+
} finally {
|
|
299
|
+
// ALWAYS tear the remote ephemeral clone down — nothing persists on the DevBox.
|
|
300
|
+
if (plan && plan.remoteCloneDir) {
|
|
301
|
+
try { rec.remoteDiscarded = await teardown(plan.remoteCloneDir, rec.devBox); }
|
|
302
|
+
catch (e) {
|
|
303
|
+
shared.log('warn', `pr-devbox: failed to tear down remote clone ${plan.remoteCloneDir}: ${e?.message || e}`);
|
|
304
|
+
rec.remoteDiscarded = false;
|
|
305
|
+
}
|
|
306
|
+
} else {
|
|
307
|
+
rec.remoteDiscarded = true;
|
|
308
|
+
}
|
|
309
|
+
void provisioned; // (kept for readability; teardown above is unconditional)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
module.exports = {
|
|
314
|
+
DEVBOX_TMP_PREFIX,
|
|
315
|
+
DEFAULT_REMOTE_TMP_ROOT,
|
|
316
|
+
planDevBox,
|
|
317
|
+
executeDevBox,
|
|
318
|
+
// Exported for testing.
|
|
319
|
+
_resolveRef,
|
|
320
|
+
_safeDirSlug,
|
|
321
|
+
_remoteJoin,
|
|
322
|
+
};
|