@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/dashboard/js/render-pipelines.js +12 -3
- package/dashboard/js/render-prs.js +55 -1
- package/dashboard/js/settings.js +30 -10
- package/dashboard.js +102 -2
- package/docs/README.md +1 -0
- package/docs/live-checkout-mode.md +6 -0
- package/docs/worktree-lifecycle.md +77 -1
- package/engine/ado.js +1 -1
- package/engine/cleanup.js +4 -4
- package/engine/db/migrations/016-pr-mirror-hash.js +31 -0
- package/engine/lifecycle.js +93 -5
- package/engine/live-checkout.js +141 -0
- package/engine/pipeline.js +87 -10
- package/engine/playbook.js +6 -5
- package/engine/pull-requests-store.js +70 -9
- package/engine/queries.js +44 -29
- package/engine/shared.js +208 -3
- package/engine.js +519 -87
- package/package.json +1 -1
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
|
|
887
|
-
//
|
|
888
|
-
|
|
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
|
|
892
|
-
if (
|
|
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
|
|
911
|
-
const matchesUrl = urlScope &&
|
|
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
|
-
|
|
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
|
-
|
|
2380
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|