@yemi33/minions 0.1.1003 → 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.
@@ -111,8 +111,8 @@ function checkPlanCompletion(meta, config) {
111
111
  const prs = safeJson(prPath) || [];
112
112
  const prLinks = getPrLinks();
113
113
  for (const pr of prs) {
114
- const linkedItemId = prLinks[pr.id];
115
- if (linkedItemId && doneItems.find(w => w.id === linkedItemId)) {
114
+ const linkedItemIds = prLinks[pr.id] || [];
115
+ if (linkedItemIds.some(itemId => doneItems.find(w => w.id === itemId))) {
116
116
  prsCreated.push(pr);
117
117
  }
118
118
  }
@@ -199,8 +199,8 @@ function checkPlanCompletion(meta, config) {
199
199
  const prLinks = getPrLinks();
200
200
  const prs = (safeJson(shared.projectPrPath(p)) || [])
201
201
  .filter(pr => {
202
- const linkedId = prLinks[pr.id];
203
- return pr.status === PR_STATUS.ACTIVE && linkedId && doneItems.find(w => w.id === linkedId);
202
+ const linkedIds = prLinks[pr.id] || [];
203
+ return pr.status === PR_STATUS.ACTIVE && linkedIds.some(itemId => doneItems.find(w => w.id === itemId));
204
204
  });
205
205
  if (prs.length > 0) {
206
206
  projectPrs[p.name] = { project: p, prs, mainBranch: shared.resolveMainBranch(p.localPath, p.mainBranch) };
@@ -413,8 +413,8 @@ function cleanupPlanWorktrees(planFile, plan, projects, config) {
413
413
  const prs = safeJson(shared.projectPrPath(p)) || [];
414
414
  const prLinks = getPrLinks();
415
415
  for (const pr of prs) {
416
- const linkedId = prLinks[pr.id];
417
- if (linkedId && planItems.find(w => w.id === linkedId) && pr.branch) {
416
+ const linkedIds = prLinks[pr.id] || [];
417
+ if (linkedIds.some(itemId => planItems.find(w => w.id === itemId)) && pr.branch) {
418
418
  branchSlugs.add(shared.sanitizeBranch(pr.branch).toLowerCase());
419
419
  }
420
420
  }
@@ -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: extractPrUrl(prId),
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 || String(p.id) === String(prId))) continue;
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) addPrLink(fullId, 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 = prs.find(p => p.id === pr.id);
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 = prs.find(p => p.id === pr.id);
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.id || '').replace('PR-', '');
1093
+ const prNum = shared.getPrNumber(pr);
1087
1094
 
1088
1095
  if (pr.branch && project) {
1089
1096
  const root = path.resolve(project.localPath);
@@ -1109,13 +1116,14 @@ async function handlePostMerge(pr, project, config, newStatus) {
1109
1116
  if (newStatus !== PR_STATUS.MERGED) return;
1110
1117
 
1111
1118
  // Resolve linked work item from pr-links or PR branch name
1112
- let mergedItemId = getPrLinks()[pr.id];
1113
- if (!mergedItemId && pr.branch) {
1119
+ const mergedItemIds = [...(getPrLinks()[pr.id] || [])];
1120
+ if (mergedItemIds.length === 0 && pr.branch) {
1114
1121
  const branchMatch = pr.branch.match(/(P-[a-z0-9]{6,})/i) || pr.branch.match(/(W-[a-z0-9]{6,})/i) || pr.branch.match(/(PL-[a-z0-9]{6,})/i);
1115
- if (branchMatch) mergedItemId = branchMatch[1];
1122
+ if (branchMatch) mergedItemIds.push(branchMatch[1]);
1116
1123
  }
1117
1124
 
1118
- if (mergedItemId) {
1125
+ if (mergedItemIds.length > 0) {
1126
+ const mergedItemSet = new Set(mergedItemIds);
1119
1127
  // Mark PRD feature as implemented
1120
1128
  const prdDir = path.join(MINIONS_DIR, 'prd');
1121
1129
  try {
@@ -1124,49 +1132,62 @@ async function handlePostMerge(pr, project, config, newStatus) {
1124
1132
  for (const pf of planFiles) {
1125
1133
  const plan = safeJson(path.join(prdDir, pf));
1126
1134
  if (!plan?.missing_features) continue;
1127
- const feature = plan.missing_features.find(f => f.id === mergedItemId);
1128
- if (feature && feature.status !== WI_STATUS.DONE) {
1129
- feature.status = WI_STATUS.DONE;
1135
+ let changed = false;
1136
+ for (const feature of plan.missing_features) {
1137
+ if (mergedItemSet.has(feature.id) && feature.status !== WI_STATUS.DONE) {
1138
+ feature.status = WI_STATUS.DONE;
1139
+ changed = true;
1140
+ updated++;
1141
+ }
1142
+ }
1143
+ if (changed) {
1130
1144
  shared.safeWrite(path.join(prdDir, pf), plan);
1131
- updated++;
1132
1145
  }
1133
1146
  }
1134
- if (updated > 0) log('info', `Post-merge: marked ${mergedItemId} as done for ${pr.id}`);
1147
+ if (updated > 0) log('info', `Post-merge: marked ${mergedItemIds.join(', ')} as done for ${pr.id}`);
1135
1148
  } catch (err) { log('warn', `Post-merge PRD update: ${err.message}`); }
1136
1149
 
1137
1150
  // Mark work item as done
1138
1151
  const wiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
1139
1152
  for (const p of shared.getProjects(config)) wiPaths.push(shared.projectWorkItemsPath(p));
1153
+ const remainingMergedIds = new Set(mergedItemIds);
1140
1154
  for (const wiPath of wiPaths) {
1141
1155
  try {
1142
- let found = false;
1143
1156
  mutateWorkItems(wiPath, items => {
1144
- const item = items.find(i => i.id === mergedItemId);
1145
- if (item && item.status !== WI_STATUS.DONE) {
1146
- log('info', `Post-merge: marking work item ${mergedItemId} as done (was ${item.status}) for ${pr.id}`);
1147
- item.status = WI_STATUS.DONE;
1148
- item.completedAt = ts();
1149
- item._mergedVia = pr.id;
1150
- found = true;
1157
+ for (const item of items) {
1158
+ if (!remainingMergedIds.has(item.id)) continue;
1159
+ if (item.status !== WI_STATUS.DONE) {
1160
+ log('info', `Post-merge: marking work item ${item.id} as done (was ${item.status}) for ${pr.id}`);
1161
+ item.status = WI_STATUS.DONE;
1162
+ item.completedAt = ts();
1163
+ item._mergedVia = pr.id;
1164
+ }
1165
+ remainingMergedIds.delete(item.id);
1151
1166
  }
1152
1167
  });
1153
- if (found) break;
1168
+ if (remainingMergedIds.size === 0) break;
1154
1169
  } catch (err) { log('warn', `Post-merge work item update: ${err.message}`); }
1155
1170
  }
1156
1171
 
1157
1172
  // Rebase dependent PRs onto main now that this dependency is merged
1158
1173
  try {
1159
- const dependentPrs = findDependentActivePrs(mergedItemId, config);
1160
- for (const { pr: depPr, project: depProject } of dependentPrs) {
1161
- if (isBranchActive(depPr.branch)) {
1162
- queuePendingRebase(depPr, depProject, mergedItemId);
1163
- log('info', `Post-merge rebase deferred: ${depPr.branch} locked by active agent`);
1164
- continue;
1165
- }
1166
- const result = await rebaseBranchOntoMain(depPr, depProject, config);
1167
- if (!result.success) {
1168
- shared.writeToInbox('engine', `rebase-fail-${depPr.id}`,
1169
- `# Rebase Failed: ${depPr.id}\n\nBranch \`${depPr.branch}\` could not be rebased onto main after dependency ${mergedItemId} merged.\n\nError: ${result.error}\n\nManual rebase may be needed.`);
1174
+ const rebasedPrs = new Set();
1175
+ for (const mergedItemId of mergedItemIds) {
1176
+ const dependentPrs = findDependentActivePrs(mergedItemId, config);
1177
+ for (const { pr: depPr, project: depProject } of dependentPrs) {
1178
+ const rebaseKey = `${depProject.name}:${depPr.id}`;
1179
+ if (rebasedPrs.has(rebaseKey)) continue;
1180
+ rebasedPrs.add(rebaseKey);
1181
+ if (isBranchActive(depPr.branch)) {
1182
+ queuePendingRebase(depPr, depProject, mergedItemId);
1183
+ log('info', `Post-merge rebase deferred: ${depPr.branch} locked by active agent`);
1184
+ continue;
1185
+ }
1186
+ const result = await rebaseBranchOntoMain(depPr, depProject, config);
1187
+ if (!result.success) {
1188
+ shared.writeToInbox('engine', `rebase-fail-${depPr.id}`,
1189
+ `# Rebase Failed: ${depPr.id}\n\nBranch \`${depPr.branch}\` could not be rebased onto main after dependency ${mergedItemId} merged.\n\nError: ${result.error}\n\nManual rebase may be needed.`);
1190
+ }
1170
1191
  }
1171
1192
  }
1172
1193
  } catch (err) { log('warn', `Post-merge rebase phase error: ${err.message}`); }
@@ -1315,7 +1336,8 @@ function createReviewFeedbackForAuthor(reviewerAgentId, pr, config) {
1315
1336
  const reviewFiles = inboxFiles.filter(f => f.includes(reviewerAgentId) && f.includes(today));
1316
1337
  if (reviewFiles.length === 0) return;
1317
1338
  const reviewContent = reviewFiles.map(f => safeRead(path.join(INBOX_DIR, f))).filter(Boolean).join('\n\n');
1318
- const feedbackFile = `feedback-${authorAgentId}-from-${reviewerAgentId}-${pr.id}-${today}.md`;
1339
+ const prSlug = shared.safeSlugComponent(pr.id, 60);
1340
+ const feedbackFile = `feedback-${authorAgentId}-from-${reviewerAgentId}-${prSlug}-${today}.md`;
1319
1341
  const feedbackPath = shared.uniquePath(path.join(INBOX_DIR, feedbackFile));
1320
1342
  const content = `# Review Feedback for ${config.agents[authorAgentId]?.name || authorAgentId}\n\n` +
1321
1343
  `**PR:** ${pr.id} — ${pr.title || ''}\n` +
@@ -1780,7 +1802,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
1780
1802
  // Detect implement tasks that completed without creating a PR
1781
1803
  if (effectiveSuccess && (type === WORK_TYPE.IMPLEMENT || type === WORK_TYPE.IMPLEMENT_LARGE || type === WORK_TYPE.FIX) && prsCreatedCount === 0 && meta?.item?.id && !meta?.item?.skipPr && meta?.project?.localPath) {
1782
1804
  // Check if a PR already exists linked to this work item (from a previous attempt)
1783
- let existingPrFound = Object.values(getPrLinks()).includes(meta.item.id);
1805
+ let existingPrFound = Object.values(getPrLinks()).some(linkedIds => (linkedIds || []).includes(meta.item.id));
1784
1806
  // Also check pull-requests.json for PRs with matching prdItems or branch
1785
1807
  if (!existingPrFound) {
1786
1808
  const allProjects = shared.getProjects(config);
@@ -1800,22 +1822,49 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
1800
1822
  if (projectObj) {
1801
1823
  try {
1802
1824
  let found = null;
1803
- if (projectObj.repoHost === 'github') {
1825
+ const host = projectObj.repoHost || 'ado';
1826
+ if (host === 'github') {
1804
1827
  const ghSlug = projectObj.prUrlBase?.match(/github\.com\/([^/]+\/[^/]+)\/pull/)?.[1];
1805
1828
  if (ghSlug) {
1806
- const raw = await execAsync(`gh pr list --head "${meta.branch}" --repo ${ghSlug} --json number,url,state --limit 1`, { timeout: 15000, windowsHide: true });
1807
- const hits = JSON.parse(raw || '[]');
1808
- if (hits.length > 0 && hits[0].state === 'OPEN') found = { prNumber: hits[0].number, url: hits[0].url };
1829
+ // Retry up to 3 times newly created PRs can take a few seconds to appear in the API
1830
+ for (let attempt = 0; attempt < 3 && !found; attempt++) {
1831
+ if (attempt > 0) await new Promise(r => setTimeout(r, 3000));
1832
+ let raw = '';
1833
+ try {
1834
+ raw = await execAsync(`gh pr list --head "${meta.branch}" --repo ${ghSlug} --json number,url,state --limit 1`, { timeout: 15000, windowsHide: true });
1835
+ const parsed = JSON.parse(raw || '[]');
1836
+ const hits = Array.isArray(parsed) ? parsed : [];
1837
+ if (hits.length > 0 && hits[0].state === 'OPEN') {
1838
+ found = { prNumber: hits[0].number, url: hits[0].url };
1839
+ } else if (attempt === 2) {
1840
+ log('warn', `Auto-link fallback: no open PR found on branch ${meta.branch} after 3 attempts (raw: ${(raw || '').slice(0, 200)})`);
1841
+ }
1842
+ } catch (err) {
1843
+ if (attempt === 2) {
1844
+ const rawSuffix = raw ? ` (raw: ${raw.slice(0, 200)})` : '';
1845
+ log('warn', `Auto-link fallback: gh pr list lookup failed on branch ${meta.branch} after 3 attempts: ${err.message}${rawSuffix}`);
1846
+ }
1847
+ }
1848
+ }
1809
1849
  }
1810
- } else {
1850
+ } else if (host === 'ado') {
1811
1851
  found = await require('./ado').findOpenPrOnBranch(projectObj, meta.branch);
1852
+ } else {
1853
+ log('debug', `Skipping branch PR lookup for unsupported repo host "${host}" on ${projectObj.name}`);
1812
1854
  }
1813
1855
  if (found) {
1814
- const fullId = `PR-${found.prNumber}`;
1856
+ const fullId = shared.getCanonicalPrId(projectObj, found.prNumber, found.url);
1815
1857
  const prPath = shared.projectPrPath(projectObj);
1816
1858
  mutateJsonFileLocked(prPath, prs => {
1817
1859
  if (!Array.isArray(prs)) prs = [];
1818
- if (prs.some(p => p.id === fullId)) return prs;
1860
+ const existingPr = prs.find(p => p.id === fullId);
1861
+ if (existingPr) {
1862
+ if (meta.item?.id) {
1863
+ if (!Array.isArray(existingPr.prdItems)) existingPr.prdItems = [];
1864
+ if (!existingPr.prdItems.includes(meta.item.id)) existingPr.prdItems.push(meta.item.id);
1865
+ }
1866
+ return prs;
1867
+ }
1819
1868
  prs.push({
1820
1869
  id: fullId, prNumber: found.prNumber, title: meta.item?.title || '',
1821
1870
  agent: agentId, branch: meta.branch, reviewStatus: 'pending',
@@ -1825,7 +1874,6 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
1825
1874
  });
1826
1875
  return prs;
1827
1876
  });
1828
- if (meta.item?.id) addPrLink(fullId, meta.item.id);
1829
1877
  log('info', `Auto-linked existing PR ${fullId} on branch ${meta.branch} for ${meta.item?.id}`);
1830
1878
  existingPrFound = true;
1831
1879
  }
@@ -2069,4 +2117,3 @@ module.exports = {
2069
2117
  processPendingRebases,
2070
2118
  findDependentActivePrs,
2071
2119
  };
2072
-
@@ -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 = prs.find(p => p.id === prId);
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) return safeJson(projectPrPath(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 && pr.id) pr.url = base + String(pr.id).replace('PR-', '');
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 prId = String(item._pr).replace('PR-', '');
741
- const pr = allPrs.find(p => String(p.id).includes(prId));
742
- if (pr) item._prUrl = pr.url;
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)
@@ -949,21 +965,28 @@ function getPrdInfo(config) {
949
965
  for (const pr of allPrs) prById[pr.id] = pr;
950
966
 
951
967
  const prdToPr = {};
952
- const prLinks = shared.getPrLinks(); // { "PR-xxxx": "P-xxxx" }
953
- for (const [prId, itemId] of Object.entries(prLinks)) {
968
+ const prLinks = shared.getPrLinks(); // { "PR-xxxx": ["P-xxxx", "P-yyyy"] }
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 url = pr?.url || (project?.prUrlBase ? project.prUrlBase + prId.replace('PR-', '') : '');
957
- if (!prdToPr[itemId]) prdToPr[itemId] = [];
958
- prdToPr[itemId].push({ id: prId, url, title: pr?.title || '', status: pr?.status || 'active', _project: pr?._project || '' });
972
+ const prNumber = shared.getPrNumber(pr || prId);
973
+ const url = pr?.url || (project?.prUrlBase && prNumber != null ? project.prUrlBase + prNumber : '');
974
+ for (const itemId of (itemIds || [])) {
975
+ if (!prdToPr[itemId]) prdToPr[itemId] = [];
976
+ prdToPr[itemId].push({ id: prId, url, title: pr?.title || '', status: pr?.status || 'active', _project: pr?._project || '' });
977
+ }
959
978
  }
960
979
  // Fallback: work item _pr field for anything still missing
961
980
  for (const wi of Object.values(wiById)) {
962
981
  if (!wi._pr || prdToPr[wi.id]?.length) continue;
963
- const pr = prById[wi._pr];
964
- const project = projects.find(p => p.name === wi.project || p.name === wi._source);
965
- const url = pr?.url || (project?.prUrlBase ? project.prUrlBase + wi._pr.replace('PR-', '') : '');
966
- prdToPr[wi.id] = [{ id: wi._pr, url, title: pr?.title || '', status: pr?.status || 'active', _project: project?.name || '' }];
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 || '' }];
967
990
  }
968
991
  // Aggregate sub-task PRs to decomposed parent (sub-tasks aren't PRD items but their PRs should show)
969
992
  for (const pr of allPrs) {
@@ -976,7 +999,8 @@ function getPrdInfo(config) {
976
999
  if (!prdToPr[parentId]) prdToPr[parentId] = [];
977
1000
  if (!prdToPr[parentId].some(p => p.id === pr.id)) {
978
1001
  const project = projects.find(p => p.name === pr._project) || projects[0] || null;
979
- const url = pr.url || (project?.prUrlBase ? project.prUrlBase + pr.id.replace('PR-', '') : '');
1002
+ const prNumber = shared.getPrNumber(pr);
1003
+ const url = pr.url || (project?.prUrlBase && prNumber != null ? project.prUrlBase + prNumber : '');
980
1004
  prdToPr[parentId].push({ id: pr.id, url, title: pr.title || '', status: pr.status || 'active', _project: pr._project || '' });
981
1005
  }
982
1006
  }
@@ -1091,4 +1115,3 @@ module.exports = {
1091
1115
  // Work items & PRD
1092
1116
  getWorkItems, getPrdInfo,
1093
1117
  };
1094
-