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.
- package/out/404.html +1 -1
- package/out/__next.__PAGE__.txt +1 -1
- package/out/__next._full.txt +1 -1
- package/out/__next._head.txt +1 -1
- package/out/__next._index.txt +1 -1
- package/out/__next._tree.txt +1 -1
- package/out/_not-found/__next._full.txt +1 -1
- package/out/_not-found/__next._head.txt +1 -1
- package/out/_not-found/__next._index.txt +1 -1
- package/out/_not-found/__next._not-found.__PAGE__.txt +1 -1
- package/out/_not-found/__next._not-found.txt +1 -1
- package/out/_not-found/__next._tree.txt +1 -1
- package/out/_not-found.html +1 -1
- package/out/_not-found.txt +1 -1
- package/out/app-shell/__next._full.txt +1 -1
- package/out/app-shell/__next._head.txt +1 -1
- package/out/app-shell/__next._index.txt +1 -1
- package/out/app-shell/__next._tree.txt +1 -1
- package/out/app-shell/__next.app-shell.__PAGE__.txt +1 -1
- package/out/app-shell/__next.app-shell.txt +1 -1
- package/out/app-shell.html +1 -1
- package/out/app-shell.txt +1 -1
- package/out/index.html +1 -1
- package/out/index.txt +1 -1
- package/out/project/_/__next._full.txt +1 -1
- package/out/project/_/__next._head.txt +1 -1
- package/out/project/_/__next._index.txt +1 -1
- package/out/project/_/__next._tree.txt +1 -1
- package/out/project/_/__next.project.$d$id.__PAGE__.txt +1 -1
- package/out/project/_/__next.project.$d$id.txt +1 -1
- package/out/project/_/__next.project.txt +1 -1
- package/out/project/_/queue/__next._full.txt +1 -1
- package/out/project/_/queue/__next._head.txt +1 -1
- package/out/project/_/queue/__next._index.txt +1 -1
- package/out/project/_/queue/__next._tree.txt +1 -1
- package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +1 -1
- package/out/project/_/queue/__next.project.$d$id.queue.txt +1 -1
- package/out/project/_/queue/__next.project.$d$id.txt +1 -1
- package/out/project/_/queue/__next.project.txt +1 -1
- package/out/project/_/queue.html +1 -1
- package/out/project/_/queue.txt +1 -1
- package/out/project/_.html +1 -1
- package/out/project/_.txt +1 -1
- package/out/settings/__next._full.txt +1 -1
- package/out/settings/__next._head.txt +1 -1
- package/out/settings/__next._index.txt +1 -1
- package/out/settings/__next._tree.txt +1 -1
- package/out/settings/__next.settings.__PAGE__.txt +1 -1
- package/out/settings/__next.settings.txt +1 -1
- package/out/settings.html +1 -1
- package/out/settings.txt +1 -1
- package/out/setup/__next._full.txt +1 -1
- package/out/setup/__next._head.txt +1 -1
- package/out/setup/__next._index.txt +1 -1
- package/out/setup/__next._tree.txt +1 -1
- package/out/setup/__next.setup.__PAGE__.txt +1 -1
- package/out/setup/__next.setup.txt +1 -1
- package/out/setup.html +1 -1
- package/out/setup.txt +1 -1
- package/package.json +1 -1
- package/server/routes.js +413 -22
- /package/out/_next/static/{55YICUuE6JNvvGBzMKKu0 → K7A3YZrh4sLaRRP1-Lq7v}/_buildManifest.js +0 -0
- /package/out/_next/static/{55YICUuE6JNvvGBzMKKu0 → K7A3YZrh4sLaRRP1-Lq7v}/_clientMiddlewareManifest.js +0 -0
- /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
|
-
// #
|
|
1932
|
-
//
|
|
1933
|
-
//
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|