@yemi33/minions 0.1.2355 → 0.1.2356
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/settings.js +30 -10
- package/dashboard.js +24 -1
- package/engine/db/migrations/016-pr-mirror-hash.js +31 -0
- package/engine/lifecycle.js +21 -5
- package/engine/pipeline.js +25 -2
- package/engine/pull-requests-store.js +70 -9
- package/engine/queries.js +10 -7
- package/engine/shared.js +167 -2
- package/engine.js +33 -7
- package/package.json +1 -1
package/dashboard/js/settings.js
CHANGED
|
@@ -342,17 +342,31 @@ async function openSettings() {
|
|
|
342
342
|
'<option value="off"' + (autoStashRaw === 'off' ? ' selected' : '') + '>Off — fail on dirty tree</option>' +
|
|
343
343
|
'</select>' +
|
|
344
344
|
'<div style="font-size:var(--text-xs);color:var(--muted);margin-top:2px;line-height:1.4">Live mode only. On = `git stash push --include-untracked` a dirty tree before dispatch instead of failing; the engine never pops the stash (run `git stash pop` manually). Overrides the fleet-wide setting under Worktrees.</div>' +
|
|
345
|
-
|
|
345
|
+
'</div>';
|
|
346
|
+
// W-mrchrfe100067d3a — per-project explicit allowlist of secondary repo
|
|
347
|
+
// scopes this project's checkout is also allowed to track PRs for (e.g.
|
|
348
|
+
// minions-opg's checkout has both an `origin` remote → opg-microsoft/minions
|
|
349
|
+
// and a `yemi33` remote → yemi33/minions for backport work — see
|
|
350
|
+
// CLAUDE.md "Remotes"). Comma-separated canonical scope strings
|
|
351
|
+
// (`github:owner/repo` or `ado:org/project/repo`); parsed + validated
|
|
352
|
+
// server-side by shared.validateAdditionalRepoScopes on save.
|
|
353
|
+
var additionalScopesSearch = 'additional repo scopes multi-scope dual-remote backport yemi33';
|
|
354
|
+
var additionalScopesBlock = '<div data-search="' + escHtml(additionalScopesSearch) + '" style="margin-bottom:6px">' +
|
|
355
|
+
settingsField('Additional repo scopes (secondary remotes)', 'set-additionalRepoScopes-' + p.name,
|
|
356
|
+
(Array.isArray(p.additionalRepoScopes) ? p.additionalRepoScopes : []).join(', '), '',
|
|
357
|
+
'Comma-separated canonical scopes this project\'s PRs may ALSO belong to (e.g. "github:yemi33/minions"). Explicit opt-in allowlist only — PRs matching one of these scopes are treated as compatible for auto-review/auto-fix without being flagged pr_scope_mismatch. Leave empty for single-scope projects.') +
|
|
358
|
+
'</div>';
|
|
346
359
|
return '<div data-settings-project="' + escHtml(p.name) + '" data-search="project ' + escHtml(p.name.toLowerCase()) + '" style="border:1px solid var(--border);border-radius:6px;padding:10px 12px;margin-bottom:12px">' +
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
360
|
+
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">' +
|
|
361
|
+
'<div style="font-size:var(--text-md);font-weight:600">' + escHtml(p.name) + '</div>' +
|
|
362
|
+
'<button onclick="MinionsSettings.removeProject(\'' + escHtml(p.name) + '\')" style="font-size:var(--text-xs);padding:2px 8px;background:transparent;color:var(--red);border:1px solid var(--red);border-radius:3px;cursor:pointer">Remove</button>' +
|
|
363
|
+
'</div>' +
|
|
364
|
+
pathRow +
|
|
365
|
+
branchGrid +
|
|
366
|
+
worktreeModeBlock +
|
|
367
|
+
autoStashBlock +
|
|
368
|
+
additionalScopesBlock +
|
|
369
|
+
driftNote +
|
|
356
370
|
'<div style="display:flex;flex-direction:column;gap:6px;margin-top:8px">' +
|
|
357
371
|
settingsToggle('Discover from PRs', 'set-ws-prs-' + p.name, p.workSources.pullRequests.enabled, 'Discovery gate: scan repo for open PRs and surface them as review tasks. Independent of ADO/GitHub polling — does not affect already-tracked PRs.') +
|
|
358
372
|
settingsToggle('Discover from Work Items', 'set-ws-wi-' + p.name, p.workSources.workItems.enabled, 'Auto-discover work from ADO/GitHub work items') +
|
|
@@ -1257,12 +1271,18 @@ async function saveSettings() {
|
|
|
1257
1271
|
const autoStashInput = document.getElementById('set-liveCheckoutAutoStash-' + p.name);
|
|
1258
1272
|
const autoStashRaw = autoStashInput ? autoStashInput.value : '';
|
|
1259
1273
|
const autoStashValue = autoStashRaw === 'on' ? true : autoStashRaw === 'off' ? false : null;
|
|
1274
|
+
// W-mrchrfe100067d3a — parse the comma-separated scope list back into an
|
|
1275
|
+
// array; empty string → null (clears the override server-side).
|
|
1276
|
+
const additionalScopesInput = document.getElementById('set-additionalRepoScopes-' + p.name);
|
|
1277
|
+
const additionalScopesRaw = additionalScopesInput ? additionalScopesInput.value : '';
|
|
1278
|
+
const additionalRepoScopes = additionalScopesRaw.split(',').map(function(s) { return s.trim(); }).filter(Boolean);
|
|
1260
1279
|
return {
|
|
1261
1280
|
name: p.name,
|
|
1262
1281
|
mainBranch: mainBranchValue || null,
|
|
1263
1282
|
checkoutMode: checkoutMode,
|
|
1264
1283
|
liveValidation: liveValidation,
|
|
1265
1284
|
liveCheckoutAutoStash: autoStashValue,
|
|
1285
|
+
additionalRepoScopes: additionalRepoScopes.length > 0 ? additionalRepoScopes : null,
|
|
1266
1286
|
workSources: {
|
|
1267
1287
|
pullRequests: { enabled: document.getElementById('set-ws-prs-' + p.name)?.checked ?? true },
|
|
1268
1288
|
workItems: { enabled: document.getElementById('set-ws-wi-' + p.name)?.checked ?? true }
|
package/dashboard.js
CHANGED
|
@@ -11184,6 +11184,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
11184
11184
|
// tri-state dropdown. Undefined means "use fleet default"
|
|
11185
11185
|
// (engine.liveCheckoutAutoStash); only an explicit boolean overrides.
|
|
11186
11186
|
liveCheckoutAutoStash: (typeof p.liveCheckoutAutoStash === 'boolean') ? p.liveCheckoutAutoStash : undefined,
|
|
11187
|
+
// W-mrchrfe100067d3a — surface the per-project secondary-repo
|
|
11188
|
+
// allowlist so the Settings → Projects UI can pre-fill / edit it.
|
|
11189
|
+
// Empty array when unset (never null) so the UI can render a
|
|
11190
|
+
// simple add/remove list without an extra null-check.
|
|
11191
|
+
additionalRepoScopes: shared.getProjectAdditionalPrScopes(p),
|
|
11187
11192
|
workSources: {
|
|
11188
11193
|
pullRequests: { enabled: p.workSources?.pullRequests?.enabled !== false, cooldownMinutes: p.workSources?.pullRequests?.cooldownMinutes ?? 30 },
|
|
11189
11194
|
workItems: { enabled: p.workSources?.workItems?.enabled !== false, cooldownMinutes: p.workSources?.workItems?.cooldownMinutes ?? 0 }
|
|
@@ -11668,6 +11673,24 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
11668
11673
|
proj.liveCheckoutAutoStash = !!rawStash;
|
|
11669
11674
|
}
|
|
11670
11675
|
}
|
|
11676
|
+
// W-mrchrfe100067d3a — per-project explicit allowlist of secondary
|
|
11677
|
+
// repo scopes (e.g. minions-opg's checkout also legitimately opens
|
|
11678
|
+
// PRs against yemi33/minions for backport work — see CLAUDE.md
|
|
11679
|
+
// "Remotes"). Empty array / null / '' clears the override (falls
|
|
11680
|
+
// back to single-scope matching); any other value flows through
|
|
11681
|
+
// shared.validateAdditionalRepoScopes, which throws HTTP 400 on a
|
|
11682
|
+
// non-array or malformed scope string (errors propagate to the
|
|
11683
|
+
// outer catch below via e.statusCode/e.message).
|
|
11684
|
+
if (Object.prototype.hasOwnProperty.call(update, 'additionalRepoScopes')) {
|
|
11685
|
+
const rawScopes = update.additionalRepoScopes;
|
|
11686
|
+
if (rawScopes === '' || rawScopes === null || (Array.isArray(rawScopes) && rawScopes.length === 0)) {
|
|
11687
|
+
delete proj.additionalRepoScopes;
|
|
11688
|
+
} else {
|
|
11689
|
+
const validatedScopes = shared.validateAdditionalRepoScopes(rawScopes);
|
|
11690
|
+
if (validatedScopes === undefined) delete proj.additionalRepoScopes;
|
|
11691
|
+
else proj.additionalRepoScopes = validatedScopes;
|
|
11692
|
+
}
|
|
11693
|
+
}
|
|
11671
11694
|
}
|
|
11672
11695
|
}
|
|
11673
11696
|
|
|
@@ -14707,7 +14730,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
14707
14730
|
|
|
14708
14731
|
// Settings
|
|
14709
14732
|
{ method: 'GET', path: '/api/settings', desc: 'Return current engine + claude + routing config', handler: handleSettingsRead },
|
|
14710
|
-
{ method: 'POST', path: '/api/settings', desc: 'Update engine + claude + agent + projects config', params: 'engine?, claude?, agents?, projects? (per-project: checkoutMode, liveValidation, mainBranch, workSources)', handler: handleSettingsUpdate },
|
|
14733
|
+
{ method: 'POST', path: '/api/settings', desc: 'Update engine + claude + agent + projects config', params: 'engine?, claude?, agents?, projects? (per-project: checkoutMode, liveValidation, mainBranch, workSources, additionalRepoScopes)', handler: handleSettingsUpdate },
|
|
14711
14734
|
{ method: 'POST', path: '/api/settings/routing', desc: 'Update routing.md', params: 'content', handler: handleSettingsRouting },
|
|
14712
14735
|
{ method: 'POST', path: '/api/settings/reset', desc: 'Reset engine + claude + agent settings to defaults', handler: handleSettingsReset },
|
|
14713
14736
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// engine/db/migrations/016-pr-mirror-hash.js
|
|
2
|
+
//
|
|
3
|
+
// Fix for opg-microsoft/minions#759: pull-requests-store.js tracked its
|
|
4
|
+
// JSON-mirror "last known hash" per scope in an in-memory-only Map
|
|
5
|
+
// (`_lastMirrorHashByScope`). That state resets to empty on every engine
|
|
6
|
+
// restart, so the very first touch of a scope after a restart has no way to
|
|
7
|
+
// tell "SQL and JSON already agree" from "JSON just changed out from under
|
|
8
|
+
// us" — it falls back to a lastHash==null heuristic that only partially
|
|
9
|
+
// covers the case. Combined with a restart landing mid-flight of another
|
|
10
|
+
// process's mirror write, a stale JSON snapshot could get treated as
|
|
11
|
+
// canonical and used to rebuild (blindly DELETE + reinsert) the SQL table
|
|
12
|
+
// for that scope, permanently dropping any rows not present in the stale
|
|
13
|
+
// file.
|
|
14
|
+
//
|
|
15
|
+
// This table makes the mirror-hash durable across restarts: it is now the
|
|
16
|
+
// single, persisted cross-process source of truth for "what mirror state
|
|
17
|
+
// did the store last agree with", instead of a per-process cache that goes
|
|
18
|
+
// blank on every restart.
|
|
19
|
+
module.exports = {
|
|
20
|
+
version: 16,
|
|
21
|
+
description: 'pr_mirror_hashes — persist pull-requests.json mirror-hash across restarts (#759)',
|
|
22
|
+
up(db) {
|
|
23
|
+
db.exec(`
|
|
24
|
+
CREATE TABLE pr_mirror_hashes (
|
|
25
|
+
scope TEXT PRIMARY KEY,
|
|
26
|
+
hash TEXT NOT NULL,
|
|
27
|
+
updated_at INTEGER NOT NULL
|
|
28
|
+
);
|
|
29
|
+
`);
|
|
30
|
+
},
|
|
31
|
+
};
|
package/engine/lifecycle.js
CHANGED
|
@@ -1122,6 +1122,13 @@ function syncPrsFromOutput(output, agentId, meta, config, opts = {}) {
|
|
|
1122
1122
|
const projScope = shared.getProjectPrScope(p);
|
|
1123
1123
|
if (projScope && projScope === urlScope) return p;
|
|
1124
1124
|
}
|
|
1125
|
+
// W-mrchrfe100067d3a — a PR scoped to one of a project's allowlisted
|
|
1126
|
+
// additionalRepoScopes (e.g. a backport PR opened against
|
|
1127
|
+
// yemi33/minions from the minions-opg checkout) belongs to that
|
|
1128
|
+
// project too, even though it isn't the project's primary scope.
|
|
1129
|
+
for (const p of projects) {
|
|
1130
|
+
if (shared.getProjectAdditionalPrScopes(p).includes(urlScope)) return p;
|
|
1131
|
+
}
|
|
1125
1132
|
}
|
|
1126
1133
|
const evidenceText = `${outputText}\n${evidenceUrl}`;
|
|
1127
1134
|
for (const p of projects) {
|
|
@@ -6611,16 +6618,25 @@ function pruneScopeMismatchDuplicatePrs(config) {
|
|
|
6611
6618
|
const projects = shared.getProjects(config) || [];
|
|
6612
6619
|
if (projects.length === 0) return { pruned: 0, relocated: 0, scanned: 0 };
|
|
6613
6620
|
|
|
6614
|
-
// Map project name -> canonical scope so we can find which project IS
|
|
6615
|
-
// correctly-scoped owner for a given mismatch record.
|
|
6621
|
+
// Map project name -> canonical scope(s) so we can find which project IS
|
|
6622
|
+
// the correctly-scoped owner for a given mismatch record. Includes
|
|
6623
|
+
// additionalRepoScopes (W-mrchrfe100067d3a) so a stale `_invalidProjectScope`
|
|
6624
|
+
// stamp left over from before a project opted into a secondary scope (or
|
|
6625
|
+
// any record from another host that hasn't been re-normalized yet) maps
|
|
6626
|
+
// back to the project that now allowlists it, instead of relocating to a
|
|
6627
|
+
// nonexistent project or getting stuck unresolved.
|
|
6616
6628
|
const scopeByProject = new Map();
|
|
6617
6629
|
const projectByScope = new Map();
|
|
6618
6630
|
for (const p of projects) {
|
|
6619
6631
|
if (!p || !p.name) continue;
|
|
6620
6632
|
const sc = shared.getProjectPrScope(p);
|
|
6621
|
-
if (
|
|
6622
|
-
|
|
6623
|
-
|
|
6633
|
+
if (sc) {
|
|
6634
|
+
scopeByProject.set(p.name, sc);
|
|
6635
|
+
if (!projectByScope.has(sc)) projectByScope.set(sc, p);
|
|
6636
|
+
}
|
|
6637
|
+
for (const additionalScope of shared.getProjectAdditionalPrScopes(p)) {
|
|
6638
|
+
if (!projectByScope.has(additionalScope)) projectByScope.set(additionalScope, p);
|
|
6639
|
+
}
|
|
6624
6640
|
}
|
|
6625
6641
|
|
|
6626
6642
|
// Index all PRs by canonical id across all projects (post-_scope decoration).
|
package/engine/pipeline.js
CHANGED
|
@@ -8,7 +8,7 @@ const fs = require('fs');
|
|
|
8
8
|
const path = require('path');
|
|
9
9
|
const shared = require('./shared');
|
|
10
10
|
const queries = require('./queries');
|
|
11
|
-
const { safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, mutatePipelineRuns, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, READ_ONLY_ROOT_TASK_TYPES, ENGINE_DEFAULTS, MINIONS_DIR } = shared;
|
|
11
|
+
const { safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, mutatePipelineRuns, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, READ_ONLY_ROOT_TASK_TYPES, ENGINE_DEFAULTS, MINIONS_DIR, isPrdArchived } = shared;
|
|
12
12
|
const routing = require('./routing');
|
|
13
13
|
const http = require('http');
|
|
14
14
|
const { shouldRunNow } = require('./scheduler');
|
|
@@ -537,8 +537,26 @@ function _canonicalPlanName(planFile) {
|
|
|
537
537
|
return String(planFile || '').replace(/^(.*-\d{4}-\d{2}-\d{2})-\d+(\.md)$/i, '$1$2');
|
|
538
538
|
}
|
|
539
539
|
|
|
540
|
+
// PRD top-level statuses that indicate a genuine, materialized plan-to-prd
|
|
541
|
+
// conversion (mirrors PLAN_STATUS — a PRD's status field is drawn from the
|
|
542
|
+
// same enum as a plan's). 'archived' is intentionally NOT in PLAN_STATUS (it's
|
|
543
|
+
// an in-place-archive-only status stamped by dashboard.js's _archivePrdPostProcess,
|
|
544
|
+
// not a plan lifecycle state) but is a real status a converted-then-archived PRD
|
|
545
|
+
// can carry — checked separately below via isPrdArchived so an already-archived
|
|
546
|
+
// conversion isn't mistaken for a stub and re-triggered.
|
|
547
|
+
const _VALID_EXISTING_PRD_STATUSES = new Set(Object.values(PLAN_STATUS));
|
|
548
|
+
|
|
540
549
|
// Check if a PRD already exists for a given plan file (plan already converted).
|
|
541
550
|
// Matches by canonical name so a collision-bumped plan still finds its PRD.
|
|
551
|
+
//
|
|
552
|
+
// A matching `source_plan` alone is not proof of a successful conversion — a
|
|
553
|
+
// crashed/interrupted plan-to-prd write, or a fragment-looking LLM response
|
|
554
|
+
// (the same garbage-content failure mode fixed for plan stages in PR #750),
|
|
555
|
+
// can leave a stub PRD JSON with `source_plan` set but no recognized `status`
|
|
556
|
+
// and no `missing_features`. Treating that stub as "already converted" would
|
|
557
|
+
// permanently skip re-running plan-to-prd for a plan that never actually got
|
|
558
|
+
// a usable PRD. Require both a recognized top-level status AND an array
|
|
559
|
+
// `missing_features` field before counting the PRD as an existing conversion.
|
|
542
560
|
function _findExistingPrdForPlan(planFile, prdDir) {
|
|
543
561
|
if (!fs.existsSync(prdDir)) return null;
|
|
544
562
|
const prdFiles = safeReadDir(prdDir).filter(f => f.endsWith('.json'));
|
|
@@ -547,7 +565,12 @@ function _findExistingPrdForPlan(planFile, prdDir) {
|
|
|
547
565
|
// safeJsonNoRestore: PRDs are terminal artifacts — never restore archived
|
|
548
566
|
// PRDs from a stale .backup sidecar (W-mouptdh1000h9f39).
|
|
549
567
|
const prd = safeJsonNoRestore(path.join(prdDir, pf));
|
|
550
|
-
if (prd?.source_plan
|
|
568
|
+
if (!prd?.source_plan || _canonicalPlanName(path.basename(String(prd.source_plan))) !== planKey) continue;
|
|
569
|
+
if (!(_VALID_EXISTING_PRD_STATUSES.has(prd.status) || isPrdArchived(prd)) || !Array.isArray(prd.missing_features)) {
|
|
570
|
+
log('warn', `Pipeline plan-to-prd: found PRD "${pf}" for plan "${planFile}" but it lacks a valid status/missing_features (garbage/incomplete conversion) — treating plan-to-prd as not yet done`);
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
return pf;
|
|
551
574
|
}
|
|
552
575
|
return null;
|
|
553
576
|
}
|
|
@@ -64,7 +64,33 @@ function _readJsonArrayFallback(scope) {
|
|
|
64
64
|
|
|
65
65
|
// Content-hash fingerprint per scope: same-length swaps (timestamps,
|
|
66
66
|
// reviewStatus enums) collide on (mtime, size) but never on SHA-1.
|
|
67
|
-
|
|
67
|
+
//
|
|
68
|
+
// W-mrcg7j9k000ja233 (#759): this used to be an in-memory-only Map, which
|
|
69
|
+
// resets to empty on every process restart. Persist it in SQL instead
|
|
70
|
+
// (`pr_mirror_hashes`) so a restart can't blank out the "does JSON already
|
|
71
|
+
// agree with SQL" trust state — that reset, combined with a restart landing
|
|
72
|
+
// mid-flight of another process's mirror write, is exactly what let a stale
|
|
73
|
+
// JSON snapshot get treated as canonical and silently wipe SQL rows for a
|
|
74
|
+
// scope (see _resyncScopeIfJsonDiverged / _hydrateScopeFromJson below).
|
|
75
|
+
function _getLastMirrorHash(db, scope) {
|
|
76
|
+
try {
|
|
77
|
+
const row = db.prepare('SELECT hash FROM pr_mirror_hashes WHERE scope = ?').get(scope);
|
|
78
|
+
return row ? row.hash : null;
|
|
79
|
+
} catch { return null; }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function _setLastMirrorHash(db, scope, hash) {
|
|
83
|
+
try {
|
|
84
|
+
db.prepare(`
|
|
85
|
+
INSERT INTO pr_mirror_hashes (scope, hash, updated_at) VALUES (?, ?, ?)
|
|
86
|
+
ON CONFLICT(scope) DO UPDATE SET hash = excluded.hash, updated_at = excluded.updated_at
|
|
87
|
+
`).run(scope, hash, Date.now());
|
|
88
|
+
} catch { /* best-effort — a missed persisted-hash write just means the next resync re-checks */ }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function _forgetLastMirrorHash(db, scope) {
|
|
92
|
+
try { db.prepare('DELETE FROM pr_mirror_hashes WHERE scope = ?').run(scope); } catch { /* best-effort */ }
|
|
93
|
+
}
|
|
68
94
|
|
|
69
95
|
const crypto = require('crypto');
|
|
70
96
|
|
|
@@ -78,7 +104,24 @@ function _fileContentHash(filePath) {
|
|
|
78
104
|
|
|
79
105
|
function _hydrateScopeFromJson(db, scope) {
|
|
80
106
|
const jsonItems = _readJsonArrayFallback(scope);
|
|
107
|
+
const beforeCount = db.prepare('SELECT COUNT(*) AS n FROM pull_requests WHERE scope = ?').get(scope).n;
|
|
108
|
+
const beforeIds = db.prepare('SELECT id FROM pull_requests WHERE scope = ?').all(scope).map(r => r.id);
|
|
81
109
|
db.prepare('DELETE FROM pull_requests WHERE scope = ?').run(scope);
|
|
110
|
+
// #759 — this DELETE used to be completely silent. Log every hydrate at
|
|
111
|
+
// warn level with before/after counts + the ids that are about to be
|
|
112
|
+
// dropped, so a JSON<->SQL resync that removes rows the operator didn't
|
|
113
|
+
// expect leaves a trace in engine-stdio.log instead of vanishing without
|
|
114
|
+
// explanation.
|
|
115
|
+
try {
|
|
116
|
+
const afterIds = new Set(jsonItems.filter(pr => pr && pr.id).map(pr => String(pr.id)));
|
|
117
|
+
const droppedIds = beforeIds.filter(id => !afterIds.has(id));
|
|
118
|
+
// eslint-disable-next-line no-console
|
|
119
|
+
console.warn(
|
|
120
|
+
`[pull-requests-store] _hydrateScopeFromJson(scope=${scope}): rebuilding SQL from JSON mirror — `
|
|
121
|
+
+ `before=${beforeCount} row(s), after=${jsonItems.length} row(s)`
|
|
122
|
+
+ (droppedIds.length ? `, dropping id(s): ${droppedIds.join(', ')}` : ''),
|
|
123
|
+
);
|
|
124
|
+
} catch { /* logging must never block the resync */ }
|
|
82
125
|
if (jsonItems.length === 0) return;
|
|
83
126
|
const now = Date.now();
|
|
84
127
|
const ins = db.prepare(`
|
|
@@ -107,18 +150,18 @@ function _hydrateScopeFromJson(db, scope) {
|
|
|
107
150
|
function _resyncScopeIfJsonDiverged(db, scope) {
|
|
108
151
|
const jsonPath = _filePathForScope(scope);
|
|
109
152
|
const currentHash = _fileContentHash(jsonPath);
|
|
110
|
-
const lastHash =
|
|
153
|
+
const lastHash = _getLastMirrorHash(db, scope);
|
|
111
154
|
if (currentHash == null) return;
|
|
112
155
|
if (lastHash != null && currentHash === lastHash) return;
|
|
113
156
|
if (lastHash == null) {
|
|
114
157
|
const sqlHas = db.prepare('SELECT 1 FROM pull_requests WHERE scope = ? LIMIT 1').get(scope);
|
|
115
158
|
if (sqlHas) {
|
|
116
|
-
|
|
159
|
+
_setLastMirrorHash(db, scope, currentHash);
|
|
117
160
|
return;
|
|
118
161
|
}
|
|
119
162
|
}
|
|
120
163
|
_hydrateScopeFromJson(db, scope);
|
|
121
|
-
|
|
164
|
+
_setLastMirrorHash(db, scope, currentHash);
|
|
122
165
|
}
|
|
123
166
|
|
|
124
167
|
function readPullRequestsForScope(scope) {
|
|
@@ -253,7 +296,7 @@ function _applyPullRequestsDiff(db, diff) {
|
|
|
253
296
|
return true;
|
|
254
297
|
}
|
|
255
298
|
|
|
256
|
-
function applyPullRequestsMutation(scope, mutator) {
|
|
299
|
+
function applyPullRequestsMutation(scope, mutator, options = {}) {
|
|
257
300
|
const { getDb, withTransaction } = require('./db');
|
|
258
301
|
const db = getDb();
|
|
259
302
|
|
|
@@ -270,6 +313,23 @@ function applyPullRequestsMutation(scope, mutator) {
|
|
|
270
313
|
: (Array.isArray(next) ? next : before);
|
|
271
314
|
const diff = _computePullRequestsDiff(scope, beforeSnapshot, after);
|
|
272
315
|
const wrote = _applyPullRequestsDiff(db, diff);
|
|
316
|
+
// W-mrcg7j9k000ja233 (#759) — mirror the JSON sidecar INSIDE this same
|
|
317
|
+
// SQL write transaction, BEFORE it commits, instead of after (as the
|
|
318
|
+
// caller in shared.js used to do post-return). This mirrors the fix
|
|
319
|
+
// already applied to work-items-store.js (W-mr9vcxvy000cdb4e): SQLite
|
|
320
|
+
// only allows one writer transaction at a time, so mirroring here
|
|
321
|
+
// guarantees the on-disk JSON file reflects this commit by the time it
|
|
322
|
+
// becomes visible to any other reader/writer. Without this ordering, a
|
|
323
|
+
// sibling process (dashboard vs. engine — separate Node processes, each
|
|
324
|
+
// with its own resync state) could observe the freshly committed SQL
|
|
325
|
+
// rows before the mirror file caught up, decide its own cached hash no
|
|
326
|
+
// longer matched the (still-stale) JSON file, and call
|
|
327
|
+
// _hydrateScopeFromJson — which DELETEs + reinserts the whole scope
|
|
328
|
+
// from that stale file, silently dropping the row this transaction just
|
|
329
|
+
// committed.
|
|
330
|
+
if (wrote) {
|
|
331
|
+
try { _mirrorJsonFromSql(scope, options.mirrorPath); } catch { /* best-effort — mirror failure must not block the SQL write */ }
|
|
332
|
+
}
|
|
273
333
|
return { wrote, result: after };
|
|
274
334
|
});
|
|
275
335
|
}
|
|
@@ -293,7 +353,7 @@ function _mirrorJsonFromSql(scope, filePath) {
|
|
|
293
353
|
const target = filePath || _filePathForScope(scope);
|
|
294
354
|
shared.safeWrite(target, items);
|
|
295
355
|
const h = _fileContentHash(target);
|
|
296
|
-
if (h != null)
|
|
356
|
+
if (h != null) _setLastMirrorHash(db, scope, h);
|
|
297
357
|
} catch {
|
|
298
358
|
// Mirror failures are non-fatal — SQL has already committed.
|
|
299
359
|
}
|
|
@@ -312,7 +372,8 @@ function _mirrorJsonFromSql(scope, filePath) {
|
|
|
312
372
|
function dropScope(scope) {
|
|
313
373
|
try {
|
|
314
374
|
const { getDb } = require('./db');
|
|
315
|
-
const
|
|
375
|
+
const db = getDb();
|
|
376
|
+
const result = db.prepare('DELETE FROM pull_requests WHERE scope = ?').run(scope);
|
|
316
377
|
// Mirror the work-items-store.js fix (W-mr96m3hm000fd5d7): don't blindly
|
|
317
378
|
// forget the mirror-hash. _resyncScopeIfJsonDiverged treats "no recorded
|
|
318
379
|
// hash" + "zero SQL rows" as a first-install hydrate and rebuilds SQL from
|
|
@@ -323,8 +384,8 @@ function dropScope(scope) {
|
|
|
323
384
|
// actually gone, e.g. dataMode: 'purge'/'archive').
|
|
324
385
|
const jsonPath = _filePathForScope(scope);
|
|
325
386
|
const currentHash = _fileContentHash(jsonPath);
|
|
326
|
-
if (currentHash == null)
|
|
327
|
-
else
|
|
387
|
+
if (currentHash == null) _forgetLastMirrorHash(db, scope);
|
|
388
|
+
else _setLastMirrorHash(db, scope, currentHash);
|
|
328
389
|
if (result && result.changes > 0) {
|
|
329
390
|
try { require('./db-events').emitStateEvent('pull_requests'); } catch { /* optional */ }
|
|
330
391
|
}
|
package/engine/queries.js
CHANGED
|
@@ -883,13 +883,16 @@ function _prFreshnessMs(pr) {
|
|
|
883
883
|
function _pickPrDedupeWinner(group, projects) {
|
|
884
884
|
if (group.length === 1) return { winner: group[0], dropped: [] };
|
|
885
885
|
|
|
886
|
-
// Map each project name -> its
|
|
887
|
-
//
|
|
888
|
-
|
|
886
|
+
// Map each project name -> its set of canonical scopes (primary +
|
|
887
|
+
// additionalRepoScopes, W-mrchrfe100067d3a), so we can detect when a
|
|
888
|
+
// record's _scope matches the PR url-derived scope (preference #2 below)
|
|
889
|
+
// even when the match is via an allowlisted secondary scope rather than
|
|
890
|
+
// the project's primary repo.
|
|
891
|
+
const projectScopesByName = new Map();
|
|
889
892
|
for (const p of projects || []) {
|
|
890
893
|
if (!p || !p.name) continue;
|
|
891
|
-
const
|
|
892
|
-
if (
|
|
894
|
+
const scopes = shared.getProjectAllPrScopes(p);
|
|
895
|
+
if (scopes.length > 0) projectScopesByName.set(p.name, scopes);
|
|
893
896
|
}
|
|
894
897
|
|
|
895
898
|
// Resolve the PR's "correct" scope from its URL (independent of _scope).
|
|
@@ -907,8 +910,8 @@ function _pickPrDedupeWinner(group, projects) {
|
|
|
907
910
|
// bit 0: freshness rank (broken into a separate tiebreak after)
|
|
908
911
|
function scoreOf(pr) {
|
|
909
912
|
const scopeOk = !pr._invalidProjectScope ? 1 : 0;
|
|
910
|
-
const
|
|
911
|
-
const matchesUrl = urlScope &&
|
|
913
|
+
const projectScopes = projectScopesByName.get(pr._scope) || [];
|
|
914
|
+
const matchesUrl = urlScope && projectScopes.includes(urlScope) ? 1 : 0;
|
|
912
915
|
return (scopeOk << 1) | matchesUrl;
|
|
913
916
|
}
|
|
914
917
|
|
package/engine/shared.js
CHANGED
|
@@ -7227,6 +7227,74 @@ function getPrScopeInfo(prRef, url = '') {
|
|
|
7227
7227
|
return canonical ? { ...canonical, source: 'id' } : null;
|
|
7228
7228
|
}
|
|
7229
7229
|
|
|
7230
|
+
// W-mrchrfe100067d3a — validate + normalize `project.additionalRepoScopes`,
|
|
7231
|
+
// an explicit per-project opt-in allowlist of secondary canonical PR scopes
|
|
7232
|
+
// (e.g. `["github:yemi33/minions"]` for the minions-opg project's live
|
|
7233
|
+
// checkout, which legitimately carries a second git remote for backport
|
|
7234
|
+
// work — see CLAUDE.md "Remotes"). Deliberately NOT a wildcard: a scope must
|
|
7235
|
+
// be an exact `host:owner/repo` (or ADO `host:org/project/repo`) match to be
|
|
7236
|
+
// treated as compatible, so a typo'd or malicious PR URL can't slip into
|
|
7237
|
+
// auto-management just because *some* additional scope is configured.
|
|
7238
|
+
// Returns a deduped, normalized (lowercase, trimmed) array of scope strings;
|
|
7239
|
+
// malformed / empty entries are silently dropped (read-side leniency — write
|
|
7240
|
+
// paths should validate with validateAdditionalRepoScopes instead).
|
|
7241
|
+
function getProjectAdditionalPrScopes(project) {
|
|
7242
|
+
const raw = project?.additionalRepoScopes;
|
|
7243
|
+
if (!Array.isArray(raw) || raw.length === 0) return [];
|
|
7244
|
+
const out = [];
|
|
7245
|
+
const seen = new Set();
|
|
7246
|
+
for (const entry of raw) {
|
|
7247
|
+
const scope = _normalizeScopeString(entry);
|
|
7248
|
+
if (scope && !seen.has(scope)) {
|
|
7249
|
+
seen.add(scope);
|
|
7250
|
+
out.push(scope);
|
|
7251
|
+
}
|
|
7252
|
+
}
|
|
7253
|
+
return out;
|
|
7254
|
+
}
|
|
7255
|
+
|
|
7256
|
+
// Normalizes+validates a single scope string of the shape `github:owner/repo`
|
|
7257
|
+
// or `ado:org/project/repo`. Returns '' for anything malformed.
|
|
7258
|
+
function _normalizeScopeString(value) {
|
|
7259
|
+
const match = String(value || '').trim().match(/^(github|ado):(.+)$/i);
|
|
7260
|
+
if (!match) return '';
|
|
7261
|
+
const host = match[1].toLowerCase();
|
|
7262
|
+
const parts = match[2].split('/').map(normalizePrScopeSegment).filter(Boolean);
|
|
7263
|
+
const expectedParts = host === 'github' ? 2 : 3;
|
|
7264
|
+
if (parts.length !== expectedParts) return '';
|
|
7265
|
+
return `${host}:${parts.join('/')}`;
|
|
7266
|
+
}
|
|
7267
|
+
|
|
7268
|
+
// Validates a project-settings-update payload for `additionalRepoScopes`.
|
|
7269
|
+
// Mirrors the validateCheckoutMode/validateLiveValidation contract: returns
|
|
7270
|
+
// undefined when the caller should clear the field (empty/null/[] input);
|
|
7271
|
+
// otherwise returns the normalized array, or throws HTTP 400 on malformed
|
|
7272
|
+
// input (non-array, non-string entries, or entries that don't match the
|
|
7273
|
+
// canonical `host:owner/repo[/repo]` scope grammar).
|
|
7274
|
+
function validateAdditionalRepoScopes(value) {
|
|
7275
|
+
if (value === undefined || value === null) return undefined;
|
|
7276
|
+
if (!Array.isArray(value)) {
|
|
7277
|
+
throw _httpError(400, `Invalid additionalRepoScopes: must be an array of scope strings (got ${typeof value}).`);
|
|
7278
|
+
}
|
|
7279
|
+
if (value.length === 0) return undefined;
|
|
7280
|
+
const out = [];
|
|
7281
|
+
const seen = new Set();
|
|
7282
|
+
for (const entry of value) {
|
|
7283
|
+
if (typeof entry !== 'string' || !entry.trim()) {
|
|
7284
|
+
throw _httpError(400, `Invalid additionalRepoScopes entry: "${entry}" — must be a non-empty string.`);
|
|
7285
|
+
}
|
|
7286
|
+
const scope = _normalizeScopeString(entry);
|
|
7287
|
+
if (!scope) {
|
|
7288
|
+
throw _httpError(400, `Invalid additionalRepoScopes entry: "${entry}" — expected "github:owner/repo" or "ado:org/project/repo".`);
|
|
7289
|
+
}
|
|
7290
|
+
if (!seen.has(scope)) {
|
|
7291
|
+
seen.add(scope);
|
|
7292
|
+
out.push(scope);
|
|
7293
|
+
}
|
|
7294
|
+
}
|
|
7295
|
+
return out.length > 0 ? out : undefined;
|
|
7296
|
+
}
|
|
7297
|
+
|
|
7230
7298
|
function getPrProjectScopeMismatch(project, prRef, url = '') {
|
|
7231
7299
|
const projectScope = getProjectPrScope(project);
|
|
7232
7300
|
if (!projectScope) return null;
|
|
@@ -7240,6 +7308,11 @@ function getPrProjectScopeMismatch(project, prRef, url = '') {
|
|
|
7240
7308
|
const refParts = refRest.split('/');
|
|
7241
7309
|
if (projectParts[0] === refParts[0] && projectParts[1] === refParts[1]) return null;
|
|
7242
7310
|
}
|
|
7311
|
+
// W-mrchrfe100067d3a — explicit opt-in allowlist: a PR scoped to one of
|
|
7312
|
+
// this project's configured additionalRepoScopes is NOT a mismatch. This
|
|
7313
|
+
// is intentionally an exact-match allowlist (not "any scope compatible")
|
|
7314
|
+
// so an unrelated/typo'd repo URL still gets flagged.
|
|
7315
|
+
if (getProjectAdditionalPrScopes(project).includes(refScope)) return null;
|
|
7243
7316
|
return { reason: 'pr_scope_mismatch', projectScope, prScope: refScope };
|
|
7244
7317
|
}
|
|
7245
7318
|
|
|
@@ -7247,6 +7320,85 @@ function isPrCompatibleWithProject(project, prRef, url = '') {
|
|
|
7247
7320
|
return !getPrProjectScopeMismatch(project, prRef, url);
|
|
7248
7321
|
}
|
|
7249
7322
|
|
|
7323
|
+
// All canonical scopes this project is configured to own: its primary scope
|
|
7324
|
+
// plus any additionalRepoScopes. Used by cross-project PR dedupe scoring
|
|
7325
|
+
// (queries.js) so a record scoped to a secondary allowlisted repo isn't
|
|
7326
|
+
// penalized relative to records in projects whose PRIMARY scope matches.
|
|
7327
|
+
function getProjectAllPrScopes(project) {
|
|
7328
|
+
const primary = getProjectPrScope(project);
|
|
7329
|
+
const additional = getProjectAdditionalPrScopes(project);
|
|
7330
|
+
const out = [];
|
|
7331
|
+
if (primary) out.push(primary);
|
|
7332
|
+
for (const s of additional) if (!out.includes(s)) out.push(s);
|
|
7333
|
+
return out;
|
|
7334
|
+
}
|
|
7335
|
+
|
|
7336
|
+
// W-mrchrfe100067d3a — cache of `localPath -> Map(scope -> remoteName)`
|
|
7337
|
+
// derived from `git remote -v`, so repeated dependency-fetch resolution
|
|
7338
|
+
// within a tick (or across nearby ticks) doesn't re-shell-out per branch.
|
|
7339
|
+
const _gitRemoteScopeCache = new Map();
|
|
7340
|
+
const GIT_REMOTE_SCOPE_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
7341
|
+
|
|
7342
|
+
// Best-effort parse of a git remote fetch URL into a canonical scope string.
|
|
7343
|
+
// Handles the URL shapes git itself produces for `git remote -v` output:
|
|
7344
|
+
// SSH (`git@host:owner/repo.git`), HTTPS (`https://host/owner/repo.git`),
|
|
7345
|
+
// and the two common ADO host forms (`dev.azure.com/org/project/_git/repo`,
|
|
7346
|
+
// `org.visualstudio.com/project/_git/repo`). Returns null when unrecognized.
|
|
7347
|
+
function _parseRemoteUrlToScope(url) {
|
|
7348
|
+
const cleaned = String(url || '').trim().replace(/\.git$/i, '');
|
|
7349
|
+
if (!cleaned) return null;
|
|
7350
|
+
let m = cleaned.match(/github\.com[:/]+([^/]+)\/([^/]+)\/?$/i);
|
|
7351
|
+
if (m) return `github:${normalizePrScopeSegment(m[1])}/${normalizePrScopeSegment(m[2])}`;
|
|
7352
|
+
m = cleaned.match(/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)\/?$/i);
|
|
7353
|
+
if (m) return `ado:${normalizePrScopeSegment(m[1])}/${normalizePrScopeSegment(m[2])}/${normalizePrScopeSegment(m[3])}`;
|
|
7354
|
+
m = cleaned.match(/([^/@:]+)\.visualstudio\.com\/(?:DefaultCollection\/)?([^/]+)\/_git\/([^/]+)\/?$/i);
|
|
7355
|
+
if (m) return `ado:${normalizePrScopeSegment(m[1])}/${normalizePrScopeSegment(m[2])}/${normalizePrScopeSegment(m[3])}`;
|
|
7356
|
+
return null;
|
|
7357
|
+
}
|
|
7358
|
+
|
|
7359
|
+
function _listGitRemoteScopes(localPath) {
|
|
7360
|
+
if (!localPath) return new Map();
|
|
7361
|
+
const now = Date.now();
|
|
7362
|
+
const cached = _gitRemoteScopeCache.get(localPath);
|
|
7363
|
+
if (cached && (now - cached.at) < GIT_REMOTE_SCOPE_CACHE_TTL_MS) return cached.remotes;
|
|
7364
|
+
const remotes = new Map();
|
|
7365
|
+
try {
|
|
7366
|
+
const out = shellSafeGitSync(['remote', '-v'], { cwd: localPath, timeout: 10000 });
|
|
7367
|
+
for (const line of String(out || '').split('\n')) {
|
|
7368
|
+
const m = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)/);
|
|
7369
|
+
if (!m) continue;
|
|
7370
|
+
const scope = _parseRemoteUrlToScope(m[2]);
|
|
7371
|
+
if (scope && !remotes.has(scope)) remotes.set(scope, m[1]);
|
|
7372
|
+
}
|
|
7373
|
+
} catch { /* best effort — caller falls back to 'origin' */ }
|
|
7374
|
+
_gitRemoteScopeCache.set(localPath, { at: now, remotes });
|
|
7375
|
+
return remotes;
|
|
7376
|
+
}
|
|
7377
|
+
|
|
7378
|
+
// W-mrchrfe100067d3a — resolve which git remote name owns a PR's branch when
|
|
7379
|
+
// that PR is scoped to one of the project's additionalRepoScopes entries
|
|
7380
|
+
// (e.g. a backport PR opened against `yemi33/minions` from the minions-opg
|
|
7381
|
+
// checkout, which has both `origin` -> opg-microsoft/minions and `yemi33` ->
|
|
7382
|
+
// yemi33/minions configured). For the project's own primary scope, or any
|
|
7383
|
+
// scope NOT in additionalRepoScopes, this always returns 'origin' —
|
|
7384
|
+
// preserving today's universal default for every project that doesn't opt
|
|
7385
|
+
// into multi-scope tracking. Only when the scope is allowlisted AND a local
|
|
7386
|
+
// remote's fetch URL actually resolves to that scope do we return the
|
|
7387
|
+
// non-origin remote name; otherwise 'origin' remains the safe fallback (the
|
|
7388
|
+
// caller's existing fetch-failure handling is unchanged).
|
|
7389
|
+
function resolveGitRemoteForPrScope(project, prScopeOrRef, url = '') {
|
|
7390
|
+
if (!project) return 'origin';
|
|
7391
|
+
const scope = (typeof prScopeOrRef === 'string' && /^(github|ado):/i.test(prScopeOrRef))
|
|
7392
|
+
? prScopeOrRef
|
|
7393
|
+
: (getPrScopeInfo(prScopeOrRef, url)?.scope || '');
|
|
7394
|
+
if (!scope) return 'origin';
|
|
7395
|
+
const projectScope = getProjectPrScope(project);
|
|
7396
|
+
if (scope === projectScope) return 'origin';
|
|
7397
|
+
if (!getProjectAdditionalPrScopes(project).includes(scope)) return 'origin';
|
|
7398
|
+
const remotes = _listGitRemoteScopes(project.localPath);
|
|
7399
|
+
return remotes.get(scope) || 'origin';
|
|
7400
|
+
}
|
|
7401
|
+
|
|
7250
7402
|
/**
|
|
7251
7403
|
* Check if a parsed ADO PR scope is compatible with a project config,
|
|
7252
7404
|
* considering that the repo segment in the URL might be either the friendly
|
|
@@ -8012,12 +8164,21 @@ function mutatePullRequests(filePath, mutator) {
|
|
|
8012
8164
|
if (insideMinionsDir) {
|
|
8013
8165
|
const store = require('./pull-requests-store');
|
|
8014
8166
|
const scope = store.scopeForFilePath(filePath);
|
|
8167
|
+
// W-mrcg7j9k000ja233 (#759) — the JSON mirror is now written INSIDE
|
|
8168
|
+
// applyPullRequestsMutation's SQL transaction (before commit), not here
|
|
8169
|
+
// after the fact. Mirroring post-commit left a cross-process window
|
|
8170
|
+
// where a sibling process (dashboard vs. engine — separate Node
|
|
8171
|
+
// processes) could observe the new SQL row before the JSON file caught
|
|
8172
|
+
// up and destructively rehydrate the scope from that stale file,
|
|
8173
|
+
// dropping the just-written row. See
|
|
8174
|
+
// pull-requests-store.js#applyPullRequestsMutation for the full
|
|
8175
|
+
// rationale (mirrors the identical work-items-store.js fix,
|
|
8176
|
+
// W-mr9vcxvy000cdb4e).
|
|
8015
8177
|
const { wrote, result } = store.applyPullRequestsMutation(scope, (prs) => {
|
|
8016
8178
|
if (!Array.isArray(prs)) prs = [];
|
|
8017
8179
|
return mutator(prs) || prs;
|
|
8018
|
-
});
|
|
8180
|
+
}, { mirrorPath: filePath });
|
|
8019
8181
|
if (wrote) {
|
|
8020
|
-
try { store._mirrorJsonFromSql(scope, filePath); } catch { /* mirror best-effort */ }
|
|
8021
8182
|
try { require('./db-events').emitStateEvent('pull_requests'); } catch { /* optional */ }
|
|
8022
8183
|
}
|
|
8023
8184
|
return result;
|
|
@@ -9098,6 +9259,10 @@ module.exports = {
|
|
|
9098
9259
|
rewriteInboxRefsAcrossProjects,
|
|
9099
9260
|
_artifactNoteFileToken, // exported so the one-time note-link backfill shares the token grammar
|
|
9100
9261
|
getProjectPrScope,
|
|
9262
|
+
getProjectAdditionalPrScopes,
|
|
9263
|
+
getProjectAllPrScopes,
|
|
9264
|
+
validateAdditionalRepoScopes,
|
|
9265
|
+
resolveGitRemoteForPrScope,
|
|
9101
9266
|
getPrNumber,
|
|
9102
9267
|
getPrDisplayId,
|
|
9103
9268
|
getPrScopeInfo,
|
package/engine.js
CHANGED
|
@@ -710,6 +710,12 @@ function resolveDependencyBranches(depIds, sourcePlan, project, config) {
|
|
|
710
710
|
projectName: p.name || null,
|
|
711
711
|
projectRoot: depRoot,
|
|
712
712
|
isCrossRepo: !!currentProjectName && p.name !== currentProjectName,
|
|
713
|
+
// W-mrchrfe100067d3a — same-project deps whose PR is scoped to one
|
|
714
|
+
// of `p`'s additionalRepoScopes entries (e.g. a backport PR opened
|
|
715
|
+
// against yemi33/minions from the minions-opg checkout) live on a
|
|
716
|
+
// different git remote than 'origin'. Resolved once here so the
|
|
717
|
+
// fetch/push loop below doesn't hardcode 'origin' for those.
|
|
718
|
+
remote: shared.resolveGitRemoteForPrScope(p, pr),
|
|
713
719
|
});
|
|
714
720
|
}
|
|
715
721
|
}
|
|
@@ -3758,12 +3764,26 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3758
3764
|
depMergeFailed = true;
|
|
3759
3765
|
}
|
|
3760
3766
|
const fetchResults = await Promise.allSettled(
|
|
3761
|
-
fetchable.map(({ branch: depBranch }) =>
|
|
3767
|
+
fetchable.map(({ branch: depBranch, remote: depRemote }) => {
|
|
3762
3768
|
// runAdoGit for ADO projects auto-retries once on auth failure
|
|
3763
3769
|
// with a refreshed bearer token (handles mid-dispatch expiry).
|
|
3764
3770
|
// For non-ADO projects it's a thin pass-through to shellSafeGit.
|
|
3765
|
-
|
|
3766
|
-
|
|
3771
|
+
// W-mrchrfe100067d3a — depRemote resolves to a non-'origin'
|
|
3772
|
+
// name only when this dep's PR is scoped to one of the
|
|
3773
|
+
// project's allowlisted additionalRepoScopes AND a local git
|
|
3774
|
+
// remote actually maps to it. In that case fetch with an
|
|
3775
|
+
// explicit refspec that writes into refs/remotes/origin/* —
|
|
3776
|
+
// an aliased local ref path — so every downstream merge-base /
|
|
3777
|
+
// merge-tree / merge step that reads `origin/<depBranch>`
|
|
3778
|
+
// (below, and in pruneAncestorDeps / preflightMergeSimulation)
|
|
3779
|
+
// keeps working unchanged, regardless of which real remote the
|
|
3780
|
+
// branch actually lives on.
|
|
3781
|
+
const remote = depRemote || 'origin';
|
|
3782
|
+
const fetchArgs = remote === 'origin'
|
|
3783
|
+
? ['fetch', 'origin', depBranch]
|
|
3784
|
+
: ['fetch', remote, `+${depBranch}:refs/remotes/origin/${depBranch}`];
|
|
3785
|
+
return adoGitAuth.runAdoGit(project, fetchArgs, { ..._gitOpts, cwd: rootDir }).then(() => depBranch);
|
|
3786
|
+
})
|
|
3767
3787
|
);
|
|
3768
3788
|
const hasFetchFailures = fetchResults.some(r => r.status === 'rejected');
|
|
3769
3789
|
const allPrsForFetch = hasFetchFailures ? shared.getProjects(config).reduce((acc, p) => acc.concat(safeJsonArr(shared.projectPrPath(p))), []) : [];
|
|
@@ -3773,6 +3793,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3773
3793
|
if (fetchResults[i].status === 'rejected') {
|
|
3774
3794
|
const failedBranch = fetchable[i].branch;
|
|
3775
3795
|
const failedPrId = fetchable[i].prId;
|
|
3796
|
+
const failedRemote = fetchable[i].remote || 'origin';
|
|
3776
3797
|
const errMsg = fetchResults[i].reason?.message || '';
|
|
3777
3798
|
const pr = allPrsForFetch.find(p => p.id === failedPrId);
|
|
3778
3799
|
if (pr && (pr.status === PR_STATUS.MERGED || pr.status === PR_STATUS.CLOSED)) {
|
|
@@ -3783,10 +3804,15 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3783
3804
|
if (errMsg.includes('couldn\'t find remote ref') || errMsg.includes('not found in upstream')) {
|
|
3784
3805
|
try {
|
|
3785
3806
|
await shared.shellSafeGit(['rev-parse', '--verify', `refs/heads/${failedBranch}`], { ..._gitOpts, cwd: rootDir });
|
|
3786
|
-
// Branch exists locally — push it to
|
|
3787
|
-
log('info', `Dependency ${failedBranch} exists locally but not on remote — pushing to
|
|
3788
|
-
await shared.shellSafeGit(['push',
|
|
3789
|
-
|
|
3807
|
+
// Branch exists locally — push it to its resolved remote
|
|
3808
|
+
log('info', `Dependency ${failedBranch} exists locally but not on remote — pushing to ${failedRemote}`);
|
|
3809
|
+
await shared.shellSafeGit(['push', failedRemote, failedBranch], { ..._gitOpts, cwd: rootDir, timeout: 60000 });
|
|
3810
|
+
// Keep the refs/remotes/origin/* alias in sync with the
|
|
3811
|
+
// local branch so downstream `origin/<branch>` reads
|
|
3812
|
+
// (merge-base/merge-tree/merge) resolve correctly even
|
|
3813
|
+
// though the push landed on a non-origin remote.
|
|
3814
|
+
await shared.shellSafeGit(['update-ref', `refs/remotes/origin/${failedBranch}`, `refs/heads/${failedBranch}`], { ..._gitOpts, cwd: rootDir });
|
|
3815
|
+
log('info', `Successfully pushed local-only dependency branch ${failedBranch} to ${failedRemote}`);
|
|
3790
3816
|
recoveredBranches.add(failedBranch);
|
|
3791
3817
|
continue;
|
|
3792
3818
|
} catch (localErr) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2356",
|
|
4
4
|
"description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
|
|
5
5
|
"bin": {
|
|
6
6
|
"minions": "bin/minions.js"
|