quadwork 1.19.1 → 1.19.2

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 (64) hide show
  1. package/out/404.html +1 -1
  2. package/out/__next.__PAGE__.txt +1 -1
  3. package/out/__next._full.txt +1 -1
  4. package/out/__next._head.txt +1 -1
  5. package/out/__next._index.txt +1 -1
  6. package/out/__next._tree.txt +1 -1
  7. package/out/_not-found/__next._full.txt +1 -1
  8. package/out/_not-found/__next._head.txt +1 -1
  9. package/out/_not-found/__next._index.txt +1 -1
  10. package/out/_not-found/__next._not-found.__PAGE__.txt +1 -1
  11. package/out/_not-found/__next._not-found.txt +1 -1
  12. package/out/_not-found/__next._tree.txt +1 -1
  13. package/out/_not-found.html +1 -1
  14. package/out/_not-found.txt +1 -1
  15. package/out/app-shell/__next._full.txt +1 -1
  16. package/out/app-shell/__next._head.txt +1 -1
  17. package/out/app-shell/__next._index.txt +1 -1
  18. package/out/app-shell/__next._tree.txt +1 -1
  19. package/out/app-shell/__next.app-shell.__PAGE__.txt +1 -1
  20. package/out/app-shell/__next.app-shell.txt +1 -1
  21. package/out/app-shell.html +1 -1
  22. package/out/app-shell.txt +1 -1
  23. package/out/index.html +1 -1
  24. package/out/index.txt +1 -1
  25. package/out/project/_/__next._full.txt +1 -1
  26. package/out/project/_/__next._head.txt +1 -1
  27. package/out/project/_/__next._index.txt +1 -1
  28. package/out/project/_/__next._tree.txt +1 -1
  29. package/out/project/_/__next.project.$d$id.__PAGE__.txt +1 -1
  30. package/out/project/_/__next.project.$d$id.txt +1 -1
  31. package/out/project/_/__next.project.txt +1 -1
  32. package/out/project/_/queue/__next._full.txt +1 -1
  33. package/out/project/_/queue/__next._head.txt +1 -1
  34. package/out/project/_/queue/__next._index.txt +1 -1
  35. package/out/project/_/queue/__next._tree.txt +1 -1
  36. package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +1 -1
  37. package/out/project/_/queue/__next.project.$d$id.queue.txt +1 -1
  38. package/out/project/_/queue/__next.project.$d$id.txt +1 -1
  39. package/out/project/_/queue/__next.project.txt +1 -1
  40. package/out/project/_/queue.html +1 -1
  41. package/out/project/_/queue.txt +1 -1
  42. package/out/project/_.html +1 -1
  43. package/out/project/_.txt +1 -1
  44. package/out/settings/__next._full.txt +1 -1
  45. package/out/settings/__next._head.txt +1 -1
  46. package/out/settings/__next._index.txt +1 -1
  47. package/out/settings/__next._tree.txt +1 -1
  48. package/out/settings/__next.settings.__PAGE__.txt +1 -1
  49. package/out/settings/__next.settings.txt +1 -1
  50. package/out/settings.html +1 -1
  51. package/out/settings.txt +1 -1
  52. package/out/setup/__next._full.txt +1 -1
  53. package/out/setup/__next._head.txt +1 -1
  54. package/out/setup/__next._index.txt +1 -1
  55. package/out/setup/__next._tree.txt +1 -1
  56. package/out/setup/__next.setup.__PAGE__.txt +1 -1
  57. package/out/setup/__next.setup.txt +1 -1
  58. package/out/setup.html +1 -1
  59. package/out/setup.txt +1 -1
  60. package/package.json +1 -1
  61. package/server/routes.js +413 -22
  62. /package/out/_next/static/{55YICUuE6JNvvGBzMKKu0 → K7A3YZrh4sLaRRP1-Lq7v}/_buildManifest.js +0 -0
  63. /package/out/_next/static/{55YICUuE6JNvvGBzMKKu0 → K7A3YZrh4sLaRRP1-Lq7v}/_clientMiddlewareManifest.js +0 -0
  64. /package/out/_next/static/{55YICUuE6JNvvGBzMKKu0 → K7A3YZrh4sLaRRP1-Lq7v}/_ssgManifest.js +0 -0
