@yemi33/minions 0.1.2355 → 0.1.2357

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/engine/queries.js CHANGED
@@ -72,6 +72,25 @@ function _resetPrEnrichmentCacheForTest() {
72
72
  _prEnrichmentInFlight.clear();
73
73
  }
74
74
 
75
+ // W-mrcs8yqk001o6893 — shared enrichment-fallback resolver. Both the prLinks
76
+ // loop and the wi._pr fallback loop in getPrdInfo() need the SAME defensive
77
+ // behavior when the canonical PR id has no live record in pull-requests.json
78
+ // (e.g. it was merged and then swept by the merged-PR cleanup routine): check
79
+ // the enrichment cache first, else synthesize PR_STATUS.ACTIVE as a
80
+ // placeholder and kick off a fire-and-forget enrollment so the next poll
81
+ // (within PR_ENRICHMENT_TTL_MS) resolves the real status. Previously only the
82
+ // wi._pr loop did this (W-mq5uzmc6001d708f); the prLinks loop hardcoded
83
+ // `pr?.status || PR_STATUS.ACTIVE` with no self-heal, so PRs reached only via
84
+ // pr_links stayed 'active' forever once removed from pull-requests.json.
85
+ function _resolvePrStatusWithEnrichment(pr, canonicalPrId, project, itemId) {
86
+ if (pr?.status) return pr.status;
87
+ if (!canonicalPrId) return PR_STATUS.ACTIVE;
88
+ const cachedStatus = _getCachedPrEnrichment(canonicalPrId);
89
+ if (cachedStatus) return cachedStatus;
90
+ _scheduleAsyncPrEnrollment(canonicalPrId, project, itemId);
91
+ return PR_STATUS.ACTIVE;
92
+ }
93
+
75
94
  /**
76
95
  * Read the first `bytes` and last `bytes` of a file efficiently using byte offsets.
77
96
  * For files <= 2*bytes, reads the whole file. Returns { head, tail } strings.
@@ -572,15 +591,6 @@ function _buildArchiveNotesByWiMap() {
572
591
  return out;
573
592
  }
574
593
 
575
- // Test/maintenance hook — drop the archive notes cache (mirrors the other
576
- // invalidate* helpers). Not normally needed: the cache self-heals via the
577
- // dir-mtime gate + TTL.
578
- function invalidateArchiveNotesByWiCache() {
579
- _archiveNotesByWiCache = null;
580
- _archiveNotesByWiCacheAt = 0;
581
- _archiveNotesByWiCacheMtime = null;
582
- }
583
-
584
594
  // Build a wiId → [filename, ...] map by scanning the inbox + archive dirs.
585
595
  // Archive entries are prefixed with `archive:` so the dashboard renderer can
586
596
  // tell them apart (matches the convention already used by _artifacts.notes).
@@ -883,13 +893,16 @@ function _prFreshnessMs(pr) {
883
893
  function _pickPrDedupeWinner(group, projects) {
884
894
  if (group.length === 1) return { winner: group[0], dropped: [] };
885
895
 
886
- // Map each project name -> its canonical scope, so we can detect when a
887
- // record's _scope matches the PR url-derived scope (preference #2 below).
888
- const projectScopeByName = new Map();
896
+ // Map each project name -> its set of canonical scopes (primary +
897
+ // additionalRepoScopes, W-mrchrfe100067d3a), so we can detect when a
898
+ // record's _scope matches the PR url-derived scope (preference #2 below)
899
+ // even when the match is via an allowlisted secondary scope rather than
900
+ // the project's primary repo.
901
+ const projectScopesByName = new Map();
889
902
  for (const p of projects || []) {
890
903
  if (!p || !p.name) continue;
891
- const sc = shared.getProjectPrScope(p);
892
- if (sc) projectScopeByName.set(p.name, sc);
904
+ const scopes = shared.getProjectAllPrScopes(p);
905
+ if (scopes.length > 0) projectScopesByName.set(p.name, scopes);
893
906
  }
894
907
 
895
908
  // Resolve the PR's "correct" scope from its URL (independent of _scope).
@@ -907,8 +920,8 @@ function _pickPrDedupeWinner(group, projects) {
907
920
  // bit 0: freshness rank (broken into a separate tiebreak after)
908
921
  function scoreOf(pr) {
909
922
  const scopeOk = !pr._invalidProjectScope ? 1 : 0;
910
- const projectScope = projectScopeByName.get(pr._scope) || '';
911
- const matchesUrl = urlScope && projectScope && projectScope === urlScope ? 1 : 0;
923
+ const projectScopes = projectScopesByName.get(pr._scope) || [];
924
+ const matchesUrl = urlScope && projectScopes.includes(urlScope) ? 1 : 0;
912
925
  return (scopeOk << 1) | matchesUrl;
913
926
  }
914
927
 
@@ -2360,7 +2373,18 @@ function getPrdInfo(config) {
2360
2373
  const url = buildPrUrlFromId(prId, pr, projects);
2361
2374
  for (const itemId of (itemIds || [])) {
2362
2375
  if (!prdToPr[itemId]) prdToPr[itemId] = [];
2363
- prdToPr[itemId].push({ id: prId, url, title: pr?.title || '', status: pr?.status || PR_STATUS.ACTIVE, _project: pr?._project || '' });
2376
+ // W-mrcs8yqk001o6893 when the pr_links mapping outlives the PR record
2377
+ // (merged-PR sweep removes it from pull-requests.json but pr_links is
2378
+ // never pruned), resolve the item's project so we can self-heal the
2379
+ // status the same way the wi._pr fallback loop below does.
2380
+ let itemProject = null;
2381
+ if (!pr) {
2382
+ const itemSource = (wiById[itemId] || allWiById[itemId] || {}).project
2383
+ || (wiById[itemId] || allWiById[itemId] || {})._source || null;
2384
+ itemProject = itemSource ? shared.resolveProjectSource(itemSource, projects, { allowCentral: false }).project || null : null;
2385
+ }
2386
+ const status = _resolvePrStatusWithEnrichment(pr, prId, itemProject, itemId);
2387
+ prdToPr[itemId].push({ id: prId, url, title: pr?.title || '', status, _project: pr?._project || '' });
2364
2388
  }
2365
2389
  }
2366
2390
  // Fallback: work item _pr field for anything still missing
@@ -2376,17 +2400,8 @@ function getPrdInfo(config) {
2376
2400
  // exists (orphan _pr pointer), use a cached status from a prior async
2377
2401
  // enrollment if available, else synthesize ACTIVE and schedule a
2378
2402
  // fire-and-forget enrollment so the next status poll renders correctly.
2379
- let synthesizedStatus = pr?.status;
2380
- if (!pr && canonicalPrId) {
2381
- const cachedStatus = _getCachedPrEnrichment(canonicalPrId);
2382
- if (cachedStatus) {
2383
- synthesizedStatus = cachedStatus;
2384
- } else {
2385
- synthesizedStatus = PR_STATUS.ACTIVE;
2386
- _scheduleAsyncPrEnrollment(canonicalPrId, project, wi.id);
2387
- }
2388
- }
2389
- prdToPr[wi.id] = [{ id: pr?.id || canonicalPrId || wi._pr, url, title: pr?.title || '', status: synthesizedStatus || PR_STATUS.ACTIVE, _project: project?.name || '' }];
2403
+ const synthesizedStatus = _resolvePrStatusWithEnrichment(pr, canonicalPrId, project, wi.id);
2404
+ prdToPr[wi.id] = [{ id: pr?.id || canonicalPrId || wi._pr, url, title: pr?.title || '', status: synthesizedStatus, _project: project?.name || '' }];
2390
2405
  }
2391
2406
  // Aggregate sub-task PRs to decomposed parent (sub-tasks aren't PRD items but their PRs should show)
2392
2407
  for (const pr of allPrs) {
@@ -3419,7 +3434,7 @@ module.exports = {
3419
3434
  getKnowledgeBaseEntries, getKnowledgeBaseEntriesSnapshot, getKnowledgeBaseIndex,
3420
3435
 
3421
3436
  // Work items & PRD
3422
- getWorkItems, invalidateWorkItemsCache, invalidateArchiveNotesByWiCache, getPrdInfo,
3437
+ getWorkItems, invalidateWorkItemsCache, getPrdInfo,
3423
3438
 
3424
3439
  // W-mq5uzmc6001d708f — test hooks for the defensive PR enrichment cache.
3425
3440
  _resetPrEnrichmentCacheForTest,
package/engine/shared.js CHANGED
@@ -2306,8 +2306,47 @@ function runFile(file, args, opts = {}) {
2306
2306
  return _spawn(file, args, { windowsHide: true, ...opts });
2307
2307
  }
2308
2308
 
2309
+ // True when `p` is a UNC path (`\\server\share\...` or its forward-slash
2310
+ // form `//server/share/...`, e.g. `\\wsl.localhost\Ubuntu\...` / `\\wsl$\...`
2311
+ // for a WSL-hosted project). Windows' cmd.exe — the default shell
2312
+ // `execSync`/`exec` spawn under — refuses a UNC path as its working
2313
+ // directory ("UNC paths are not supported. Defaulting to Windows
2314
+ // directory."), which silently redirects the child's actual cwd elsewhere.
2315
+ function isUncPath(p) {
2316
+ return typeof p === 'string' && (p.startsWith('\\\\') || p.startsWith('//'));
2317
+ }
2318
+
2319
+ // Minimal argv tokenizer for the small, hardcoded `git ...` command strings
2320
+ // built elsewhere in this file — splits on whitespace, honoring
2321
+ // double-quoted segments (the only quoting these callers use, e.g. a branch
2322
+ // name interpolated as `"refs/heads/${branch}"`). Not a general shell
2323
+ // parser; only ever fed strings this codebase constructs itself.
2324
+ function _splitGitCommandLine(cmd) {
2325
+ const argv = [];
2326
+ const re = /"([^"]*)"|(\S+)/g;
2327
+ let m;
2328
+ while ((m = re.exec(cmd)) !== null) argv.push(m[1] !== undefined ? m[1] : m[2]);
2329
+ return argv;
2330
+ }
2331
+
2309
2332
  function execSilent(cmd, opts = {}) {
2310
- return _execSync(cmd, { stdio: 'pipe', windowsHide: true, ...opts });
2333
+ const merged = { stdio: 'pipe', windowsHide: true, ...opts };
2334
+ // W-mrcv3ky7000c2338 / opg#772 — a `git ...` shellout whose `cwd` is a UNC
2335
+ // path (WSL-hosted project, or any other UNC-rooted checkout) would
2336
+ // otherwise run through cmd.exe, which can't chdir into a UNC path and
2337
+ // silently defaults to the Windows directory instead — the subsequent git
2338
+ // command then fails with "fatal: not a git repository", and callers like
2339
+ // cleanup.js's orphan/quarantine worktree-dir reaper have no ground truth
2340
+ // for which worktrees are registered, so quarantined worktree husks are
2341
+ // never reaped (unbounded disk growth). Re-parse into argv and run via
2342
+ // execFileSync (shell:false) instead — that bypasses cmd.exe entirely and
2343
+ // Windows' CreateProcess honors a UNC cwd correctly, exactly like
2344
+ // shared.shellSafeGitSync / shared.removeWorktree already do.
2345
+ if (typeof cmd === 'string' && /^git\b/.test(cmd.trim()) && isUncPath(merged.cwd)) {
2346
+ const argv = _splitGitCommandLine(cmd);
2347
+ return _execFileSync(argv[0], argv.slice(1), merged);
2348
+ }
2349
+ return _execSync(cmd, merged);
2311
2350
  }
2312
2351
 
2313
2352
  /**
@@ -7227,6 +7266,74 @@ function getPrScopeInfo(prRef, url = '') {
7227
7266
  return canonical ? { ...canonical, source: 'id' } : null;
7228
7267
  }
7229
7268
 
7269
+ // W-mrchrfe100067d3a — validate + normalize `project.additionalRepoScopes`,
7270
+ // an explicit per-project opt-in allowlist of secondary canonical PR scopes
7271
+ // (e.g. `["github:yemi33/minions"]` for the minions-opg project's live
7272
+ // checkout, which legitimately carries a second git remote for backport
7273
+ // work — see CLAUDE.md "Remotes"). Deliberately NOT a wildcard: a scope must
7274
+ // be an exact `host:owner/repo` (or ADO `host:org/project/repo`) match to be
7275
+ // treated as compatible, so a typo'd or malicious PR URL can't slip into
7276
+ // auto-management just because *some* additional scope is configured.
7277
+ // Returns a deduped, normalized (lowercase, trimmed) array of scope strings;
7278
+ // malformed / empty entries are silently dropped (read-side leniency — write
7279
+ // paths should validate with validateAdditionalRepoScopes instead).
7280
+ function getProjectAdditionalPrScopes(project) {
7281
+ const raw = project?.additionalRepoScopes;
7282
+ if (!Array.isArray(raw) || raw.length === 0) return [];
7283
+ const out = [];
7284
+ const seen = new Set();
7285
+ for (const entry of raw) {
7286
+ const scope = _normalizeScopeString(entry);
7287
+ if (scope && !seen.has(scope)) {
7288
+ seen.add(scope);
7289
+ out.push(scope);
7290
+ }
7291
+ }
7292
+ return out;
7293
+ }
7294
+
7295
+ // Normalizes+validates a single scope string of the shape `github:owner/repo`
7296
+ // or `ado:org/project/repo`. Returns '' for anything malformed.
7297
+ function _normalizeScopeString(value) {
7298
+ const match = String(value || '').trim().match(/^(github|ado):(.+)$/i);
7299
+ if (!match) return '';
7300
+ const host = match[1].toLowerCase();
7301
+ const parts = match[2].split('/').map(normalizePrScopeSegment).filter(Boolean);
7302
+ const expectedParts = host === 'github' ? 2 : 3;
7303
+ if (parts.length !== expectedParts) return '';
7304
+ return `${host}:${parts.join('/')}`;
7305
+ }
7306
+
7307
+ // Validates a project-settings-update payload for `additionalRepoScopes`.
7308
+ // Mirrors the validateCheckoutMode/validateLiveValidation contract: returns
7309
+ // undefined when the caller should clear the field (empty/null/[] input);
7310
+ // otherwise returns the normalized array, or throws HTTP 400 on malformed
7311
+ // input (non-array, non-string entries, or entries that don't match the
7312
+ // canonical `host:owner/repo[/repo]` scope grammar).
7313
+ function validateAdditionalRepoScopes(value) {
7314
+ if (value === undefined || value === null) return undefined;
7315
+ if (!Array.isArray(value)) {
7316
+ throw _httpError(400, `Invalid additionalRepoScopes: must be an array of scope strings (got ${typeof value}).`);
7317
+ }
7318
+ if (value.length === 0) return undefined;
7319
+ const out = [];
7320
+ const seen = new Set();
7321
+ for (const entry of value) {
7322
+ if (typeof entry !== 'string' || !entry.trim()) {
7323
+ throw _httpError(400, `Invalid additionalRepoScopes entry: "${entry}" — must be a non-empty string.`);
7324
+ }
7325
+ const scope = _normalizeScopeString(entry);
7326
+ if (!scope) {
7327
+ throw _httpError(400, `Invalid additionalRepoScopes entry: "${entry}" — expected "github:owner/repo" or "ado:org/project/repo".`);
7328
+ }
7329
+ if (!seen.has(scope)) {
7330
+ seen.add(scope);
7331
+ out.push(scope);
7332
+ }
7333
+ }
7334
+ return out.length > 0 ? out : undefined;
7335
+ }
7336
+
7230
7337
  function getPrProjectScopeMismatch(project, prRef, url = '') {
7231
7338
  const projectScope = getProjectPrScope(project);
7232
7339
  if (!projectScope) return null;
@@ -7240,6 +7347,11 @@ function getPrProjectScopeMismatch(project, prRef, url = '') {
7240
7347
  const refParts = refRest.split('/');
7241
7348
  if (projectParts[0] === refParts[0] && projectParts[1] === refParts[1]) return null;
7242
7349
  }
7350
+ // W-mrchrfe100067d3a — explicit opt-in allowlist: a PR scoped to one of
7351
+ // this project's configured additionalRepoScopes is NOT a mismatch. This
7352
+ // is intentionally an exact-match allowlist (not "any scope compatible")
7353
+ // so an unrelated/typo'd repo URL still gets flagged.
7354
+ if (getProjectAdditionalPrScopes(project).includes(refScope)) return null;
7243
7355
  return { reason: 'pr_scope_mismatch', projectScope, prScope: refScope };
7244
7356
  }
7245
7357
 
@@ -7247,6 +7359,85 @@ function isPrCompatibleWithProject(project, prRef, url = '') {
7247
7359
  return !getPrProjectScopeMismatch(project, prRef, url);
7248
7360
  }
7249
7361
 
7362
+ // All canonical scopes this project is configured to own: its primary scope
7363
+ // plus any additionalRepoScopes. Used by cross-project PR dedupe scoring
7364
+ // (queries.js) so a record scoped to a secondary allowlisted repo isn't
7365
+ // penalized relative to records in projects whose PRIMARY scope matches.
7366
+ function getProjectAllPrScopes(project) {
7367
+ const primary = getProjectPrScope(project);
7368
+ const additional = getProjectAdditionalPrScopes(project);
7369
+ const out = [];
7370
+ if (primary) out.push(primary);
7371
+ for (const s of additional) if (!out.includes(s)) out.push(s);
7372
+ return out;
7373
+ }
7374
+
7375
+ // W-mrchrfe100067d3a — cache of `localPath -> Map(scope -> remoteName)`
7376
+ // derived from `git remote -v`, so repeated dependency-fetch resolution
7377
+ // within a tick (or across nearby ticks) doesn't re-shell-out per branch.
7378
+ const _gitRemoteScopeCache = new Map();
7379
+ const GIT_REMOTE_SCOPE_CACHE_TTL_MS = 5 * 60 * 1000;
7380
+
7381
+ // Best-effort parse of a git remote fetch URL into a canonical scope string.
7382
+ // Handles the URL shapes git itself produces for `git remote -v` output:
7383
+ // SSH (`git@host:owner/repo.git`), HTTPS (`https://host/owner/repo.git`),
7384
+ // and the two common ADO host forms (`dev.azure.com/org/project/_git/repo`,
7385
+ // `org.visualstudio.com/project/_git/repo`). Returns null when unrecognized.
7386
+ function _parseRemoteUrlToScope(url) {
7387
+ const cleaned = String(url || '').trim().replace(/\.git$/i, '');
7388
+ if (!cleaned) return null;
7389
+ let m = cleaned.match(/github\.com[:/]+([^/]+)\/([^/]+)\/?$/i);
7390
+ if (m) return `github:${normalizePrScopeSegment(m[1])}/${normalizePrScopeSegment(m[2])}`;
7391
+ m = cleaned.match(/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)\/?$/i);
7392
+ if (m) return `ado:${normalizePrScopeSegment(m[1])}/${normalizePrScopeSegment(m[2])}/${normalizePrScopeSegment(m[3])}`;
7393
+ m = cleaned.match(/([^/@:]+)\.visualstudio\.com\/(?:DefaultCollection\/)?([^/]+)\/_git\/([^/]+)\/?$/i);
7394
+ if (m) return `ado:${normalizePrScopeSegment(m[1])}/${normalizePrScopeSegment(m[2])}/${normalizePrScopeSegment(m[3])}`;
7395
+ return null;
7396
+ }
7397
+
7398
+ function _listGitRemoteScopes(localPath) {
7399
+ if (!localPath) return new Map();
7400
+ const now = Date.now();
7401
+ const cached = _gitRemoteScopeCache.get(localPath);
7402
+ if (cached && (now - cached.at) < GIT_REMOTE_SCOPE_CACHE_TTL_MS) return cached.remotes;
7403
+ const remotes = new Map();
7404
+ try {
7405
+ const out = shellSafeGitSync(['remote', '-v'], { cwd: localPath, timeout: 10000 });
7406
+ for (const line of String(out || '').split('\n')) {
7407
+ const m = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)/);
7408
+ if (!m) continue;
7409
+ const scope = _parseRemoteUrlToScope(m[2]);
7410
+ if (scope && !remotes.has(scope)) remotes.set(scope, m[1]);
7411
+ }
7412
+ } catch { /* best effort — caller falls back to 'origin' */ }
7413
+ _gitRemoteScopeCache.set(localPath, { at: now, remotes });
7414
+ return remotes;
7415
+ }
7416
+
7417
+ // W-mrchrfe100067d3a — resolve which git remote name owns a PR's branch when
7418
+ // that PR is scoped to one of the project's additionalRepoScopes entries
7419
+ // (e.g. a backport PR opened against `yemi33/minions` from the minions-opg
7420
+ // checkout, which has both `origin` -> opg-microsoft/minions and `yemi33` ->
7421
+ // yemi33/minions configured). For the project's own primary scope, or any
7422
+ // scope NOT in additionalRepoScopes, this always returns 'origin' —
7423
+ // preserving today's universal default for every project that doesn't opt
7424
+ // into multi-scope tracking. Only when the scope is allowlisted AND a local
7425
+ // remote's fetch URL actually resolves to that scope do we return the
7426
+ // non-origin remote name; otherwise 'origin' remains the safe fallback (the
7427
+ // caller's existing fetch-failure handling is unchanged).
7428
+ function resolveGitRemoteForPrScope(project, prScopeOrRef, url = '') {
7429
+ if (!project) return 'origin';
7430
+ const scope = (typeof prScopeOrRef === 'string' && /^(github|ado):/i.test(prScopeOrRef))
7431
+ ? prScopeOrRef
7432
+ : (getPrScopeInfo(prScopeOrRef, url)?.scope || '');
7433
+ if (!scope) return 'origin';
7434
+ const projectScope = getProjectPrScope(project);
7435
+ if (scope === projectScope) return 'origin';
7436
+ if (!getProjectAdditionalPrScopes(project).includes(scope)) return 'origin';
7437
+ const remotes = _listGitRemoteScopes(project.localPath);
7438
+ return remotes.get(scope) || 'origin';
7439
+ }
7440
+
7250
7441
  /**
7251
7442
  * Check if a parsed ADO PR scope is compatible with a project config,
7252
7443
  * considering that the repo segment in the URL might be either the friendly
@@ -8012,12 +8203,21 @@ function mutatePullRequests(filePath, mutator) {
8012
8203
  if (insideMinionsDir) {
8013
8204
  const store = require('./pull-requests-store');
8014
8205
  const scope = store.scopeForFilePath(filePath);
8206
+ // W-mrcg7j9k000ja233 (#759) — the JSON mirror is now written INSIDE
8207
+ // applyPullRequestsMutation's SQL transaction (before commit), not here
8208
+ // after the fact. Mirroring post-commit left a cross-process window
8209
+ // where a sibling process (dashboard vs. engine — separate Node
8210
+ // processes) could observe the new SQL row before the JSON file caught
8211
+ // up and destructively rehydrate the scope from that stale file,
8212
+ // dropping the just-written row. See
8213
+ // pull-requests-store.js#applyPullRequestsMutation for the full
8214
+ // rationale (mirrors the identical work-items-store.js fix,
8215
+ // W-mr9vcxvy000cdb4e).
8015
8216
  const { wrote, result } = store.applyPullRequestsMutation(scope, (prs) => {
8016
8217
  if (!Array.isArray(prs)) prs = [];
8017
8218
  return mutator(prs) || prs;
8018
- });
8219
+ }, { mirrorPath: filePath });
8019
8220
  if (wrote) {
8020
- try { store._mirrorJsonFromSql(scope, filePath); } catch { /* mirror best-effort */ }
8021
8221
  try { require('./db-events').emitStateEvent('pull_requests'); } catch { /* optional */ }
8022
8222
  }
8023
8223
  return result;
@@ -9013,6 +9213,7 @@ module.exports = {
9013
9213
  exec,
9014
9214
  execAsync,
9015
9215
  execSilent,
9216
+ isUncPath,
9016
9217
  shellSafeGh,
9017
9218
  shellSafeGit,
9018
9219
  removeStaleIndexLock,
@@ -9098,6 +9299,10 @@ module.exports = {
9098
9299
  rewriteInboxRefsAcrossProjects,
9099
9300
  _artifactNoteFileToken, // exported so the one-time note-link backfill shares the token grammar
9100
9301
  getProjectPrScope,
9302
+ getProjectAdditionalPrScopes,
9303
+ getProjectAllPrScopes,
9304
+ validateAdditionalRepoScopes,
9305
+ resolveGitRemoteForPrScope,
9101
9306
  getPrNumber,
9102
9307
  getPrDisplayId,
9103
9308
  getPrScopeInfo,