@yemi33/minions 0.1.2196 → 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/modal-qa.js +21 -3
- package/dashboard.js +95 -0
- package/engine/db/migrations/014-pr-fix-target-prefs.js +29 -0
- package/engine/dispatch.js +39 -4
- package/engine/gh-token.js +31 -0
- package/engine/lifecycle.js +21 -7
- 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/engine/shared.js +35 -13
- package/engine.js +4 -6
- package/package.json +1 -1
- package/prompts/cc-system.md +33 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/pr-temp-clone.js — Temp-clone fix executor (the DEFAULT execution target
|
|
3
|
+
* for a projectless one-off PR fix).
|
|
4
|
+
*
|
|
5
|
+
* Plan: projectless-pr-actions, Phase 2, item P-ppa10006. This is the first
|
|
6
|
+
* executor in the chain that actually MUTATES code. The execution-target choice
|
|
7
|
+
* surface (engine/pr-fix-target.js) routes `temp-clone` here once the user has
|
|
8
|
+
* explicitly picked it (the "never clone silently" invariant lives upstream — by
|
|
9
|
+
* the time we run, the user has chosen to clone).
|
|
10
|
+
*
|
|
11
|
+
* Contract (from the choice surface's description): "Ephemeral clone on D:, fix +
|
|
12
|
+
* run tests, push to the PR branch, then discard. Nothing persists."
|
|
13
|
+
*
|
|
14
|
+
* planTempClone(input, opts) — resolve the PR ref + its source branch + a
|
|
15
|
+
* clone URL, and compute the ephemeral clone
|
|
16
|
+
* dir. PLANNING ONLY: nothing is created on
|
|
17
|
+
* disk, nothing is cloned.
|
|
18
|
+
* executeTempClone(plan, opts) — run the full ephemeral lifecycle:
|
|
19
|
+
* clone → fix → validate → commit → push →
|
|
20
|
+
* ALWAYS discard.
|
|
21
|
+
* cleanupTempClone(dir, opts) — idempotent recursive remove of an ephemeral
|
|
22
|
+
* clone dir (Windows file-lock tolerant).
|
|
23
|
+
*
|
|
24
|
+
* Load-bearing invariants (read before extending):
|
|
25
|
+
* - EPHEMERAL: the clone dir lives OUTSIDE the worktree pool / project roots so
|
|
26
|
+
* the worktree GC + lifecycle never touch it, and it is ALWAYS removed in a
|
|
27
|
+
* `finally` — success, failure, or noop. Nothing persists by design.
|
|
28
|
+
* - DELEGATED EDIT: this module owns the ENVIRONMENT (clone, branch, validate,
|
|
29
|
+
* push, discard) but not the edit itself. The actual code change is performed
|
|
30
|
+
* by the injected `opts.runFix` runner (the engine wires a fix-agent spawn in
|
|
31
|
+
* the clone dir). Without a runner there is nothing to execute — we fail fast
|
|
32
|
+
* BEFORE cloning rather than clone-and-discard for nothing.
|
|
33
|
+
* - VALIDATE-THEN-PUSH: when a validator is supplied and it fails, we DO NOT
|
|
34
|
+
* push (the temp-clone target's whole point over remote-patch is that it runs
|
|
35
|
+
* tests). A noop fix (no file changes) also never pushes.
|
|
36
|
+
* - ALL git/network/edit steps are injectable seams so unit tests never clone a
|
|
37
|
+
* real repo; the defaults are real (shellSafeGit / fetchPrBranches).
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const fs = require('fs');
|
|
41
|
+
const path = require('path');
|
|
42
|
+
|
|
43
|
+
const shared = require('./shared');
|
|
44
|
+
const prResolve = require('./pr-resolve');
|
|
45
|
+
const ghToken = require('./gh-token');
|
|
46
|
+
const { PrActionError } = require('./pr-action');
|
|
47
|
+
|
|
48
|
+
const TEMP_CLONE_TMP_PREFIX = 'pr-temp-clone-';
|
|
49
|
+
const CLONE_TIMEOUT_MS = 5 * 60 * 1000; // shallow clones are quick but allow for big repos
|
|
50
|
+
const GIT_OP_TIMEOUT_MS = 60 * 1000;
|
|
51
|
+
|
|
52
|
+
// ── Ref resolution ────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Resolve `{ url }` (raw PR URL / canonical id), an already-normalized ref, or a
|
|
56
|
+
* routed plan (carrying `.ref`) into a normalized PR ref. Throws
|
|
57
|
+
* `PrActionError` (400) on missing/unrecognized input — mirrors the read-only
|
|
58
|
+
* and choice-surface paths so the dashboard reflects a 400, not a 500.
|
|
59
|
+
*/
|
|
60
|
+
function _resolveRef(input) {
|
|
61
|
+
if (input && typeof input === 'object') {
|
|
62
|
+
if (input.host && input.slug && input.number) return input; // already normalized
|
|
63
|
+
if (input.ref && input.ref.host) return input.ref; // plan/handle carrying a ref
|
|
64
|
+
}
|
|
65
|
+
const rawUrl = input && typeof input === 'object'
|
|
66
|
+
? (typeof input.url === 'string' ? input.url.trim() : '')
|
|
67
|
+
: (typeof input === 'string' ? input.trim() : '');
|
|
68
|
+
if (!rawUrl) throw new PrActionError('url required');
|
|
69
|
+
const ref = prResolve.normalizePrRef(rawUrl);
|
|
70
|
+
if (!ref) throw new PrActionError(`unrecognized PR reference: ${JSON.stringify(rawUrl.slice(0, 120))}`);
|
|
71
|
+
return ref;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function _prId(ref) {
|
|
75
|
+
if (!ref) return null;
|
|
76
|
+
return ref.id || (ref.host && ref.slug && ref.number ? `${ref.host}:${ref.slug}#${ref.number}` : null);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Filesystem-safe slug for a PR id (used in the ephemeral dir name). */
|
|
80
|
+
function _safeDirSlug(prId) {
|
|
81
|
+
return String(prId || 'pr').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'pr';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The ephemeral-clone root — on the working drive, OUTSIDE the worktree pool. */
|
|
85
|
+
function _tmpRoot(opts = {}) {
|
|
86
|
+
return opts.tmpRoot || path.join(shared.MINIONS_DIR, 'engine', 'tmp', 'pr-temp-clones');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Planning ──────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Plan a temp-clone fix: resolve the PR ref, fetch its source branch + clone URL,
|
|
93
|
+
* and compute the ephemeral clone dir. NOTHING is created or cloned here.
|
|
94
|
+
*
|
|
95
|
+
* `input` — `{ url }`, a normalized ref, or a routed plan from
|
|
96
|
+
* engine/pr-fix-target.js#resolvePrFixTarget.
|
|
97
|
+
* `opts.fetchPrBranches` — override the branch fetch (test seam).
|
|
98
|
+
* `opts.prUrl` — original user URL (preferred for surfacing).
|
|
99
|
+
* `opts.tmpRoot` — override the ephemeral-clone root.
|
|
100
|
+
*
|
|
101
|
+
* Returns `{ status:'temp-clone-planned', target:'temp-clone', ref, prId, prUrl,
|
|
102
|
+
* sourceBranch, targetBranch, cloneUrl, sourceRepoSlug, isFork, cloneDir,
|
|
103
|
+
* validates:true }`.
|
|
104
|
+
* Throws `PrActionError` (400) on bad input; re-throws fetch errors.
|
|
105
|
+
*/
|
|
106
|
+
async function planTempClone(input = {}, opts = {}) {
|
|
107
|
+
const ref = _resolveRef(input);
|
|
108
|
+
const prUrl = opts.prUrl || (input && typeof input === 'object' ? input.url : undefined) || null;
|
|
109
|
+
const prId = _prId(ref);
|
|
110
|
+
|
|
111
|
+
const fetchBranches = opts.fetchPrBranches || prResolve.fetchPrBranches;
|
|
112
|
+
const branches = await fetchBranches(ref, opts);
|
|
113
|
+
const sourceBranch = branches && branches.sourceBranch;
|
|
114
|
+
if (!sourceBranch) throw new PrActionError(`could not resolve source branch for ${prId || 'PR'}`, 502);
|
|
115
|
+
// Defensive re-validation (fetchPrBranches already validates, but a custom seam
|
|
116
|
+
// might not) — a poisoned ref name must never reach `git`.
|
|
117
|
+
shared.validateGitRef(sourceBranch);
|
|
118
|
+
|
|
119
|
+
const cloneDir = path.join(_tmpRoot(opts), `${TEMP_CLONE_TMP_PREFIX}${_safeDirSlug(prId)}-${shared.uid()}`);
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
status: 'temp-clone-planned',
|
|
123
|
+
target: 'temp-clone',
|
|
124
|
+
ref,
|
|
125
|
+
prId,
|
|
126
|
+
prUrl,
|
|
127
|
+
sourceBranch,
|
|
128
|
+
targetBranch: branches.targetBranch || null,
|
|
129
|
+
cloneUrl: branches.cloneUrl || null,
|
|
130
|
+
sourceRepoSlug: branches.sourceRepoSlug || ref.slug,
|
|
131
|
+
isFork: !!branches.isFork,
|
|
132
|
+
cloneDir,
|
|
133
|
+
validates: true,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Default git seams (real implementations; tests inject their own) ───────────
|
|
138
|
+
|
|
139
|
+
/** Resolve an auth token for the PR's host (GitHub PAT per-slug / ADO bearer). */
|
|
140
|
+
async function _resolveToken(plan, opts = {}) {
|
|
141
|
+
if (opts.token) return opts.token;
|
|
142
|
+
if (plan.ref.host === 'github') {
|
|
143
|
+
const resolve = opts._resolveTokenForSlug || ghToken.resolveTokenForSlug;
|
|
144
|
+
return resolve(plan.sourceRepoSlug || plan.ref.slug) || null;
|
|
145
|
+
}
|
|
146
|
+
if (plan.ref.host === 'ado') {
|
|
147
|
+
const acquire = opts._acquireAdoToken || require('./ado-token').acquireAdoToken;
|
|
148
|
+
const acquired = await acquire();
|
|
149
|
+
return (acquired && acquired.token) || null;
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Build an HTTPS clone URL with an embedded GitHub token (forks + private). */
|
|
155
|
+
function _authedGithubUrl(cloneUrl, token) {
|
|
156
|
+
if (!token) return cloneUrl;
|
|
157
|
+
try {
|
|
158
|
+
const u = new URL(cloneUrl);
|
|
159
|
+
u.username = 'x-access-token';
|
|
160
|
+
u.password = token;
|
|
161
|
+
return u.toString();
|
|
162
|
+
} catch {
|
|
163
|
+
return cloneUrl;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Per-invocation git auth args. ADO injects a bearer header; GitHub embeds in URL. */
|
|
168
|
+
function _gitAuthArgs(plan, token) {
|
|
169
|
+
if (plan.ref.host === 'ado' && token) {
|
|
170
|
+
return ['-c', `http.extraHeader=Authorization: Bearer ${token}`];
|
|
171
|
+
}
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function _defaultGitClone(plan, token, opts = {}) {
|
|
176
|
+
const runGit = opts._shellSafeGit || shared.shellSafeGit;
|
|
177
|
+
const url = plan.ref.host === 'github' ? _authedGithubUrl(plan.cloneUrl, token) : plan.cloneUrl;
|
|
178
|
+
await runGit(
|
|
179
|
+
['clone', '--depth', '1', '--single-branch', '--branch', plan.sourceBranch, url, plan.cloneDir],
|
|
180
|
+
{ timeout: CLONE_TIMEOUT_MS, gitExtraArgs: _gitAuthArgs(plan, token) },
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** True when the working tree has staged/unstaged changes after the fix runs. */
|
|
185
|
+
async function _defaultHasChanges(cloneDir, opts = {}) {
|
|
186
|
+
const runGit = opts._shellSafeGit || shared.shellSafeGit;
|
|
187
|
+
const out = await runGit(['-C', cloneDir, 'status', '--porcelain'], { timeout: GIT_OP_TIMEOUT_MS });
|
|
188
|
+
return String(out || '').trim().length > 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function _defaultCommit(plan, opts = {}) {
|
|
192
|
+
const runGit = opts._shellSafeGit || shared.shellSafeGit;
|
|
193
|
+
await runGit(['-C', plan.cloneDir, 'add', '-A'], { timeout: GIT_OP_TIMEOUT_MS });
|
|
194
|
+
await runGit(
|
|
195
|
+
['-C', plan.cloneDir,
|
|
196
|
+
'-c', 'user.email=minions@localhost',
|
|
197
|
+
'-c', 'user.name=Minions',
|
|
198
|
+
'commit', '-m', `Fix ${plan.prId || plan.sourceBranch}`],
|
|
199
|
+
{ timeout: GIT_OP_TIMEOUT_MS },
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function _defaultGitPush(plan, token, opts = {}) {
|
|
204
|
+
const runGit = opts._shellSafeGit || shared.shellSafeGit;
|
|
205
|
+
await runGit(
|
|
206
|
+
['-C', plan.cloneDir, 'push', 'origin', `HEAD:${plan.sourceBranch}`],
|
|
207
|
+
{ timeout: GIT_OP_TIMEOUT_MS, gitExtraArgs: _gitAuthArgs(plan, token) },
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── Cleanup ───────────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Idempotently remove an ephemeral clone dir. Windows file-lock tolerant via
|
|
215
|
+
* fs.rm's built-in retry. Returns true on success (or already-gone), false if it
|
|
216
|
+
* could not be removed. NEVER throws — cleanup runs in a `finally` and must not
|
|
217
|
+
* mask the real result.
|
|
218
|
+
*/
|
|
219
|
+
async function cleanupTempClone(dir, opts = {}) {
|
|
220
|
+
if (!dir) return true;
|
|
221
|
+
const rm = opts._rm || ((d) => fs.promises.rm(d, { recursive: true, force: true, maxRetries: 6, retryDelay: 200 }));
|
|
222
|
+
try {
|
|
223
|
+
await rm(dir);
|
|
224
|
+
return true;
|
|
225
|
+
} catch (e) {
|
|
226
|
+
shared.log('warn', `pr-temp-clone: failed to discard ephemeral clone ${dir}: ${e?.message || e}`);
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── Execution ─────────────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
function _record(plan, fields) {
|
|
234
|
+
return {
|
|
235
|
+
status: 'failed',
|
|
236
|
+
target: 'temp-clone',
|
|
237
|
+
ref: plan.ref,
|
|
238
|
+
prId: plan.prId,
|
|
239
|
+
prUrl: plan.prUrl,
|
|
240
|
+
sourceBranch: plan.sourceBranch,
|
|
241
|
+
cloneDir: plan.cloneDir,
|
|
242
|
+
changed: false,
|
|
243
|
+
validated: false,
|
|
244
|
+
validationOk: null,
|
|
245
|
+
pushed: false,
|
|
246
|
+
discarded: false,
|
|
247
|
+
output: '',
|
|
248
|
+
failure_class: null,
|
|
249
|
+
retryable: false,
|
|
250
|
+
error: null,
|
|
251
|
+
...fields,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Execute the temp-clone fix lifecycle for a planned (or raw) PR fix:
|
|
257
|
+
* clone the PR's source branch into an ephemeral dir, run the injected fix
|
|
258
|
+
* runner, validate, commit + push to the PR branch, then ALWAYS discard the
|
|
259
|
+
* clone.
|
|
260
|
+
*
|
|
261
|
+
* `planOrInput` — a plan from `planTempClone`, a routed plan from
|
|
262
|
+
* pr-fix-target.js, a normalized ref, or `{ url }`.
|
|
263
|
+
* `opts`:
|
|
264
|
+
* - `runFix({ cloneDir, ref, prId, sourceBranch })` — REQUIRED. Performs the
|
|
265
|
+
* edits in `cloneDir`. May return `{ changed, summary }` (advisory — the real
|
|
266
|
+
* change signal is `git status`). Throws to fail the fix.
|
|
267
|
+
* - `validate({ cloneDir, ref, prId, sourceBranch })` — OPTIONAL. Returns
|
|
268
|
+
* `{ ok, output }`. When supplied and `ok === false`, the fix is NOT pushed.
|
|
269
|
+
* - test seams: `gitClone`, `hasChanges`, `commit`, `gitPush`, `cleanup`,
|
|
270
|
+
* `fetchPrBranches`, `token`/`_resolveTokenForSlug`/`_acquireAdoToken`.
|
|
271
|
+
*
|
|
272
|
+
* Returns a terminal record. Throws `PrActionError` (400) only for bad input;
|
|
273
|
+
* every downstream failure is captured in a `failed` record (clone still
|
|
274
|
+
* discarded).
|
|
275
|
+
*/
|
|
276
|
+
async function executeTempClone(planOrInput = {}, opts = {}) {
|
|
277
|
+
// Fail fast BEFORE any clone when there is no edit runner — temp-clone is
|
|
278
|
+
// delegated-edit; cloning-then-discarding for nothing wastes disk + network.
|
|
279
|
+
const runFix = opts.runFix;
|
|
280
|
+
if (typeof runFix !== 'function') {
|
|
281
|
+
// Resolve identity for the record without a network fetch where possible.
|
|
282
|
+
let ref = null; let prId = null; let prUrl = null;
|
|
283
|
+
try { ref = _resolveRef(planOrInput); prId = _prId(ref); } catch { /* leave null */ }
|
|
284
|
+
prUrl = (planOrInput && typeof planOrInput === 'object' ? (planOrInput.prUrl || planOrInput.url) : null) || null;
|
|
285
|
+
return _record({ ref, prId, prUrl, sourceBranch: null, cloneDir: null }, {
|
|
286
|
+
status: 'failed',
|
|
287
|
+
failure_class: shared.FAILURE_CLASS.CONFIG_ERROR,
|
|
288
|
+
retryable: false,
|
|
289
|
+
error: 'temp-clone executor requires a fix runner (opts.runFix)',
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Accept an already-built plan, else plan now (resolves branch + clone dir).
|
|
294
|
+
let plan;
|
|
295
|
+
if (planOrInput && planOrInput.status === 'temp-clone-planned' && planOrInput.cloneDir) {
|
|
296
|
+
plan = planOrInput;
|
|
297
|
+
} else {
|
|
298
|
+
plan = await planTempClone(planOrInput, opts); // PrActionError(400) on bad input propagates
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const gitClone = opts.gitClone || ((p, token) => _defaultGitClone(p, token, opts));
|
|
302
|
+
const hasChanges = opts.hasChanges || ((dir) => _defaultHasChanges(dir, opts));
|
|
303
|
+
const commit = opts.commit || ((p) => _defaultCommit(p, opts));
|
|
304
|
+
const gitPush = opts.gitPush || ((p, token) => _defaultGitPush(p, token, opts));
|
|
305
|
+
const cleanup = opts.cleanup || ((dir) => cleanupTempClone(dir, opts));
|
|
306
|
+
|
|
307
|
+
let token = null;
|
|
308
|
+
let cloned = false;
|
|
309
|
+
const rec = _record(plan, {});
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
token = await _resolveToken(plan, opts);
|
|
313
|
+
|
|
314
|
+
// 1) Ephemeral clone of the PR's source branch.
|
|
315
|
+
try {
|
|
316
|
+
await fs.promises.mkdir(path.dirname(plan.cloneDir), { recursive: true });
|
|
317
|
+
await gitClone(plan, token);
|
|
318
|
+
cloned = true;
|
|
319
|
+
} catch (e) {
|
|
320
|
+
rec.failure_class = shared.FAILURE_CLASS.NETWORK_ERROR;
|
|
321
|
+
rec.retryable = true;
|
|
322
|
+
rec.error = `clone failed: ${e?.message || e}`;
|
|
323
|
+
return rec;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// 2) The delegated edit.
|
|
327
|
+
let fixResult;
|
|
328
|
+
try {
|
|
329
|
+
fixResult = await runFix({ cloneDir: plan.cloneDir, ref: plan.ref, prId: plan.prId, sourceBranch: plan.sourceBranch });
|
|
330
|
+
} catch (e) {
|
|
331
|
+
rec.failure_class = shared.FAILURE_CLASS.UNKNOWN;
|
|
332
|
+
rec.retryable = true;
|
|
333
|
+
rec.error = `fix runner failed: ${e?.message || e}`;
|
|
334
|
+
return rec;
|
|
335
|
+
}
|
|
336
|
+
if (fixResult && typeof fixResult.summary === 'string') rec.output = fixResult.summary;
|
|
337
|
+
|
|
338
|
+
// 3) Did the fix actually change anything? A clean tree is a legit noop.
|
|
339
|
+
let changed;
|
|
340
|
+
try {
|
|
341
|
+
changed = await hasChanges(plan.cloneDir);
|
|
342
|
+
} catch (e) {
|
|
343
|
+
rec.failure_class = shared.FAILURE_CLASS.UNKNOWN;
|
|
344
|
+
rec.retryable = true;
|
|
345
|
+
rec.error = `git status failed: ${e?.message || e}`;
|
|
346
|
+
return rec;
|
|
347
|
+
}
|
|
348
|
+
rec.changed = changed;
|
|
349
|
+
if (!changed) {
|
|
350
|
+
rec.status = 'done';
|
|
351
|
+
rec.noop = true;
|
|
352
|
+
rec.failure_class = null;
|
|
353
|
+
rec.retryable = false;
|
|
354
|
+
rec.error = null;
|
|
355
|
+
return rec;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// 4) Validate (run tests) BEFORE pushing — temp-clone's defining feature.
|
|
359
|
+
if (typeof opts.validate === 'function') {
|
|
360
|
+
rec.validated = true;
|
|
361
|
+
let v;
|
|
362
|
+
try {
|
|
363
|
+
v = await opts.validate({ cloneDir: plan.cloneDir, ref: plan.ref, prId: plan.prId, sourceBranch: plan.sourceBranch });
|
|
364
|
+
} catch (e) {
|
|
365
|
+
v = { ok: false, output: `validation threw: ${e?.message || e}` };
|
|
366
|
+
}
|
|
367
|
+
rec.validationOk = !!(v && v.ok);
|
|
368
|
+
if (v && typeof v.output === 'string' && v.output) rec.output = v.output;
|
|
369
|
+
if (!rec.validationOk) {
|
|
370
|
+
rec.failure_class = shared.FAILURE_CLASS.BUILD_FAILURE;
|
|
371
|
+
rec.retryable = true;
|
|
372
|
+
rec.error = 'validation failed — fix not pushed';
|
|
373
|
+
return rec;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// 5) Commit + push to the PR's source branch.
|
|
378
|
+
try {
|
|
379
|
+
await commit(plan);
|
|
380
|
+
await gitPush(plan, token);
|
|
381
|
+
rec.pushed = true;
|
|
382
|
+
} catch (e) {
|
|
383
|
+
rec.failure_class = shared.FAILURE_CLASS.AUTH;
|
|
384
|
+
rec.retryable = true;
|
|
385
|
+
rec.error = `push failed: ${e?.message || e}`;
|
|
386
|
+
return rec;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
rec.status = 'done';
|
|
390
|
+
rec.failure_class = null;
|
|
391
|
+
rec.retryable = false;
|
|
392
|
+
rec.error = null;
|
|
393
|
+
return rec;
|
|
394
|
+
} finally {
|
|
395
|
+
// ALWAYS discard the ephemeral clone — nothing persists.
|
|
396
|
+
if (cloned || (plan && plan.cloneDir)) {
|
|
397
|
+
rec.discarded = await cleanup(plan.cloneDir);
|
|
398
|
+
} else {
|
|
399
|
+
rec.discarded = true;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
module.exports = {
|
|
405
|
+
TEMP_CLONE_TMP_PREFIX,
|
|
406
|
+
planTempClone,
|
|
407
|
+
executeTempClone,
|
|
408
|
+
cleanupTempClone,
|
|
409
|
+
// Exported for testing.
|
|
410
|
+
_resolveRef,
|
|
411
|
+
_safeDirSlug,
|
|
412
|
+
_authedGithubUrl,
|
|
413
|
+
_gitAuthArgs,
|
|
414
|
+
};
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/pr-track.js — Promote a one-off PR to TRACKED auto-fix (enrollment
|
|
3
|
+
* wiring).
|
|
4
|
+
*
|
|
5
|
+
* Plan: projectless-pr-actions, Phase 3, item P-ppa10010. The "Track for auto-fix"
|
|
6
|
+
* follow-up chip (P-ppa10004) enrolls a PR the user previously ran a read-only
|
|
7
|
+
* pr-action on (P-ppa10003) into the EXISTING auto-managed pipeline:
|
|
8
|
+
* pollers → PR discovery → review / fix / re-review → auto-merge.
|
|
9
|
+
*
|
|
10
|
+
* There is NO new fix engine here — enrollment is "mostly wiring":
|
|
11
|
+
* 1. The canonical auto-managed signal is a SINGLE field: `contextOnly:false`
|
|
12
|
+
* (`shared.isAutoManagedPrRecord` — engine/shared.js). Enrolling a PR means
|
|
13
|
+
* ensuring a tracked record exists with `contextOnly:false` so the existing
|
|
14
|
+
* PR-discovery auto-managed gate flips on.
|
|
15
|
+
* 2. If the PR's repo is NOT a configured project, ongoing auto-fix needs a
|
|
16
|
+
* PERSISTENT checkout, so promotion surfaces the SAME execution-target
|
|
17
|
+
* choice as a one-off fix (engine/pr-fix-target.js, P-ppa10005) but with
|
|
18
|
+
* "Clone & keep" (engine/pr-clone-keep.js, P-ppa10007 — the persistent
|
|
19
|
+
* project executor) as the implied/default selection. NOTHING is cloned
|
|
20
|
+
* until the user picks.
|
|
21
|
+
* 3. Once the repo is project-ready (already configured, or after Clone & keep
|
|
22
|
+
* registers it), enrollment links the PR with `contextOnly:false` and the
|
|
23
|
+
* existing machinery takes over unchanged.
|
|
24
|
+
*
|
|
25
|
+
* Load-bearing invariants (read before extending):
|
|
26
|
+
* - NO new engine: this module registers no poller / timer / discovery surface
|
|
27
|
+
* and introduces no fix executor. A source-inspection test enforces it.
|
|
28
|
+
* - NEVER clone silently: the unconfigured-repo branch reuses the choice surface
|
|
29
|
+
* and PAUSES; this module imports no clone / worktree / spawn primitive.
|
|
30
|
+
* - "Clone & keep" implied: temp-clone is ephemeral (the clone is discarded after
|
|
31
|
+
* one push), so it is NOT a valid ongoing-tracking target — the recommended /
|
|
32
|
+
* default selection for promotion is `clone-keep`.
|
|
33
|
+
* - The actual record write is done by the existing manual-link path
|
|
34
|
+
* (dashboard.js#linkPullRequestForTracking → shared.upsertPullRequestRecord),
|
|
35
|
+
* which both creates a missing record and PROMOTES an existing context-only
|
|
36
|
+
* record to auto-managed (upsert refuses to DEMOTE a managed PR). This module
|
|
37
|
+
* only computes the canonical enrollment signal — it does not duplicate the
|
|
38
|
+
* project-resolution / persistence logic that link already owns.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
const prResolve = require('./pr-resolve');
|
|
42
|
+
const prFixTarget = require('./pr-fix-target');
|
|
43
|
+
const { PrActionError } = require('./pr-action');
|
|
44
|
+
|
|
45
|
+
// For ongoing auto-fix the checkout must persist across ticks, so "Clone & keep"
|
|
46
|
+
// (engine/pr-clone-keep.js, P-ppa10007) is the implied selection when promoting a
|
|
47
|
+
// PR whose repo is not yet a configured project. temp-clone discards its clone
|
|
48
|
+
// after one push, so it can never back ongoing tracking.
|
|
49
|
+
const TRACK_RECOMMENDED_TARGET = 'clone-keep';
|
|
50
|
+
|
|
51
|
+
// ── Ref resolution ──────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve `{ url }` (raw PR URL / canonical id), an already-normalized ref, or a
|
|
55
|
+
* plan carrying `.ref` into a normalized PR ref. Throws `PrActionError` (400) on
|
|
56
|
+
* missing/unrecognized input so the dashboard reflects a 400 — mirrors the
|
|
57
|
+
* read-only and choice-surface paths.
|
|
58
|
+
*/
|
|
59
|
+
function _resolveRef(input) {
|
|
60
|
+
if (input && typeof input === 'object') {
|
|
61
|
+
if (input.host && input.slug && input.number) return input; // already normalized
|
|
62
|
+
if (input.ref && input.ref.host) return input.ref; // plan/handle carrying a ref
|
|
63
|
+
}
|
|
64
|
+
const rawUrl = input && typeof input === 'object'
|
|
65
|
+
? (typeof input.url === 'string' ? input.url.trim() : '')
|
|
66
|
+
: (typeof input === 'string' ? input.trim() : '');
|
|
67
|
+
if (!rawUrl) throw new PrActionError('url required');
|
|
68
|
+
const ref = prResolve.normalizePrRef(rawUrl);
|
|
69
|
+
if (!ref) throw new PrActionError(`unrecognized PR reference: ${JSON.stringify(rawUrl.slice(0, 120))}`);
|
|
70
|
+
return ref;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function _prId(ref) {
|
|
74
|
+
if (!ref) return null;
|
|
75
|
+
return ref.id || (ref.host && ref.slug && ref.number ? `${ref.host}:${ref.slug}#${ref.number}` : null);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Choice surface (Clone & keep implied) ─────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The Command Center text presenting the promotion choice for an unconfigured
|
|
82
|
+
* repo. It is the fix choice surface, re-flavored for ongoing tracking: Clone &
|
|
83
|
+
* keep is recommended because temp clone is discarded after one push.
|
|
84
|
+
*/
|
|
85
|
+
function buildPrTrackTargetPrompt(plan) {
|
|
86
|
+
const prId = (plan && (plan.prId || (plan.ref && plan.ref.id))) || 'this pull request';
|
|
87
|
+
const lines = [
|
|
88
|
+
`Tracking ${prId} for auto-fix needs a PERSISTENT checkout, but its repo is not a configured project.`,
|
|
89
|
+
'Choose an execution target before anything is cloned (nothing is cloned until you pick):',
|
|
90
|
+
'',
|
|
91
|
+
];
|
|
92
|
+
for (const t of prFixTarget.EXECUTION_TARGETS) {
|
|
93
|
+
const tag = t.id === TRACK_RECOMMENDED_TARGET
|
|
94
|
+
? ' [recommended for ongoing tracking]'
|
|
95
|
+
: (t.id === prFixTarget.DEFAULT_EXECUTION_TARGET ? ' [one-off default — discarded after one push]' : (t.trivialOnly ? ' [trivial only]' : ''));
|
|
96
|
+
lines.push(`- ${t.label}${tag} — ${t.description}`);
|
|
97
|
+
}
|
|
98
|
+
lines.push('');
|
|
99
|
+
lines.push(`Reply with one of: ${prFixTarget.EXECUTION_TARGET_IDS.join(', ')} (recommended: ${TRACK_RECOMMENDED_TARGET}). "Clone & keep" registers a persistent project so the existing pollers/auto-fix loop can track this PR going forward; temp clone leaves nothing behind and cannot back ongoing tracking.`);
|
|
100
|
+
return lines.join('\n');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── State machine ─────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Plan the promotion of a PR to tracked auto-fix. The decision/routing entry
|
|
107
|
+
* point — it does NOT write anything.
|
|
108
|
+
*
|
|
109
|
+
* Delegates configured-project detection + the choice surface to
|
|
110
|
+
* engine/pr-fix-target.js (so the "never clone silently" invariant + the
|
|
111
|
+
* configured-vs-unconfigured logic live in exactly one place), then re-flavors
|
|
112
|
+
* the unconfigured branch for ongoing tracking (Clone & keep recommended).
|
|
113
|
+
*
|
|
114
|
+
* `input` — `{ url }` (raw PR URL / canonical id) or an already-normalized ref.
|
|
115
|
+
* `opts.config` — full config (for configured-project detection).
|
|
116
|
+
* `opts.prUrl` — original user-supplied URL (preferred in the surfaces).
|
|
117
|
+
*
|
|
118
|
+
* Returns one of:
|
|
119
|
+
* - `{ status:'project-ready', ref, prId, prUrl, project, projectName,
|
|
120
|
+
* recommendedTarget }` — the repo IS already a configured project; the
|
|
121
|
+
* caller can enroll immediately (link with contextOnly:false). No clone.
|
|
122
|
+
* - `{ status:'awaiting-target-choice', ref, prId, prUrl, options, default,
|
|
123
|
+
* recommendedTarget, reason, prompt, modal }` — the repo is unconfigured;
|
|
124
|
+
* PAUSE for the user to pick a persistent target. NOTHING is cloned here.
|
|
125
|
+
*
|
|
126
|
+
* Throws `PrActionError` (400) on bad input.
|
|
127
|
+
*/
|
|
128
|
+
function planPrTrack(input = {}, opts = {}) {
|
|
129
|
+
const fixPlan = prFixTarget.planPrFix(input, opts);
|
|
130
|
+
|
|
131
|
+
if (fixPlan.status === 'project-ready') {
|
|
132
|
+
return {
|
|
133
|
+
status: 'project-ready',
|
|
134
|
+
ref: fixPlan.ref,
|
|
135
|
+
prId: fixPlan.prId,
|
|
136
|
+
prUrl: fixPlan.prUrl,
|
|
137
|
+
project: fixPlan.project,
|
|
138
|
+
projectName: fixPlan.projectName,
|
|
139
|
+
recommendedTarget: TRACK_RECOMMENDED_TARGET,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Unconfigured repo — reuse the SAME choice surface but recommend Clone & keep
|
|
144
|
+
// (ongoing tracking needs persistence). We override the default but keep the
|
|
145
|
+
// full option set so the user can still pick temp-clone/remote-patch knowingly.
|
|
146
|
+
const modal = {
|
|
147
|
+
...fixPlan.modal,
|
|
148
|
+
default: TRACK_RECOMMENDED_TARGET,
|
|
149
|
+
recommendedTarget: TRACK_RECOMMENDED_TARGET,
|
|
150
|
+
title: 'Track for auto-fix — choose execution target',
|
|
151
|
+
message: 'Tracking this PR for ongoing auto-fix needs a persistent checkout. "Clone & keep" registers a project so the existing pollers/auto-fix loop can track it; temp clone is discarded after one push and cannot back ongoing tracking. Nothing is cloned until you choose.',
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
status: 'awaiting-target-choice',
|
|
156
|
+
ref: fixPlan.ref,
|
|
157
|
+
prId: fixPlan.prId,
|
|
158
|
+
prUrl: fixPlan.prUrl,
|
|
159
|
+
options: fixPlan.options,
|
|
160
|
+
default: TRACK_RECOMMENDED_TARGET,
|
|
161
|
+
recommendedTarget: TRACK_RECOMMENDED_TARGET,
|
|
162
|
+
reason: 'Ongoing auto-fix needs a persistent checkout, so "Clone & keep" is recommended (temp clone is discarded after one push).',
|
|
163
|
+
prompt: buildPrTrackTargetPrompt(fixPlan),
|
|
164
|
+
modal,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Build the canonical enrollment signal for a PR (or plan/ref). Enrollment is a
|
|
170
|
+
* single field: `contextOnly:false` — that is the ONE thing
|
|
171
|
+
* `shared.isAutoManagedPrRecord` reads to decide a PR is auto-managed. The caller
|
|
172
|
+
* feeds `{ url, contextOnly }` to the existing manual-link path
|
|
173
|
+
* (dashboard.js#linkPullRequestForTracking), which creates the record if missing
|
|
174
|
+
* and promotes an existing context-only record (upsert refuses to demote).
|
|
175
|
+
*
|
|
176
|
+
* `observe:true` is included as the public/back-compat inverse used by
|
|
177
|
+
* `POST /api/pull-requests/observe` (observe → contextOnly = !observe).
|
|
178
|
+
*
|
|
179
|
+
* Returns `{ host, slug, number, prId, prUrl, url, contextOnly:false,
|
|
180
|
+
* observe:true }`. Throws `PrActionError` (400) on bad input.
|
|
181
|
+
*/
|
|
182
|
+
function buildTrackEnrollment(input = {}, opts = {}) {
|
|
183
|
+
const ref = _resolveRef(input);
|
|
184
|
+
const prUrl = opts.prUrl
|
|
185
|
+
|| (input && typeof input === 'object' ? (input.prUrl || input.url) : undefined)
|
|
186
|
+
|| null;
|
|
187
|
+
const prId = _prId(ref);
|
|
188
|
+
return {
|
|
189
|
+
host: ref.host,
|
|
190
|
+
slug: ref.slug,
|
|
191
|
+
number: ref.number,
|
|
192
|
+
prId,
|
|
193
|
+
prUrl,
|
|
194
|
+
url: prUrl || prId,
|
|
195
|
+
// Canonical auto-managed signal (shared.isAutoManagedPrRecord).
|
|
196
|
+
contextOnly: false,
|
|
197
|
+
// Public/back-compat inverse for POST /api/pull-requests/observe.
|
|
198
|
+
observe: true,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
module.exports = {
|
|
203
|
+
TRACK_RECOMMENDED_TARGET,
|
|
204
|
+
planPrTrack,
|
|
205
|
+
buildTrackEnrollment,
|
|
206
|
+
buildPrTrackTargetPrompt,
|
|
207
|
+
// Exported for testing.
|
|
208
|
+
_resolveRef,
|
|
209
|
+
};
|