@yemi33/minions 0.1.1004 → 0.1.1005
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 +2 -2
- package/dashboard.js +12 -2
- package/engine/ado-status.js +2 -1
- package/engine/ado.js +10 -8
- package/engine/consolidation.js +2 -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 +258 -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,6 +1,6 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
3
|
+
## 0.1.1005 (2026-04-16)
|
|
4
4
|
|
|
5
5
|
### Features
|
|
6
6
|
- fix stopAfter=0 watch expiry — run forever for all conditions (#1117)
|
|
@@ -25,6 +25,7 @@
|
|
|
25
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/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/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()}`;
|
|
@@ -738,6 +744,7 @@ const DEFAULT_CLAUDE = {
|
|
|
738
744
|
// ── Project Helpers ──────────────────────────────────────────────────────────
|
|
739
745
|
|
|
740
746
|
function getProjects(config) {
|
|
747
|
+
if (!config) config = safeJson(path.join(MINIONS_DIR, 'config.json')) || {};
|
|
741
748
|
if (config && config.projects && Array.isArray(config.projects)) {
|
|
742
749
|
return config.projects.filter(p => {
|
|
743
750
|
if (!p || typeof p !== 'object') return false;
|
|
@@ -771,6 +778,14 @@ function projectPrPath(project) {
|
|
|
771
778
|
return path.join(projectStateDir(project), 'pull-requests.json');
|
|
772
779
|
}
|
|
773
780
|
|
|
781
|
+
function resolveProjectForPrPath(filePath, config = null) {
|
|
782
|
+
const resolvedPath = path.resolve(filePath);
|
|
783
|
+
for (const project of getProjects(config)) {
|
|
784
|
+
if (path.resolve(projectPrPath(project)) === resolvedPath) return project;
|
|
785
|
+
}
|
|
786
|
+
return null;
|
|
787
|
+
}
|
|
788
|
+
|
|
774
789
|
// ── ID Generation ────────────────────────────────────────────────────────────
|
|
775
790
|
|
|
776
791
|
function nextWorkItemId(items, prefix) {
|
|
@@ -859,9 +874,151 @@ function parseSkillFrontmatter(content, filename) {
|
|
|
859
874
|
// Stable single-writer file: maps PR IDs → PRD item IDs.
|
|
860
875
|
// Never touched by polling loops — only written when a PR is first linked to a PRD item.
|
|
861
876
|
|
|
877
|
+
function normalizePrScopeSegment(value) {
|
|
878
|
+
return String(value || '').trim().replace(/^\/+|\/+$/g, '').toLowerCase();
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function parseCanonicalPrId(value) {
|
|
882
|
+
const match = String(value || '').trim().match(/^(github|ado):(.+?)#(\d+)$/i);
|
|
883
|
+
if (!match) return null;
|
|
884
|
+
const scope = `${match[1].toLowerCase()}:${match[2].split('/').map(normalizePrScopeSegment).join('/')}`;
|
|
885
|
+
return { scope, prNumber: parseInt(match[3], 10) };
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function parseGitHubPrUrl(url) {
|
|
889
|
+
const match = String(url || '').match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/i);
|
|
890
|
+
if (!match) return null;
|
|
891
|
+
return {
|
|
892
|
+
scope: `github:${normalizePrScopeSegment(match[1])}/${normalizePrScopeSegment(match[2])}`,
|
|
893
|
+
prNumber: parseInt(match[3], 10),
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function parseAdoPrUrl(url) {
|
|
898
|
+
const devAzure = String(url || '').match(/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)\/pullrequest\/(\d+)/i);
|
|
899
|
+
if (devAzure) {
|
|
900
|
+
return {
|
|
901
|
+
scope: `ado:${normalizePrScopeSegment(devAzure[1])}/${normalizePrScopeSegment(devAzure[2])}/${normalizePrScopeSegment(decodeURIComponent(devAzure[3]))}`,
|
|
902
|
+
prNumber: parseInt(devAzure[4], 10),
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
const visualStudio = String(url || '').match(/https?:\/\/([^/.]+)\.visualstudio\.com\/(?:DefaultCollection\/)?([^/]+)\/_git\/([^/]+)\/pullrequest\/(\d+)/i);
|
|
906
|
+
if (!visualStudio) return null;
|
|
907
|
+
return {
|
|
908
|
+
scope: `ado:${normalizePrScopeSegment(visualStudio[1])}/${normalizePrScopeSegment(visualStudio[2])}/${normalizePrScopeSegment(decodeURIComponent(visualStudio[3]))}`,
|
|
909
|
+
prNumber: parseInt(visualStudio[4], 10),
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function getProjectPrScope(project) {
|
|
914
|
+
if (!project) return '';
|
|
915
|
+
const host = String(project.repoHost || '').toLowerCase();
|
|
916
|
+
if (host === 'github') {
|
|
917
|
+
const parsed = parseGitHubPrUrl(project.prUrlBase || '');
|
|
918
|
+
if (parsed?.scope) return parsed.scope;
|
|
919
|
+
const owner = normalizePrScopeSegment(project.adoOrg);
|
|
920
|
+
const repo = normalizePrScopeSegment(project.repoName);
|
|
921
|
+
return owner && repo ? `github:${owner}/${repo}` : '';
|
|
922
|
+
}
|
|
923
|
+
if (host === 'ado' || !host) {
|
|
924
|
+
const parsed = parseAdoPrUrl(project.prUrlBase || '');
|
|
925
|
+
if (parsed?.scope) return parsed.scope;
|
|
926
|
+
const org = normalizePrScopeSegment(project.adoOrg);
|
|
927
|
+
const adoProject = normalizePrScopeSegment(project.adoProject);
|
|
928
|
+
const repo = normalizePrScopeSegment(project.repoName || project.repositoryId);
|
|
929
|
+
return org && adoProject && repo ? `ado:${org}/${adoProject}/${repo}` : '';
|
|
930
|
+
}
|
|
931
|
+
return '';
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function getPrNumber(value) {
|
|
935
|
+
if (value && typeof value === 'object') {
|
|
936
|
+
if (value.prNumber != null && /^\d+$/.test(String(value.prNumber))) {
|
|
937
|
+
return parseInt(String(value.prNumber), 10);
|
|
938
|
+
}
|
|
939
|
+
return getPrNumber(value.id || value.url || '');
|
|
940
|
+
}
|
|
941
|
+
const raw = String(value || '').trim();
|
|
942
|
+
if (!raw) return null;
|
|
943
|
+
const canonical = parseCanonicalPrId(raw);
|
|
944
|
+
if (canonical) return canonical.prNumber;
|
|
945
|
+
const parsedUrl = parseGitHubPrUrl(raw) || parseAdoPrUrl(raw);
|
|
946
|
+
if (parsedUrl) return parsedUrl.prNumber;
|
|
947
|
+
const legacy = raw.match(/^PR-(\d+)$/i) || raw.match(/^(\d+)$/);
|
|
948
|
+
return legacy ? parseInt(legacy[1], 10) : null;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function getPrDisplayId(value, fallbackPrNumber = null) {
|
|
952
|
+
const prNumber = getPrNumber(value) ?? getPrNumber(fallbackPrNumber);
|
|
953
|
+
if (prNumber != null) return `PR-${prNumber}`;
|
|
954
|
+
return typeof value === 'object' ? String(value?.id || '') : String(value || '');
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function getCanonicalPrId(project, prRef, url = '') {
|
|
958
|
+
const isObjectRef = !!prRef && typeof prRef === 'object';
|
|
959
|
+
const rawId = isObjectRef ? (prRef.id || '') : String(prRef || '');
|
|
960
|
+
const canonical = parseCanonicalPrId(rawId);
|
|
961
|
+
if (canonical) return `${canonical.scope}#${canonical.prNumber}`;
|
|
962
|
+
const parsedUrl = parseGitHubPrUrl(url || (isObjectRef ? prRef.url || '' : ''))
|
|
963
|
+
|| parseAdoPrUrl(url || (isObjectRef ? prRef.url || '' : ''));
|
|
964
|
+
const prNumber = getPrNumber(isObjectRef ? (prRef.prNumber ?? prRef.id ?? prRef.url) : prRef);
|
|
965
|
+
if (prNumber == null) return rawId;
|
|
966
|
+
const scope = getProjectPrScope(project) || parsedUrl?.scope || '';
|
|
967
|
+
return scope ? `${scope}#${prNumber}` : `PR-${prNumber}`;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function findPrRecord(prs, prRef, project = null) {
|
|
971
|
+
if (!Array.isArray(prs) || !prRef) return null;
|
|
972
|
+
const isObjectRef = typeof prRef === 'object';
|
|
973
|
+
const rawId = isObjectRef ? String(prRef.id || '') : String(prRef || '');
|
|
974
|
+
const refUrl = isObjectRef ? String(prRef.url || '') : '';
|
|
975
|
+
const canonicalId = getCanonicalPrId(project, prRef, refUrl);
|
|
976
|
+
if (canonicalId) {
|
|
977
|
+
const canonicalMatch = prs.find(pr => pr?.id === canonicalId);
|
|
978
|
+
if (canonicalMatch) return canonicalMatch;
|
|
979
|
+
}
|
|
980
|
+
if (rawId) {
|
|
981
|
+
const rawMatch = prs.find(pr => pr?.id === rawId);
|
|
982
|
+
if (rawMatch) return rawMatch;
|
|
983
|
+
}
|
|
984
|
+
if (refUrl) {
|
|
985
|
+
const urlMatch = prs.find(pr => pr?.url === refUrl);
|
|
986
|
+
if (urlMatch) return urlMatch;
|
|
987
|
+
}
|
|
988
|
+
const refNumber = getPrNumber(isObjectRef ? (prRef.prNumber ?? prRef.id ?? prRef.url) : prRef);
|
|
989
|
+
if (refNumber == null) return null;
|
|
990
|
+
const numberMatches = prs.filter(pr => getPrNumber(pr) === refNumber);
|
|
991
|
+
return numberMatches.length === 1 ? numberMatches[0] : null;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function normalizePrRecord(pr, project = null) {
|
|
995
|
+
if (!pr || typeof pr !== 'object') return false;
|
|
996
|
+
let changed = false;
|
|
997
|
+
const prNumber = getPrNumber(pr.prNumber ?? pr.id ?? pr.url);
|
|
998
|
+
if (prNumber != null && pr.prNumber !== prNumber) {
|
|
999
|
+
pr.prNumber = prNumber;
|
|
1000
|
+
changed = true;
|
|
1001
|
+
}
|
|
1002
|
+
const canonicalId = getCanonicalPrId(project, pr, pr.url || '');
|
|
1003
|
+
if (canonicalId && pr.id !== canonicalId) {
|
|
1004
|
+
pr.id = canonicalId;
|
|
1005
|
+
changed = true;
|
|
1006
|
+
}
|
|
1007
|
+
return changed;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function normalizePrRecords(prs, project = null) {
|
|
1011
|
+
if (!Array.isArray(prs)) return 0;
|
|
1012
|
+
let changed = 0;
|
|
1013
|
+
for (const pr of prs) {
|
|
1014
|
+
if (normalizePrRecord(pr, project)) changed++;
|
|
1015
|
+
}
|
|
1016
|
+
return changed;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
862
1019
|
function normalizePrLinkItems(value) {
|
|
863
1020
|
const items = Array.isArray(value) ? value : [value];
|
|
864
|
-
return [...new Set(items.filter(
|
|
1021
|
+
return [...new Set(items.filter(item => typeof item === 'string' && item))];
|
|
865
1022
|
}
|
|
866
1023
|
|
|
867
1024
|
function mergePrLinkItems(links, prId, itemIds) {
|
|
@@ -872,25 +1029,56 @@ function mergePrLinkItems(links, prId, itemIds) {
|
|
|
872
1029
|
|
|
873
1030
|
function getPrLinks() {
|
|
874
1031
|
const links = {};
|
|
1032
|
+
const knownPrIdsByDisplay = new Map();
|
|
1033
|
+
const registerPrId = (pr) => {
|
|
1034
|
+
if (!pr?.id) return;
|
|
1035
|
+
const displayId = getPrDisplayId(pr);
|
|
1036
|
+
if (!displayId) return;
|
|
1037
|
+
if (!knownPrIdsByDisplay.has(displayId)) knownPrIdsByDisplay.set(displayId, new Set());
|
|
1038
|
+
knownPrIdsByDisplay.get(displayId).add(pr.id);
|
|
1039
|
+
};
|
|
875
1040
|
// Primary source: derive from all projects/*/pull-requests.json prdItems
|
|
876
1041
|
const projectsDir = path.join(MINIONS_DIR, 'projects');
|
|
1042
|
+
const projectsByName = new Map(getProjects().map(project => [project.name || path.basename(project.localPath || ''), project]));
|
|
877
1043
|
try {
|
|
878
1044
|
for (const d of fs.readdirSync(projectsDir, { withFileTypes: true })) {
|
|
879
1045
|
if (!d.isDirectory()) continue;
|
|
880
1046
|
try {
|
|
881
1047
|
const prs = JSON.parse(fs.readFileSync(path.join(projectsDir, d.name, 'pull-requests.json'), 'utf8'));
|
|
1048
|
+
const project = projectsByName.get(d.name) || null;
|
|
1049
|
+
normalizePrRecords(prs, project);
|
|
882
1050
|
for (const pr of prs) {
|
|
883
1051
|
if (!pr.id) continue;
|
|
1052
|
+
registerPrId(pr);
|
|
884
1053
|
mergePrLinkItems(links, pr.id, pr.prdItems || []);
|
|
885
1054
|
}
|
|
886
1055
|
} catch { /* missing or invalid */ }
|
|
887
1056
|
}
|
|
888
1057
|
} catch { /* projects dir missing */ }
|
|
1058
|
+
try {
|
|
1059
|
+
const centralPrs = JSON.parse(fs.readFileSync(path.join(MINIONS_DIR, 'pull-requests.json'), 'utf8'));
|
|
1060
|
+
normalizePrRecords(centralPrs, null);
|
|
1061
|
+
for (const pr of centralPrs) {
|
|
1062
|
+
if (!pr.id) continue;
|
|
1063
|
+
registerPrId(pr);
|
|
1064
|
+
mergePrLinkItems(links, pr.id, pr.prdItems || []);
|
|
1065
|
+
}
|
|
1066
|
+
} catch { /* central file optional */ }
|
|
889
1067
|
// Fallback: static pr-links.json for entries not covered above
|
|
890
1068
|
try {
|
|
891
1069
|
const static_ = JSON.parse(fs.readFileSync(PR_LINKS_PATH, 'utf8'));
|
|
892
1070
|
for (const [k, v] of Object.entries(static_)) {
|
|
893
|
-
|
|
1071
|
+
const canonical = parseCanonicalPrId(k);
|
|
1072
|
+
let normalizedKey = canonical ? `${canonical.scope}#${canonical.prNumber}` : k;
|
|
1073
|
+
if (!canonical) {
|
|
1074
|
+
const candidates = knownPrIdsByDisplay.get(getPrDisplayId(k));
|
|
1075
|
+
if (candidates?.size === 1) normalizedKey = [...candidates][0];
|
|
1076
|
+
else if (candidates?.size > 1) {
|
|
1077
|
+
log('warn', `Skipping ambiguous legacy PR link "${k}" — multiple canonical PR IDs share display ID ${getPrDisplayId(k)}`);
|
|
1078
|
+
continue;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
if (!links[normalizedKey]) mergePrLinkItems(links, normalizedKey, v);
|
|
894
1082
|
}
|
|
895
1083
|
} catch { /* missing */ }
|
|
896
1084
|
return links;
|
|
@@ -913,13 +1101,48 @@ function backfillPrPrdItems(prs, prLinks) {
|
|
|
913
1101
|
return backfilled;
|
|
914
1102
|
}
|
|
915
1103
|
|
|
916
|
-
function addPrLink(prId, itemId) {
|
|
917
|
-
|
|
918
|
-
const
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
links
|
|
922
|
-
|
|
1104
|
+
function addPrLink(prId, itemId, { project = null, url = '', prNumber = null } = {}) {
|
|
1105
|
+
const canonicalPrId = getCanonicalPrId(project, prNumber ?? prId, url);
|
|
1106
|
+
const effectivePrId = canonicalPrId || prId;
|
|
1107
|
+
if (!effectivePrId || !itemId) return;
|
|
1108
|
+
const legacyPrId = String(prId || '');
|
|
1109
|
+
mutateJsonFileLocked(PR_LINKS_PATH, (links) => {
|
|
1110
|
+
if (!links || Array.isArray(links) || typeof links !== 'object') links = {};
|
|
1111
|
+
const mergedCurrent = new Set(normalizePrLinkItems(links[effectivePrId]));
|
|
1112
|
+
if (legacyPrId && legacyPrId !== effectivePrId && links[legacyPrId]) {
|
|
1113
|
+
for (const linkedItem of normalizePrLinkItems(links[legacyPrId])) mergedCurrent.add(linkedItem);
|
|
1114
|
+
delete links[legacyPrId];
|
|
1115
|
+
}
|
|
1116
|
+
if (!mergedCurrent.has(itemId)) mergedCurrent.add(itemId);
|
|
1117
|
+
links[effectivePrId] = [...mergedCurrent];
|
|
1118
|
+
return links;
|
|
1119
|
+
}, { defaultValue: {} });
|
|
1120
|
+
|
|
1121
|
+
if (!project) return;
|
|
1122
|
+
const prPath = projectPrPath(project);
|
|
1123
|
+
const effectivePrNumber = getPrNumber(prNumber ?? effectivePrId);
|
|
1124
|
+
const prLockPath = `${prPath}.lock`;
|
|
1125
|
+
withFileLock(prLockPath, () => {
|
|
1126
|
+
if (!fs.existsSync(prPath)) return;
|
|
1127
|
+
let prs = safeJson(prPath);
|
|
1128
|
+
if (!Array.isArray(prs)) prs = [];
|
|
1129
|
+
normalizePrRecords(prs, project);
|
|
1130
|
+
const existingPr = prs.find(pr =>
|
|
1131
|
+
pr?.id === effectivePrId
|
|
1132
|
+
|| (url && pr?.url === url)
|
|
1133
|
+
|| (effectivePrNumber != null && getPrNumber(pr) === effectivePrNumber)
|
|
1134
|
+
);
|
|
1135
|
+
if (!existingPr) return;
|
|
1136
|
+
const backupPath = prPath + '.backup';
|
|
1137
|
+
try { if (fs.existsSync(prPath)) fs.copyFileSync(prPath, backupPath); } catch { /* backup is best-effort */ }
|
|
1138
|
+
existingPr.prdItems = Array.isArray(existingPr.prdItems) ? existingPr.prdItems : [];
|
|
1139
|
+
if (existingPr.prdItems.includes(itemId)) return;
|
|
1140
|
+
existingPr.prdItems.push(itemId);
|
|
1141
|
+
safeWrite(prPath, prs);
|
|
1142
|
+
}, {
|
|
1143
|
+
retries: ENGINE_DEFAULTS.lockRetries,
|
|
1144
|
+
retryBackoffMs: ENGINE_DEFAULTS.lockRetryBackoffMs
|
|
1145
|
+
});
|
|
923
1146
|
}
|
|
924
1147
|
|
|
925
1148
|
// ─── Cross-Platform Process Kill Helpers ─────────────────────────────────────
|
|
@@ -1089,10 +1312,24 @@ function slugify(text, maxLen = 50) {
|
|
|
1089
1312
|
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, maxLen);
|
|
1090
1313
|
}
|
|
1091
1314
|
|
|
1315
|
+
function safeSlugComponent(text, maxLen = 80) {
|
|
1316
|
+
const raw = String(text || '').trim();
|
|
1317
|
+
if (!raw) return 'item';
|
|
1318
|
+
if (/^[A-Za-z0-9._-]+$/.test(raw) && raw.length <= maxLen) return raw;
|
|
1319
|
+
const hash = crypto.createHash('md5').update(raw).digest('hex').slice(0, 8);
|
|
1320
|
+
const base = slugify(raw, Math.max(8, maxLen - 9)) || 'item';
|
|
1321
|
+
return `${base}-${hash}`.slice(0, maxLen);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1092
1324
|
function formatTranscriptEntry(t) {
|
|
1093
1325
|
return '### ' + (t.agent || 'agent') + ' (' + (t.type || '') + ', Round ' + (t.round || '?') + ')\n\n' + (t.content || '');
|
|
1094
1326
|
}
|
|
1095
1327
|
|
|
1328
|
+
function getPinnedItems() {
|
|
1329
|
+
const pins = safeJson(PINNED_ITEMS_PATH);
|
|
1330
|
+
return Array.isArray(pins) ? pins.filter(item => typeof item === 'string' && item) : [];
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1096
1333
|
// ── Throttle Tracker Factory ────────────────────────────────────────────────
|
|
1097
1334
|
// Generic rate-limit tracker reusable by both ADO and GitHub integrations.
|
|
1098
1335
|
// Returns an object with recordThrottle, recordSuccess, isThrottled, getState.
|
|
@@ -1149,6 +1386,7 @@ function createThrottleTracker({ label, baseBackoffMs = 60000, maxBackoffMs = 32
|
|
|
1149
1386
|
module.exports = {
|
|
1150
1387
|
MINIONS_DIR,
|
|
1151
1388
|
PR_LINKS_PATH,
|
|
1389
|
+
PINNED_ITEMS_PATH,
|
|
1152
1390
|
LOG_PATH,
|
|
1153
1391
|
ts,
|
|
1154
1392
|
logTs,
|
|
@@ -1193,6 +1431,14 @@ module.exports = {
|
|
|
1193
1431
|
projectPrPath,
|
|
1194
1432
|
getPrLinks,
|
|
1195
1433
|
addPrLink,
|
|
1434
|
+
parseCanonicalPrId,
|
|
1435
|
+
getProjectPrScope,
|
|
1436
|
+
getPrNumber,
|
|
1437
|
+
getPrDisplayId,
|
|
1438
|
+
getCanonicalPrId,
|
|
1439
|
+
findPrRecord,
|
|
1440
|
+
normalizePrRecord,
|
|
1441
|
+
normalizePrRecords,
|
|
1196
1442
|
nextWorkItemId,
|
|
1197
1443
|
getAdoOrgBase,
|
|
1198
1444
|
sanitizePath,
|
|
@@ -1208,7 +1454,9 @@ module.exports = {
|
|
|
1208
1454
|
LOCK_STALE_MS,
|
|
1209
1455
|
flushLogs,
|
|
1210
1456
|
slugify,
|
|
1457
|
+
safeSlugComponent,
|
|
1211
1458
|
formatTranscriptEntry,
|
|
1459
|
+
getPinnedItems,
|
|
1212
1460
|
_logBuffer, // exported for testing
|
|
1213
1461
|
createThrottleTracker,
|
|
1214
1462
|
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.1005",
|
|
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"
|