@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,443 @@
1
+ /**
2
+ * engine/pr-resolve.js — Projectless PR reference resolver + diff/metadata fetcher.
3
+ *
4
+ * Plan: projectless-pr-actions, item P-ppa10001. Foundation for all projectless
5
+ * PR actions (review / summarize / comment / triage with no configured project
6
+ * and no local clone).
7
+ *
8
+ * Two responsibilities:
9
+ * 1. `normalizePrRef(input)` — accept any GitHub or ADO PR URL OR the canonical
10
+ * `github:owner/repo#N` / `ado:org/proj/repo#N` id and return a normalized
11
+ * `{ host, slug, repo, number, ... }` struct. Reuses the existing parsers in
12
+ * engine/shared.js (`parsePrUrl`, `parseCanonicalPrId`).
13
+ * 2. `fetchPrPayload(refOrInput)` — fetch `{ diff, title, body, comments, author }`
14
+ * for that PR WITHOUT a configured project or clone, via `gh` (GitHub) and the
15
+ * ADO REST API (`_apis/git/...`).
16
+ *
17
+ * Token routing is per-slug through engine/gh-token.js#resolveTokenForSlug; for an
18
+ * unknown GitHub slug (no mapping, no fleet default) it falls back across the
19
+ * authed accounts configured in `engine.ghAccounts` rather than relying on the
20
+ * active `gh auth` profile. It NEVER runs `gh auth switch`. ADO tokens come from
21
+ * engine/ado-token.js (`az account get-access-token` / azureauth).
22
+ *
23
+ * All shell-outs use argv form (shell:false) and slug/number are validated before
24
+ * interpolation, so poisoned PR refs cannot inject shell metacharacters.
25
+ */
26
+
27
+ const path = require('path');
28
+ const shared = require('./shared');
29
+ const ghToken = require('./gh-token');
30
+ const adoToken = require('./ado-token');
31
+
32
+ const { safeJson, MINIONS_DIR, log } = shared;
33
+
34
+ const GH_FETCH_TIMEOUT_MS = 30000;
35
+ const GH_MAX_BUFFER = 20 * 1024 * 1024; // diffs can be large
36
+ const ADO_FETCH_TIMEOUT_MS = 30000;
37
+ const ADO_API_VERSION = '7.1';
38
+
39
+ // ── Normalization ───────────────────────────────────────────────────────────
40
+
41
+ /**
42
+ * Turn a `{ scope, prNumber }` pair (as returned by shared.parsePrUrl /
43
+ * parseCanonicalPrId) into a normalized projectless PR ref. Returns null when the
44
+ * scope is not a recognizable github/ado scope or the number is not a positive int.
45
+ */
46
+ function _normalizeFromScope(scope, number) {
47
+ if (!scope || typeof scope !== 'string') return null;
48
+ const num = Number(number);
49
+ if (!Number.isInteger(num) || num <= 0) return null;
50
+
51
+ const sepIdx = scope.indexOf(':');
52
+ if (sepIdx <= 0) return null;
53
+ const host = scope.slice(0, sepIdx).toLowerCase();
54
+ const slug = scope.slice(sepIdx + 1);
55
+ const segs = slug.split('/').filter(Boolean);
56
+
57
+ if (host === 'github') {
58
+ if (segs.length !== 2) return null;
59
+ const [owner, repo] = segs;
60
+ return {
61
+ host: 'github',
62
+ scope,
63
+ slug: `${owner}/${repo}`,
64
+ owner,
65
+ repo,
66
+ number: num,
67
+ id: `github:${owner}/${repo}#${num}`,
68
+ };
69
+ }
70
+ if (host === 'ado') {
71
+ if (segs.length !== 3) return null;
72
+ const [org, project, repo] = segs;
73
+ return {
74
+ host: 'ado',
75
+ scope,
76
+ slug: `${org}/${project}/${repo}`,
77
+ org,
78
+ project,
79
+ repo,
80
+ number: num,
81
+ id: `ado:${org}/${project}/${repo}#${num}`,
82
+ };
83
+ }
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * Normalize any PR URL (GitHub or ADO) OR canonical id (`github:owner/repo#N` /
89
+ * `ado:org/proj/repo#N`) into `{ host, slug, repo, number, ... }`. Returns null
90
+ * when the input is unrecognizable. Idempotent on an already-normalized ref.
91
+ */
92
+ function normalizePrRef(input) {
93
+ if (input && typeof input === 'object' && input.host && input.slug && input.number) {
94
+ // Already a normalized ref — re-derive defensively so callers can pass either.
95
+ return _normalizeFromScope(input.scope || `${input.host}:${input.slug}`, input.number);
96
+ }
97
+ const text = String(input == null ? '' : input).trim();
98
+ if (!text) return null;
99
+ // URL forms first, then the canonical `host:scope#N` id form.
100
+ const parsed = shared.parsePrUrl(text) || shared.parseCanonicalPrId(text);
101
+ if (!parsed) return null;
102
+ return _normalizeFromScope(parsed.scope, parsed.prNumber);
103
+ }
104
+
105
+ // ── Config (for the unknown-slug account fallback) ───────────────────────────
106
+
107
+ function _readConfig(opts = {}) {
108
+ if (opts.config) return opts.config;
109
+ return safeJson(path.join(MINIONS_DIR, 'config.json')) || {};
110
+ }
111
+
112
+ // ── GitHub fetch ─────────────────────────────────────────────────────────────
113
+
114
+ function _dedupeTokens(tokens) {
115
+ const seen = new Set();
116
+ let sawAmbient = false;
117
+ const out = [];
118
+ for (const t of tokens) {
119
+ if (t == null) {
120
+ if (sawAmbient) continue;
121
+ sawAmbient = true;
122
+ } else {
123
+ const key = String(t);
124
+ if (seen.has(key)) continue;
125
+ seen.add(key);
126
+ }
127
+ out.push(t);
128
+ }
129
+ return out;
130
+ }
131
+
132
+ /**
133
+ * Build the ordered list of GH tokens to try for `slug`:
134
+ * - the per-slug mapped token first (when `engine.ghAccounts` maps the owner or
135
+ * a fleet default applies), else
136
+ * - one token per distinct authed account in `engine.ghAccounts` (the
137
+ * unknown-slug fallback — never the active `gh auth` profile), else
138
+ * - `null`, meaning "let `gh` use its ambient identity".
139
+ */
140
+ function _githubTokenCandidates(slug, opts = {}) {
141
+ const resolveTokenForSlug = opts._resolveTokenForSlug || ghToken.resolveTokenForSlug;
142
+ const tokenForAccount = opts._tokenForAccount || ghToken.tokenForAccount;
143
+ const listAccounts = opts._listConfiguredAccounts || ghToken.listConfiguredAccounts;
144
+
145
+ const config = _readConfig(opts);
146
+ const candidates = [];
147
+ const mapped = resolveTokenForSlug(slug, { config });
148
+ if (mapped) {
149
+ candidates.push(mapped);
150
+ } else {
151
+ for (const account of listAccounts({ config })) {
152
+ const tok = tokenForAccount(account, { config });
153
+ if (tok) candidates.push(tok);
154
+ }
155
+ }
156
+ if (!candidates.length) candidates.push(null);
157
+ return _dedupeTokens(candidates);
158
+ }
159
+
160
+ async function _fetchGitHubPayload(ref, opts = {}) {
161
+ const runGh = opts._shellSafeGh || shared.shellSafeGh;
162
+ const slug = shared.validateGhSlug(ref.slug);
163
+ const num = shared.validatePrNum(ref.number);
164
+ const tokens = _githubTokenCandidates(slug, opts);
165
+
166
+ let lastErr = null;
167
+ for (const token of tokens) {
168
+ try {
169
+ const env = token ? { ...process.env, GH_TOKEN: token } : process.env;
170
+ const ghOpts = { env, timeout: GH_FETCH_TIMEOUT_MS, maxBuffer: GH_MAX_BUFFER };
171
+
172
+ const prRaw = await runGh(['api', `repos/${slug}/pulls/${num}`], ghOpts);
173
+ const pr = JSON.parse(prRaw);
174
+
175
+ // Unified diff via the diff media type (raw text, not JSON).
176
+ let diff = '';
177
+ try {
178
+ diff = String(await runGh(
179
+ ['api', '-H', 'Accept: application/vnd.github.v3.diff', `repos/${slug}/pulls/${num}`],
180
+ ghOpts,
181
+ ) || '');
182
+ } catch (e) {
183
+ log('warn', `pr-resolve: GitHub diff fetch failed for ${ref.id}: ${e?.message || e}`);
184
+ }
185
+
186
+ // Issue comments (PR conversation). Review comments are a separate endpoint;
187
+ // the conversation comments are the relevant untrusted surface for triage.
188
+ let comments = [];
189
+ try {
190
+ const raw = await runGh(['api', '--paginate', `repos/${slug}/issues/${num}/comments`], ghOpts);
191
+ const parsed = JSON.parse(raw);
192
+ if (Array.isArray(parsed)) {
193
+ comments = parsed.map((c) => ({ author: c?.user?.login || '', body: c?.body || '' }));
194
+ }
195
+ } catch (e) {
196
+ log('warn', `pr-resolve: GitHub comments fetch failed for ${ref.id}: ${e?.message || e}`);
197
+ }
198
+
199
+ return {
200
+ ref,
201
+ host: 'github',
202
+ number: num,
203
+ slug,
204
+ title: pr.title || '',
205
+ body: pr.body || '',
206
+ author: pr?.user?.login || '',
207
+ diff,
208
+ comments,
209
+ };
210
+ } catch (e) {
211
+ lastErr = e;
212
+ }
213
+ }
214
+ throw new Error(`pr-resolve: GitHub PR fetch failed for ${ref.id}: ${lastErr ? (lastErr.message || lastErr) : 'no authed account'}`);
215
+ }
216
+
217
+ // ── ADO fetch ─────────────────────────────────────────────────────────────────
218
+
219
+ async function _defaultAdoFetch(url, token) {
220
+ const res = await fetch(url, {
221
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
222
+ signal: AbortSignal.timeout(ADO_FETCH_TIMEOUT_MS),
223
+ });
224
+ if (!res.ok) throw new Error(`ADO API ${res.status}: ${res.statusText}`);
225
+ const text = await res.text();
226
+ if (!text || text.trimStart().startsWith('<')) {
227
+ throw new Error(`ADO returned HTML instead of JSON (likely auth redirect) for ${url.split('?')[0]}`);
228
+ }
229
+ return JSON.parse(text);
230
+ }
231
+
232
+ function _flattenAdoThreads(threads) {
233
+ const out = [];
234
+ for (const thread of (threads?.value || [])) {
235
+ for (const c of (thread?.comments || [])) {
236
+ // Skip ADO system threads (status changes, vote updates, etc).
237
+ if (c?.commentType && c.commentType !== 'text') continue;
238
+ const body = c?.content || '';
239
+ if (!body) continue;
240
+ out.push({ author: c?.author?.displayName || c?.author?.uniqueName || '', body });
241
+ }
242
+ }
243
+ return out;
244
+ }
245
+
246
+ function _formatAdoChanges(changes) {
247
+ const entries = changes?.changeEntries || changes?.value || [];
248
+ const lines = [];
249
+ for (const ch of entries) {
250
+ const p = ch?.item?.path || ch?.originalPath || '';
251
+ if (!p) continue;
252
+ lines.push(`${(ch?.changeType || 'edit')}\t${p}`);
253
+ }
254
+ return lines.join('\n');
255
+ }
256
+
257
+ async function _fetchAdoPayload(ref, opts = {}) {
258
+ const doFetch = opts._adoFetch || _defaultAdoFetch;
259
+ let token = opts.adoToken;
260
+ if (!token) {
261
+ const acquire = opts._acquireAdoToken || adoToken.acquireAdoToken;
262
+ const acquired = await acquire();
263
+ token = acquired && acquired.token;
264
+ }
265
+ if (!token) throw new Error(`pr-resolve: could not acquire ADO token for ${ref.id}`);
266
+
267
+ const orgBase = opts.adoOrgBase || `https://dev.azure.com/${encodeURIComponent(ref.org)}`;
268
+ const project = encodeURIComponent(ref.project);
269
+ const repo = encodeURIComponent(ref.repo);
270
+ const base = `${orgBase}/${project}/_apis/git/repositories/${repo}/pullRequests/${ref.number}`;
271
+
272
+ const pr = await doFetch(`${base}?api-version=${ADO_API_VERSION}`, token);
273
+
274
+ let comments = [];
275
+ try {
276
+ const threads = await doFetch(`${base}/threads?api-version=${ADO_API_VERSION}`, token);
277
+ comments = _flattenAdoThreads(threads);
278
+ } catch (e) {
279
+ log('warn', `pr-resolve: ADO threads fetch failed for ${ref.id}: ${e?.message || e}`);
280
+ }
281
+
282
+ // ADO has no single unified-diff endpoint without a clone; the latest
283
+ // iteration's change list is the projectless diff surrogate downstream can fence.
284
+ let diff = '';
285
+ try {
286
+ const iters = await doFetch(`${base}/iterations?api-version=${ADO_API_VERSION}`, token);
287
+ const last = (iters?.value || []).slice(-1)[0];
288
+ if (last && last.id != null) {
289
+ const changes = await doFetch(`${base}/iterations/${last.id}/changes?api-version=${ADO_API_VERSION}`, token);
290
+ diff = _formatAdoChanges(changes);
291
+ }
292
+ } catch (e) {
293
+ log('warn', `pr-resolve: ADO changes fetch failed for ${ref.id}: ${e?.message || e}`);
294
+ }
295
+
296
+ return {
297
+ ref,
298
+ host: 'ado',
299
+ number: ref.number,
300
+ slug: ref.slug,
301
+ title: pr.title || '',
302
+ body: typeof pr.description === 'string' ? pr.description : '',
303
+ author: pr?.createdBy?.displayName || pr?.createdBy?.uniqueName || '',
304
+ diff,
305
+ comments,
306
+ };
307
+ }
308
+
309
+ // ── Branch / clone metadata (for the temp-clone executor, P-ppa10006) ────────
310
+
311
+ function _stripRefsHeads(ref) {
312
+ return typeof ref === 'string' ? ref.replace(/^refs\/heads\//, '') : '';
313
+ }
314
+
315
+ /**
316
+ * Fetch the source/target branch names + a clone URL for a PR with NO configured
317
+ * project and NO local clone. The temp-clone fix executor (engine/pr-temp-clone.js)
318
+ * needs the PR's HEAD branch to clone + push the fix back to, plus the clone URL
319
+ * (fork-aware on GitHub). Token routing mirrors `fetchPrPayload` (per-slug GH
320
+ * tokens / ADO bearer); shell-outs are argv-form and validated.
321
+ *
322
+ * Returns `{ ref, host, slug, number, sourceBranch, targetBranch, cloneUrl,
323
+ * sourceRepoSlug, isFork }`. `sourceBranch` is validated via
324
+ * `shared.validateGitRef` before return so a poisoned ref name can never reach a
325
+ * downstream `git` invocation. Throws when the branch can't be resolved.
326
+ */
327
+ async function _fetchGitHubBranches(ref, opts = {}) {
328
+ const runGh = opts._shellSafeGh || shared.shellSafeGh;
329
+ const slug = shared.validateGhSlug(ref.slug);
330
+ const num = shared.validatePrNum(ref.number);
331
+ const tokens = _githubTokenCandidates(slug, opts);
332
+
333
+ let lastErr = null;
334
+ for (const token of tokens) {
335
+ try {
336
+ const env = token ? { ...process.env, GH_TOKEN: token } : process.env;
337
+ const ghOpts = { env, timeout: GH_FETCH_TIMEOUT_MS };
338
+ const prRaw = await runGh(['api', `repos/${slug}/pulls/${num}`], ghOpts);
339
+ const pr = JSON.parse(prRaw);
340
+
341
+ const sourceBranch = pr?.head?.ref ? String(pr.head.ref).trim() : '';
342
+ if (!sourceBranch) throw new Error('PR head.ref missing');
343
+ shared.validateGitRef(sourceBranch);
344
+
345
+ const sourceRepoSlug = pr?.head?.repo?.full_name || slug;
346
+ const isFork = sourceRepoSlug.toLowerCase() !== slug.toLowerCase();
347
+ const cloneUrl = pr?.head?.repo?.clone_url || `https://github.com/${sourceRepoSlug}.git`;
348
+
349
+ return {
350
+ ref,
351
+ host: 'github',
352
+ slug,
353
+ number: num,
354
+ sourceBranch,
355
+ targetBranch: pr?.base?.ref ? String(pr.base.ref).trim() : '',
356
+ cloneUrl,
357
+ sourceRepoSlug,
358
+ isFork,
359
+ };
360
+ } catch (e) {
361
+ lastErr = e;
362
+ }
363
+ }
364
+ throw new Error(`pr-resolve: GitHub branch fetch failed for ${ref.id}: ${lastErr ? (lastErr.message || lastErr) : 'no authed account'}`);
365
+ }
366
+
367
+ async function _fetchAdoBranches(ref, opts = {}) {
368
+ const doFetch = opts._adoFetch || _defaultAdoFetch;
369
+ let token = opts.adoToken;
370
+ if (!token) {
371
+ const acquire = opts._acquireAdoToken || adoToken.acquireAdoToken;
372
+ const acquired = await acquire();
373
+ token = acquired && acquired.token;
374
+ }
375
+ if (!token) throw new Error(`pr-resolve: could not acquire ADO token for ${ref.id}`);
376
+
377
+ const orgBase = opts.adoOrgBase || `https://dev.azure.com/${encodeURIComponent(ref.org)}`;
378
+ const project = encodeURIComponent(ref.project);
379
+ const repo = encodeURIComponent(ref.repo);
380
+ const base = `${orgBase}/${project}/_apis/git/repositories/${repo}/pullRequests/${ref.number}`;
381
+ const pr = await doFetch(`${base}?api-version=${ADO_API_VERSION}`, token);
382
+
383
+ const sourceBranch = _stripRefsHeads(pr.sourceRefName);
384
+ if (!sourceBranch) throw new Error('PR sourceRefName missing');
385
+ shared.validateGitRef(sourceBranch);
386
+
387
+ const cloneUrl = pr?.repository?.remoteUrl || `${orgBase}/${project}/_git/${repo}`;
388
+ return {
389
+ ref,
390
+ host: 'ado',
391
+ slug: ref.slug,
392
+ number: ref.number,
393
+ sourceBranch,
394
+ targetBranch: _stripRefsHeads(pr.targetRefName),
395
+ cloneUrl,
396
+ sourceRepoSlug: ref.slug,
397
+ isFork: false,
398
+ };
399
+ }
400
+
401
+ /**
402
+ * Resolve a PR's branch + clone metadata projectlessly. Accepts a raw
403
+ * URL/canonical-id string or an already-normalized ref.
404
+ */
405
+ async function fetchPrBranches(refOrInput, opts = {}) {
406
+ const ref = (refOrInput && typeof refOrInput === 'object' && refOrInput.host)
407
+ ? refOrInput
408
+ : normalizePrRef(refOrInput);
409
+ if (!ref) throw new Error(`pr-resolve: unrecognized PR reference: ${JSON.stringify(String(refOrInput).slice(0, 120))}`);
410
+ if (ref.host === 'github') return _fetchGitHubBranches(ref, opts);
411
+ if (ref.host === 'ado') return _fetchAdoBranches(ref, opts);
412
+ throw new Error(`pr-resolve: unsupported PR host: ${ref.host}`);
413
+ }
414
+
415
+ // ── Public entry ──────────────────────────────────────────────────────────────
416
+
417
+ /**
418
+ * Resolve + fetch a PR's diff/metadata with no configured project. Accepts a raw
419
+ * URL/canonical-id string or an already-normalized ref. Returns
420
+ * `{ ref, host, number, slug, title, body, author, diff, comments }`.
421
+ * Throws when the input is unrecognizable or every fetch attempt fails.
422
+ */
423
+ async function fetchPrPayload(refOrInput, opts = {}) {
424
+ const ref = (refOrInput && typeof refOrInput === 'object' && refOrInput.host)
425
+ ? refOrInput
426
+ : normalizePrRef(refOrInput);
427
+ if (!ref) throw new Error(`pr-resolve: unrecognized PR reference: ${JSON.stringify(String(refOrInput).slice(0, 120))}`);
428
+ if (ref.host === 'github') return _fetchGitHubPayload(ref, opts);
429
+ if (ref.host === 'ado') return _fetchAdoPayload(ref, opts);
430
+ throw new Error(`pr-resolve: unsupported PR host: ${ref.host}`);
431
+ }
432
+
433
+ module.exports = {
434
+ normalizePrRef,
435
+ fetchPrPayload,
436
+ fetchPrBranches,
437
+ // Exported for testing.
438
+ _normalizeFromScope,
439
+ _githubTokenCandidates,
440
+ _flattenAdoThreads,
441
+ _formatAdoChanges,
442
+ _stripRefsHeads,
443
+ };