@yemi33/minions 0.1.2375 → 0.1.2376

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.
Files changed (2) hide show
  1. package/engine/github.js +97 -73
  2. package/package.json +1 -1
package/engine/github.js CHANGED
@@ -99,6 +99,28 @@ function getRepoSlug(project) {
99
99
  return `${org}/${repo}`;
100
100
  }
101
101
 
102
+ function getPrRepoSlug(project, pr) {
103
+ const scope = shared.getPrScopeInfo(pr, pr?.url || '')?.scope || '';
104
+ if (scope && scope === shared.getProjectPrScope(project)) return getRepoSlug(project);
105
+ if (scope.startsWith('github:')) return scope.slice('github:'.length);
106
+ return getRepoSlug(project);
107
+ }
108
+
109
+ function getProjectRepoSlugs(project) {
110
+ const primary = getRepoSlug(project);
111
+ const slugs = primary ? [primary] : [];
112
+ const seen = new Set(slugs.map(slug => slug.toLowerCase()));
113
+ for (const scope of shared.getProjectAllPrScopes(project)) {
114
+ if (!scope.startsWith('github:')) continue;
115
+ const slug = scope.slice('github:'.length);
116
+ if (!seen.has(slug)) {
117
+ slugs.push(slug);
118
+ seen.add(slug);
119
+ }
120
+ }
121
+ return slugs;
122
+ }
123
+
102
124
  function getConfiguredGitHubAuthorLogins(config = {}) {
103
125
  const accounts = config?.engine?.ghAccounts;
104
126
  if (!accounts || typeof accounts !== 'object') return new Set();
@@ -520,40 +542,40 @@ async function forEachActiveGhPr(config, callback) {
520
542
  const src = project?.workSources?.pullRequests || config?.workSources?.pullRequests;
521
543
  if (src && src.enabled === false) continue;
522
544
 
523
- const slug = getRepoSlug(project);
524
- if (!slug) continue;
525
-
526
- // Skip projects in backoff (inaccessible repo)
527
- if (isSlugInBackoff(slug)) continue;
528
-
529
545
  const prs = getPrs(project);
530
546
  const activePrs = prs.filter(pr => PR_POLLABLE_STATUSES.has(pr.status)
531
547
  && shared.isPrCompatibleWithProject(project, pr, pr.url || ''));
532
548
  if (activePrs.length === 0) continue;
533
549
 
534
- // Probe repo accessibility before iterating PRs — avoids N warnings per inaccessible repo.
535
- // W-mp5trwh60008386d: ghApi returns the GH_NOT_FOUND sentinel on 404 (a frozen object,
536
- // *not* null). The pre-fix gate only matched `null`, so a 404 on the base repo (caused by
537
- // a multi-account `gh auth` switch, network blip, or token rotation) fell through and every
538
- // per-PR call below 404'd, permanently flipping all active PRs to `abandoned`. We now treat
539
- // both null and GH_NOT_FOUND as "skip the project for this tick" and explicitly do NOT
540
- // increment per-PR `_consecutive404s` counters since no per-PR call was made.
541
- const probe = await ghApi('', slug);
542
- if (probe === null || probe === GH_NOT_FOUND) {
543
- if (probe === GH_NOT_FOUND) {
544
- log('warn', `GitHub repo probe for ${slug} returned 404 — skipping all per-PR polls for this project this tick (avoids spurious abandonments). Per-PR 404 counters NOT incremented.`);
545
- }
546
- recordSlugFailure(slug);
547
- continue;
548
- }
549
- resetSlugBackoff(slug);
550
-
551
550
  let projectUpdated = 0;
552
551
  const updatedRecords = [];
552
+ const slugProbes = new Map();
553
553
 
554
554
  for (const pr of activePrs) {
555
555
  const prNum = shared.getPrNumber(pr);
556
556
  if (!prNum) continue;
557
+ const slug = getPrRepoSlug(project, pr);
558
+ if (!slug) continue;
559
+
560
+ let probeResult = slugProbes.get(slug);
561
+ if (probeResult === undefined) {
562
+ if (isSlugInBackoff(slug)) {
563
+ probeResult = 'fail';
564
+ } else {
565
+ const probe = await ghApi('', slug);
566
+ probeResult = (probe === null || probe === GH_NOT_FOUND) ? 'fail' : 'ok';
567
+ if (probeResult === 'fail') {
568
+ if (probe === GH_NOT_FOUND) {
569
+ log('warn', `GitHub repo probe for ${slug} returned 404 — skipping all per-PR polls for this repository this tick (avoids spurious abandonments). Per-PR 404 counters NOT incremented.`);
570
+ }
571
+ recordSlugFailure(slug);
572
+ } else {
573
+ resetSlugBackoff(slug);
574
+ }
575
+ }
576
+ slugProbes.set(slug, probeResult);
577
+ }
578
+ if (probeResult !== 'ok') continue;
557
579
 
558
580
  try {
559
581
  const before = shared.snapshotPrRecord(pr);
@@ -612,7 +634,10 @@ async function forEachActiveGhPr(config, callback) {
612
634
  const centralPrs = safeJsonArr(centralPath);
613
635
  // Build a slug→project map for configured GitHub projects so we can detect
614
636
  // PRs that are actually owned by the project loop (present in per-project file).
615
- const configuredGhProjectsBySlug = new Map(projects.map(p => [getRepoSlug(p), p]));
637
+ const configuredGhProjectsBySlug = new Map();
638
+ for (const project of projects) {
639
+ for (const slug of getProjectRepoSlugs(project)) configuredGhProjectsBySlug.set(slug.toLowerCase(), project);
640
+ }
616
641
  const activeCentral = centralPrs.filter(pr => {
617
642
  if (!PR_POLLABLE_STATUSES.has(pr.status)) return false;
618
643
  if (!pr.url) return false;
@@ -621,7 +646,7 @@ async function forEachActiveGhPr(config, callback) {
621
646
  // the project loop will never see it — the central branch must poll it.
622
647
  const ghMatch = pr.url.match(/github\.com\/([^/]+\/[^/]+)\/pull\/\d+/);
623
648
  if (ghMatch) {
624
- const owningProject = configuredGhProjectsBySlug.get(ghMatch[1]);
649
+ const owningProject = configuredGhProjectsBySlug.get(ghMatch[1].toLowerCase());
625
650
  if (owningProject) {
626
651
  const projectPrs = safeJsonArr(projectPrPath(owningProject));
627
652
  if (projectPrs.some(p => p.id === pr.id)) return false; // present → project loop handles it
@@ -1425,12 +1450,6 @@ async function reconcilePrs(config) {
1425
1450
  let totalAdded = 0;
1426
1451
 
1427
1452
  for (const project of projects) {
1428
- const slug = getRepoSlug(project);
1429
- if (!slug) continue;
1430
-
1431
- // Skip projects in backoff (inaccessible repo)
1432
- if (isSlugInBackoff(slug)) continue;
1433
-
1434
1453
  // Skip projects with no tracked PRs and no work items — nothing to reconcile
1435
1454
  const existingPrs = getPrs(project);
1436
1455
  if (existingPrs.length === 0) {
@@ -1441,18 +1460,21 @@ async function reconcilePrs(config) {
1441
1460
  } catch { continue; }
1442
1461
  }
1443
1462
 
1444
- // Fetch open PRs — paginate to handle repos with >100 open PRs
1445
- const prsData = await ghApi('/pulls?state=open&per_page=100', slug, { paginate: true, timeout: 60000 });
1446
- if (!prsData || !Array.isArray(prsData)) {
1447
- recordSlugFailure(slug);
1448
- continue;
1463
+ const ghPrs = [];
1464
+ for (const slug of getProjectRepoSlugs(project)) {
1465
+ if (isSlugInBackoff(slug)) continue;
1466
+ // Fetch open PRs — paginate to handle repos with >100 open PRs.
1467
+ const prsData = await ghApi('/pulls?state=open&per_page=100', slug, { paginate: true, timeout: 60000 });
1468
+ if (!prsData || !Array.isArray(prsData)) {
1469
+ recordSlugFailure(slug);
1470
+ continue;
1471
+ }
1472
+ resetSlugBackoff(slug);
1473
+ for (const pr of prsData) {
1474
+ const branch = pr.head?.ref || '';
1475
+ if (branchPatterns.some(pat => pat.test(branch))) ghPrs.push({ pr, slug });
1476
+ }
1449
1477
  }
1450
- resetSlugBackoff(slug);
1451
-
1452
- const ghPrs = prsData.filter(pr => {
1453
- const branch = pr.head?.ref || '';
1454
- return branchPatterns.some(pat => pat.test(branch));
1455
- });
1456
1478
 
1457
1479
  if (ghPrs.length === 0) continue;
1458
1480
 
@@ -1470,8 +1492,12 @@ async function reconcilePrs(config) {
1470
1492
  const centralItems = safeJsonArr(centralWiPath);
1471
1493
  const allItems = [...workItems, ...centralItems];
1472
1494
 
1473
- for (const ghPr of ghPrs) {
1474
- const prUrl = project.prUrlBase ? project.prUrlBase + ghPr.number : ghPr.html_url || '';
1495
+ for (const { pr: ghPr, slug } of ghPrs) {
1496
+ const primarySlug = getRepoSlug(project);
1497
+ const prUrl = ghPr.html_url
1498
+ || (slug.toLowerCase() === primarySlug?.toLowerCase() && project.prUrlBase
1499
+ ? project.prUrlBase + ghPr.number
1500
+ : `https://github.com/${slug}/pull/${ghPr.number}`);
1475
1501
  const prId = shared.getCanonicalPrId(project, ghPr.number, prUrl);
1476
1502
  // P-a7c4d2e8 (F3): validate API-derived branch ref before persistence
1477
1503
  // / regex matching. Defensive degrade — log and treat as missing on
@@ -1604,7 +1630,7 @@ async function reconcilePrs(config) {
1604
1630
  */
1605
1631
  async function checkLiveReviewStatus(pr, project) {
1606
1632
  try {
1607
- const slug = getRepoSlug(project);
1633
+ const slug = getPrRepoSlug(project, pr);
1608
1634
  if (!slug) return null;
1609
1635
  const prNum = shared.getPrNumber(pr);
1610
1636
  if (!prNum) return null;
@@ -1654,7 +1680,7 @@ async function checkLiveReviewStatus(pr, project) {
1654
1680
  async function dismissPriorViewerChangesRequestedReviews(pr, project) {
1655
1681
  let tmpFile = null;
1656
1682
  try {
1657
- const slug = getRepoSlug(project);
1683
+ const slug = getPrRepoSlug(project, pr);
1658
1684
  if (!slug) return null;
1659
1685
  const prNum = shared.getPrNumber(pr);
1660
1686
  if (!prNum) return null;
@@ -1758,7 +1784,7 @@ async function dismissPriorViewerChangesRequestedReviews(pr, project) {
1758
1784
  */
1759
1785
  async function checkLiveBuildAndConflict(pr, project) {
1760
1786
  try {
1761
- const slug = getRepoSlug(project);
1787
+ const slug = getPrRepoSlug(project, pr);
1762
1788
  if (!slug) return null;
1763
1789
  const prNum = shared.getPrNumber(pr);
1764
1790
  if (!prNum) return null;
@@ -1846,9 +1872,6 @@ async function reconcileAbandonedPrs(config) {
1846
1872
  const slugProbeCache = new Map(); // slug → 'ok' | 'fail'
1847
1873
 
1848
1874
  for (const project of projects) {
1849
- const slug = getRepoSlug(project);
1850
- if (!slug) continue;
1851
-
1852
1875
  const prPath = projectPrPath(project);
1853
1876
  const prs = safeJsonArr(prPath);
1854
1877
  // Filter: only abandoned PRs that don't already have the confirmed-404
@@ -1862,37 +1885,35 @@ async function reconcileAbandonedPrs(config) {
1862
1885
  );
1863
1886
  if (abandonedPrs.length === 0) continue;
1864
1887
 
1865
- // Probe base repo (cached). Failure skip ALL of this slug's abandoned
1866
- // PRs and increment skipped counter — auth/access issue at boot, retry
1867
- // next restart.
1868
- let probeResult = slugProbeCache.get(slug);
1869
- if (probeResult === undefined) {
1870
- const probe = await ghApi('', slug);
1871
- probeResult = (probe === null || probe === GH_NOT_FOUND) ? 'fail' : 'ok';
1872
- slugProbeCache.set(slug, probeResult);
1873
- }
1874
- if (probeResult === 'fail') {
1875
- log('warn', `Abandoned PR reconciliation: skipping ${slug} (${abandonedPrs.length} PR${abandonedPrs.length === 1 ? '' : 's'}) — base-repo probe failed, retry next startup`);
1876
- skipped += abandonedPrs.length;
1877
- continue;
1878
- }
1879
-
1880
- // Per-PR re-probe. Collect updates first, then apply via mutatePullRequests
1881
- // for atomic single-writer semantics. We match by prNumber on writeback
1882
- // (not id) because mutatePullRequests calls normalizePrRecords which can
1883
- // lowercase the id slug — prNumber is the stable key within a project's
1884
- // pull-requests.json.
1885
- const updates = []; // { prNumber, action, newStatus?, mergedAt? }
1888
+ // Per-PR re-probe. Canonical ids keep same-number PRs from different
1889
+ // repository scopes isolated during writeback.
1890
+ const updates = []; // { prId, prNumber, action, newStatus?, mergedAt? }
1886
1891
  for (const pr of abandonedPrs) {
1887
1892
  const prNum = shared.getPrNumber(pr);
1888
1893
  if (!prNum) continue;
1894
+ const slug = getPrRepoSlug(project, pr);
1895
+ if (!slug) continue;
1896
+ const prId = shared.getCanonicalPrId(project, pr, pr.url || '');
1897
+ if (!prId) continue;
1898
+
1899
+ let probeResult = slugProbeCache.get(slug);
1900
+ if (probeResult === undefined) {
1901
+ const probe = await ghApi('', slug);
1902
+ probeResult = (probe === null || probe === GH_NOT_FOUND) ? 'fail' : 'ok';
1903
+ slugProbeCache.set(slug, probeResult);
1904
+ }
1905
+ if (probeResult === 'fail') {
1906
+ log('warn', `Abandoned PR reconciliation: skipping ${slug} — base-repo probe failed, retry next startup`);
1907
+ skipped++;
1908
+ continue;
1909
+ }
1889
1910
 
1890
1911
  try {
1891
1912
  const prData = await ghApi(`/pulls/${prNum}`, slug);
1892
1913
  if (prData === GH_NOT_FOUND) {
1893
1914
  // 404 with base-probe OK → genuinely deleted. Mark so we don't
1894
1915
  // re-probe this PR on future startups.
1895
- updates.push({ prNumber: prNum, action: 'confirm404' });
1916
+ updates.push({ prId, prNumber: prNum, action: 'confirm404' });
1896
1917
  confirmedDeleted++;
1897
1918
  log('info', `Confirmed PR #${prNum} (${slug}): truly deleted, leaving abandoned`);
1898
1919
  } else if (prData) {
@@ -1915,6 +1936,7 @@ async function reconcileAbandonedPrs(config) {
1915
1936
  continue;
1916
1937
  }
1917
1938
  updates.push({
1939
+ prId,
1918
1940
  prNumber: prNum,
1919
1941
  action: 'flip',
1920
1942
  newStatus,
@@ -1938,7 +1960,9 @@ async function reconcileAbandonedPrs(config) {
1938
1960
  const reconciledAt = ts();
1939
1961
  mutatePullRequests(prPath, (currentPrs) => {
1940
1962
  for (const upd of updates) {
1941
- const pr = currentPrs.find(p => shared.getPrNumber(p) === upd.prNumber);
1963
+ const pr = currentPrs.find(p =>
1964
+ shared.getCanonicalPrId(project, p, p.url || '') === upd.prId
1965
+ );
1942
1966
  if (!pr) continue;
1943
1967
  // Defensive: never downgrade a merged record. Should already be
1944
1968
  // filtered by the abandoned-only scan above, but a concurrent writer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2375",
3
+ "version": "0.1.2376",
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"