@yemi33/minions 0.1.2197 → 0.1.2199

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.
@@ -0,0 +1,384 @@
1
+ /**
2
+ * engine/pr-remote-patch.js — Remote patch fast path (the "no validation"
3
+ * execution target for a projectless one-off PR fix).
4
+ *
5
+ * Plan: projectless-pr-actions, Phase 2, item P-ppa10008. The execution-target
6
+ * choice surface (engine/pr-fix-target.js) routes `remote-patch` here once the
7
+ * user has EXPLICITLY picked it (the "never clone silently" invariant lives
8
+ * upstream — by the time we run, the user has consented to a no-clone push).
9
+ *
10
+ * Contract (from the choice surface's description): "Trivial single-file edits
11
+ * only via the GitHub Contents API — no clone and NO test run. Never use for
12
+ * non-trivial changes." This is the deliberately fast, deliberately unsafe path:
13
+ * it applies ONE file edit straight through the GitHub Contents API and pushes a
14
+ * commit to the PR's source branch without ever cloning or running tests. Every
15
+ * terminal record is stamped `noValidation:true` + a human label so the UI can
16
+ * make the lack of validation unmistakable.
17
+ *
18
+ * planRemotePatch(input, opts) — resolve the PR ref + its source branch and
19
+ * normalize/validate the single-file patch.
20
+ * PLANNING ONLY: no network mutation.
21
+ * executeRemotePatch(plan, opts) — GET the file's current blob sha, then PUT
22
+ * the new content via the Contents API. No
23
+ * clone, no worktree, no tests, no cleanup.
24
+ *
25
+ * Load-bearing invariants (read before extending):
26
+ * - GITHUB-ONLY: ADO has no equivalent simple "edit one file" Contents API path
27
+ * without a clone, so the fast path refuses ADO refs at plan time with a clear
28
+ * error (documented unsupported — use temp-clone / clone-keep for ADO).
29
+ * - TRIVIAL SINGLE-FILE ONLY: the patch MUST resolve to exactly one file. A
30
+ * multi-file / non-trivial patch is refused at plan time (the whole guardrail
31
+ * of this target) — it never reaches the network.
32
+ * - NO VALIDATION: there is no clone and no test run by design. Records carry
33
+ * `validated:false`, `noValidation:true`, and `label` so the result is clearly
34
+ * labeled "no validation" everywhere it surfaces.
35
+ * - NO CLONE, NO CLEANUP: nothing is written to disk, so unlike temp-clone there
36
+ * is no ephemeral dir to discard.
37
+ * - All git/network steps are injectable seams so unit tests never hit a real
38
+ * repo; the defaults are real (shellSafeGh + fetchPrBranches).
39
+ */
40
+
41
+ const shared = require('./shared');
42
+ const prResolve = require('./pr-resolve');
43
+ const ghToken = require('./gh-token');
44
+ const { PrActionError } = require('./pr-action');
45
+
46
+ const GH_API_TIMEOUT_MS = 30000;
47
+ const GH_MAX_BUFFER = 20 * 1024 * 1024; // a file blob can be large
48
+
49
+ // Stamped onto every terminal record so the "no validation" nature of this path
50
+ // is unmistakable in CC, the dashboard, and any downstream log.
51
+ const REMOTE_PATCH_NO_VALIDATION_LABEL =
52
+ 'no validation — applied via the GitHub Contents API with no clone and no test run';
53
+
54
+ // ── Ref resolution ────────────────────────────────────────────────────────────
55
+
56
+ /**
57
+ * Resolve `{ url }` (raw PR URL / canonical id), an already-normalized ref, or a
58
+ * routed plan (carrying `.ref`) into a normalized PR ref. Throws
59
+ * `PrActionError` (400) on missing/unrecognized input — mirrors the read-only,
60
+ * choice-surface, and temp-clone paths so the dashboard reflects a 400, not 500.
61
+ */
62
+ function _resolveRef(input) {
63
+ if (input && typeof input === 'object') {
64
+ if (input.host && input.slug && input.number) return input; // already normalized
65
+ if (input.ref && input.ref.host) return input.ref; // plan/handle carrying a ref
66
+ }
67
+ const rawUrl = input && typeof input === 'object'
68
+ ? (typeof input.url === 'string' ? input.url.trim() : '')
69
+ : (typeof input === 'string' ? input.trim() : '');
70
+ if (!rawUrl) throw new PrActionError('url required');
71
+ const ref = prResolve.normalizePrRef(rawUrl);
72
+ if (!ref) throw new PrActionError(`unrecognized PR reference: ${JSON.stringify(rawUrl.slice(0, 120))}`);
73
+ return ref;
74
+ }
75
+
76
+ function _prId(ref) {
77
+ if (!ref) return null;
78
+ return ref.id || (ref.host && ref.slug && ref.number ? `${ref.host}:${ref.slug}#${ref.number}` : null);
79
+ }
80
+
81
+ // ── Patch normalization (the trivial-single-file guardrail) ───────────────────
82
+
83
+ /**
84
+ * Validate a repo-relative file path for the Contents API. Rejects absolute
85
+ * paths, parent traversal, backslashes, and argument-injection (leading dash) so
86
+ * a poisoned path can neither escape the repo nor reach `gh` as a flag. Returns
87
+ * the normalized forward-slash path. Throws `PrActionError` (400).
88
+ */
89
+ function _validateFilePath(p) {
90
+ const raw = typeof p === 'string' ? p.trim() : '';
91
+ if (!raw) throw new PrActionError('remote-patch requires a file path');
92
+ const norm = raw.replace(/\\/g, '/');
93
+ if (norm.startsWith('/')) throw new PrActionError(`remote-patch file path must be repo-relative: ${JSON.stringify(p)}`);
94
+ if (norm.startsWith('-')) throw new PrActionError(`invalid remote-patch file path: ${JSON.stringify(p)}`);
95
+ const segs = norm.split('/');
96
+ for (const seg of segs) {
97
+ if (seg === '' || seg === '.' || seg === '..') {
98
+ throw new PrActionError(`invalid remote-patch file path: ${JSON.stringify(p)}`);
99
+ }
100
+ }
101
+ return norm;
102
+ }
103
+
104
+ /**
105
+ * Normalize the caller-supplied patch into a single `{ path, content }` edit,
106
+ * enforcing the "trivial single-file only" guardrail. Accepts:
107
+ * - `{ path, content }` / `{ file, content }`
108
+ * - `{ files: [{ path, content }] }` (must contain exactly one)
109
+ * - `[{ path, content }]` (must contain exactly one)
110
+ * A multi-file patch — or one missing a path/content — is REFUSED here, before
111
+ * any network call. `content` must be a string (the file's full new contents).
112
+ * Throws `PrActionError` (400).
113
+ */
114
+ function _normalizePatch(patch) {
115
+ if (patch == null) {
116
+ throw new PrActionError('remote-patch requires a single-file patch (opts.patch { path, content })');
117
+ }
118
+ // Collapse the accepted shapes into a list of file edits.
119
+ let files;
120
+ if (Array.isArray(patch)) {
121
+ files = patch;
122
+ } else if (Array.isArray(patch.files)) {
123
+ files = patch.files;
124
+ } else if (typeof patch === 'object') {
125
+ files = [patch];
126
+ } else {
127
+ throw new PrActionError('remote-patch requires a single-file patch (opts.patch { path, content })');
128
+ }
129
+
130
+ if (files.length === 0) {
131
+ throw new PrActionError('remote-patch requires a single-file patch (opts.patch { path, content })');
132
+ }
133
+ if (files.length > 1) {
134
+ throw new PrActionError(
135
+ `remote-patch is single-file only (got ${files.length} files). Use temp-clone or clone-keep for multi-file changes.`,
136
+ );
137
+ }
138
+
139
+ const file = files[0] || {};
140
+ const path = _validateFilePath(file.path != null ? file.path : file.file);
141
+ if (typeof file.content !== 'string') {
142
+ throw new PrActionError('remote-patch file requires string `content` (the full new file contents)');
143
+ }
144
+ return { path, content: file.content };
145
+ }
146
+
147
+ // ── Planning ──────────────────────────────────────────────────────────────────
148
+
149
+ /**
150
+ * Plan a remote-patch fix: resolve the PR ref (GitHub only), resolve its source
151
+ * branch, and normalize/validate the single-file patch. NOTHING is mutated here.
152
+ *
153
+ * `input` — `{ url }`, a normalized ref, or a routed plan from
154
+ * engine/pr-fix-target.js#resolvePrFixTarget.
155
+ * `opts.patch` — REQUIRED single-file patch (see `_normalizePatch`).
156
+ * `opts.fetchPrBranches` — override the branch fetch (test seam).
157
+ * `opts.prUrl` — original user URL (preferred for surfacing).
158
+ * `opts.message` — optional commit message override.
159
+ *
160
+ * Returns `{ status:'remote-patch-planned', target:'remote-patch', ref, prId,
161
+ * prUrl, host:'github', slug, sourceBranch, file:{ path, content }, message,
162
+ * validates:false, noValidation:true, label }`.
163
+ * Throws `PrActionError` (400) on bad input / ADO host / multi-file patch.
164
+ */
165
+ async function planRemotePatch(input = {}, opts = {}) {
166
+ const ref = _resolveRef(input);
167
+ const prUrl = opts.prUrl || (input && typeof input === 'object' ? input.url : undefined) || null;
168
+ const prId = _prId(ref);
169
+
170
+ // GITHUB-ONLY guardrail. ADO has no simple Contents-API edit-one-file path
171
+ // without a clone, so the fast path is explicitly unsupported there.
172
+ if (ref.host !== 'github') {
173
+ throw new PrActionError(
174
+ `remote-patch fast path is GitHub-only — ${ref.host} has no equivalent no-clone Contents API. ` +
175
+ 'Use temp-clone or clone-keep for this PR.',
176
+ );
177
+ }
178
+
179
+ // Validate the single-file patch BEFORE any network — the trivial-only guardrail.
180
+ const file = _normalizePatch(opts.patch);
181
+
182
+ const slug = shared.validateGhSlug(ref.slug);
183
+
184
+ const fetchBranches = opts.fetchPrBranches || prResolve.fetchPrBranches;
185
+ const branches = await fetchBranches(ref, opts);
186
+ const sourceBranch = branches && branches.sourceBranch;
187
+ if (!sourceBranch) throw new PrActionError(`could not resolve source branch for ${prId || 'PR'}`, 502);
188
+ // Defensive re-validation (fetchPrBranches already validates, but a custom seam
189
+ // might not) — a poisoned ref name must never reach the Contents API.
190
+ shared.validateGitRef(sourceBranch);
191
+
192
+ const message = (typeof opts.message === 'string' && opts.message.trim())
193
+ ? opts.message.trim()
194
+ : `Fix ${prId || sourceBranch}: ${file.path}`;
195
+
196
+ return {
197
+ status: 'remote-patch-planned',
198
+ target: 'remote-patch',
199
+ ref,
200
+ prId,
201
+ prUrl,
202
+ host: 'github',
203
+ slug,
204
+ sourceBranch,
205
+ file,
206
+ message,
207
+ validates: false,
208
+ noValidation: true,
209
+ label: REMOTE_PATCH_NO_VALIDATION_LABEL,
210
+ };
211
+ }
212
+
213
+ // ── Default GitHub seams (real implementations; tests inject their own) ─────────
214
+
215
+ /** Resolve a GitHub PAT for the PR's slug (per-slug routing; never gh auth switch). */
216
+ function _resolveToken(plan, opts = {}) {
217
+ if (opts.token) return opts.token;
218
+ const resolve = opts._resolveTokenForSlug || ghToken.resolveTokenForSlug;
219
+ return resolve(plan.slug || plan.ref.slug) || null;
220
+ }
221
+
222
+ function _ghOpts(token) {
223
+ const env = token ? { ...process.env, GH_TOKEN: token } : process.env;
224
+ return { env, timeout: GH_API_TIMEOUT_MS, maxBuffer: GH_MAX_BUFFER };
225
+ }
226
+
227
+ /**
228
+ * Fetch the current blob sha for the file on the PR's source branch, or null if
229
+ * the file does not exist yet (a new-file create). Re-throws on any other error
230
+ * so a transient API failure is not silently treated as "create".
231
+ */
232
+ async function _defaultGetFileSha(plan, token, opts = {}) {
233
+ const runGh = opts._shellSafeGh || shared.shellSafeGh;
234
+ try {
235
+ const raw = await runGh(
236
+ ['api', `repos/${plan.slug}/contents/${plan.file.path}`, '-f', `ref=${plan.sourceBranch}`],
237
+ _ghOpts(token),
238
+ );
239
+ const parsed = JSON.parse(raw);
240
+ return parsed && parsed.sha ? String(parsed.sha) : null;
241
+ } catch (e) {
242
+ // 404 → the file does not exist on the branch yet; treat as a create.
243
+ if (/404|Not Found|No such file/i.test(e?.message || '')) return null;
244
+ throw e;
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Apply the single-file edit via the Contents API (PUT). Passing `sha` updates an
250
+ * existing file; omitting it creates a new one. Returns `{ commitSha, htmlUrl }`.
251
+ */
252
+ async function _defaultPutFile(plan, token, sha, opts = {}) {
253
+ const runGh = opts._shellSafeGh || shared.shellSafeGh;
254
+ const contentB64 = Buffer.from(plan.file.content, 'utf8').toString('base64');
255
+ const args = [
256
+ 'api', '--method', 'PUT', `repos/${plan.slug}/contents/${plan.file.path}`,
257
+ '-f', `message=${plan.message}`,
258
+ '-f', `content=${contentB64}`,
259
+ '-f', `branch=${plan.sourceBranch}`,
260
+ ];
261
+ if (sha) args.push('-f', `sha=${sha}`);
262
+ const raw = await runGh(args, _ghOpts(token));
263
+ let parsed = null;
264
+ try { parsed = JSON.parse(raw); } catch { /* gh returned non-JSON; tolerate */ }
265
+ return {
266
+ commitSha: parsed?.commit?.sha || null,
267
+ htmlUrl: parsed?.commit?.html_url || parsed?.content?.html_url || null,
268
+ };
269
+ }
270
+
271
+ // ── Execution ─────────────────────────────────────────────────────────────────
272
+
273
+ function _record(plan, fields) {
274
+ return {
275
+ status: 'failed',
276
+ target: 'remote-patch',
277
+ ref: plan.ref,
278
+ prId: plan.prId,
279
+ prUrl: plan.prUrl,
280
+ sourceBranch: plan.sourceBranch || null,
281
+ file: plan.file ? { path: plan.file.path } : null,
282
+ changed: false,
283
+ validated: false, // remote-patch NEVER validates
284
+ validationOk: null,
285
+ noValidation: true,
286
+ label: REMOTE_PATCH_NO_VALIDATION_LABEL,
287
+ pushed: false,
288
+ created: false,
289
+ commit: null,
290
+ output: '',
291
+ failure_class: null,
292
+ retryable: false,
293
+ error: null,
294
+ ...fields,
295
+ };
296
+ }
297
+
298
+ /**
299
+ * Execute the remote-patch fast path for a planned (or raw) PR fix: resolve a
300
+ * GitHub token, fetch the file's current blob sha, then PUT the new content to
301
+ * the PR's source branch via the Contents API. NO clone, NO worktree, NO tests.
302
+ *
303
+ * `planOrInput` — a plan from `planRemotePatch`, a routed plan from
304
+ * pr-fix-target.js, a normalized ref, or `{ url }` (the patch must then come
305
+ * via `opts.patch`).
306
+ * `opts`:
307
+ * - `patch` — REQUIRED when `planOrInput` is not already a built plan.
308
+ * - test seams: `getFileSha`, `putFile`, `fetchPrBranches`,
309
+ * `token`/`_resolveTokenForSlug`, `_shellSafeGh`.
310
+ *
311
+ * Returns a terminal record stamped `noValidation:true` + `label`. Throws
312
+ * `PrActionError` (400) only for bad input / ADO host / multi-file patch; every
313
+ * downstream (token/network) failure is captured in a `failed` record.
314
+ */
315
+ async function executeRemotePatch(planOrInput = {}, opts = {}) {
316
+ // Accept an already-built plan, else plan now (resolves branch + validates the
317
+ // single-file patch). PrActionError(400) on bad input / ADO host / multi-file
318
+ // propagates so the endpoint reflects a 400.
319
+ let plan;
320
+ if (planOrInput && planOrInput.status === 'remote-patch-planned' && planOrInput.file) {
321
+ plan = planOrInput;
322
+ } else {
323
+ plan = await planRemotePatch(planOrInput, opts);
324
+ }
325
+
326
+ const getFileSha = opts.getFileSha || ((p, token) => _defaultGetFileSha(p, token, opts));
327
+ const putFile = opts.putFile || ((p, token, sha) => _defaultPutFile(p, token, sha, opts));
328
+
329
+ const rec = _record(plan, {});
330
+
331
+ // 1) Resolve the push token for the target repo.
332
+ const token = _resolveToken(plan, opts);
333
+ if (!token) {
334
+ rec.failure_class = shared.FAILURE_CLASS.AUTH;
335
+ rec.retryable = false;
336
+ rec.error = `no GitHub token resolved for ${plan.slug}`;
337
+ return rec;
338
+ }
339
+
340
+ // 2) Look up the file's current blob sha (null → new-file create).
341
+ let sha;
342
+ try {
343
+ sha = await getFileSha(plan, token);
344
+ } catch (e) {
345
+ rec.failure_class = shared.FAILURE_CLASS.NETWORK_ERROR;
346
+ rec.retryable = true;
347
+ rec.error = `contents lookup failed: ${e?.message || e}`;
348
+ return rec;
349
+ }
350
+ rec.created = !sha;
351
+
352
+ // 3) Apply the single-file edit via the Contents API.
353
+ let result;
354
+ try {
355
+ result = await putFile(plan, token, sha);
356
+ } catch (e) {
357
+ // A 4xx (perms/branch protection) reads as auth; other failures as network.
358
+ const auth = /403|401|Permission|protected branch|Resource not accessible/i.test(e?.message || '');
359
+ rec.failure_class = auth ? shared.FAILURE_CLASS.AUTH : shared.FAILURE_CLASS.NETWORK_ERROR;
360
+ rec.retryable = !auth;
361
+ rec.error = `remote patch failed: ${e?.message || e}`;
362
+ return rec;
363
+ }
364
+
365
+ rec.status = 'done';
366
+ rec.changed = true;
367
+ rec.pushed = true;
368
+ rec.commit = result || null;
369
+ rec.failure_class = null;
370
+ rec.retryable = false;
371
+ rec.error = null;
372
+ rec.output = `Applied single-file patch to ${plan.file.path} on ${plan.sourceBranch} (${REMOTE_PATCH_NO_VALIDATION_LABEL}).`;
373
+ return rec;
374
+ }
375
+
376
+ module.exports = {
377
+ REMOTE_PATCH_NO_VALIDATION_LABEL,
378
+ planRemotePatch,
379
+ executeRemotePatch,
380
+ // Exported for testing.
381
+ _resolveRef,
382
+ _validateFilePath,
383
+ _normalizePatch,
384
+ };