package/server/routes.js CHANGED
@@ -1380,6 +1380,382 @@ function getRepo(projectId) {
1380
1380
  }
1381
1381
  }
1382
1382
 
1383
+ // ─── #703: Batched GraphQL layer ──────────────────────────────────────────
1384
+ // Instead of spawning individual `gh issue list` / `gh pr list` subprocesses
1385
+ // per project per endpoint, we fetch ALL configured projects' GitHub data in
1386
+ // a single GraphQL query. The per-project endpoints read from this shared
1387
+ // cache, falling back to individual gh CLI calls if GraphQL fails.
1388
+
1389
+ const _graphqlCache = new Map(); // repo → { ts, issues, prs, closedIssues, mergedPrs }
1390
+ const GRAPHQL_CACHE_TTL = 60_000; // same as GH_ENDPOINT_CACHE_TTL
1391
+ let _graphqlRefreshInFlight = false;
1392
+
1393
+ const RECENT_FETCH_LIMIT = 20;
1394
+ const RECENT_DISPLAY_LIMIT = 5;
1395
+
1396
+ // Build and execute a batched GraphQL query for all configured projects.
1397
+ // Returns a Map of repo → { issues, prs, closedIssues, mergedPrs }.
1398
+ async function fetchAllProjectsGraphQL() {
1399
+ let cfg;
1400
+ try {
1401
+ cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
1402
+ } catch {
1403
+ return null;
1404
+ }
1405
+ const projects = (cfg.projects || []).filter((p) => p.repo && REPO_RE.test(p.repo));
1406
+ if (projects.length === 0) return null;
1407
+
1408
+ // Build aliased repository fields — one per project.
1409
+ // Alias must be a valid GraphQL identifier: letters/digits/underscore only.
1410
+ const seen = new Set();
1411
+ const fragments = [];
1412
+ for (const p of projects) {
1413
+ const [owner, name] = p.repo.split("/");
1414
+ const alias = p.repo.replace(/[^a-zA-Z0-9]/g, "_");
1415
+ if (seen.has(alias)) continue; // skip duplicate repos
1416
+ seen.add(alias);
1417
+ fragments.push(`${alias}: repository(owner: "${owner}", name: "${name}") { ...repoFields }`);
1418
+ }
1419
+
1420
+ const query = `query {
1421
+ ${fragments.join("\n ")}
1422
+ }
1423
+ fragment repoFields on Repository {
1424
+ openIssues: issues(first: 50, states: OPEN, orderBy: {field: UPDATED_AT, direction: DESC}) {
1425
+ nodes { number title url state labels(first: 5) { nodes { name } } assignees(first: 5) { nodes { login } } createdAt }
1426
+ }
1427
+ closedIssues: issues(first: ${RECENT_FETCH_LIMIT}, states: CLOSED, orderBy: {field: UPDATED_AT, direction: DESC}) {
1428
+ nodes { number title url state closedAt }
1429
+ }
1430
+ openPRs: pullRequests(first: 50, states: OPEN, orderBy: {field: UPDATED_AT, direction: DESC}) {
1431
+ nodes { number title url state author { login } reviews(last: 100) { nodes { state author { login } submittedAt } } createdAt }
1432
+ }
1433
+ mergedPRs: pullRequests(first: ${RECENT_FETCH_LIMIT}, states: MERGED, orderBy: {field: UPDATED_AT, direction: DESC}) {
1434
+ nodes { number title url state mergedAt author { login } }
1435
+ }
1436
+ }`;
1437
+
1438
+ try {
1439
+ const { stdout } = await _execFileAsync("gh", [
1440
+ "api", "graphql", "-f", `query=${query}`,
1441
+ ], { encoding: "utf-8", timeout: 15000 });
1442
+ const data = JSON.parse(stdout).data;
1443
+ if (!data) return null;
1444
+
1445
+ const result = new Map();
1446
+ for (const p of projects) {
1447
+ const alias = p.repo.replace(/[^a-zA-Z0-9]/g, "_");
1448
+ const repoData = data[alias];
1449
+ if (!repoData) continue;
1450
+
1451
+ // Transform GraphQL nodes into the same shape as gh CLI JSON output.
1452
+ const issues = (repoData.openIssues?.nodes || []).map((n) => ({
1453
+ number: n.number,
1454
+ title: n.title,
1455
+ state: n.state === "OPEN" ? "open" : n.state?.toLowerCase() || n.state,
1456
+ url: n.url,
1457
+ labels: (n.labels?.nodes || []).map((l) => ({ name: l.name })),
1458
+ assignees: (n.assignees?.nodes || []).map((a) => ({ login: a.login })),
1459
+ createdAt: n.createdAt,
1460
+ }));
1461
+
1462
+ const prs = (repoData.openPRs?.nodes || []).map((n) => ({
1463
+ number: n.number,
1464
+ title: n.title,
1465
+ state: n.state === "OPEN" ? "open" : n.state?.toLowerCase() || n.state,
1466
+ url: n.url,
1467
+ author: n.author ? { login: n.author.login } : null,
1468
+ assignees: [],
1469
+ reviews: (n.reviews?.nodes || []).map((r) => ({
1470
+ state: r.state,
1471
+ author: r.author ? { login: r.author.login } : null,
1472
+ submittedAt: r.submittedAt,
1473
+ })),
1474
+ createdAt: n.createdAt,
1475
+ }));
1476
+
1477
+ const closedIssues = (repoData.closedIssues?.nodes || [])
1478
+ .slice()
1479
+ .sort((a, b) => {
1480
+ const ta = a?.closedAt ? Date.parse(a.closedAt) : 0;
1481
+ const tb = b?.closedAt ? Date.parse(b.closedAt) : 0;
1482
+ return tb - ta;
1483
+ })
1484
+ .slice(0, RECENT_DISPLAY_LIMIT)
1485
+ .map((n) => ({
1486
+ number: n.number,
1487
+ title: n.title,
1488
+ state: n.state?.toLowerCase() || "closed",
1489
+ url: n.url,
1490
+ closedAt: n.closedAt,
1491
+ }));
1492
+
1493
+ const mergedPrs = (repoData.mergedPRs?.nodes || [])
1494
+ .slice()
1495
+ .sort((a, b) => {
1496
+ const ta = a?.mergedAt ? Date.parse(a.mergedAt) : 0;
1497
+ const tb = b?.mergedAt ? Date.parse(b.mergedAt) : 0;
1498
+ return tb - ta;
1499
+ })
1500
+ .slice(0, RECENT_DISPLAY_LIMIT)
1501
+ .map((n) => ({
1502
+ number: n.number,
1503
+ title: n.title,
1504
+ state: n.state?.toLowerCase() || "merged",
1505
+ url: n.url,
1506
+ mergedAt: n.mergedAt,
1507
+ author: n.author ? { login: n.author.login } : null,
1508
+ }));
1509
+
1510
+ result.set(p.repo, { issues, prs, closedIssues, mergedPrs });
1511
+ }
1512
+ return result;
1513
+ } catch {
1514
+ return null; // fallback to individual gh CLI calls
1515
+ }
1516
+ }
1517
+
1518
+ // Refresh the shared GraphQL cache for all projects. Called on a timer
1519
+ // and on demand when a per-project endpoint has no cached data.
1520
+ async function refreshGraphQLCache() {
1521
+ if (_graphqlRefreshInFlight) return;
1522
+ if (isRateLimited()) return; // don't burn quota when critically low
1523
+ _graphqlRefreshInFlight = true;
1524
+ try {
1525
+ const data = await fetchAllProjectsGraphQL();
1526
+ if (data) {
1527
+ const now = Date.now();
1528
+ for (const [repo, repoData] of data) {
1529
+ _graphqlCache.set(repo, { ts: now, ...repoData });
1530
+ // Also populate the per-endpoint _ghEndpointCache so stale-while-
1531
+ // revalidate and existing per-project endpoints pick up the data.
1532
+ _ghEndpointCache.set(`issues:${repo}`, { ts: now, data: repoData.issues, stale: false });
1533
+ _ghEndpointCache.set(`prs:${repo}`, { ts: now, data: repoData.prs, stale: false });
1534
+ _ghEndpointCache.set(`closed-issues:${repo}`, { ts: now, data: repoData.closedIssues, stale: false });
1535
+ _ghEndpointCache.set(`merged-prs:${repo}`, { ts: now, data: repoData.mergedPrs, stale: false });
1536
+ }
1537
+ }
1538
+ } catch {
1539
+ // Non-fatal — per-project endpoints still work via individual gh CLI.
1540
+ } finally {
1541
+ _graphqlRefreshInFlight = false;
1542
+ }
1543
+ }
1544
+
1545
+ // Start background GraphQL polling alongside rate-limit polling.
1546
+ let _graphqlPollTimer = null;
1547
+ function startGraphQLPolling() {
1548
+ if (_graphqlPollTimer) return;
1549
+ // Initial fetch after a short delay (let rate-limit poll run first).
1550
+ setTimeout(() => refreshGraphQLCache(), 2000);
1551
+ _graphqlPollTimer = setInterval(refreshGraphQLCache, GRAPHQL_CACHE_TTL);
1552
+ }
1553
+
1554
+ // #703: Batched GraphQL for batch progress — fetch all issue states +
1555
+ // linked PRs in a single query instead of 2N individual gh calls.
1556
+ async function fetchBatchProgressGraphQL(repo, issueNumbers) {
1557
+ if (!issueNumbers || issueNumbers.length === 0) return null;
1558
+ const [owner, name] = repo.split("/");
1559
+ if (!owner || !name) return null;
1560
+
1561
+ // Build aliased issue fields.
1562
+ const issueFields = issueNumbers.map((n) =>
1563
+ `issue${n}: issue(number: ${n}) {
1564
+ number title state url
1565
+ closedByPullRequestsReferences(first: 3) {
1566
+ nodes { number state url merged reviews(last: 100) { nodes { state author { login } submittedAt } } }
1567
+ }
1568
+ }`
1569
+ ).join("\n ");
1570
+
1571
+ const query = `query {
1572
+ repository(owner: "${owner}", name: "${name}") {
1573
+ ${issueFields}
1574
+ }
1575
+ }`;
1576
+
1577
+ try {
1578
+ const { stdout } = await _execFileAsync("gh", [
1579
+ "api", "graphql", "-f", `query=${query}`,
1580
+ ], { encoding: "utf-8", timeout: 15000 });
1581
+ const data = JSON.parse(stdout).data;
1582
+ if (!data?.repository) return null;
1583
+ return data.repository;
1584
+ } catch {
1585
+ return null; // fallback to individual gh CLI calls
1586
+ }
1587
+ }
1588
+
1589
+ // Convert a GraphQL batch progress issue node into the same progress
1590
+ // row shape that progressForItemAsync produces.
1591
+ function graphqlIssueToProgressRow(issueData) {
1592
+ if (!issueData) return null;
1593
+
1594
+ const linked = issueData.closedByPullRequestsReferences?.nodes || [];
1595
+ const pr = linked.length > 0
1596
+ ? linked.slice().sort((a, b) => (b.number || 0) - (a.number || 0))[0]
1597
+ : null;
1598
+
1599
+ // No linked PR — delegate to the existing buildNoPrRow helper.
1600
+ if (!pr) {
1601
+ return buildNoPrRow({
1602
+ number: issueData.number,
1603
+ title: issueData.title,
1604
+ state: issueData.state,
1605
+ url: issueData.url,
1606
+ });
1607
+ }
1608
+
1609
+ const merged = pr.merged && issueData.state === "CLOSED";
1610
+ if (merged) {
1611
+ return {
1612
+ issue_number: issueData.number,
1613
+ title: issueData.title,
1614
+ url: pr.url || issueData.url,
1615
+ pr_number: pr.number,
1616
+ status: "merged",
1617
+ progress: 100,
1618
+ label: "Merged ✓",
1619
+ };
1620
+ }
1621
+
1622
+ // Count distinct APPROVED reviews per author.
1623
+ const reviews = (pr.reviews?.nodes || []).slice();
1624
+ reviews.sort((a, b) => {
1625
+ const ta = a?.submittedAt ? Date.parse(a.submittedAt) : 0;
1626
+ const tb = b?.submittedAt ? Date.parse(b.submittedAt) : 0;
1627
+ return ta - tb;
1628
+ });
1629
+ const latestByAuthor = new Map();
1630
+ for (const r of reviews) {
1631
+ const author = r?.author?.login || "";
1632
+ if (!author) continue;
1633
+ latestByAuthor.set(author, r.state);
1634
+ }
1635
+ let approvalCount = 0;
1636
+ for (const state of latestByAuthor.values()) {
1637
+ if (state === "APPROVED") approvalCount++;
1638
+ }
1639
+
1640
+ if (approvalCount >= 2) {
1641
+ return {
1642
+ issue_number: issueData.number,
1643
+ title: issueData.title,
1644
+ url: pr.url || issueData.url,
1645
+ pr_number: pr.number,
1646
+ status: "ready",
1647
+ progress: 80,
1648
+ label: `PR #${pr.number} · 2 approvals · ready`,
1649
+ };
1650
+ }
1651
+ if (approvalCount === 1) {
1652
+ return {
1653
+ issue_number: issueData.number,
1654
+ title: issueData.title,
1655
+ url: pr.url || issueData.url,
1656
+ pr_number: pr.number,
1657
+ status: "approved1",
1658
+ progress: 50,
1659
+ label: `PR #${pr.number} · 1 approval`,
1660
+ };
1661
+ }
1662
+ return {
1663
+ issue_number: issueData.number,
1664
+ title: issueData.title,
1665
+ url: pr.url || issueData.url,
1666
+ pr_number: pr.number,
1667
+ status: "in_review",
1668
+ progress: 20,
1669
+ label: `PR #${pr.number} · waiting on review`,
1670
+ };
1671
+ }
1672
+
1673
+ // ─── /api/github/all — batched endpoint (#703) ────────────────────────────
1674
+ // Returns all projects' GitHub data in one response. The frontend can
1675
+ // optionally filter by project query param. Serves from GraphQL cache
1676
+ // with on-demand refresh if stale.
1677
+ router.get("/api/github/all", async (req, res) => {
1678
+ const projectFilter = req.query.project || "";
1679
+
1680
+ // Ensure cache is populated.
1681
+ const anyStale = (() => {
1682
+ let cfg;
1683
+ try { cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")); } catch { return true; }
1684
+ const projects = (cfg.projects || []).filter((p) => p.repo && REPO_RE.test(p.repo));
1685
+ for (const p of projects) {
1686
+ const cached = _graphqlCache.get(p.repo);
1687
+ if (!cached || Date.now() - cached.ts > adaptiveTTL(GRAPHQL_CACHE_TTL)) return true;
1688
+ }
1689
+ return false;
1690
+ })();
1691
+ if (anyStale) await refreshGraphQLCache();
1692
+
1693
+ // Build response.
1694
+ let cfg;
1695
+ try { cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")); } catch { return res.status(500).json({ error: "Config unreadable" }); }
1696
+ const projects = (cfg.projects || []).filter((p) => p.repo && REPO_RE.test(p.repo));
1697
+
1698
+ const result = {};
1699
+ const fallbackNeeded = [];
1700
+ for (const p of projects) {
1701
+ if (projectFilter && p.id !== projectFilter) continue;
1702
+ const cached = _graphqlCache.get(p.repo);
1703
+ if (cached) {
1704
+ result[p.id] = {
1705
+ issues: cached.issues,
1706
+ prs: cached.prs,
1707
+ closedIssues: cached.closedIssues,
1708
+ mergedPrs: cached.mergedPrs,
1709
+ _stale: Date.now() - cached.ts > adaptiveTTL(GRAPHQL_CACHE_TTL),
1710
+ };
1711
+ } else {
1712
+ fallbackNeeded.push(p);
1713
+ }
1714
+ }
1715
+
1716
+ // Fallback: fetch missing projects via individual gh CLI calls.
1717
+ if (fallbackNeeded.length > 0 && !isRateLimited()) {
1718
+ const fallbackResults = await Promise.allSettled(
1719
+ fallbackNeeded.map(async (p) => {
1720
+ const repo = p.repo;
1721
+ const [issues, prs, closedIssues, mergedPrs] = await Promise.allSettled([
1722
+ _execFileAsync("gh", ["issue", "list", "-R", repo, "--json", "number,title,state,assignees,labels,createdAt,url", "--limit", "50"], { encoding: "utf-8", timeout: 15000 }).then(({ stdout }) => JSON.parse(stdout)),
1723
+ _execFileAsync("gh", ["pr", "list", "-R", repo, "--json", "number,title,state,author,assignees,reviewDecision,reviews,statusCheckRollup,url,createdAt", "--limit", "50"], { encoding: "utf-8", timeout: 15000 }).then(({ stdout }) => JSON.parse(stdout)),
1724
+ _execFileAsync("gh", ["issue", "list", "-R", repo, "--state", "closed", "--json", "number,title,state,url,closedAt", "--limit", String(RECENT_FETCH_LIMIT)], { encoding: "utf-8", timeout: 15000 }).then(({ stdout }) => {
1725
+ const items = JSON.parse(stdout);
1726
+ return Array.isArray(items)
1727
+ ? items.sort((a, b) => (Date.parse(b?.closedAt || 0)) - (Date.parse(a?.closedAt || 0))).slice(0, RECENT_DISPLAY_LIMIT)
1728
+ : items;
1729
+ }),
1730
+ _execFileAsync("gh", ["pr", "list", "-R", repo, "--state", "merged", "--json", "number,title,state,url,mergedAt,author", "--limit", String(RECENT_FETCH_LIMIT)], { encoding: "utf-8", timeout: 15000 }).then(({ stdout }) => {
1731
+ const items = JSON.parse(stdout);
1732
+ return Array.isArray(items)
1733
+ ? items.sort((a, b) => (Date.parse(b?.mergedAt || 0)) - (Date.parse(a?.mergedAt || 0))).slice(0, RECENT_DISPLAY_LIMIT)
1734
+ : items;
1735
+ }),
1736
+ ]);
1737
+ return {
1738
+ id: p.id,
1739
+ issues: issues.status === "fulfilled" ? issues.value : [],
1740
+ prs: prs.status === "fulfilled" ? prs.value : [],
1741
+ closedIssues: closedIssues.status === "fulfilled" ? closedIssues.value : [],
1742
+ mergedPrs: mergedPrs.status === "fulfilled" ? mergedPrs.value : [],
1743
+ _fallback: true,
1744
+ };
1745
+ }),
1746
+ );
1747
+ for (const r of fallbackResults) {
1748
+ if (r.status === "fulfilled") {
1749
+ result[r.value.id] = r.value;
1750
+ }
1751
+ }
1752
+ }
1753
+
1754
+ res.json(result);
1755
+ });
1756
+
1757
+ // ─── Per-project endpoints (backward compat, served from shared cache) ────
1758
+
1383
1759
  router.get("/api/github/issues", (req, res) => {
1384
1760
  const repo = getRepo(req.query.project || "");
1385
1761
  if (!repo) return res.status(400).json({ error: "No repo configured for project" });
@@ -1409,8 +1785,6 @@ router.get("/api/github/prs", (req, res) => {
1409
1785
  // so a stale-but-recently-closed item can sit below a fresh-but-
1410
1786
  // older one. We pull a wider window and re-sort by close/merge time
1411
1787
  // before truncating to 5 to honor #281's "newest first" requirement.
1412
- const RECENT_FETCH_LIMIT = 20;
1413
- const RECENT_DISPLAY_LIMIT = 5;
1414
1788
 
1415
1789
  router.get("/api/github/closed-issues", (req, res) => {
1416
1790
  const repo = getRepo(req.query.project || "");
@@ -1928,26 +2302,41 @@ router.get("/api/batch-progress", async (req, res) => {
1928
2302
  return res.json(data);
1929
2303
  }
1930
2304
 
1931
- // #416 / quadwork#299: parallelize the per-item gh fetches.
1932
- // Sequential execFileSync was costing ~10s on a cold cache for a
1933
- // 5-item batch (2 gh calls per item, ~1s each); Promise.allSettled
1934
- // over progressForItemAsync drops that to roughly the time of the
1935
- // slowest single item-pair (~2s). One failed item resolves with a
1936
- // synthetic "unknown" row instead of failing the whole response.
1937
- const settled = await Promise.allSettled(
1938
- issueNumbers.map((n) => progressForItemAsync(repo, n)),
1939
- );
1940
- const items = settled.map((r, i) => {
1941
- if (r.status === "fulfilled") return r.value;
1942
- return {
1943
- issue_number: issueNumbers[i],
1944
- title: `#${issueNumbers[i]} (fetch failed)`,
1945
- url: null,
1946
- status: "unknown",
1947
- progress: 0,
1948
- label: "fetch failed",
1949
- };
1950
- });
2305
+ // #703: Try batched GraphQL first one query for all batch items.
2306
+ // Falls back to individual gh CLI calls (the #416 parallel approach)
2307
+ // if GraphQL fails.
2308
+ let items;
2309
+ const graphqlData = await fetchBatchProgressGraphQL(repo, issueNumbers);
2310
+ if (graphqlData) {
2311
+ items = issueNumbers.map((n) => {
2312
+ const issueNode = graphqlData[`issue${n}`];
2313
+ const row = issueNode ? graphqlIssueToProgressRow(issueNode) : null;
2314
+ return row || {
2315
+ issue_number: n,
2316
+ title: `#${n} (fetch failed)`,
2317
+ url: null,
2318
+ status: "unknown",
2319
+ progress: 0,
2320
+ label: "fetch failed",
2321
+ };
2322
+ });
2323
+ } else {
2324
+ // Fallback: #416 parallel individual gh CLI calls.
2325
+ const settled = await Promise.allSettled(
2326
+ issueNumbers.map((n) => progressForItemAsync(repo, n)),
2327
+ );
2328
+ items = settled.map((r, i) => {
2329
+ if (r.status === "fulfilled") return r.value;
2330
+ return {
2331
+ issue_number: issueNumbers[i],
2332
+ title: `#${issueNumbers[i]} (fetch failed)`,
2333
+ url: null,
2334
+ status: "unknown",
2335
+ progress: 0,
2336
+ label: "fetch failed",
2337
+ };
2338
+ });
2339
+ }
1951
2340
  const summary = summarizeItems(items);
1952
2341
  // #350: treat CLOSED-without-PR items as complete alongside merged
1953
2342
  // so batches that mix runbook/superseded closes with real PRs
@@ -3575,6 +3964,8 @@ router.put("/api/project/:projectId/agent-models/:agentId", (req, res) => {
3575
3964
 
3576
3965
  // #554: start rate-limit polling as soon as routes are loaded.
3577
3966
  startRateLimitPolling();
3967
+ // #703: start batched GraphQL polling for dashboard data.
3968
+ startGraphQLPolling();
3578
3969
 
3579
3970
  module.exports = router;
3580
3971
  // #341: export parseActiveBatch for unit tests. No production callers