@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.
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 prefix = `${agentId}-${slug}-${dateStamp()}`;
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,40 +874,275 @@ 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
+
1019
+ function normalizePrLinkItems(value) {
1020
+ const items = Array.isArray(value) ? value : [value];
1021
+ return [...new Set(items.filter(item => typeof item === 'string' && item))];
1022
+ }
1023
+
1024
+ function mergePrLinkItems(links, prId, itemIds) {
1025
+ if (!prId) return;
1026
+ const merged = new Set([...(links[prId] || []), ...normalizePrLinkItems(itemIds)]);
1027
+ if (merged.size > 0) links[prId] = [...merged];
1028
+ }
1029
+
862
1030
  function getPrLinks() {
863
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
+ };
864
1040
  // Primary source: derive from all projects/*/pull-requests.json prdItems
865
1041
  const projectsDir = path.join(MINIONS_DIR, 'projects');
1042
+ const projectsByName = new Map(getProjects().map(project => [project.name || path.basename(project.localPath || ''), project]));
866
1043
  try {
867
1044
  for (const d of fs.readdirSync(projectsDir, { withFileTypes: true })) {
868
1045
  if (!d.isDirectory()) continue;
869
1046
  try {
870
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);
871
1050
  for (const pr of prs) {
872
1051
  if (!pr.id) continue;
873
- for (const itemId of (pr.prdItems || [])) {
874
- if (itemId) links[pr.id] = itemId;
875
- }
1052
+ registerPrId(pr);
1053
+ mergePrLinkItems(links, pr.id, pr.prdItems || []);
876
1054
  }
877
1055
  } catch { /* missing or invalid */ }
878
1056
  }
879
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 */ }
880
1067
  // Fallback: static pr-links.json for entries not covered above
881
1068
  try {
882
1069
  const static_ = JSON.parse(fs.readFileSync(PR_LINKS_PATH, 'utf8'));
883
1070
  for (const [k, v] of Object.entries(static_)) {
884
- if (!links[k]) links[k] = v;
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);
885
1082
  }
886
1083
  } catch { /* missing */ }
887
1084
  return links;
888
1085
  }
889
1086
 
890
- function addPrLink(prId, itemId) {
891
- if (!prId || !itemId) return;
892
- const links = getPrLinks();
893
- if (links[prId] === itemId) return; // already correct, no write needed
894
- links[prId] = itemId;
895
- safeWrite(PR_LINKS_PATH, links);
1087
+ function backfillPrPrdItems(prs, prLinks) {
1088
+ let backfilled = 0;
1089
+ for (const pr of prs) {
1090
+ const linkedItems = prLinks[pr.id] || [];
1091
+ if (linkedItems.length > 0) {
1092
+ pr.prdItems = Array.isArray(pr.prdItems) ? pr.prdItems : [];
1093
+ for (const linked of linkedItems) {
1094
+ if (!pr.prdItems.includes(linked)) {
1095
+ pr.prdItems.push(linked);
1096
+ backfilled++;
1097
+ }
1098
+ }
1099
+ }
1100
+ }
1101
+ return backfilled;
1102
+ }
1103
+
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
+ });
896
1146
  }
897
1147
 
898
1148
  // ─── Cross-Platform Process Kill Helpers ─────────────────────────────────────
@@ -1062,10 +1312,24 @@ function slugify(text, maxLen = 50) {
1062
1312
  return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, maxLen);
1063
1313
  }
1064
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
+
1065
1324
  function formatTranscriptEntry(t) {
1066
1325
  return '### ' + (t.agent || 'agent') + ' (' + (t.type || '') + ', Round ' + (t.round || '?') + ')\n\n' + (t.content || '');
1067
1326
  }
1068
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
+
1069
1333
  // ── Throttle Tracker Factory ────────────────────────────────────────────────
1070
1334
  // Generic rate-limit tracker reusable by both ADO and GitHub integrations.
1071
1335
  // Returns an object with recordThrottle, recordSuccess, isThrottled, getState.
@@ -1122,6 +1386,7 @@ function createThrottleTracker({ label, baseBackoffMs = 60000, maxBackoffMs = 32
1122
1386
  module.exports = {
1123
1387
  MINIONS_DIR,
1124
1388
  PR_LINKS_PATH,
1389
+ PINNED_ITEMS_PATH,
1125
1390
  LOG_PATH,
1126
1391
  ts,
1127
1392
  logTs,
@@ -1166,6 +1431,14 @@ module.exports = {
1166
1431
  projectPrPath,
1167
1432
  getPrLinks,
1168
1433
  addPrLink,
1434
+ parseCanonicalPrId,
1435
+ getProjectPrScope,
1436
+ getPrNumber,
1437
+ getPrDisplayId,
1438
+ getCanonicalPrId,
1439
+ findPrRecord,
1440
+ normalizePrRecord,
1441
+ normalizePrRecords,
1169
1442
  nextWorkItemId,
1170
1443
  getAdoOrgBase,
1171
1444
  sanitizePath,
@@ -1181,8 +1454,10 @@ module.exports = {
1181
1454
  LOCK_STALE_MS,
1182
1455
  flushLogs,
1183
1456
  slugify,
1457
+ safeSlugComponent,
1184
1458
  formatTranscriptEntry,
1459
+ getPinnedItems,
1185
1460
  _logBuffer, // exported for testing
1186
1461
  createThrottleTracker,
1462
+ backfillPrPrdItems,
1187
1463
  };
1188
-
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 = prs.find(p => p.id === pr.id);
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 || []).find(p =>
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 || []).find(p =>
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) {