@yemi33/minions 0.1.2365 → 0.1.2367

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dashboard.js CHANGED
@@ -14125,14 +14125,50 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14125
14125
 
14126
14126
  { method: 'POST', path: '/api/pull-requests/delete', desc: 'Remove a PR from tracking (sets userDeleted tombstone so pollers do not re-add it)', params: 'id, project?', handler: async (req, res) => {
14127
14127
  const body = await readBody(req);
14128
- const { id } = body;
14128
+ const { id, project: requestedProjectName } = body;
14129
14129
  if (!id) return jsonReply(res, 400, { error: 'id required' });
14130
14130
  reloadConfig();
14131
14131
  // Search all project PR files and central file
14132
- const prPaths = [
14133
- ...shared.getProjects(CONFIG).map(p => shared.projectPrPath(p)),
14134
- path.join(MINIONS_DIR, 'pull-requests.json'),
14135
- ];
14132
+ const projects = shared.getProjects(CONFIG);
14133
+ const projectByName = new Map(projects.map(project => [project.name, project]));
14134
+ const store = require('./engine/pull-requests-store');
14135
+ const storedPrs = store.readAllPullRequests() || [];
14136
+ const prScopes = new Map([
14137
+ ...projects.map(project => [project.name, { prPath: shared.projectPrPath(project), project }]),
14138
+ ['central', { prPath: path.join(MINIONS_DIR, 'pull-requests.json'), project: null }],
14139
+ ]);
14140
+ const canonicalRequest = shared.parseCanonicalPrId(id);
14141
+ const requestedIdentities = new Set();
14142
+ if (canonicalRequest) {
14143
+ const compatibleProject = canonicalRequest.scope.startsWith('ado:')
14144
+ ? projects.find(project => shared.isAdoPrScopeCompatible(canonicalRequest.scope, project))
14145
+ : null;
14146
+ requestedIdentities.add(shared.getPrIdentityKey(id, compatibleProject || null));
14147
+ } else {
14148
+ for (const pr of storedPrs) {
14149
+ if (pr?.id !== id) continue;
14150
+ if (requestedProjectName && pr._scope !== requestedProjectName) continue;
14151
+ const ownerProject = pr._scope && pr._scope !== 'central'
14152
+ ? projectByName.get(pr._scope) || null
14153
+ : null;
14154
+ const identity = shared.getPrIdentityKey(pr, ownerProject);
14155
+ if (identity) requestedIdentities.add(identity);
14156
+ }
14157
+ }
14158
+ if (requestedIdentities.size > 1) {
14159
+ return jsonReply(res, 409, { error: 'Legacy PR id is ambiguous; specify project' });
14160
+ }
14161
+ if (requestedIdentities.size === 0) {
14162
+ return jsonReply(res, 404, { error: 'PR not found' });
14163
+ }
14164
+ for (const pr of storedPrs) {
14165
+ const scope = pr?._scope || 'central';
14166
+ const ownerProject = scope !== 'central' ? projectByName.get(scope) || null : null;
14167
+ if (!requestedIdentities.has(shared.getPrIdentityKey(pr, ownerProject))) continue;
14168
+ if (!prScopes.has(scope)) {
14169
+ prScopes.set(scope, { prPath: store._filePathForScope(scope), project: ownerProject });
14170
+ }
14171
+ }
14136
14172
  let found = false;
14137
14173
  // Issue #803: tombstone EVERY scope that has a copy of this PR id, not
14138
14174
  // just the first one found. When enrollPrFromCanonicalId (issue #802)
@@ -14141,14 +14177,16 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14141
14177
  // the other scope's copy fully untouched — queries.js#getPullRequests
14142
14178
  // then de-dupes by picking that untouched duplicate as the "winner"
14143
14179
  // and silently resurrects the "deleted" PR on the next read.
14144
- for (const prPath of prPaths) {
14180
+ for (const { prPath, project } of prScopes.values()) {
14145
14181
  // Issue #384: use a tombstone (userDeleted:true) instead of splice so
14146
14182
  // the next poller cycle cannot re-add the PR. The entry stays in the
14147
14183
  // file but is filtered from API responses and skipped by pollers.
14148
14184
  shared.mutatePullRequests(prPath, (prs) => {
14149
14185
  if (!Array.isArray(prs)) return prs;
14150
- const pr = prs.find(p => p.id === id);
14151
- if (pr) {
14186
+ const matches = prs.filter(pr =>
14187
+ requestedIdentities.has(shared.getPrIdentityKey(pr, project))
14188
+ );
14189
+ for (const pr of matches) {
14152
14190
  pr.userDeleted = true;
14153
14191
  pr.userDeletedAt = shared.ts();
14154
14192
  found = true;
@@ -6695,6 +6695,7 @@ function pruneScopeMismatchDuplicatePrs(config) {
6695
6695
  config = config || getConfig();
6696
6696
  const projects = shared.getProjects(config) || [];
6697
6697
  if (projects.length === 0) return { pruned: 0, relocated: 0, scanned: 0 };
6698
+ const projectByName = new Map(projects.filter(project => project?.name).map(project => [project.name, project]));
6698
6699
 
6699
6700
  // Map project name -> canonical scope(s) so we can find which project IS
6700
6701
  // the correctly-scoped owner for a given mismatch record. Includes
@@ -6717,14 +6718,19 @@ function pruneScopeMismatchDuplicatePrs(config) {
6717
6718
  }
6718
6719
  }
6719
6720
 
6720
- // Index all PRs by canonical id across all projects (post-_scope decoration).
6721
+ // Index all PRs by repository-scoped identity across all projects.
6721
6722
  const store = require('./pull-requests-store');
6722
6723
  const allPrs = store.readAllPullRequests() || [];
6723
6724
  const byId = new Map();
6724
6725
  for (const pr of allPrs) {
6725
6726
  if (!pr || !pr.id) continue;
6726
- if (!byId.has(pr.id)) byId.set(pr.id, []);
6727
- byId.get(pr.id).push(pr);
6727
+ const ownerProject = pr._scope && pr._scope !== 'central'
6728
+ ? projectByName.get(pr._scope) || null
6729
+ : null;
6730
+ const identity = shared.getPrIdentityKey(pr, ownerProject);
6731
+ if (!identity) continue;
6732
+ if (!byId.has(identity)) byId.set(identity, []);
6733
+ byId.get(identity).push(pr);
6728
6734
  }
6729
6735
 
6730
6736
  // Build per-project delete sets keyed by id, so we batch one mutation per
@@ -6741,6 +6747,9 @@ function pruneScopeMismatchDuplicatePrs(config) {
6741
6747
  for (const records of byId.values()) {
6742
6748
  for (const rec of records) {
6743
6749
  scanned++;
6750
+ // A tombstone is the durable record of explicit user intent. Never prune
6751
+ // it in favor of an active sibling, or the sibling becomes visible again.
6752
+ if (rec.userDeleted === true) continue;
6744
6753
  if (!rec._invalidProjectScope || rec._invalidProjectScope.reason !== 'pr_scope_mismatch') continue;
6745
6754
  const correctScope = rec._invalidProjectScope.prScope;
6746
6755
  if (!correctScope) continue;
@@ -6831,8 +6840,8 @@ function pruneScopeMismatchDuplicatePrs(config) {
6831
6840
  return { pruned, relocated, scanned };
6832
6841
  }
6833
6842
 
6834
- // Repair helper: collapse prNumber duplicates across all project-scoped and
6835
- // the central pull-requests file. Called once per reconcile cycle alongside
6843
+ // Repair helper: collapse duplicate repository-scoped PR identities across all
6844
+ // project-scoped and central tracker data. Called once per reconcile cycle alongside
6836
6845
  // pruneScopeMismatchDuplicatePrs so accumulated duplicates are cleaned up
6837
6846
  // without requiring a one-off migration.
6838
6847
  function collapseAllDuplicatePrRecords(config) {
package/engine/queries.js CHANGED
@@ -1001,25 +1001,34 @@ function getPullRequests(config) {
1001
1001
  // only tombstones one scope's copy would still have its untouched
1002
1002
  // cross-scope duplicate survive grouping below and get picked as the
1003
1003
  // dedupe "winner", silently resurrecting a PR the user just deleted.
1004
- const tombstonedIds = new Set();
1004
+ const identityOf = (pr) => {
1005
+ const ownerProject = pr?._scope && pr._scope !== 'central'
1006
+ ? projectByName.get(pr._scope) || null
1007
+ : null;
1008
+ return shared.getPrIdentityKey(pr, ownerProject);
1009
+ };
1010
+ const tombstonedIdentities = new Set();
1005
1011
  for (const pr of sqlPrs) {
1006
- if (pr && pr.id && pr.userDeleted === true) tombstonedIds.add(pr.id);
1012
+ const identity = identityOf(pr);
1013
+ if (identity && pr.userDeleted === true) tombstonedIdentities.add(identity);
1007
1014
  }
1008
1015
 
1009
1016
  const groups = [];
1010
- const groupsById = new Map();
1017
+ const groupsByIdentity = new Map();
1011
1018
  for (const pr of sqlPrs) {
1012
1019
  if (!pr || !pr.id) continue;
1020
+ const identity = identityOf(pr);
1021
+ if (!identity) continue;
1013
1022
  // Issue #384/#803: skip tombstoned records (user explicitly deleted), and
1014
- // skip ANY record sharing an id that has a tombstoned copy elsewhere.
1023
+ // skip ANY alias of an identity that has a tombstoned copy elsewhere.
1015
1024
  if (pr.userDeleted === true) continue;
1016
- if (tombstonedIds.has(pr.id)) continue;
1017
- if (!groupsById.has(pr.id)) {
1025
+ if (tombstonedIdentities.has(identity)) continue;
1026
+ if (!groupsByIdentity.has(identity)) {
1018
1027
  const g = [];
1019
- groupsById.set(pr.id, g);
1028
+ groupsByIdentity.set(identity, g);
1020
1029
  groups.push(g);
1021
1030
  }
1022
- groupsById.get(pr.id).push(pr);
1031
+ groupsByIdentity.get(identity).push(pr);
1023
1032
  }
1024
1033
  for (const group of groups) {
1025
1034
  const { winner, dropped } = _pickPrDedupeWinner(group, projects);
package/engine/shared.js CHANGED
@@ -7519,6 +7519,19 @@ function getCanonicalPrId(project, prRef, url = '') {
7519
7519
  return scope ? `${scope}#${prNumber}` : `PR-${prNumber}`;
7520
7520
  }
7521
7521
 
7522
+ function getPrIdentityKey(prRef, project = null) {
7523
+ if (!prRef) return '';
7524
+ const url = typeof prRef === 'object' ? String(prRef.url || '') : '';
7525
+ const rawId = typeof prRef === 'object' ? prRef.id : prRef;
7526
+ const identity = getCanonicalPrId(project, prRef, url) || String(rawId || '');
7527
+ const parsed = parseCanonicalPrId(identity);
7528
+ if (parsed && project && isAdoPrScopeCompatible(parsed.scope, project)) {
7529
+ const projectScope = getProjectPrScope(project);
7530
+ if (projectScope?.startsWith('ado:')) return `${projectScope}#${parsed.prNumber}`;
7531
+ }
7532
+ return identity;
7533
+ }
7534
+
7522
7535
  function findPrRecord(prs, prRef, project = null) {
7523
7536
  if (!Array.isArray(prs) || !prRef) return null;
7524
7537
  const isObjectRef = typeof prRef === 'object';
@@ -7814,14 +7827,19 @@ function _mergeDuplicatePrInto(winner, loser) {
7814
7827
  }
7815
7828
  }
7816
7829
  winner.prdItems = normalizePrLinkItems([...(winner.prdItems || []), ...(loser.prdItems || [])]);
7830
+ if (winner.userDeleted === true || loser.userDeleted === true) {
7831
+ winner.userDeleted = true;
7832
+ if (winner.userDeletedAt == null && loser.userDeletedAt != null) {
7833
+ winner.userDeletedAt = loser.userDeletedAt;
7834
+ }
7835
+ }
7817
7836
  }
7818
7837
 
7819
7838
  /**
7820
- * P-e9f0a2b4 — One-time repair helper: collapse duplicate PR records that share
7821
- * the same `prNumber` within a single `pull-requests.json` file.
7839
+ * P-e9f0a2b4 — Repair helper: collapse duplicate records for the same
7840
+ * repository-scoped PR identity within a single tracker scope.
7822
7841
  *
7823
- * Call this at engine start for every project's PR file (and the central file).
7824
- * Idempotent — no-op when no duplicates exist.
7842
+ * Called from the reconciliation sweep for every tracker scope. Idempotent.
7825
7843
  *
7826
7844
  * Selection rule (matching the task spec):
7827
7845
  * 1. Prefer `merged` status over any other status.
@@ -7839,19 +7857,20 @@ function collapseDuplicatePrRecords(prPath, { project = null } = {}) {
7839
7857
  let collapsed = 0;
7840
7858
  mutatePullRequests(prPath, (prs) => {
7841
7859
  normalizePrRecords(prs, project);
7842
- // Group by prNumber.
7843
- const byNumber = new Map();
7860
+ // PR numbers are only unique within a repository. Grouping by number alone
7861
+ // merges unrelated GitHub/ADO repositories and can discard a tombstone.
7862
+ const byIdentity = new Map();
7844
7863
  for (const pr of prs) {
7845
7864
  if (!pr) continue;
7846
- const num = getPrNumber(pr);
7847
- if (num == null) continue;
7848
- if (!byNumber.has(num)) byNumber.set(num, []);
7849
- byNumber.get(num).push(pr);
7865
+ const identity = getPrIdentityKey(pr, project);
7866
+ if (!identity) continue;
7867
+ if (!byIdentity.has(identity)) byIdentity.set(identity, []);
7868
+ byIdentity.get(identity).push(pr);
7850
7869
  }
7851
7870
  // Identify loser records to remove (by object reference, not id, because
7852
7871
  // normalization may have changed multiple records to the same canonical id).
7853
7872
  const toRemove = new Set();
7854
- for (const [, group] of byNumber) {
7873
+ for (const [, group] of byIdentity) {
7855
7874
  if (group.length <= 1) continue;
7856
7875
  const best = _pickBestPrRecord(group);
7857
7876
  for (const dup of group) {
@@ -7867,6 +7886,35 @@ function collapseDuplicatePrRecords(prPath, { project = null } = {}) {
7867
7886
  return { collapsed };
7868
7887
  }
7869
7888
 
7889
+ function _clearPrTombstonesForIdentity(identity) {
7890
+ if (!identity) return 0;
7891
+ const projectsByName = new Map(getProjects().map(project => [project.name, project]));
7892
+ const scopes = new Map();
7893
+ const store = require('./pull-requests-store');
7894
+ for (const pr of store.readAllPullRequests() || []) {
7895
+ if (pr?.userDeleted !== true) continue;
7896
+ const project = pr._scope && pr._scope !== 'central'
7897
+ ? projectsByName.get(pr._scope) || null
7898
+ : null;
7899
+ if (getPrIdentityKey(pr, project) !== identity) continue;
7900
+ const prPath = store._filePathForScope(pr._scope || 'central');
7901
+ scopes.set(path.resolve(prPath), { prPath, project });
7902
+ }
7903
+ let cleared = 0;
7904
+ for (const { prPath, project } of scopes.values()) {
7905
+ mutatePullRequests(prPath, (prs) => {
7906
+ for (const pr of prs) {
7907
+ if (pr?.userDeleted !== true || getPrIdentityKey(pr, project) !== identity) continue;
7908
+ delete pr.userDeleted;
7909
+ delete pr.userDeletedAt;
7910
+ cleared++;
7911
+ }
7912
+ return prs;
7913
+ });
7914
+ }
7915
+ return cleared;
7916
+ }
7917
+
7870
7918
  /**
7871
7919
  * Canonical PR-producing work contract helper.
7872
7920
  *
@@ -7884,17 +7932,15 @@ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null,
7884
7932
  ...(Array.isArray(itemIds) ? itemIds : [itemId]),
7885
7933
  ]);
7886
7934
  const prNumber = getPrNumber(entry.prNumber ?? entry.id ?? entry.url);
7887
- // Issue #802: when the caller already resolved a scope-qualified canonical
7888
- // id (e.g. `ado:office/office/office#5370216`, as enrollPrFromCanonicalId
7889
- // does before calling here), it must win over recomputing from a bare
7890
- // entry.prNumber + project. Recomputing silently downgrades to a legacy
7891
- // `PR-<n>` stub whenever project is null/unresolvable (getCanonicalPrId
7892
- // can't derive a scope), discarding the good id even though it was passed
7893
- // in. Prefer entry.id whenever it already parses as canonical.
7894
- const hasCanonicalEntryId = typeof entry.id === 'string' && parseCanonicalPrId(entry.id) != null;
7895
- const canonicalId = hasCanonicalEntryId
7896
- ? getCanonicalPrId(project, entry.id, entry.url || '')
7897
- : getCanonicalPrId(project, entry.prNumber ?? entry.id ?? entry.url ?? prNumber, entry.url || '');
7935
+ // getPrIdentityKey(entry, project) passes the whole entry object through to
7936
+ // getCanonicalPrId, which (for object refs) reads `entry.id` directly rather
7937
+ // than the `entry.prNumber ?? entry.id ?? ...` coalesce used for bare refs.
7938
+ // That already preserves an already-resolved scope-qualified `entry.id`
7939
+ // (e.g. `ado:office/office/office#5370216`, as enrollPrFromCanonicalId
7940
+ // produces) instead of silently downgrading to a legacy `PR-<n>` stub when
7941
+ // `project` is null/unresolvable see issue #802 and
7942
+ // test/unit/pr-number-dedup.test.js's "(#802)" case.
7943
+ const canonicalId = getPrIdentityKey(entry, project);
7898
7944
  if (!canonicalId) throw new Error('PR id required');
7899
7945
  const normalizedEntry = {
7900
7946
  ...entry,
@@ -7918,12 +7964,19 @@ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null,
7918
7964
  let created = false;
7919
7965
  let linked = false;
7920
7966
  let skipped = false;
7921
- let resurrected = false;
7967
+ let resurrected = allowResurrect && _clearPrTombstonesForIdentity(canonicalId) > 0;
7922
7968
  let record = null;
7923
7969
 
7924
7970
  mutatePullRequests(prPath, (prs) => {
7925
7971
  normalizePrRecords(prs, project);
7926
- let target = findPrRecord(prs, normalizedEntry, project);
7972
+ const identityMatches = prs.filter(pr => getPrIdentityKey(pr, project) === canonicalId);
7973
+ let target = identityMatches.length > 0 ? _pickBestPrRecord(identityMatches) : null;
7974
+ if (target && identityMatches.length > 1) {
7975
+ const duplicates = new Set(identityMatches.filter(pr => pr !== target));
7976
+ for (const duplicate of duplicates) _mergeDuplicatePrInto(target, duplicate);
7977
+ prs = prs.filter(pr => !duplicates.has(pr));
7978
+ }
7979
+ const tombstonedMatches = target?.userDeleted === true ? [target] : [];
7927
7980
  // Issue #384: tombstoned records (userDeleted:true) must not be re-activated
7928
7981
  // by any poller path. Return skipped so reconcilePrs / shared-branch-reconcile
7929
7982
  // cannot resurrect a PR the user explicitly removed.
@@ -7933,13 +7986,15 @@ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null,
7933
7986
  // via POST /api/pull-requests/link is explicit intent to bring the PR back.
7934
7987
  // Callers that want that behavior opt in with `allowResurrect: true`, which
7935
7988
  // clears the tombstone here and lets the normal field-merge logic below run.
7936
- if (target && target.userDeleted === true) {
7989
+ if (tombstonedMatches.length > 0) {
7937
7990
  if (!allowResurrect) {
7938
7991
  skipped = true;
7939
7992
  return prs;
7940
7993
  }
7941
- delete target.userDeleted;
7942
- delete target.userDeletedAt;
7994
+ for (const match of tombstonedMatches) {
7995
+ delete match.userDeleted;
7996
+ delete match.userDeletedAt;
7997
+ }
7943
7998
  resurrected = true;
7944
7999
  }
7945
8000
  // W-mrduef3q000m2172 — defense-in-depth: `prs` here is scoped to a single
@@ -7948,37 +8003,23 @@ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null,
7948
8003
  // record actually lives in a DIFFERENT scope's file), the above check
7949
8004
  // never sees it and would otherwise fall through to insert-as-new,
7950
8005
  // silently clearing the tombstone. Check globally before inserting.
7951
- if (!target) {
8006
+ if (!target && !allowResurrect) {
7952
8007
  try {
7953
8008
  const store = require('./pull-requests-store');
7954
- const globalMatch = (store.readAllPullRequests() || [])
7955
- .find(pr => pr && pr.id === canonicalId && pr.userDeleted === true);
8009
+ const projectsByName = new Map(getProjects().map(candidate => [candidate.name, candidate]));
8010
+ const globalMatch = (store.readAllPullRequests() || []).find(pr => {
8011
+ if (!pr || pr.userDeleted !== true) return false;
8012
+ const ownerProject = pr._scope && pr._scope !== 'central'
8013
+ ? projectsByName.get(pr._scope) || null
8014
+ : null;
8015
+ return getPrIdentityKey(pr, ownerProject) === canonicalId;
8016
+ });
7956
8017
  if (globalMatch) {
7957
8018
  skipped = true;
7958
8019
  return prs;
7959
8020
  }
7960
8021
  } catch { /* best-effort global tombstone check — fall through on failure */ }
7961
8022
  }
7962
- // P-e9f0a2b4 — When findPrRecord returns null but multiple records share the
7963
- // same prNumber (e.g. cross-scope contamination or a project-config change
7964
- // that shifted canonical IDs), collapse the duplicates before inserting.
7965
- // Without this check, every subsequent upsert would append yet another record
7966
- // because numberMatches.length > 1 forces findPrRecord to return null.
7967
- if (!target && prNumber != null) {
7968
- const numberMatches = prs.filter(pr => pr && !pr.userDeleted && getPrNumber(pr) === prNumber);
7969
- if (numberMatches.length > 1) {
7970
- const best = _pickBestPrRecord(numberMatches);
7971
- const toRemoveIds = new Set(numberMatches.filter(p => p !== best).map(p => p.id));
7972
- for (const dup of numberMatches) {
7973
- if (dup === best) continue;
7974
- _mergeDuplicatePrInto(best, dup);
7975
- }
7976
- for (let i = prs.length - 1; i >= 0; i--) {
7977
- if (prs[i] && toRemoveIds.has(prs[i].id)) prs.splice(i, 1);
7978
- }
7979
- target = best; // update the surviving record instead of inserting new
7980
- }
7981
- }
7982
8023
  if (!target && typeof beforeInsert === 'function' && beforeInsert(prs, normalizedEntry) === false) {
7983
8024
  skipped = true;
7984
8025
  return prs;
@@ -9363,6 +9404,7 @@ module.exports = {
9363
9404
  isPrCompatibleWithProject,
9364
9405
  isAdoPrScopeCompatible,
9365
9406
  getCanonicalPrId,
9407
+ getPrIdentityKey,
9366
9408
  findPrRecord,
9367
9409
  isPlaceholderPrTitle,
9368
9410
  snapshotPrRecord,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2365",
3
+ "version": "0.1.2367",
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"