@yemi33/minions 0.1.1004 → 0.1.1006
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/CHANGELOG.md +3 -3
- package/dashboard.js +12 -2
- package/engine/ado-status.js +2 -1
- package/engine/ado.js +10 -8
- package/engine/cleanup.js +17 -3
- package/engine/consolidation.js +2 -2
- package/engine/cooldown.js +12 -2
- package/engine/dispatch.js +4 -3
- package/engine/github.js +9 -8
- package/engine/lifecycle.js +19 -11
- package/engine/pipeline.js +1 -1
- package/engine/queries.js +33 -11
- package/engine/shared.js +259 -10
- package/engine/teams.js +1 -1
- package/engine/watches.js +10 -6
- package/engine.js +31 -21
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
3
|
+
## 0.1.1006 (2026-04-16)
|
|
4
4
|
|
|
5
5
|
### Features
|
|
6
|
+
- cap pendingContexts in cooldowns.json to prevent bloat (#1126)
|
|
6
7
|
- fix stopAfter=0 watch expiry — run forever for all conditions (#1117)
|
|
7
8
|
- fix doc-chat Clear chat not persisting session deletion to localStorage (#1102)
|
|
8
9
|
- remove DEFAULTS alias from timeout.js (#1101)
|
|
@@ -22,9 +23,9 @@
|
|
|
22
23
|
- reassign tasks when preferred agent is busy too long
|
|
23
24
|
- wire agentBusyReassignMs into settings UI and persist
|
|
24
25
|
- ADO throttle detection, poll guards, and dashboard banner (#1051)
|
|
25
|
-
- gate auto-fix conflict dispatch behind autoFixConflicts flag
|
|
26
26
|
|
|
27
27
|
### Fixes
|
|
28
|
+
- harden repo-scoped PR identity handling
|
|
28
29
|
- only gate reviews/fixes when no free agent slots remain
|
|
29
30
|
- extend auto-link fallback to ADO projects
|
|
30
31
|
- auto-link existing GitHub PR when agent completes without creating one
|
|
@@ -44,7 +45,6 @@
|
|
|
44
45
|
- defer review setCooldown to post-gating in discoverWork
|
|
45
46
|
- move review verdict check before updateWorkItemStatus(DONE)
|
|
46
47
|
- address review feedback — move writeToInbox outside lock, add absolute condition auto-expire
|
|
47
|
-
- fix watches feature gaps — human notifications, branch stub, status-change init, unique keys
|
|
48
48
|
|
|
49
49
|
### Other
|
|
50
50
|
- refactor: migrate PR links to array format and consolidate shared helpers
|
package/dashboard.js
CHANGED
|
@@ -2172,7 +2172,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
2172
2172
|
}
|
|
2173
2173
|
|
|
2174
2174
|
// Build a manifest of all KB entries with their content (skip pinned — user wants to keep them)
|
|
2175
|
-
const
|
|
2175
|
+
const requestPinnedKeys = Array.isArray(body.pinnedKeys)
|
|
2176
|
+
? body.pinnedKeys.filter(k => typeof k === 'string' && k.startsWith('knowledge/'))
|
|
2177
|
+
: [];
|
|
2178
|
+
const serverPinnedKeys = shared.getPinnedItems().filter(k => k.startsWith('knowledge/'));
|
|
2179
|
+
const pinnedKeys = new Set([...serverPinnedKeys, ...requestPinnedKeys]);
|
|
2176
2180
|
const manifest = [];
|
|
2177
2181
|
for (const e of entries) {
|
|
2178
2182
|
if (pinnedKeys.has('knowledge/' + e.cat + '/' + e.file)) continue;
|
|
@@ -2180,6 +2184,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
2180
2184
|
if (!content) continue;
|
|
2181
2185
|
manifest.push({ category: e.cat, file: e.file, title: e.title, agent: e.agent, date: e.date, content: content.slice(0, 3000) });
|
|
2182
2186
|
}
|
|
2187
|
+
if (manifest.length < 2) {
|
|
2188
|
+
global._kbSweepLastResult = { ok: true, summary: 'nothing to sweep (< 2 unpinned entries)' };
|
|
2189
|
+
global._kbSweepLastCompletedAt = Date.now();
|
|
2190
|
+
return;
|
|
2191
|
+
}
|
|
2183
2192
|
|
|
2184
2193
|
const { callLLM, trackEngineUsage } = require('./engine/llm');
|
|
2185
2194
|
const BATCH_SIZE = 30; // ~30 entries per batch to stay within Haiku context
|
|
@@ -4364,7 +4373,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
4364
4373
|
// Extract PR number from URL
|
|
4365
4374
|
const prNumMatch = url.match(/\/pull\/(\d+)|pullrequest\/(\d+)/);
|
|
4366
4375
|
const prNum = prNumMatch ? (prNumMatch[1] || prNumMatch[2]) : Date.now().toString().slice(-6);
|
|
4367
|
-
const prId =
|
|
4376
|
+
const prId = shared.getCanonicalPrId(targetProject, prNum, url);
|
|
4368
4377
|
|
|
4369
4378
|
// Atomic check-and-insert to prevent duplicates and races with polling loops
|
|
4370
4379
|
let duplicate = false;
|
|
@@ -4373,6 +4382,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
4373
4382
|
if (prs.some(p => p.id === prId || p.url === url)) { duplicate = true; return prs; }
|
|
4374
4383
|
prs.push({
|
|
4375
4384
|
id: prId,
|
|
4385
|
+
prNumber: parseInt(prNum, 10) || null,
|
|
4376
4386
|
title: (title || 'PR #' + prNum + ' (polling...)').slice(0, 120),
|
|
4377
4387
|
description: '',
|
|
4378
4388
|
agent: 'human',
|
package/engine/ado-status.js
CHANGED
|
@@ -57,7 +57,8 @@ const prNumber = parseInt(prNumberArg, 10);
|
|
|
57
57
|
function findInCache(projects) {
|
|
58
58
|
for (const project of projects) {
|
|
59
59
|
const prs = safeJsonArr(projectPrPath(project));
|
|
60
|
-
|
|
60
|
+
shared.normalizePrRecords(prs, project);
|
|
61
|
+
const pr = prs.find(p => p.prNumber === prNumber || p.id === shared.getCanonicalPrId(project, prNumber) || shared.getPrDisplayId(p) === `PR-${prNumber}`);
|
|
61
62
|
if (!pr) continue;
|
|
62
63
|
const out = {
|
|
63
64
|
prNumber: pr.prNumber || prNumber,
|
package/engine/ado.js
CHANGED
|
@@ -255,7 +255,7 @@ async function forEachActivePr(config, token, callback) {
|
|
|
255
255
|
for (let i = 0; i < activePrs.length; i += CONCURRENCY) {
|
|
256
256
|
const batch = activePrs.slice(i, i + CONCURRENCY);
|
|
257
257
|
const results = await Promise.allSettled(batch.map(async (pr) => {
|
|
258
|
-
const prNum = (pr
|
|
258
|
+
const prNum = shared.getPrNumber(pr);
|
|
259
259
|
if (!prNum) return false;
|
|
260
260
|
return callback(project, pr, prNum, orgBase);
|
|
261
261
|
}));
|
|
@@ -680,6 +680,7 @@ async function reconcilePrs(config) {
|
|
|
680
680
|
|
|
681
681
|
const prPath = shared.projectPrPath(project);
|
|
682
682
|
const existingPrs = shared.safeJson(prPath) || [];
|
|
683
|
+
shared.normalizePrRecords(existingPrs, project);
|
|
683
684
|
const existingIds = new Set(existingPrs.map(p => p.id));
|
|
684
685
|
let projectAdded = 0;
|
|
685
686
|
|
|
@@ -692,7 +693,8 @@ async function reconcilePrs(config) {
|
|
|
692
693
|
|
|
693
694
|
let projectUpdated = 0;
|
|
694
695
|
for (const adoPr of adoPrs) {
|
|
695
|
-
const
|
|
696
|
+
const prUrl = getAdoPrUrl(project, adoPr.pullRequestId);
|
|
697
|
+
const prId = shared.getCanonicalPrId(project, adoPr.pullRequestId, prUrl);
|
|
696
698
|
const branch = stripRefsHeads(adoPr.sourceRefName);
|
|
697
699
|
const title = adoPr.title || '';
|
|
698
700
|
// Extract item ID from branch name or PR title (e.g., feat(P-2cafdc2a): ...)
|
|
@@ -710,7 +712,7 @@ async function reconcilePrs(config) {
|
|
|
710
712
|
}
|
|
711
713
|
// PR already tracked — write link to pr-links.json if we can extract an ID
|
|
712
714
|
if (confirmedItemId) {
|
|
713
|
-
addPrLink(prId, confirmedItemId);
|
|
715
|
+
addPrLink(prId, confirmedItemId, { project, prNumber: adoPr.pullRequestId, url: prUrl });
|
|
714
716
|
if (existing && !(existing.prdItems || []).includes(confirmedItemId)) {
|
|
715
717
|
existing.prdItems = Array.isArray(existing.prdItems) ? existing.prdItems : [];
|
|
716
718
|
existing.prdItems.push(confirmedItemId);
|
|
@@ -725,7 +727,6 @@ async function reconcilePrs(config) {
|
|
|
725
727
|
// are human-authored and should not be auto-tracked or auto-reviewed.
|
|
726
728
|
if (!confirmedItemId) continue;
|
|
727
729
|
|
|
728
|
-
const prUrl = project.prUrlBase ? project.prUrlBase + adoPr.pullRequestId : '';
|
|
729
730
|
existingPrs.push({
|
|
730
731
|
id: prId,
|
|
731
732
|
prNumber: adoPr.pullRequestId,
|
|
@@ -738,7 +739,7 @@ async function reconcilePrs(config) {
|
|
|
738
739
|
url: prUrl,
|
|
739
740
|
prdItems: [confirmedItemId],
|
|
740
741
|
});
|
|
741
|
-
addPrLink(prId, confirmedItemId);
|
|
742
|
+
addPrLink(prId, confirmedItemId, { project, prNumber: adoPr.pullRequestId, url: prUrl });
|
|
742
743
|
existingIds.add(prId);
|
|
743
744
|
projectAdded++;
|
|
744
745
|
log('info', `PR reconciliation: added ${prId} (branch: ${branch}, linked to ${confirmedItemId}) to ${project.name}`);
|
|
@@ -747,7 +748,7 @@ async function reconcilePrs(config) {
|
|
|
747
748
|
// Backfill prNumber from pr.id for any PR missing it (e.g. created before prNumber was stored)
|
|
748
749
|
for (const pr of existingPrs) {
|
|
749
750
|
if (pr.prNumber == null) {
|
|
750
|
-
const derived =
|
|
751
|
+
const derived = shared.getPrNumber(pr);
|
|
751
752
|
if (derived) pr.prNumber = derived;
|
|
752
753
|
}
|
|
753
754
|
}
|
|
@@ -786,7 +787,8 @@ async function checkLiveReviewStatus(pr, project) {
|
|
|
786
787
|
const token = await getAdoToken();
|
|
787
788
|
if (!token) return null;
|
|
788
789
|
const orgBase = shared.getAdoOrgBase(project);
|
|
789
|
-
const prNum = (pr
|
|
790
|
+
const prNum = shared.getPrNumber(pr);
|
|
791
|
+
if (!prNum) return null;
|
|
790
792
|
const url = `${orgBase}/${project.adoProject}/_apis/git/repositories/${project.repositoryId}/pullrequests/${prNum}?api-version=7.1`;
|
|
791
793
|
const result = await execAsync(`curl -s --max-time 4 -H "Authorization: Bearer ${token}" "${url}"`, { encoding: 'utf-8', timeout: 5000, windowsHide: true });
|
|
792
794
|
const prData = JSON.parse(result);
|
|
@@ -851,7 +853,7 @@ async function fetchSinglePrBuildStatus(project, prNumber) {
|
|
|
851
853
|
}));
|
|
852
854
|
const logParts = [];
|
|
853
855
|
const seenBuildIds = new Set();
|
|
854
|
-
const pr = { id:
|
|
856
|
+
const pr = { id: shared.getCanonicalPrId(project, prNumber, getAdoPrUrl(project, prNumber)), branch: stripRefsHeads(prData.sourceRefName) };
|
|
855
857
|
for (const fb of failedBuilds.slice(0, 3)) {
|
|
856
858
|
const errorLog = await fetchAdoBuildErrorLog(orgBase, project, fb, token, pr, seenBuildIds);
|
|
857
859
|
if (errorLog) logParts.push(errorLog);
|
package/engine/cleanup.js
CHANGED
|
@@ -8,7 +8,7 @@ const path = require('path');
|
|
|
8
8
|
const shared = require('./shared');
|
|
9
9
|
const queries = require('./queries');
|
|
10
10
|
|
|
11
|
-
const { exec, execSilent, log, ts } = shared;
|
|
11
|
+
const { exec, execSilent, log, ts, ENGINE_DEFAULTS } = shared;
|
|
12
12
|
const { safeJson, safeWrite, safeReadDir, mutateWorkItems, getProjects, projectWorkItemsPath, projectPrPath,
|
|
13
13
|
sanitizeBranch, KB_CATEGORIES } = shared;
|
|
14
14
|
const { getDispatch, getAgentStatus } = queries;
|
|
@@ -597,11 +597,23 @@ function runCleanup(config, verbose = false) {
|
|
|
597
597
|
} catch (e) { log('warn', 'prune doc-sessions: ' + e.message); }
|
|
598
598
|
|
|
599
599
|
// 11. Cap cooldowns.json — keep at most 500 entries (on top of 24h TTL in cooldown.js)
|
|
600
|
+
// Also trim pendingContexts arrays to ENGINE_DEFAULTS.maxPendingContexts to prevent bloat.
|
|
600
601
|
cleaned.cooldowns = 0;
|
|
602
|
+
cleaned.pendingContextsTrimmed = 0;
|
|
601
603
|
try {
|
|
602
604
|
const cooldownPath = path.join(ENGINE_DIR, 'cooldowns.json');
|
|
603
605
|
const cooldowns = safeJson(cooldownPath);
|
|
604
606
|
if (cooldowns && typeof cooldowns === 'object') {
|
|
607
|
+
let dirty = false;
|
|
608
|
+
// Trim oversized pendingContexts arrays (one-time migration + ongoing cap)
|
|
609
|
+
const pendingCtxCap = ENGINE_DEFAULTS.maxPendingContexts;
|
|
610
|
+
for (const v of Object.values(cooldowns)) {
|
|
611
|
+
if (Array.isArray(v.pendingContexts) && v.pendingContexts.length > pendingCtxCap) {
|
|
612
|
+
v.pendingContexts = v.pendingContexts.slice(-pendingCtxCap);
|
|
613
|
+
cleaned.pendingContextsTrimmed++;
|
|
614
|
+
dirty = true;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
605
617
|
const entries = Object.entries(cooldowns);
|
|
606
618
|
const COOLDOWN_CAP = 500;
|
|
607
619
|
if (entries.length > COOLDOWN_CAP) {
|
|
@@ -610,6 +622,8 @@ function runCleanup(config, verbose = false) {
|
|
|
610
622
|
const keep = Object.fromEntries(entries.slice(0, COOLDOWN_CAP));
|
|
611
623
|
cleaned.cooldowns = entries.length - COOLDOWN_CAP;
|
|
612
624
|
safeWrite(cooldownPath, keep);
|
|
625
|
+
} else if (dirty) {
|
|
626
|
+
safeWrite(cooldownPath, cooldowns);
|
|
613
627
|
}
|
|
614
628
|
}
|
|
615
629
|
} catch (e) { log('warn', 'cap cooldowns: ' + e.message); }
|
|
@@ -653,8 +667,8 @@ function runCleanup(config, verbose = false) {
|
|
|
653
667
|
}
|
|
654
668
|
} catch { /* optional — file may not exist */ }
|
|
655
669
|
|
|
656
|
-
if (cleaned.ccSessions + cleaned.docSessions + cleaned.cooldowns + cleaned.pidFiles > 0) {
|
|
657
|
-
log('info', `Cleanup (resources): ${cleaned.ccSessions} cc-sessions, ${cleaned.docSessions} doc-sessions, ${cleaned.cooldowns} cooldowns, ${cleaned.pidFiles} PID files`);
|
|
670
|
+
if (cleaned.ccSessions + cleaned.docSessions + cleaned.cooldowns + cleaned.pidFiles + cleaned.pendingContextsTrimmed > 0) {
|
|
671
|
+
log('info', `Cleanup (resources): ${cleaned.ccSessions} cc-sessions, ${cleaned.docSessions} doc-sessions, ${cleaned.cooldowns} cooldowns, ${cleaned.pendingContextsTrimmed} pendingCtx trimmed, ${cleaned.pidFiles} PID files`);
|
|
658
672
|
}
|
|
659
673
|
|
|
660
674
|
return cleaned;
|
package/engine/consolidation.js
CHANGED
|
@@ -25,7 +25,8 @@ function consolidateInbox(config) {
|
|
|
25
25
|
|
|
26
26
|
const { ENGINE_DEFAULTS } = shared;
|
|
27
27
|
const threshold = config.engine?.inboxConsolidateThreshold || ENGINE_DEFAULTS.inboxConsolidateThreshold;
|
|
28
|
-
const
|
|
28
|
+
const pinnedInboxKeys = new Set(shared.getPinnedItems().filter(k => k.startsWith('notes/inbox/')));
|
|
29
|
+
const files = getInboxFiles().filter(f => !_processingFiles.has(f) && !pinnedInboxKeys.has('notes/inbox/' + f));
|
|
29
30
|
if (files.length < threshold) return;
|
|
30
31
|
// Auto-reset stale flag if consolidation has been running for >5 minutes (process died without cleanup)
|
|
31
32
|
if (_consolidationInFlight && (Date.now() - _consolidationStartedAt) > 300000) {
|
|
@@ -485,4 +486,3 @@ module.exports = {
|
|
|
485
486
|
classifyToKnowledgeBase,
|
|
486
487
|
checkDuplicateHash,
|
|
487
488
|
};
|
|
488
|
-
|
package/engine/cooldown.js
CHANGED
|
@@ -7,7 +7,7 @@ const path = require('path');
|
|
|
7
7
|
const shared = require('./shared');
|
|
8
8
|
const queries = require('./queries');
|
|
9
9
|
|
|
10
|
-
const { safeJson, safeWrite, log } = shared;
|
|
10
|
+
const { safeJson, safeWrite, log, ENGINE_DEFAULTS } = shared;
|
|
11
11
|
const { ENGINE_DIR } = queries;
|
|
12
12
|
|
|
13
13
|
const COOLDOWN_PATH = path.join(ENGINE_DIR, 'cooldowns.json');
|
|
@@ -37,6 +37,13 @@ function saveCooldowns() {
|
|
|
37
37
|
for (const [k, v] of dispatchCooldowns) {
|
|
38
38
|
if (now - v.timestamp > 24 * 60 * 60 * 1000) dispatchCooldowns.delete(k);
|
|
39
39
|
}
|
|
40
|
+
// Trim pendingContexts arrays before writing to prevent bloat
|
|
41
|
+
const cap = ENGINE_DEFAULTS.maxPendingContexts;
|
|
42
|
+
for (const [, v] of dispatchCooldowns) {
|
|
43
|
+
if (Array.isArray(v.pendingContexts) && v.pendingContexts.length > cap) {
|
|
44
|
+
v.pendingContexts = v.pendingContexts.slice(-cap);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
40
47
|
const obj = Object.fromEntries(dispatchCooldowns);
|
|
41
48
|
try {
|
|
42
49
|
safeWrite(COOLDOWN_PATH, obj);
|
|
@@ -61,8 +68,11 @@ function setCooldown(key) {
|
|
|
61
68
|
|
|
62
69
|
function setCooldownWithContext(key, context) {
|
|
63
70
|
const existing = dispatchCooldowns.get(key);
|
|
64
|
-
|
|
71
|
+
let pendingContexts = existing?.pendingContexts || [];
|
|
65
72
|
if (context) pendingContexts.push(context);
|
|
73
|
+
// Cap to last N entries to prevent unbounded growth (cooldowns.json bloat)
|
|
74
|
+
const cap = ENGINE_DEFAULTS.maxPendingContexts;
|
|
75
|
+
if (pendingContexts.length > cap) pendingContexts = pendingContexts.slice(-cap);
|
|
66
76
|
dispatchCooldowns.set(key, {
|
|
67
77
|
timestamp: Date.now(),
|
|
68
78
|
failures: existing?.failures || 0,
|
package/engine/dispatch.js
CHANGED
|
@@ -223,7 +223,7 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
|
|
|
223
223
|
try {
|
|
224
224
|
const prsPath = projectPrPath(project);
|
|
225
225
|
mutatePullRequests(prsPath, prs => {
|
|
226
|
-
const target =
|
|
226
|
+
const target = shared.findPrRecord(prs, { id: prId }, project);
|
|
227
227
|
if (target?.humanFeedback) target.humanFeedback.pendingFix = true;
|
|
228
228
|
});
|
|
229
229
|
log('info', `Restored pendingFix=true on ${prId} after failed human-feedback fix`);
|
|
@@ -246,9 +246,10 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
|
|
|
246
246
|
|
|
247
247
|
function writeInboxAlert(slug, content) {
|
|
248
248
|
try {
|
|
249
|
-
const
|
|
249
|
+
const safeSlug = shared.safeSlugComponent(slug, 100);
|
|
250
|
+
const file = path.join(INBOX_DIR, `engine-alert-${safeSlug}-${dateStamp()}.md`);
|
|
250
251
|
// Dedupe: don't write the same alert twice in the same day
|
|
251
|
-
const existing = safeReadDir(INBOX_DIR).find(f => f.startsWith(`engine-alert-${
|
|
252
|
+
const existing = safeReadDir(INBOX_DIR).find(f => f.startsWith(`engine-alert-${safeSlug}-${dateStamp()}`));
|
|
252
253
|
if (existing) return;
|
|
253
254
|
safeWrite(file, content);
|
|
254
255
|
} catch (e) { log('warn', 'write inbox alert: ' + e.message); }
|
package/engine/github.js
CHANGED
|
@@ -199,7 +199,7 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
199
199
|
let projectUpdated = 0;
|
|
200
200
|
|
|
201
201
|
for (const pr of activePrs) {
|
|
202
|
-
const prNum = (pr
|
|
202
|
+
const prNum = shared.getPrNumber(pr);
|
|
203
203
|
if (!prNum) continue;
|
|
204
204
|
|
|
205
205
|
try {
|
|
@@ -621,6 +621,7 @@ async function reconcilePrs(config) {
|
|
|
621
621
|
|
|
622
622
|
const prPath = projectPrPath(project);
|
|
623
623
|
const currentPrs = safeJson(prPath) || [];
|
|
624
|
+
shared.normalizePrRecords(currentPrs, project);
|
|
624
625
|
const existingIds = new Set(currentPrs.map(p => p.id));
|
|
625
626
|
let projectAdded = 0;
|
|
626
627
|
|
|
@@ -632,7 +633,8 @@ async function reconcilePrs(config) {
|
|
|
632
633
|
const allItems = [...workItems, ...centralItems];
|
|
633
634
|
|
|
634
635
|
for (const ghPr of ghPrs) {
|
|
635
|
-
const
|
|
636
|
+
const prUrl = project.prUrlBase ? project.prUrlBase + ghPr.number : ghPr.html_url || '';
|
|
637
|
+
const prId = shared.getCanonicalPrId(project, ghPr.number, prUrl);
|
|
636
638
|
const branch = ghPr.head?.ref || '';
|
|
637
639
|
const wiMatch = branch.match(/(P-[a-z0-9]{6,})/i) || branch.match(/(W-[a-z0-9]{6,})/i) || branch.match(/(PL-[a-z0-9]{6,})/i);
|
|
638
640
|
const linkedItemId = wiMatch ? wiMatch[1] : null;
|
|
@@ -646,7 +648,7 @@ async function reconcilePrs(config) {
|
|
|
646
648
|
existing.prNumber = ghPr.number;
|
|
647
649
|
}
|
|
648
650
|
if (confirmedItemId) {
|
|
649
|
-
addPrLink(prId, confirmedItemId);
|
|
651
|
+
addPrLink(prId, confirmedItemId, { project, prNumber: ghPr.number, url: prUrl });
|
|
650
652
|
if (existing && !(existing.prdItems || []).includes(confirmedItemId)) {
|
|
651
653
|
existing.prdItems = Array.isArray(existing.prdItems) ? existing.prdItems : [];
|
|
652
654
|
existing.prdItems.push(confirmedItemId);
|
|
@@ -661,8 +663,6 @@ async function reconcilePrs(config) {
|
|
|
661
663
|
// Only auto-track PRs linked to a minions work item — skip human-authored PRs
|
|
662
664
|
if (!confirmedItemId && !isE2eBranch) continue;
|
|
663
665
|
|
|
664
|
-
const prUrl = project.prUrlBase ? project.prUrlBase + ghPr.number : ghPr.html_url || '';
|
|
665
|
-
|
|
666
666
|
currentPrs.push({
|
|
667
667
|
id: prId,
|
|
668
668
|
prNumber: ghPr.number,
|
|
@@ -675,7 +675,7 @@ async function reconcilePrs(config) {
|
|
|
675
675
|
url: prUrl,
|
|
676
676
|
prdItems: [confirmedItemId],
|
|
677
677
|
});
|
|
678
|
-
addPrLink(prId, confirmedItemId);
|
|
678
|
+
addPrLink(prId, confirmedItemId, { project, prNumber: ghPr.number, url: prUrl });
|
|
679
679
|
existingIds.add(prId);
|
|
680
680
|
projectAdded++;
|
|
681
681
|
|
|
@@ -685,7 +685,7 @@ async function reconcilePrs(config) {
|
|
|
685
685
|
// Backfill prNumber from pr.id for any PR missing it (e.g. created before prNumber was stored)
|
|
686
686
|
for (const pr of currentPrs) {
|
|
687
687
|
if (pr.prNumber == null) {
|
|
688
|
-
const derived =
|
|
688
|
+
const derived = shared.getPrNumber(pr);
|
|
689
689
|
if (derived) pr.prNumber = derived;
|
|
690
690
|
}
|
|
691
691
|
}
|
|
@@ -719,7 +719,8 @@ async function checkLiveReviewStatus(pr, project) {
|
|
|
719
719
|
try {
|
|
720
720
|
const slug = getRepoSlug(project);
|
|
721
721
|
if (!slug) return null;
|
|
722
|
-
const prNum = (pr
|
|
722
|
+
const prNum = shared.getPrNumber(pr);
|
|
723
|
+
if (!prNum) return null;
|
|
723
724
|
const reviews = await ghApi(`/pulls/${prNum}/reviews`, slug);
|
|
724
725
|
if (!reviews || !Array.isArray(reviews)) return null;
|
|
725
726
|
const latestByUser = new Map();
|
package/engine/lifecycle.js
CHANGED
|
@@ -781,10 +781,11 @@ function syncPrsFromOutput(output, agentId, meta, config) {
|
|
|
781
781
|
const newPrsByPath = new Map(); // prPath -> [{ prId, newEntry }]
|
|
782
782
|
|
|
783
783
|
for (const prId of prMatches) {
|
|
784
|
-
const fullId = `PR-${prId}`;
|
|
785
784
|
const targetProject = useCentral ? null : resolveProjectForPr(prId);
|
|
786
785
|
const targetName = targetProject ? targetProject.name : '_central';
|
|
787
786
|
const prPath = targetProject ? shared.projectPrPath(targetProject) : centralPrPath;
|
|
787
|
+
const prUrl = extractPrUrl(prId);
|
|
788
|
+
const fullId = shared.getCanonicalPrId(targetProject, prId, prUrl);
|
|
788
789
|
|
|
789
790
|
let title = meta?.item?.title || '';
|
|
790
791
|
const titleMatch = output.match(new RegExp(`${prId}[^\\n]*?[—–-]\\s*([^\\n]+)`, 'i'));
|
|
@@ -793,7 +794,7 @@ function syncPrsFromOutput(output, agentId, meta, config) {
|
|
|
793
794
|
title = meta?.item?.title || '';
|
|
794
795
|
}
|
|
795
796
|
|
|
796
|
-
if (!newPrsByPath.has(prPath)) newPrsByPath.set(prPath, { name: targetName, entries: [] });
|
|
797
|
+
if (!newPrsByPath.has(prPath)) newPrsByPath.set(prPath, { name: targetName, project: targetProject, entries: [] });
|
|
797
798
|
newPrsByPath.get(prPath).entries.push({
|
|
798
799
|
prId, fullId,
|
|
799
800
|
entry: {
|
|
@@ -805,7 +806,7 @@ function syncPrsFromOutput(output, agentId, meta, config) {
|
|
|
805
806
|
reviewStatus: 'pending',
|
|
806
807
|
status: PR_STATUS.ACTIVE,
|
|
807
808
|
created: ts(),
|
|
808
|
-
url:
|
|
809
|
+
url: prUrl,
|
|
809
810
|
prdItems: meta?.item?.id ? [meta.item.id] : [],
|
|
810
811
|
sourcePlan: meta?.item?.sourcePlan || '',
|
|
811
812
|
itemType: meta?.item?.itemType || ''
|
|
@@ -813,7 +814,8 @@ function syncPrsFromOutput(output, agentId, meta, config) {
|
|
|
813
814
|
});
|
|
814
815
|
}
|
|
815
816
|
|
|
816
|
-
for (const [prPath, { name, entries }] of newPrsByPath) {
|
|
817
|
+
for (const [prPath, { name, project: targetProject, entries }] of newPrsByPath) {
|
|
818
|
+
const linksToPersist = [];
|
|
817
819
|
mutateJsonFileLocked(prPath, (data) => {
|
|
818
820
|
const prs = Array.isArray(data) ? data : [];
|
|
819
821
|
// Normalize legacy YYYY-MM-DD created dates to ISO
|
|
@@ -821,13 +823,18 @@ function syncPrsFromOutput(output, agentId, meta, config) {
|
|
|
821
823
|
if (p.created && p.created.length === 10) p.created = p.created + 'T00:00:00.000Z';
|
|
822
824
|
}
|
|
823
825
|
for (const { prId, fullId, entry } of entries) {
|
|
824
|
-
if (prs.some(p => p.id === fullId ||
|
|
826
|
+
if (prs.some(p => p.id === fullId || (p.url && p.url === entry.url))) continue;
|
|
825
827
|
prs.push(entry);
|
|
826
|
-
if (meta?.item?.id)
|
|
828
|
+
if (meta?.item?.id) {
|
|
829
|
+
linksToPersist.push({ prId: fullId, itemId: meta.item.id, project: targetProject, prNumber: entry.prNumber, url: entry.url });
|
|
830
|
+
}
|
|
827
831
|
added++;
|
|
828
832
|
}
|
|
829
833
|
return prs;
|
|
830
834
|
});
|
|
835
|
+
for (const { prId, itemId, project, prNumber, url } of linksToPersist) {
|
|
836
|
+
addPrLink(prId, itemId, { project, prNumber, url });
|
|
837
|
+
}
|
|
831
838
|
log('info', `Synced PR(s) from ${agentName}'s output to ${name === '_central' ? 'central' : name}/pull-requests.json`);
|
|
832
839
|
}
|
|
833
840
|
return added;
|
|
@@ -894,7 +901,7 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary)
|
|
|
894
901
|
let updatedTarget = null;
|
|
895
902
|
shared.mutateJsonFileLocked(prPath, (prs) => {
|
|
896
903
|
if (!Array.isArray(prs)) return prs;
|
|
897
|
-
const target =
|
|
904
|
+
const target = shared.findPrRecord(prs, pr, project);
|
|
898
905
|
if (!target) return prs;
|
|
899
906
|
// Once approved, stays approved — only changes-requested can override
|
|
900
907
|
if (postReviewStatus) {
|
|
@@ -938,7 +945,7 @@ function updatePrAfterFix(pr, project, source) {
|
|
|
938
945
|
const prPath = project ? shared.projectPrPath(project) : path.join(path.resolve(MINIONS_DIR, '..'), '.minions', 'pull-requests.json');
|
|
939
946
|
shared.mutateJsonFileLocked(prPath, (prs) => {
|
|
940
947
|
if (!Array.isArray(prs)) return prs;
|
|
941
|
-
const target =
|
|
948
|
+
const target = shared.findPrRecord(prs, pr, project);
|
|
942
949
|
if (!target) return prs;
|
|
943
950
|
// Never downgrade from approved — fix was dispatched but PR is already approved
|
|
944
951
|
if (target.reviewStatus !== 'approved') target.reviewStatus = 'waiting';
|
|
@@ -1083,7 +1090,7 @@ async function processPendingRebases(config) {
|
|
|
1083
1090
|
|
|
1084
1091
|
async function handlePostMerge(pr, project, config, newStatus) {
|
|
1085
1092
|
|
|
1086
|
-
const prNum = (pr
|
|
1093
|
+
const prNum = shared.getPrNumber(pr);
|
|
1087
1094
|
|
|
1088
1095
|
if (pr.branch && project) {
|
|
1089
1096
|
const root = path.resolve(project.localPath);
|
|
@@ -1329,7 +1336,8 @@ function createReviewFeedbackForAuthor(reviewerAgentId, pr, config) {
|
|
|
1329
1336
|
const reviewFiles = inboxFiles.filter(f => f.includes(reviewerAgentId) && f.includes(today));
|
|
1330
1337
|
if (reviewFiles.length === 0) return;
|
|
1331
1338
|
const reviewContent = reviewFiles.map(f => safeRead(path.join(INBOX_DIR, f))).filter(Boolean).join('\n\n');
|
|
1332
|
-
const
|
|
1339
|
+
const prSlug = shared.safeSlugComponent(pr.id, 60);
|
|
1340
|
+
const feedbackFile = `feedback-${authorAgentId}-from-${reviewerAgentId}-${prSlug}-${today}.md`;
|
|
1333
1341
|
const feedbackPath = shared.uniquePath(path.join(INBOX_DIR, feedbackFile));
|
|
1334
1342
|
const content = `# Review Feedback for ${config.agents[authorAgentId]?.name || authorAgentId}\n\n` +
|
|
1335
1343
|
`**PR:** ${pr.id} — ${pr.title || ''}\n` +
|
|
@@ -1845,7 +1853,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
1845
1853
|
log('debug', `Skipping branch PR lookup for unsupported repo host "${host}" on ${projectObj.name}`);
|
|
1846
1854
|
}
|
|
1847
1855
|
if (found) {
|
|
1848
|
-
const fullId =
|
|
1856
|
+
const fullId = shared.getCanonicalPrId(projectObj, found.prNumber, found.url);
|
|
1849
1857
|
const prPath = shared.projectPrPath(projectObj);
|
|
1850
1858
|
mutateJsonFileLocked(prPath, prs => {
|
|
1851
1859
|
if (!Array.isArray(prs)) prs = [];
|
package/engine/pipeline.js
CHANGED
|
@@ -703,7 +703,7 @@ function isStageComplete(stage, stageState, run, config) {
|
|
|
703
703
|
for (const project of projects) {
|
|
704
704
|
const prs = safeJson(shared.projectPrPath(project)) || [];
|
|
705
705
|
for (const prId of prIds) {
|
|
706
|
-
const pr =
|
|
706
|
+
const pr = shared.findPrRecord(prs, prId, project);
|
|
707
707
|
if (pr && pr.status !== PR_STATUS.MERGED && pr.status !== PR_STATUS.ABANDONED) return false;
|
|
708
708
|
}
|
|
709
709
|
}
|
package/engine/queries.js
CHANGED
|
@@ -419,7 +419,11 @@ function getAgentDetail(id) {
|
|
|
419
419
|
// ── Pull Requests ───────────────────────────────────────────────────────────
|
|
420
420
|
|
|
421
421
|
function getPrs(project) {
|
|
422
|
-
if (project)
|
|
422
|
+
if (project) {
|
|
423
|
+
const prs = safeJson(projectPrPath(project)) || [];
|
|
424
|
+
shared.normalizePrRecords(prs, project);
|
|
425
|
+
return prs;
|
|
426
|
+
}
|
|
423
427
|
const config = getConfig();
|
|
424
428
|
const all = [];
|
|
425
429
|
for (const p of getProjects(config)) all.push(...getPrs(p));
|
|
@@ -433,9 +437,13 @@ function getPullRequests(config) {
|
|
|
433
437
|
for (const project of projects) {
|
|
434
438
|
const prs = safeJson(projectPrPath(project));
|
|
435
439
|
if (!prs) continue;
|
|
440
|
+
shared.normalizePrRecords(prs, project);
|
|
436
441
|
const base = project.prUrlBase || '';
|
|
437
442
|
for (const pr of prs) {
|
|
438
|
-
if (!pr.url && base
|
|
443
|
+
if (!pr.url && base) {
|
|
444
|
+
const prNumber = shared.getPrNumber(pr);
|
|
445
|
+
if (prNumber != null) pr.url = base + prNumber;
|
|
446
|
+
}
|
|
439
447
|
pr._project = project.name || 'Project';
|
|
440
448
|
allPrs.push(pr);
|
|
441
449
|
}
|
|
@@ -444,6 +452,7 @@ function getPullRequests(config) {
|
|
|
444
452
|
const centralPath = path.join(MINIONS_DIR, 'pull-requests.json');
|
|
445
453
|
const centralPrs = safeJson(centralPath);
|
|
446
454
|
if (centralPrs) {
|
|
455
|
+
shared.normalizePrRecords(centralPrs, null);
|
|
447
456
|
for (const pr of centralPrs) {
|
|
448
457
|
if (!allPrs.some(p => p.id === pr.id)) {
|
|
449
458
|
pr._project = 'central';
|
|
@@ -737,9 +746,16 @@ function getWorkItems(config) {
|
|
|
737
746
|
const allPrs = getPullRequests(config);
|
|
738
747
|
for (const item of allItems) {
|
|
739
748
|
if (item._pr && !item._prUrl) {
|
|
740
|
-
const
|
|
741
|
-
const
|
|
742
|
-
|
|
749
|
+
const project = projects.find(p => p.name === item.project || p.name === item._source) || null;
|
|
750
|
+
const canonicalPrId = shared.getCanonicalPrId(project, item._pr);
|
|
751
|
+
const displayPrId = shared.getPrDisplayId(item._pr);
|
|
752
|
+
const exactPr = allPrs.find(p => p.id === canonicalPrId);
|
|
753
|
+
const displayMatches = exactPr ? [] : allPrs.filter(p => shared.getPrDisplayId(p) === displayPrId);
|
|
754
|
+
const pr = exactPr || (displayMatches.length === 1 ? displayMatches[0] : null);
|
|
755
|
+
if (pr) {
|
|
756
|
+
item._pr = pr.id;
|
|
757
|
+
item._prUrl = pr.url;
|
|
758
|
+
}
|
|
743
759
|
}
|
|
744
760
|
if (!item._pr) {
|
|
745
761
|
// Derive from PR.prdItems (single source of truth)
|
|
@@ -953,7 +969,8 @@ function getPrdInfo(config) {
|
|
|
953
969
|
for (const [prId, itemIds] of Object.entries(prLinks)) {
|
|
954
970
|
const pr = prById[prId];
|
|
955
971
|
const project = projects.find(p => p.name === pr?._project) || projects[0] || null;
|
|
956
|
-
const
|
|
972
|
+
const prNumber = shared.getPrNumber(pr || prId);
|
|
973
|
+
const url = pr?.url || (project?.prUrlBase && prNumber != null ? project.prUrlBase + prNumber : '');
|
|
957
974
|
for (const itemId of (itemIds || [])) {
|
|
958
975
|
if (!prdToPr[itemId]) prdToPr[itemId] = [];
|
|
959
976
|
prdToPr[itemId].push({ id: prId, url, title: pr?.title || '', status: pr?.status || 'active', _project: pr?._project || '' });
|
|
@@ -962,10 +979,14 @@ function getPrdInfo(config) {
|
|
|
962
979
|
// Fallback: work item _pr field for anything still missing
|
|
963
980
|
for (const wi of Object.values(wiById)) {
|
|
964
981
|
if (!wi._pr || prdToPr[wi.id]?.length) continue;
|
|
965
|
-
const
|
|
966
|
-
const
|
|
967
|
-
const
|
|
968
|
-
|
|
982
|
+
const project = projects.find(p => p.name === wi.project || p.name === wi._source) || null;
|
|
983
|
+
const canonicalPrId = shared.getCanonicalPrId(project, wi._pr);
|
|
984
|
+
const exactPr = prById[canonicalPrId] || null;
|
|
985
|
+
const displayMatches = exactPr ? [] : Object.values(prById).filter(candidate => shared.getPrDisplayId(candidate) === shared.getPrDisplayId(wi._pr));
|
|
986
|
+
const pr = exactPr || (displayMatches.length === 1 ? displayMatches[0] : null);
|
|
987
|
+
const prNumber = shared.getPrNumber(pr || wi._pr);
|
|
988
|
+
const url = pr?.url || (project?.prUrlBase && prNumber != null ? project.prUrlBase + prNumber : '');
|
|
989
|
+
prdToPr[wi.id] = [{ id: pr?.id || canonicalPrId || wi._pr, url, title: pr?.title || '', status: pr?.status || 'active', _project: project?.name || '' }];
|
|
969
990
|
}
|
|
970
991
|
// Aggregate sub-task PRs to decomposed parent (sub-tasks aren't PRD items but their PRs should show)
|
|
971
992
|
for (const pr of allPrs) {
|
|
@@ -978,7 +999,8 @@ function getPrdInfo(config) {
|
|
|
978
999
|
if (!prdToPr[parentId]) prdToPr[parentId] = [];
|
|
979
1000
|
if (!prdToPr[parentId].some(p => p.id === pr.id)) {
|
|
980
1001
|
const project = projects.find(p => p.name === pr._project) || projects[0] || null;
|
|
981
|
-
const
|
|
1002
|
+
const prNumber = shared.getPrNumber(pr);
|
|
1003
|
+
const url = pr.url || (project?.prUrlBase && prNumber != null ? project.prUrlBase + prNumber : '');
|
|
982
1004
|
prdToPr[parentId].push({ id: pr.id, url, title: pr.title || '', status: pr.status || 'active', _project: pr._project || '' });
|
|
983
1005
|
}
|
|
984
1006
|
}
|
package/engine/shared.js
CHANGED
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const path = require('path');
|
|
8
|
+
const crypto = require('crypto');
|
|
8
9
|
|
|
9
10
|
const MINIONS_DIR = process.env.MINIONS_TEST_DIR || path.resolve(__dirname, '..');
|
|
10
11
|
const PR_LINKS_PATH = path.join(MINIONS_DIR, 'engine', 'pr-links.json');
|
|
12
|
+
const PINNED_ITEMS_PATH = path.join(MINIONS_DIR, 'engine', 'kb-pins.json');
|
|
11
13
|
const LOG_PATH = path.join(MINIONS_DIR, 'engine', 'log.json');
|
|
12
14
|
|
|
13
15
|
// ── Timestamps & Logging ────────────────────────────────────────────────────
|
|
@@ -244,6 +246,9 @@ function mutateJsonFileLocked(filePath, mutateFn, {
|
|
|
244
246
|
// Back up last-known-good state before mutation (best-effort)
|
|
245
247
|
const backupPath = filePath + '.backup';
|
|
246
248
|
try { if (fs.existsSync(filePath)) fs.copyFileSync(filePath, backupPath); } catch { /* backup is best-effort */ }
|
|
249
|
+
if (path.basename(filePath) === 'pull-requests.json' && Array.isArray(data)) {
|
|
250
|
+
normalizePrRecords(data, resolveProjectForPrPath(filePath));
|
|
251
|
+
}
|
|
247
252
|
const next = mutateFn(data);
|
|
248
253
|
const finalData = next === undefined ? data : next;
|
|
249
254
|
safeWrite(filePath, finalData);
|
|
@@ -298,7 +303,8 @@ function parseNoteId(content) {
|
|
|
298
303
|
function writeToInbox(agentId, slug, content, _inboxDir, metadata) {
|
|
299
304
|
try {
|
|
300
305
|
const inboxDir = _inboxDir || path.join(MINIONS_DIR, 'notes', 'inbox');
|
|
301
|
-
const
|
|
306
|
+
const safeSlug = safeSlugComponent(slug, 80);
|
|
307
|
+
const prefix = `${agentId}-${safeSlug}-${dateStamp()}`;
|
|
302
308
|
const existing = safeReadDir(inboxDir).find(f => f.startsWith(prefix));
|
|
303
309
|
if (existing) return false;
|
|
304
310
|
const noteId = `NOTE-${uid()}`;
|
|
@@ -563,6 +569,7 @@ const ENGINE_DEFAULTS = {
|
|
|
563
569
|
ccModel: 'sonnet', // model for Command Center and doc-chat (sonnet, haiku, opus)
|
|
564
570
|
ccEffort: null, // effort level for CC/doc-chat (null, 'low', 'medium', 'high')
|
|
565
571
|
heartbeatTimeouts: {}, // populated after WORK_TYPE is defined (below)
|
|
572
|
+
maxPendingContexts: 20, // cap pendingContexts arrays in cooldowns.json to prevent unbounded growth
|
|
566
573
|
ccMaxTurns: 50, // max tool-use turns for CC/doc-chat before CLI stops
|
|
567
574
|
// Teams integration — config.teams shape: { enabled, appId, appPassword, certPath, privateKeyPath, tenantId, notifyEvents, ccMirror, inboxPollInterval }
|
|
568
575
|
// Auth modes: (1) appId + appPassword (client secret), or (2) appId + certPath + privateKeyPath + tenantId (certificate)
|
|
@@ -738,6 +745,7 @@ const DEFAULT_CLAUDE = {
|
|
|
738
745
|
// ── Project Helpers ──────────────────────────────────────────────────────────
|
|
739
746
|
|
|
740
747
|
function getProjects(config) {
|
|
748
|
+
if (!config) config = safeJson(path.join(MINIONS_DIR, 'config.json')) || {};
|
|
741
749
|
if (config && config.projects && Array.isArray(config.projects)) {
|
|
742
750
|
return config.projects.filter(p => {
|
|
743
751
|
if (!p || typeof p !== 'object') return false;
|
|
@@ -771,6 +779,14 @@ function projectPrPath(project) {
|
|
|
771
779
|
return path.join(projectStateDir(project), 'pull-requests.json');
|
|
772
780
|
}
|
|
773
781
|
|
|
782
|
+
function resolveProjectForPrPath(filePath, config = null) {
|
|
783
|
+
const resolvedPath = path.resolve(filePath);
|
|
784
|
+
for (const project of getProjects(config)) {
|
|
785
|
+
if (path.resolve(projectPrPath(project)) === resolvedPath) return project;
|
|
786
|
+
}
|
|
787
|
+
return null;
|
|
788
|
+
}
|
|
789
|
+
|
|
774
790
|
// ── ID Generation ────────────────────────────────────────────────────────────
|
|
775
791
|
|
|
776
792
|
function nextWorkItemId(items, prefix) {
|
|
@@ -859,9 +875,151 @@ function parseSkillFrontmatter(content, filename) {
|
|
|
859
875
|
// Stable single-writer file: maps PR IDs → PRD item IDs.
|
|
860
876
|
// Never touched by polling loops — only written when a PR is first linked to a PRD item.
|
|
861
877
|
|
|
878
|
+
function normalizePrScopeSegment(value) {
|
|
879
|
+
return String(value || '').trim().replace(/^\/+|\/+$/g, '').toLowerCase();
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function parseCanonicalPrId(value) {
|
|
883
|
+
const match = String(value || '').trim().match(/^(github|ado):(.+?)#(\d+)$/i);
|
|
884
|
+
if (!match) return null;
|
|
885
|
+
const scope = `${match[1].toLowerCase()}:${match[2].split('/').map(normalizePrScopeSegment).join('/')}`;
|
|
886
|
+
return { scope, prNumber: parseInt(match[3], 10) };
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function parseGitHubPrUrl(url) {
|
|
890
|
+
const match = String(url || '').match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/i);
|
|
891
|
+
if (!match) return null;
|
|
892
|
+
return {
|
|
893
|
+
scope: `github:${normalizePrScopeSegment(match[1])}/${normalizePrScopeSegment(match[2])}`,
|
|
894
|
+
prNumber: parseInt(match[3], 10),
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function parseAdoPrUrl(url) {
|
|
899
|
+
const devAzure = String(url || '').match(/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)\/pullrequest\/(\d+)/i);
|
|
900
|
+
if (devAzure) {
|
|
901
|
+
return {
|
|
902
|
+
scope: `ado:${normalizePrScopeSegment(devAzure[1])}/${normalizePrScopeSegment(devAzure[2])}/${normalizePrScopeSegment(decodeURIComponent(devAzure[3]))}`,
|
|
903
|
+
prNumber: parseInt(devAzure[4], 10),
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
const visualStudio = String(url || '').match(/https?:\/\/([^/.]+)\.visualstudio\.com\/(?:DefaultCollection\/)?([^/]+)\/_git\/([^/]+)\/pullrequest\/(\d+)/i);
|
|
907
|
+
if (!visualStudio) return null;
|
|
908
|
+
return {
|
|
909
|
+
scope: `ado:${normalizePrScopeSegment(visualStudio[1])}/${normalizePrScopeSegment(visualStudio[2])}/${normalizePrScopeSegment(decodeURIComponent(visualStudio[3]))}`,
|
|
910
|
+
prNumber: parseInt(visualStudio[4], 10),
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function getProjectPrScope(project) {
|
|
915
|
+
if (!project) return '';
|
|
916
|
+
const host = String(project.repoHost || '').toLowerCase();
|
|
917
|
+
if (host === 'github') {
|
|
918
|
+
const parsed = parseGitHubPrUrl(project.prUrlBase || '');
|
|
919
|
+
if (parsed?.scope) return parsed.scope;
|
|
920
|
+
const owner = normalizePrScopeSegment(project.adoOrg);
|
|
921
|
+
const repo = normalizePrScopeSegment(project.repoName);
|
|
922
|
+
return owner && repo ? `github:${owner}/${repo}` : '';
|
|
923
|
+
}
|
|
924
|
+
if (host === 'ado' || !host) {
|
|
925
|
+
const parsed = parseAdoPrUrl(project.prUrlBase || '');
|
|
926
|
+
if (parsed?.scope) return parsed.scope;
|
|
927
|
+
const org = normalizePrScopeSegment(project.adoOrg);
|
|
928
|
+
const adoProject = normalizePrScopeSegment(project.adoProject);
|
|
929
|
+
const repo = normalizePrScopeSegment(project.repoName || project.repositoryId);
|
|
930
|
+
return org && adoProject && repo ? `ado:${org}/${adoProject}/${repo}` : '';
|
|
931
|
+
}
|
|
932
|
+
return '';
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function getPrNumber(value) {
|
|
936
|
+
if (value && typeof value === 'object') {
|
|
937
|
+
if (value.prNumber != null && /^\d+$/.test(String(value.prNumber))) {
|
|
938
|
+
return parseInt(String(value.prNumber), 10);
|
|
939
|
+
}
|
|
940
|
+
return getPrNumber(value.id || value.url || '');
|
|
941
|
+
}
|
|
942
|
+
const raw = String(value || '').trim();
|
|
943
|
+
if (!raw) return null;
|
|
944
|
+
const canonical = parseCanonicalPrId(raw);
|
|
945
|
+
if (canonical) return canonical.prNumber;
|
|
946
|
+
const parsedUrl = parseGitHubPrUrl(raw) || parseAdoPrUrl(raw);
|
|
947
|
+
if (parsedUrl) return parsedUrl.prNumber;
|
|
948
|
+
const legacy = raw.match(/^PR-(\d+)$/i) || raw.match(/^(\d+)$/);
|
|
949
|
+
return legacy ? parseInt(legacy[1], 10) : null;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function getPrDisplayId(value, fallbackPrNumber = null) {
|
|
953
|
+
const prNumber = getPrNumber(value) ?? getPrNumber(fallbackPrNumber);
|
|
954
|
+
if (prNumber != null) return `PR-${prNumber}`;
|
|
955
|
+
return typeof value === 'object' ? String(value?.id || '') : String(value || '');
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function getCanonicalPrId(project, prRef, url = '') {
|
|
959
|
+
const isObjectRef = !!prRef && typeof prRef === 'object';
|
|
960
|
+
const rawId = isObjectRef ? (prRef.id || '') : String(prRef || '');
|
|
961
|
+
const canonical = parseCanonicalPrId(rawId);
|
|
962
|
+
if (canonical) return `${canonical.scope}#${canonical.prNumber}`;
|
|
963
|
+
const parsedUrl = parseGitHubPrUrl(url || (isObjectRef ? prRef.url || '' : ''))
|
|
964
|
+
|| parseAdoPrUrl(url || (isObjectRef ? prRef.url || '' : ''));
|
|
965
|
+
const prNumber = getPrNumber(isObjectRef ? (prRef.prNumber ?? prRef.id ?? prRef.url) : prRef);
|
|
966
|
+
if (prNumber == null) return rawId;
|
|
967
|
+
const scope = getProjectPrScope(project) || parsedUrl?.scope || '';
|
|
968
|
+
return scope ? `${scope}#${prNumber}` : `PR-${prNumber}`;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function findPrRecord(prs, prRef, project = null) {
|
|
972
|
+
if (!Array.isArray(prs) || !prRef) return null;
|
|
973
|
+
const isObjectRef = typeof prRef === 'object';
|
|
974
|
+
const rawId = isObjectRef ? String(prRef.id || '') : String(prRef || '');
|
|
975
|
+
const refUrl = isObjectRef ? String(prRef.url || '') : '';
|
|
976
|
+
const canonicalId = getCanonicalPrId(project, prRef, refUrl);
|
|
977
|
+
if (canonicalId) {
|
|
978
|
+
const canonicalMatch = prs.find(pr => pr?.id === canonicalId);
|
|
979
|
+
if (canonicalMatch) return canonicalMatch;
|
|
980
|
+
}
|
|
981
|
+
if (rawId) {
|
|
982
|
+
const rawMatch = prs.find(pr => pr?.id === rawId);
|
|
983
|
+
if (rawMatch) return rawMatch;
|
|
984
|
+
}
|
|
985
|
+
if (refUrl) {
|
|
986
|
+
const urlMatch = prs.find(pr => pr?.url === refUrl);
|
|
987
|
+
if (urlMatch) return urlMatch;
|
|
988
|
+
}
|
|
989
|
+
const refNumber = getPrNumber(isObjectRef ? (prRef.prNumber ?? prRef.id ?? prRef.url) : prRef);
|
|
990
|
+
if (refNumber == null) return null;
|
|
991
|
+
const numberMatches = prs.filter(pr => getPrNumber(pr) === refNumber);
|
|
992
|
+
return numberMatches.length === 1 ? numberMatches[0] : null;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function normalizePrRecord(pr, project = null) {
|
|
996
|
+
if (!pr || typeof pr !== 'object') return false;
|
|
997
|
+
let changed = false;
|
|
998
|
+
const prNumber = getPrNumber(pr.prNumber ?? pr.id ?? pr.url);
|
|
999
|
+
if (prNumber != null && pr.prNumber !== prNumber) {
|
|
1000
|
+
pr.prNumber = prNumber;
|
|
1001
|
+
changed = true;
|
|
1002
|
+
}
|
|
1003
|
+
const canonicalId = getCanonicalPrId(project, pr, pr.url || '');
|
|
1004
|
+
if (canonicalId && pr.id !== canonicalId) {
|
|
1005
|
+
pr.id = canonicalId;
|
|
1006
|
+
changed = true;
|
|
1007
|
+
}
|
|
1008
|
+
return changed;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function normalizePrRecords(prs, project = null) {
|
|
1012
|
+
if (!Array.isArray(prs)) return 0;
|
|
1013
|
+
let changed = 0;
|
|
1014
|
+
for (const pr of prs) {
|
|
1015
|
+
if (normalizePrRecord(pr, project)) changed++;
|
|
1016
|
+
}
|
|
1017
|
+
return changed;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
862
1020
|
function normalizePrLinkItems(value) {
|
|
863
1021
|
const items = Array.isArray(value) ? value : [value];
|
|
864
|
-
return [...new Set(items.filter(
|
|
1022
|
+
return [...new Set(items.filter(item => typeof item === 'string' && item))];
|
|
865
1023
|
}
|
|
866
1024
|
|
|
867
1025
|
function mergePrLinkItems(links, prId, itemIds) {
|
|
@@ -872,25 +1030,56 @@ function mergePrLinkItems(links, prId, itemIds) {
|
|
|
872
1030
|
|
|
873
1031
|
function getPrLinks() {
|
|
874
1032
|
const links = {};
|
|
1033
|
+
const knownPrIdsByDisplay = new Map();
|
|
1034
|
+
const registerPrId = (pr) => {
|
|
1035
|
+
if (!pr?.id) return;
|
|
1036
|
+
const displayId = getPrDisplayId(pr);
|
|
1037
|
+
if (!displayId) return;
|
|
1038
|
+
if (!knownPrIdsByDisplay.has(displayId)) knownPrIdsByDisplay.set(displayId, new Set());
|
|
1039
|
+
knownPrIdsByDisplay.get(displayId).add(pr.id);
|
|
1040
|
+
};
|
|
875
1041
|
// Primary source: derive from all projects/*/pull-requests.json prdItems
|
|
876
1042
|
const projectsDir = path.join(MINIONS_DIR, 'projects');
|
|
1043
|
+
const projectsByName = new Map(getProjects().map(project => [project.name || path.basename(project.localPath || ''), project]));
|
|
877
1044
|
try {
|
|
878
1045
|
for (const d of fs.readdirSync(projectsDir, { withFileTypes: true })) {
|
|
879
1046
|
if (!d.isDirectory()) continue;
|
|
880
1047
|
try {
|
|
881
1048
|
const prs = JSON.parse(fs.readFileSync(path.join(projectsDir, d.name, 'pull-requests.json'), 'utf8'));
|
|
1049
|
+
const project = projectsByName.get(d.name) || null;
|
|
1050
|
+
normalizePrRecords(prs, project);
|
|
882
1051
|
for (const pr of prs) {
|
|
883
1052
|
if (!pr.id) continue;
|
|
1053
|
+
registerPrId(pr);
|
|
884
1054
|
mergePrLinkItems(links, pr.id, pr.prdItems || []);
|
|
885
1055
|
}
|
|
886
1056
|
} catch { /* missing or invalid */ }
|
|
887
1057
|
}
|
|
888
1058
|
} catch { /* projects dir missing */ }
|
|
1059
|
+
try {
|
|
1060
|
+
const centralPrs = JSON.parse(fs.readFileSync(path.join(MINIONS_DIR, 'pull-requests.json'), 'utf8'));
|
|
1061
|
+
normalizePrRecords(centralPrs, null);
|
|
1062
|
+
for (const pr of centralPrs) {
|
|
1063
|
+
if (!pr.id) continue;
|
|
1064
|
+
registerPrId(pr);
|
|
1065
|
+
mergePrLinkItems(links, pr.id, pr.prdItems || []);
|
|
1066
|
+
}
|
|
1067
|
+
} catch { /* central file optional */ }
|
|
889
1068
|
// Fallback: static pr-links.json for entries not covered above
|
|
890
1069
|
try {
|
|
891
1070
|
const static_ = JSON.parse(fs.readFileSync(PR_LINKS_PATH, 'utf8'));
|
|
892
1071
|
for (const [k, v] of Object.entries(static_)) {
|
|
893
|
-
|
|
1072
|
+
const canonical = parseCanonicalPrId(k);
|
|
1073
|
+
let normalizedKey = canonical ? `${canonical.scope}#${canonical.prNumber}` : k;
|
|
1074
|
+
if (!canonical) {
|
|
1075
|
+
const candidates = knownPrIdsByDisplay.get(getPrDisplayId(k));
|
|
1076
|
+
if (candidates?.size === 1) normalizedKey = [...candidates][0];
|
|
1077
|
+
else if (candidates?.size > 1) {
|
|
1078
|
+
log('warn', `Skipping ambiguous legacy PR link "${k}" — multiple canonical PR IDs share display ID ${getPrDisplayId(k)}`);
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
if (!links[normalizedKey]) mergePrLinkItems(links, normalizedKey, v);
|
|
894
1083
|
}
|
|
895
1084
|
} catch { /* missing */ }
|
|
896
1085
|
return links;
|
|
@@ -913,13 +1102,48 @@ function backfillPrPrdItems(prs, prLinks) {
|
|
|
913
1102
|
return backfilled;
|
|
914
1103
|
}
|
|
915
1104
|
|
|
916
|
-
function addPrLink(prId, itemId) {
|
|
917
|
-
|
|
918
|
-
const
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
links
|
|
922
|
-
|
|
1105
|
+
function addPrLink(prId, itemId, { project = null, url = '', prNumber = null } = {}) {
|
|
1106
|
+
const canonicalPrId = getCanonicalPrId(project, prNumber ?? prId, url);
|
|
1107
|
+
const effectivePrId = canonicalPrId || prId;
|
|
1108
|
+
if (!effectivePrId || !itemId) return;
|
|
1109
|
+
const legacyPrId = String(prId || '');
|
|
1110
|
+
mutateJsonFileLocked(PR_LINKS_PATH, (links) => {
|
|
1111
|
+
if (!links || Array.isArray(links) || typeof links !== 'object') links = {};
|
|
1112
|
+
const mergedCurrent = new Set(normalizePrLinkItems(links[effectivePrId]));
|
|
1113
|
+
if (legacyPrId && legacyPrId !== effectivePrId && links[legacyPrId]) {
|
|
1114
|
+
for (const linkedItem of normalizePrLinkItems(links[legacyPrId])) mergedCurrent.add(linkedItem);
|
|
1115
|
+
delete links[legacyPrId];
|
|
1116
|
+
}
|
|
1117
|
+
if (!mergedCurrent.has(itemId)) mergedCurrent.add(itemId);
|
|
1118
|
+
links[effectivePrId] = [...mergedCurrent];
|
|
1119
|
+
return links;
|
|
1120
|
+
}, { defaultValue: {} });
|
|
1121
|
+
|
|
1122
|
+
if (!project) return;
|
|
1123
|
+
const prPath = projectPrPath(project);
|
|
1124
|
+
const effectivePrNumber = getPrNumber(prNumber ?? effectivePrId);
|
|
1125
|
+
const prLockPath = `${prPath}.lock`;
|
|
1126
|
+
withFileLock(prLockPath, () => {
|
|
1127
|
+
if (!fs.existsSync(prPath)) return;
|
|
1128
|
+
let prs = safeJson(prPath);
|
|
1129
|
+
if (!Array.isArray(prs)) prs = [];
|
|
1130
|
+
normalizePrRecords(prs, project);
|
|
1131
|
+
const existingPr = prs.find(pr =>
|
|
1132
|
+
pr?.id === effectivePrId
|
|
1133
|
+
|| (url && pr?.url === url)
|
|
1134
|
+
|| (effectivePrNumber != null && getPrNumber(pr) === effectivePrNumber)
|
|
1135
|
+
);
|
|
1136
|
+
if (!existingPr) return;
|
|
1137
|
+
const backupPath = prPath + '.backup';
|
|
1138
|
+
try { if (fs.existsSync(prPath)) fs.copyFileSync(prPath, backupPath); } catch { /* backup is best-effort */ }
|
|
1139
|
+
existingPr.prdItems = Array.isArray(existingPr.prdItems) ? existingPr.prdItems : [];
|
|
1140
|
+
if (existingPr.prdItems.includes(itemId)) return;
|
|
1141
|
+
existingPr.prdItems.push(itemId);
|
|
1142
|
+
safeWrite(prPath, prs);
|
|
1143
|
+
}, {
|
|
1144
|
+
retries: ENGINE_DEFAULTS.lockRetries,
|
|
1145
|
+
retryBackoffMs: ENGINE_DEFAULTS.lockRetryBackoffMs
|
|
1146
|
+
});
|
|
923
1147
|
}
|
|
924
1148
|
|
|
925
1149
|
// ─── Cross-Platform Process Kill Helpers ─────────────────────────────────────
|
|
@@ -1089,10 +1313,24 @@ function slugify(text, maxLen = 50) {
|
|
|
1089
1313
|
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, maxLen);
|
|
1090
1314
|
}
|
|
1091
1315
|
|
|
1316
|
+
function safeSlugComponent(text, maxLen = 80) {
|
|
1317
|
+
const raw = String(text || '').trim();
|
|
1318
|
+
if (!raw) return 'item';
|
|
1319
|
+
if (/^[A-Za-z0-9._-]+$/.test(raw) && raw.length <= maxLen) return raw;
|
|
1320
|
+
const hash = crypto.createHash('md5').update(raw).digest('hex').slice(0, 8);
|
|
1321
|
+
const base = slugify(raw, Math.max(8, maxLen - 9)) || 'item';
|
|
1322
|
+
return `${base}-${hash}`.slice(0, maxLen);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1092
1325
|
function formatTranscriptEntry(t) {
|
|
1093
1326
|
return '### ' + (t.agent || 'agent') + ' (' + (t.type || '') + ', Round ' + (t.round || '?') + ')\n\n' + (t.content || '');
|
|
1094
1327
|
}
|
|
1095
1328
|
|
|
1329
|
+
function getPinnedItems() {
|
|
1330
|
+
const pins = safeJson(PINNED_ITEMS_PATH);
|
|
1331
|
+
return Array.isArray(pins) ? pins.filter(item => typeof item === 'string' && item) : [];
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1096
1334
|
// ── Throttle Tracker Factory ────────────────────────────────────────────────
|
|
1097
1335
|
// Generic rate-limit tracker reusable by both ADO and GitHub integrations.
|
|
1098
1336
|
// Returns an object with recordThrottle, recordSuccess, isThrottled, getState.
|
|
@@ -1149,6 +1387,7 @@ function createThrottleTracker({ label, baseBackoffMs = 60000, maxBackoffMs = 32
|
|
|
1149
1387
|
module.exports = {
|
|
1150
1388
|
MINIONS_DIR,
|
|
1151
1389
|
PR_LINKS_PATH,
|
|
1390
|
+
PINNED_ITEMS_PATH,
|
|
1152
1391
|
LOG_PATH,
|
|
1153
1392
|
ts,
|
|
1154
1393
|
logTs,
|
|
@@ -1193,6 +1432,14 @@ module.exports = {
|
|
|
1193
1432
|
projectPrPath,
|
|
1194
1433
|
getPrLinks,
|
|
1195
1434
|
addPrLink,
|
|
1435
|
+
parseCanonicalPrId,
|
|
1436
|
+
getProjectPrScope,
|
|
1437
|
+
getPrNumber,
|
|
1438
|
+
getPrDisplayId,
|
|
1439
|
+
getCanonicalPrId,
|
|
1440
|
+
findPrRecord,
|
|
1441
|
+
normalizePrRecord,
|
|
1442
|
+
normalizePrRecords,
|
|
1196
1443
|
nextWorkItemId,
|
|
1197
1444
|
getAdoOrgBase,
|
|
1198
1445
|
sanitizePath,
|
|
@@ -1208,7 +1455,9 @@ module.exports = {
|
|
|
1208
1455
|
LOCK_STALE_MS,
|
|
1209
1456
|
flushLogs,
|
|
1210
1457
|
slugify,
|
|
1458
|
+
safeSlugComponent,
|
|
1211
1459
|
formatTranscriptEntry,
|
|
1460
|
+
getPinnedItems,
|
|
1212
1461
|
_logBuffer, // exported for testing
|
|
1213
1462
|
createThrottleTracker,
|
|
1214
1463
|
backfillPrPrdItems,
|
package/engine/teams.js
CHANGED
|
@@ -543,7 +543,7 @@ async function teamsNotifyPrEvent(pr, event, project, prFilePath) {
|
|
|
543
543
|
if (prFilePath) {
|
|
544
544
|
mutateJsonFileLocked(prFilePath, (prs) => {
|
|
545
545
|
if (!Array.isArray(prs)) return prs;
|
|
546
|
-
const target =
|
|
546
|
+
const target = shared.findPrRecord(prs, pr);
|
|
547
547
|
if (target) {
|
|
548
548
|
if (!target._teamsNotifiedEvents) target._teamsNotifiedEvents = [];
|
|
549
549
|
if (!target._teamsNotifiedEvents.includes(event)) {
|
package/engine/watches.js
CHANGED
|
@@ -20,6 +20,14 @@ function _watchesPath() { return path.join(shared.MINIONS_DIR, 'engine', 'watche
|
|
|
20
20
|
// so watches are checked every N ticks where N = ceil(interval / tickInterval).
|
|
21
21
|
const DEFAULT_WATCH_INTERVAL = 300000;
|
|
22
22
|
|
|
23
|
+
/** Find a PR by target (prNumber, canonical ID, or display ID). */
|
|
24
|
+
function findPrByTarget(pullRequests, target) {
|
|
25
|
+
const t = String(target);
|
|
26
|
+
return (pullRequests || []).find(p =>
|
|
27
|
+
String(p.prNumber) === t || p.id === t || shared.getPrDisplayId(p) === t
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
23
31
|
/**
|
|
24
32
|
* Read all watches from disk.
|
|
25
33
|
* @returns {Array<object>}
|
|
@@ -131,9 +139,7 @@ function evaluateWatch(watch, state) {
|
|
|
131
139
|
const { target, targetType, condition } = watch;
|
|
132
140
|
|
|
133
141
|
if (targetType === WATCH_TARGET_TYPE.PR) {
|
|
134
|
-
const pr = (state.pullRequests
|
|
135
|
-
String(p.prNumber) === String(target) || p.id === target
|
|
136
|
-
);
|
|
142
|
+
const pr = findPrByTarget(state.pullRequests, target);
|
|
137
143
|
if (!pr) return { triggered: false, message: `PR ${target} not found` };
|
|
138
144
|
|
|
139
145
|
// Store previous state for status-change detection
|
|
@@ -290,9 +296,7 @@ function checkWatches(config, state) {
|
|
|
290
296
|
*/
|
|
291
297
|
function _captureState(watch, state) {
|
|
292
298
|
if (watch.targetType === WATCH_TARGET_TYPE.PR) {
|
|
293
|
-
const pr = (state.pullRequests
|
|
294
|
-
String(p.prNumber) === String(watch.target) || p.id === watch.target
|
|
295
|
-
);
|
|
299
|
+
const pr = findPrByTarget(state.pullRequests, watch.target);
|
|
296
300
|
if (pr) return { status: pr.status, buildStatus: pr.buildStatus, reviewStatus: pr.reviewStatus, lastCommentDate: pr.humanFeedback?.lastProcessedCommentDate || null };
|
|
297
301
|
}
|
|
298
302
|
if (watch.targetType === WATCH_TARGET_TYPE.WORK_ITEM) {
|
package/engine.js
CHANGED
|
@@ -1878,7 +1878,7 @@ function clearPendingHumanFeedbackFlag(projectMeta, prId) {
|
|
|
1878
1878
|
try {
|
|
1879
1879
|
const prsPath = projectPrPath(projectMeta);
|
|
1880
1880
|
mutatePullRequests(prsPath, prs => {
|
|
1881
|
-
const target =
|
|
1881
|
+
const target = shared.findPrRecord(prs, prId, projectMeta);
|
|
1882
1882
|
if (!target?.humanFeedback?.pendingFix) return;
|
|
1883
1883
|
target.humanFeedback.pendingFix = false;
|
|
1884
1884
|
});
|
|
@@ -1892,11 +1892,12 @@ async function discoverFromPrs(config, project) {
|
|
|
1892
1892
|
const src = project?.workSources?.pullRequests || config.workSources?.pullRequests;
|
|
1893
1893
|
if (!src?.enabled) return [];
|
|
1894
1894
|
|
|
1895
|
-
const prs =
|
|
1895
|
+
const prs = queries.getPrs(project);
|
|
1896
1896
|
const cooldownMs = (src.cooldownMinutes || 30) * 60 * 1000;
|
|
1897
1897
|
const newWork = [];
|
|
1898
1898
|
|
|
1899
1899
|
const projMeta = { name: project?.name, localPath: project?.localPath };
|
|
1900
|
+
const projectsByName = new Map(shared.getProjects(config).map(p => [p.name, p]));
|
|
1900
1901
|
|
|
1901
1902
|
// Resolve poll-enabled per project — stale reviewStatus is untrustworthy without poller
|
|
1902
1903
|
const isAdoProject = project?.repoHost !== 'github';
|
|
@@ -1908,12 +1909,21 @@ async function discoverFromPrs(config, project) {
|
|
|
1908
1909
|
// Collect active PR dispatches to prevent simultaneous review+fix on same PR
|
|
1909
1910
|
const dispatch = getDispatch();
|
|
1910
1911
|
const activePrIds = new Set(
|
|
1911
|
-
(dispatch.active || [])
|
|
1912
|
+
(dispatch.active || [])
|
|
1913
|
+
.filter(d => d.meta?.pr?.id)
|
|
1914
|
+
.map(d => {
|
|
1915
|
+
const dispatchProject = d.meta?.project?.name
|
|
1916
|
+
? (projectsByName.get(d.meta.project.name) || d.meta.project)
|
|
1917
|
+
: (d.meta?.project || null);
|
|
1918
|
+
return shared.getCanonicalPrId(dispatchProject, d.meta.pr, d.meta.pr?.url || '');
|
|
1919
|
+
})
|
|
1920
|
+
.filter(Boolean)
|
|
1912
1921
|
);
|
|
1913
1922
|
|
|
1914
1923
|
const knownAgents = new Set(Object.keys(config.agents || {}));
|
|
1915
1924
|
for (const pr of prs) {
|
|
1916
1925
|
if (pr.status !== PR_STATUS.ACTIVE || pr._contextOnly) continue;
|
|
1926
|
+
const prDisplayId = shared.getPrDisplayId(pr);
|
|
1917
1927
|
if (activePrIds.has(pr.id)) continue; // Skip PRs with active dispatch (prevent race)
|
|
1918
1928
|
// Branch mutex: skip if PR branch is locked by any active dispatch (cross-type collision)
|
|
1919
1929
|
if (pr.branch && isBranchActive(pr.branch)) {
|
|
@@ -1925,7 +1935,7 @@ async function discoverFromPrs(config, project) {
|
|
|
1925
1935
|
const isAgentPr = knownAgents.has((pr.agent || '').toLowerCase()) || (pr.prdItems && pr.prdItems.length > 0) || pr._autoObserve;
|
|
1926
1936
|
if (!isAgentPr) continue;
|
|
1927
1937
|
|
|
1928
|
-
const prNumber = (pr
|
|
1938
|
+
const prNumber = shared.getPrNumber(pr);
|
|
1929
1939
|
// Use reviewStatus as single source of truth (synced from ADO/GitHub votes)
|
|
1930
1940
|
// minionsReview tracks metadata (reviewer, note) but not the authoritative status
|
|
1931
1941
|
const reviewStatus = pr.reviewStatus || 'pending';
|
|
@@ -1939,13 +1949,13 @@ async function discoverFromPrs(config, project) {
|
|
|
1939
1949
|
const evalCycles = pr._reviewFixCycles || 0;
|
|
1940
1950
|
const evalEscalated = evalCycles >= evalMax;
|
|
1941
1951
|
if (evalEscalated && !pr._evalEscalated) {
|
|
1942
|
-
writeInboxAlert(`eval-escalated-${pr.agent || 'unassigned'}-${
|
|
1952
|
+
writeInboxAlert(`eval-escalated-${pr.agent || 'unassigned'}-${prDisplayId}`,
|
|
1943
1953
|
`# Review Loop Escalation\n\n**PR ${pr.id}**: ${pr.title || ''} on branch \`${pr.branch || 'unknown'}\` has gone through **${evalCycles}** review→fix cycles without approval.\n\n` +
|
|
1944
1954
|
`Last review: ${pr.minionsReview?.note ? pr.minionsReview.note.slice(0, 200) : 'See PR thread'}\n\n` +
|
|
1945
1955
|
`Auto-dispatch of reviews and fixes has been suspended. Please review the PR manually.`);
|
|
1946
1956
|
try {
|
|
1947
1957
|
mutatePullRequests(projectPrPath(project), prs => {
|
|
1948
|
-
const target =
|
|
1958
|
+
const target = shared.findPrRecord(prs, pr, project);
|
|
1949
1959
|
if (target) target._evalEscalated = true;
|
|
1950
1960
|
});
|
|
1951
1961
|
} catch (e) { log('warn', 'mark eval escalated: ' + e.message); }
|
|
@@ -1957,7 +1967,7 @@ async function discoverFromPrs(config, project) {
|
|
|
1957
1967
|
const alreadyReviewed = pr.lastReviewedAt && (!pr.lastPushedAt || pr.lastPushedAt <= pr.lastReviewedAt);
|
|
1958
1968
|
const needsReview = reviewEnabled && reviewStatus === 'pending' && !alreadyReviewed && !evalEscalated;
|
|
1959
1969
|
if (needsReview) {
|
|
1960
|
-
const key = `review-${project?.name || 'default'}-${
|
|
1970
|
+
const key = `review-${project?.name || 'default'}-${prDisplayId}`;
|
|
1961
1971
|
if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
|
|
1962
1972
|
|
|
1963
1973
|
// Pre-dispatch live vote check — cached reviewStatus may be stale (poll lag ~6 min)
|
|
@@ -1972,7 +1982,7 @@ async function discoverFromPrs(config, project) {
|
|
|
1972
1982
|
try {
|
|
1973
1983
|
mutateJsonFileLocked(projectPrPath(project), data => {
|
|
1974
1984
|
if (!Array.isArray(data)) return data;
|
|
1975
|
-
const target =
|
|
1985
|
+
const target = shared.findPrRecord(data, pr, project);
|
|
1976
1986
|
if (target && target.reviewStatus !== 'approved') target.reviewStatus = liveStatus;
|
|
1977
1987
|
return data;
|
|
1978
1988
|
});
|
|
@@ -1998,7 +2008,7 @@ async function discoverFromPrs(config, project) {
|
|
|
1998
2008
|
const needsReReview = reviewEnabled && reviewStatus === 'waiting' &&
|
|
1999
2009
|
fixedAfterReview && !evalEscalated;
|
|
2000
2010
|
if (needsReReview) {
|
|
2001
|
-
const key = `rereview-${project?.name || 'default'}-${
|
|
2011
|
+
const key = `rereview-${project?.name || 'default'}-${prDisplayId}`;
|
|
2002
2012
|
// Skip isAlreadyDispatched — fixedAfterReview/lastReviewedAt already dedupe; the 1hr
|
|
2003
2013
|
// completed-dispatch window would block legitimate re-reviews within the hour after a fix
|
|
2004
2014
|
if (isOnCooldown(key, cooldownMs)) continue;
|
|
@@ -2013,7 +2023,7 @@ async function discoverFromPrs(config, project) {
|
|
|
2013
2023
|
try {
|
|
2014
2024
|
mutateJsonFileLocked(projectPrPath(project), data => {
|
|
2015
2025
|
if (!Array.isArray(data)) return data;
|
|
2016
|
-
const target =
|
|
2026
|
+
const target = shared.findPrRecord(data, pr, project);
|
|
2017
2027
|
if (target && target.reviewStatus !== 'approved') target.reviewStatus = liveStatus;
|
|
2018
2028
|
return data;
|
|
2019
2029
|
});
|
|
@@ -2036,7 +2046,7 @@ async function discoverFromPrs(config, project) {
|
|
|
2036
2046
|
// Gate on evalLoopEnabled — the review→fix cycle is the eval loop
|
|
2037
2047
|
let fixDispatched = false;
|
|
2038
2048
|
if (evalLoopEnabled && reviewStatus === 'changes-requested' && !awaitingReReview && !evalEscalated) {
|
|
2039
|
-
const key = `fix-${project?.name || 'default'}-${
|
|
2049
|
+
const key = `fix-${project?.name || 'default'}-${prDisplayId}`;
|
|
2040
2050
|
if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
|
|
2041
2051
|
const agentId = resolveAgent('fix', config, pr.agent);
|
|
2042
2052
|
if (!agentId) continue;
|
|
@@ -2050,7 +2060,7 @@ async function discoverFromPrs(config, project) {
|
|
|
2050
2060
|
// Increment review→fix cycle counter
|
|
2051
2061
|
try {
|
|
2052
2062
|
mutatePullRequests(projectPrPath(project), prs => {
|
|
2053
|
-
const target =
|
|
2063
|
+
const target = shared.findPrRecord(prs, pr, project);
|
|
2054
2064
|
if (target) target._reviewFixCycles = (target._reviewFixCycles || 0) + 1;
|
|
2055
2065
|
});
|
|
2056
2066
|
} catch (e) { log('warn', 'increment review-fix cycles: ' + e.message); }
|
|
@@ -2058,7 +2068,7 @@ async function discoverFromPrs(config, project) {
|
|
|
2058
2068
|
}
|
|
2059
2069
|
|
|
2060
2070
|
// PRs with pending human feedback (skip if review-fix already dispatched above)
|
|
2061
|
-
const humanFixKey = `human-fix-${project?.name || 'default'}-${
|
|
2071
|
+
const humanFixKey = `human-fix-${project?.name || 'default'}-${prDisplayId}`;
|
|
2062
2072
|
const hasCoalescedFeedback = (dispatchCooldowns.get(humanFixKey)?.pendingContexts || []).length > 0;
|
|
2063
2073
|
if ((pr.humanFeedback?.pendingFix || hasCoalescedFeedback) && !awaitingReReview && !fixDispatched) {
|
|
2064
2074
|
const key = humanFixKey;
|
|
@@ -2109,7 +2119,7 @@ async function discoverFromPrs(config, project) {
|
|
|
2109
2119
|
// Check if max retry cap reached — escalate to human instead of dispatching another fix
|
|
2110
2120
|
if ((pr.buildFixAttempts || 0) >= maxBuildFix) {
|
|
2111
2121
|
if (!pr.buildFixEscalated) {
|
|
2112
|
-
writeInboxAlert(`build-fix-escalated-${pr.agent || 'unassigned'}-${
|
|
2122
|
+
writeInboxAlert(`build-fix-escalated-${pr.agent || 'unassigned'}-${prDisplayId}`,
|
|
2113
2123
|
`# Build Fix Escalation\n\n` +
|
|
2114
2124
|
`**PR ${pr.id}**: ${pr.title || ''} on branch \`${pr.branch || 'unknown'}\` has failed **${pr.buildFixAttempts}** consecutive auto-fix attempts.\n` +
|
|
2115
2125
|
`**Last failure:** ${pr.buildFailReason || 'Check CI pipeline for details'}\n\n` +
|
|
@@ -2118,7 +2128,7 @@ async function discoverFromPrs(config, project) {
|
|
|
2118
2128
|
try {
|
|
2119
2129
|
const prPath = projectPrPath(project);
|
|
2120
2130
|
mutatePullRequests(prPath, prs => {
|
|
2121
|
-
const target =
|
|
2131
|
+
const target = shared.findPrRecord(prs, pr, project);
|
|
2122
2132
|
if (target) target.buildFixEscalated = true;
|
|
2123
2133
|
});
|
|
2124
2134
|
} catch (e) { log('warn', 'mark build fix escalated: ' + e.message); }
|
|
@@ -2127,7 +2137,7 @@ async function discoverFromPrs(config, project) {
|
|
|
2127
2137
|
continue;
|
|
2128
2138
|
}
|
|
2129
2139
|
|
|
2130
|
-
const key = `build-fix-${project?.name || 'default'}-${
|
|
2140
|
+
const key = `build-fix-${project?.name || 'default'}-${prDisplayId}`;
|
|
2131
2141
|
if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
|
|
2132
2142
|
const agentId = resolveAgent('fix', config, pr.agent);
|
|
2133
2143
|
if (!agentId) continue;
|
|
@@ -2147,7 +2157,7 @@ async function discoverFromPrs(config, project) {
|
|
|
2147
2157
|
try {
|
|
2148
2158
|
const prPath = projectPrPath(project);
|
|
2149
2159
|
mutatePullRequests(prPath, prs => {
|
|
2150
|
-
const target =
|
|
2160
|
+
const target = shared.findPrRecord(prs, pr, project);
|
|
2151
2161
|
if (target) {
|
|
2152
2162
|
target.buildFixAttempts = (target.buildFixAttempts || 0) + 1;
|
|
2153
2163
|
target._buildFixPushedAt = ts();
|
|
@@ -2167,12 +2177,12 @@ async function discoverFromPrs(config, project) {
|
|
|
2167
2177
|
alertBody += `**Error preview:**\n\`\`\`\n${logPreview}\n\`\`\`\n\n`;
|
|
2168
2178
|
}
|
|
2169
2179
|
alertBody += `A fix agent has been dispatched to address this. Review the fix when complete.\n`;
|
|
2170
|
-
writeInboxAlert(`build-fail-${pr.agent}-${
|
|
2180
|
+
writeInboxAlert(`build-fail-${pr.agent}-${prDisplayId}`, alertBody);
|
|
2171
2181
|
// Mark notified to prevent duplicate alerts
|
|
2172
2182
|
try {
|
|
2173
2183
|
const prPath = projectPrPath(project);
|
|
2174
2184
|
mutatePullRequests(prPath, prs => {
|
|
2175
|
-
const target =
|
|
2185
|
+
const target = shared.findPrRecord(prs, pr, project);
|
|
2176
2186
|
if (target) {
|
|
2177
2187
|
target._buildFailNotified = true;
|
|
2178
2188
|
}
|
|
@@ -2184,7 +2194,7 @@ async function discoverFromPrs(config, project) {
|
|
|
2184
2194
|
// PRs with merge conflicts — dispatch fix to resolve (gated by autoFixConflicts)
|
|
2185
2195
|
const autoFixConflicts = config.engine?.autoFixConflicts ?? ENGINE_DEFAULTS.autoFixConflicts;
|
|
2186
2196
|
if (autoFixConflicts && pr.status === PR_STATUS.ACTIVE && pr._mergeConflict && !fixDispatched) {
|
|
2187
|
-
const key = `conflict-fix-${project?.name || 'default'}-${
|
|
2197
|
+
const key = `conflict-fix-${project?.name || 'default'}-${prDisplayId}`;
|
|
2188
2198
|
// Suppress re-dispatch for 10 min after last attempt — ADO/GitHub recomputes
|
|
2189
2199
|
// mergeStatus asynchronously (1–5 min lag), so the flag may stay set even after
|
|
2190
2200
|
// a successful push. _conflictFixedAt is cleared when the poller confirms clean status.
|
|
@@ -2203,7 +2213,7 @@ async function discoverFromPrs(config, project) {
|
|
|
2203
2213
|
// Record dispatch timestamp so re-dispatch is suppressed during ADO lag window
|
|
2204
2214
|
try {
|
|
2205
2215
|
mutatePullRequests(projectPrPath(project), prs => {
|
|
2206
|
-
const target =
|
|
2216
|
+
const target = shared.findPrRecord(prs, pr, project);
|
|
2207
2217
|
if (target) target._conflictFixedAt = new Date().toISOString();
|
|
2208
2218
|
});
|
|
2209
2219
|
} catch (e) { log('warn', `conflict-fix timestamp: ${e.message}`); }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1006",
|
|
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"
|