quadwork 2.0.1 → 2.2.0
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 +3 -3
- package/out/__next._full.txt +14 -14
- package/out/__next._head.txt +4 -4
- package/out/__next._index.txt +8 -8
- package/out/__next._tree.txt +2 -2
- package/out/_next/static/chunks/{0~-kpl6f_x5s6.js → 02kx5r305y-id.js} +1 -1
- package/out/_next/static/chunks/{0jllnzexn48._.js → 044n.~stdsjlo.js} +1 -1
- package/out/_next/static/chunks/0dbfrdfwj565f.css +2 -0
- package/out/_next/static/chunks/0fvw~.-bjbvj3.js +27 -0
- package/out/_next/static/chunks/0y_59cdk3r4z6.js +1 -0
- package/out/_next/static/chunks/12yxvamsloafv.js +1 -0
- package/out/_not-found/__next._full.txt +13 -13
- package/out/_not-found/__next._head.txt +4 -4
- package/out/_not-found/__next._index.txt +8 -8
- package/out/_not-found/__next._not-found.__PAGE__.txt +2 -2
- package/out/_not-found/__next._not-found.txt +3 -3
- package/out/_not-found/__next._tree.txt +2 -2
- package/out/_not-found.html +1 -1
- package/out/_not-found.txt +13 -13
- package/out/app-shell/__next._full.txt +13 -13
- package/out/app-shell/__next._head.txt +4 -4
- package/out/app-shell/__next._index.txt +8 -8
- package/out/app-shell/__next._tree.txt +2 -2
- package/out/app-shell/__next.app-shell.__PAGE__.txt +2 -2
- package/out/app-shell/__next.app-shell.txt +3 -3
- package/out/app-shell.html +1 -1
- package/out/app-shell.txt +13 -13
- package/out/index.html +1 -1
- package/out/index.txt +14 -14
- package/out/project/_/__next._full.txt +14 -14
- package/out/project/_/__next._head.txt +4 -4
- package/out/project/_/__next._index.txt +8 -8
- package/out/project/_/__next._tree.txt +2 -2
- package/out/project/_/__next.project.$d$id.__PAGE__.txt +3 -3
- package/out/project/_/__next.project.$d$id.txt +3 -3
- package/out/project/_/__next.project.txt +3 -3
- package/out/project/_/queue/__next._full.txt +14 -14
- package/out/project/_/queue/__next._head.txt +4 -4
- package/out/project/_/queue/__next._index.txt +8 -8
- package/out/project/_/queue/__next._tree.txt +2 -2
- package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +3 -3
- package/out/project/_/queue/__next.project.$d$id.queue.txt +3 -3
- package/out/project/_/queue/__next.project.$d$id.txt +3 -3
- package/out/project/_/queue/__next.project.txt +3 -3
- package/out/project/_/queue.html +1 -1
- package/out/project/_/queue.txt +14 -14
- package/out/project/_.html +1 -1
- package/out/project/_.txt +14 -14
- package/out/settings/__next._full.txt +14 -14
- package/out/settings/__next._head.txt +4 -4
- package/out/settings/__next._index.txt +8 -8
- package/out/settings/__next._tree.txt +2 -2
- package/out/settings/__next.settings.__PAGE__.txt +3 -3
- package/out/settings/__next.settings.txt +3 -3
- package/out/settings.html +1 -1
- package/out/settings.txt +14 -14
- package/out/setup/__next._full.txt +14 -14
- package/out/setup/__next._head.txt +4 -4
- package/out/setup/__next._index.txt +8 -8
- package/out/setup/__next._tree.txt +2 -2
- package/out/setup/__next.setup.__PAGE__.txt +3 -3
- package/out/setup/__next.setup.txt +3 -3
- package/out/setup.html +1 -1
- package/out/setup.txt +14 -14
- package/package.json +2 -1
- package/server/index.js +111 -18
- package/server/routes.js +1717 -499
- package/server/run-tests.js +122 -0
- package/server/self-heal.js +100 -0
- package/templates/GITHUB.md +46 -0
- package/templates/seeds/butler.CLAUDE.md +2 -0
- package/templates/seeds/dev.AGENTS.md +12 -0
- package/templates/seeds/head.AGENTS.md +21 -6
- package/templates/seeds/re1.AGENTS.md +17 -3
- package/templates/seeds/re2.AGENTS.md +17 -3
- package/out/_next/static/chunks/0_79hkefw1mo2.js +0 -1
- package/out/_next/static/chunks/0q4bm04c1jl_3.js +0 -1
- package/out/_next/static/chunks/13xk0vgfbrcld.css +0 -2
- package/out/_next/static/chunks/163_ddkdca5q4.js +0 -25
- /package/out/_next/static/{MmPC1Rj12BOy4-HvMJjEX → h8gr2UEtEQkyXBVa2J0z9}/_buildManifest.js +0 -0
- /package/out/_next/static/{MmPC1Rj12BOy4-HvMJjEX → h8gr2UEtEQkyXBVa2J0z9}/_clientMiddlewareManifest.js +0 -0
- /package/out/_next/static/{MmPC1Rj12BOy4-HvMJjEX → h8gr2UEtEQkyXBVa2J0z9}/_ssgManifest.js +0 -0
package/server/routes.js
CHANGED
|
@@ -26,6 +26,16 @@ const ENV_PATH = path.join(CONFIG_DIR, ".env");
|
|
|
26
26
|
const TEMPLATES_DIR = path.join(__dirname, "..", "templates");
|
|
27
27
|
const REPO_RE = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/;
|
|
28
28
|
|
|
29
|
+
// #837: `gh api` list fetches (closed-PR pages with `-i`, GraphQL repo
|
|
30
|
+
// snapshot, batch progress) can exceed Node's default 1MB execFile maxBuffer.
|
|
31
|
+
// A real `pulls?state=closed&per_page=100` -i page measured ~1.74MB on
|
|
32
|
+
// realproject7/quadwork, which made ghApiConditional reject with
|
|
33
|
+
// ERR_CHILD_PROCESS_STDIO_MAXBUFFER → "Recently Merged PRs" stayed empty AND
|
|
34
|
+
// the #828/#834 queued-from-snapshot fast-path was forced off because
|
|
35
|
+
// closedPagesComplete couldn't ever become true. 32MB is well above today's
|
|
36
|
+
// page sizes and still well under node's hard 2GB cap.
|
|
37
|
+
const GH_LIST_MAX_BUFFER = 32 * 1024 * 1024;
|
|
38
|
+
|
|
29
39
|
function isLocalhost(ip) {
|
|
30
40
|
return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
|
|
31
41
|
}
|
|
@@ -40,6 +50,15 @@ const _rateLimit = {
|
|
|
40
50
|
updatedAt: 0, // epoch ms when we last fetched
|
|
41
51
|
error: null, // last fetch error message, if any
|
|
42
52
|
};
|
|
53
|
+
// #802: GitHub tracks REST (core) and GraphQL as SEPARATE budgets, so GraphQL
|
|
54
|
+
// can hit 0 while REST is full. The background GraphQL poller and the
|
|
55
|
+
// batch-progress GraphQL query must back off on THIS bucket, not the REST one.
|
|
56
|
+
const _graphqlRateLimit = {
|
|
57
|
+
limit: 5000, // GraphQL is a points/hour budget, not request count
|
|
58
|
+
remaining: 5000,
|
|
59
|
+
resetAt: 0, // epoch ms
|
|
60
|
+
updatedAt: 0, // epoch ms when we last fetched
|
|
61
|
+
};
|
|
43
62
|
const RATE_LIMIT_POLL_MS = 60_000; // refresh every 60s
|
|
44
63
|
const RATE_LIMIT_LOW_THRESHOLD = 200; // below this → back off
|
|
45
64
|
const RATE_LIMIT_CRITICAL = 50; // below this → stop all infra gh calls
|
|
@@ -47,15 +66,24 @@ let _rateLimitTimer = null;
|
|
|
47
66
|
|
|
48
67
|
async function refreshRateLimit() {
|
|
49
68
|
try {
|
|
69
|
+
// One `gh api rate_limit` call returns every resource bucket; grab both
|
|
70
|
+
// core (REST) and graphql so each stream can gate on its own budget (#802).
|
|
50
71
|
const { stdout } = await _execFileAsync("gh", [
|
|
51
|
-
"api", "rate_limit", "--jq",
|
|
72
|
+
"api", "rate_limit", "--jq",
|
|
73
|
+
"{core: (.resources.core | {limit,remaining,reset}), graphql: (.resources.graphql | {limit,remaining,reset})}",
|
|
52
74
|
], { encoding: "utf-8", timeout: 10000 });
|
|
53
75
|
const data = JSON.parse(stdout);
|
|
54
|
-
_rateLimit.limit = data.limit;
|
|
55
|
-
_rateLimit.remaining = data.remaining;
|
|
56
|
-
_rateLimit.resetAt = data.reset * 1000; // seconds → ms
|
|
76
|
+
_rateLimit.limit = data.core.limit;
|
|
77
|
+
_rateLimit.remaining = data.core.remaining;
|
|
78
|
+
_rateLimit.resetAt = data.core.reset * 1000; // seconds → ms
|
|
57
79
|
_rateLimit.updatedAt = Date.now();
|
|
58
80
|
_rateLimit.error = null;
|
|
81
|
+
if (data.graphql) {
|
|
82
|
+
_graphqlRateLimit.limit = data.graphql.limit;
|
|
83
|
+
_graphqlRateLimit.remaining = data.graphql.remaining;
|
|
84
|
+
_graphqlRateLimit.resetAt = data.graphql.reset * 1000;
|
|
85
|
+
_graphqlRateLimit.updatedAt = Date.now();
|
|
86
|
+
}
|
|
59
87
|
} catch (err) {
|
|
60
88
|
_rateLimit.error = err.message;
|
|
61
89
|
_rateLimit.updatedAt = Date.now();
|
|
@@ -65,7 +93,13 @@ async function refreshRateLimit() {
|
|
|
65
93
|
function startRateLimitPolling() {
|
|
66
94
|
if (_rateLimitTimer) return;
|
|
67
95
|
refreshRateLimit();
|
|
96
|
+
// #836: .unref() so this interval doesn't alone pin the event loop.
|
|
97
|
+
// Production keeps the process alive via the Express HTTP listener, so
|
|
98
|
+
// cadence/auto-start is unchanged; .unref() only lets test imports and
|
|
99
|
+
// one-off `node -e` invocations exit cleanly (an unref'd `node -e` poller
|
|
100
|
+
// ran for 11 days in the wild before this was caught).
|
|
68
101
|
_rateLimitTimer = setInterval(refreshRateLimit, RATE_LIMIT_POLL_MS);
|
|
102
|
+
_rateLimitTimer.unref();
|
|
69
103
|
}
|
|
70
104
|
|
|
71
105
|
function isRateLimited() {
|
|
@@ -74,6 +108,13 @@ function isRateLimited() {
|
|
|
74
108
|
function isRateLow() {
|
|
75
109
|
return _rateLimit.remaining < RATE_LIMIT_LOW_THRESHOLD;
|
|
76
110
|
}
|
|
111
|
+
// #802: same thresholds, applied to the separate GraphQL budget.
|
|
112
|
+
function isGraphqlRateLimited() {
|
|
113
|
+
return _graphqlRateLimit.remaining < RATE_LIMIT_CRITICAL;
|
|
114
|
+
}
|
|
115
|
+
function isGraphqlRateLow() {
|
|
116
|
+
return _graphqlRateLimit.remaining < RATE_LIMIT_LOW_THRESHOLD;
|
|
117
|
+
}
|
|
77
118
|
|
|
78
119
|
// Adaptive cache TTL: normal 30s, low 120s, critical ∞ (serve stale)
|
|
79
120
|
function adaptiveTTL(baseTTL) {
|
|
@@ -82,72 +123,15 @@ function adaptiveTTL(baseTTL) {
|
|
|
82
123
|
return baseTTL;
|
|
83
124
|
}
|
|
84
125
|
|
|
85
|
-
// ───
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
126
|
+
// ─── Shared GitHub endpoint cache (#554) ──────────────────────────────────
|
|
127
|
+
// Per-endpoint in-memory cache of the canonical board slices, populated by the
|
|
128
|
+
// REST+ETag fetcher (#806, githubStateFetcher / refreshRepoRest) and served by
|
|
129
|
+
// the /api/github/* routes (serveGithubList) so reads cost ~0.
|
|
130
|
+
const _ghEndpointCache = new Map(); // key → { ts, data, stale? }
|
|
89
131
|
const GH_ENDPOINT_CACHE_TTL = 60_000; // #698: 60s base TTL (was 30s)
|
|
90
|
-
|
|
91
|
-
//
|
|
92
|
-
// gh CLI calls to avoid triggering GitHub's secondary rate limit even
|
|
93
|
-
// when many endpoints expire on the same poll cycle.
|
|
94
|
-
const _ghRefreshing = new Set();
|
|
132
|
+
// #698: cap simultaneous gh calls (per-PR fan-out, multi-project refresh) so we
|
|
133
|
+
// never trip GitHub's secondary rate limit.
|
|
95
134
|
const GH_MAX_CONCURRENT = 2;
|
|
96
|
-
const _ghRefreshQueue = [];
|
|
97
|
-
let _ghActiveRefreshes = 0;
|
|
98
|
-
|
|
99
|
-
function _ghDrainQueue() {
|
|
100
|
-
while (_ghRefreshQueue.length > 0 && _ghActiveRefreshes < GH_MAX_CONCURRENT) {
|
|
101
|
-
const job = _ghRefreshQueue.shift();
|
|
102
|
-
_ghActiveRefreshes++;
|
|
103
|
-
job().finally(() => { _ghActiveRefreshes--; _ghDrainQueue(); });
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function _ghEnqueueRefresh(cacheKey, ghArgs, transform) {
|
|
108
|
-
if (_ghRefreshing.has(cacheKey)) return; // already queued/in-flight
|
|
109
|
-
_ghRefreshing.add(cacheKey);
|
|
110
|
-
_ghRefreshQueue.push(() =>
|
|
111
|
-
_execFileAsync("gh", ghArgs, { encoding: "utf-8", timeout: 15000 })
|
|
112
|
-
.then(({ stdout }) => {
|
|
113
|
-
let data = JSON.parse(stdout);
|
|
114
|
-
if (transform) data = transform(data);
|
|
115
|
-
_ghEndpointCache.set(cacheKey, { ts: Date.now(), data, stale: false });
|
|
116
|
-
})
|
|
117
|
-
.catch(() => {}) // keep serving stale on error
|
|
118
|
-
.finally(() => _ghRefreshing.delete(cacheKey))
|
|
119
|
-
);
|
|
120
|
-
_ghDrainQueue();
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function cachedGhEndpoint(cacheKey, ghArgs, res, { transform } = {}) {
|
|
124
|
-
const ttl = adaptiveTTL(GH_ENDPOINT_CACHE_TTL);
|
|
125
|
-
const cached = _ghEndpointCache.get(cacheKey);
|
|
126
|
-
if (cached && Date.now() - cached.ts < ttl) {
|
|
127
|
-
return res.json(cached.stale ? { ...cached.data, _stale: true } : cached.data);
|
|
128
|
-
}
|
|
129
|
-
// If critically rate-limited, serve whatever we have (even expired)
|
|
130
|
-
if (isRateLimited() && cached) {
|
|
131
|
-
return res.json({ ...cached.data, _stale: true, _rateLimited: true });
|
|
132
|
-
}
|
|
133
|
-
// #698: stale-while-revalidate — if we have stale data, serve it
|
|
134
|
-
// immediately and enqueue a background refresh. The queue caps
|
|
135
|
-
// concurrent gh calls to GH_MAX_CONCURRENT to prevent burst traffic.
|
|
136
|
-
if (cached) {
|
|
137
|
-
_ghEnqueueRefresh(cacheKey, ghArgs, transform);
|
|
138
|
-
return res.json({ ...cached.data, _stale: true });
|
|
139
|
-
}
|
|
140
|
-
// No cached data at all — must fetch synchronously for first load
|
|
141
|
-
try {
|
|
142
|
-
const out = execFileSync("gh", ghArgs, { encoding: "utf-8", timeout: 15000 });
|
|
143
|
-
let data = JSON.parse(out);
|
|
144
|
-
if (transform) data = transform(data);
|
|
145
|
-
_ghEndpointCache.set(cacheKey, { ts: Date.now(), data, stale: false });
|
|
146
|
-
res.json(data);
|
|
147
|
-
} catch (err) {
|
|
148
|
-
res.status(502).json({ error: "gh call failed", detail: err.message });
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
135
|
|
|
152
136
|
const DEFAULT_CONFIG = {
|
|
153
137
|
port: 8400,
|
|
@@ -240,6 +224,23 @@ function writeOvernightQueueFileSafe(projectId, projectName, repo) {
|
|
|
240
224
|
} catch { /* non-fatal */ }
|
|
241
225
|
}
|
|
242
226
|
|
|
227
|
+
// #807: seed the per-project GITHUB.md from the template (idempotent — no-op if
|
|
228
|
+
// it already exists). Mirrors writeOvernightQueueFileSafe. The machine sections
|
|
229
|
+
// are then authored by the server on each fetch pass (writeGithubFileFromSnapshot).
|
|
230
|
+
function writeGithubFileSafe(projectId, projectName, repo) {
|
|
231
|
+
try {
|
|
232
|
+
const ghPath = path.join(CONFIG_DIR, projectId, "GITHUB.md");
|
|
233
|
+
if (fs.existsSync(ghPath)) return;
|
|
234
|
+
const tpl = path.join(TEMPLATES_DIR, "GITHUB.md");
|
|
235
|
+
if (!fs.existsSync(tpl)) return;
|
|
236
|
+
ensureSecureDir(path.dirname(ghPath));
|
|
237
|
+
let content = fs.readFileSync(tpl, "utf-8");
|
|
238
|
+
content = content.replace(/\{\{project_name\}\}/g, projectName || projectId || "");
|
|
239
|
+
content = content.replace(/\{\{repo\}\}/g, repo || "");
|
|
240
|
+
fs.writeFileSync(ghPath, content);
|
|
241
|
+
} catch { /* non-fatal */ }
|
|
242
|
+
}
|
|
243
|
+
|
|
243
244
|
function getProjectMaxHops(projectId) {
|
|
244
245
|
if (!projectId) return 30;
|
|
245
246
|
const cfg = readConfigFile();
|
|
@@ -278,16 +279,20 @@ router.get("/api/chat", (req, res) => {
|
|
|
278
279
|
// Bare "head", "dev", "re1", "re2" become "@head", "@dev", "@re1", "@re2".
|
|
279
280
|
// Already-prefixed mentions are not double-prefixed; suffixed names like
|
|
280
281
|
// "head-2" or "re1-3" are left untouched.
|
|
282
|
+
// #788: `skipName` is the sender's own agent name — it is never converted, so
|
|
283
|
+
// an agent writing "head received the batch" doesn't self-mention. Pass a
|
|
284
|
+
// falsy skipName (user / bridge senders) to normalize every name.
|
|
281
285
|
const MENTION_AGENT_NAMES = ["head", "dev", "re1", "re2"];
|
|
282
|
-
function normalizeMentions(text) {
|
|
286
|
+
function normalizeMentions(text, skipName) {
|
|
283
287
|
if (typeof text !== "string" || !text) return text || "";
|
|
288
|
+
const names = skipName ? MENTION_AGENT_NAMES.filter((n) => n !== skipName) : MENTION_AGENT_NAMES;
|
|
284
289
|
const preserved = [];
|
|
285
290
|
const ph = "\x00CODE\x00";
|
|
286
291
|
let safe = text.replace(/```[\s\S]*?```|`[^`]+`/g, (m) => {
|
|
287
292
|
preserved.push(m);
|
|
288
293
|
return ph;
|
|
289
294
|
});
|
|
290
|
-
safe =
|
|
295
|
+
safe = names.reduce(
|
|
291
296
|
(t, name) =>
|
|
292
297
|
t.replace(new RegExp(`(?<![@\\w])\\b${name}\\b(?![\\w-])`, "gi"), (match, offset, str) => {
|
|
293
298
|
const before = str.slice(Math.max(0, offset - 20), offset);
|
|
@@ -710,17 +715,21 @@ router.post("/api/chat", (req, res) => {
|
|
|
710
715
|
const shimToken = req.headers["x-chat-token"];
|
|
711
716
|
const bridgeSender = req.headers["x-bridge-sender"];
|
|
712
717
|
let sender = "user";
|
|
718
|
+
// #788: only an authenticated agent (shim) sender skips its own name; user
|
|
719
|
+
// and bridge messages normalize every name.
|
|
720
|
+
let selfMentionSkip = null;
|
|
713
721
|
if (shimSender && shimToken) {
|
|
714
722
|
if (!fileChat.validateShimToken(projectId, shimSender, shimToken)) {
|
|
715
723
|
return res.status(403).json({ error: "Invalid shim token" });
|
|
716
724
|
}
|
|
717
725
|
sender = shimSender;
|
|
726
|
+
selfMentionSkip = shimSender;
|
|
718
727
|
} else if (bridgeSender && isLocalhost(req.ip)) {
|
|
719
728
|
sender = bridgeSender;
|
|
720
729
|
}
|
|
721
730
|
const msg = fileChat.appendMessage(projectId, {
|
|
722
731
|
sender,
|
|
723
|
-
text: normalizeMentions(text),
|
|
732
|
+
text: normalizeMentions(text, selfMentionSkip),
|
|
724
733
|
channel: req.body?.channel || "general",
|
|
725
734
|
type: "message",
|
|
726
735
|
});
|
|
@@ -850,14 +859,30 @@ router.get("/api/projects", async (req, res) => {
|
|
|
850
859
|
async function fetchProjectGhData(p) {
|
|
851
860
|
let openPrs = 0;
|
|
852
861
|
let lastActivity = null;
|
|
862
|
+
// #812: parked (idle) project — no gh calls; return zero/last-known metadata.
|
|
863
|
+
if (p.idle) {
|
|
864
|
+
const hasAgentsIdle = p.agents && Object.keys(p.agents).length > 0;
|
|
865
|
+
return {
|
|
866
|
+
id: p.id,
|
|
867
|
+
name: p.name,
|
|
868
|
+
repo: p.repo,
|
|
869
|
+
agentCount: hasAgentsIdle ? Object.keys(p.agents).length : 0,
|
|
870
|
+
openPrs: 0,
|
|
871
|
+
state: "idle",
|
|
872
|
+
lastActivity: null,
|
|
873
|
+
_idle: true,
|
|
874
|
+
};
|
|
875
|
+
}
|
|
853
876
|
if (REPO_RE.test(p.repo)) {
|
|
854
877
|
try {
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
878
|
+
// #806: REST + ETag instead of `gh pr list` (GraphQL-backed). Open-PR
|
|
879
|
+
// count + latest cross-state PR activity; both conditional (mostly 304).
|
|
880
|
+
const [prs, recentPrs] = await Promise.all([
|
|
881
|
+
ghApiConditional(`${p.repo}#projects-open-pulls`, `repos/${p.repo}/pulls?state=open&per_page=100`),
|
|
882
|
+
ghApiConditional(`${p.repo}#projects-last-activity`, `repos/${p.repo}/pulls?state=all&sort=updated&direction=desc&per_page=1`),
|
|
858
883
|
]);
|
|
859
|
-
if (prs.
|
|
860
|
-
if (recentPrs.
|
|
884
|
+
if (Array.isArray(prs.data)) openPrs = prs.data.length;
|
|
885
|
+
if (Array.isArray(recentPrs.data) && recentPrs.data[0]) lastActivity = recentPrs.data[0].updated_at || null;
|
|
861
886
|
} catch {}
|
|
862
887
|
}
|
|
863
888
|
const hasAgents = p.agents && Object.keys(p.agents).length > 0;
|
|
@@ -937,11 +962,25 @@ function getRepo(projectId) {
|
|
|
937
962
|
}
|
|
938
963
|
}
|
|
939
964
|
|
|
940
|
-
//
|
|
941
|
-
//
|
|
942
|
-
//
|
|
943
|
-
//
|
|
944
|
-
|
|
965
|
+
// #812: per-project Idle toggle. When a project is idle, QuadWork must
|
|
966
|
+
// initiate ZERO project-specific GitHub/API activity for it. Callers
|
|
967
|
+
// (board fetch, per-endpoint handlers, batch-progress, /api/projects)
|
|
968
|
+
// check this before issuing any gh call.
|
|
969
|
+
function isProjectIdle(projectId) {
|
|
970
|
+
if (!projectId) return false;
|
|
971
|
+
try {
|
|
972
|
+
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
|
|
973
|
+
return !!cfg.projects?.find((p) => p.id === projectId)?.idle;
|
|
974
|
+
} catch {
|
|
975
|
+
return false;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// ─── #703 / #806: shared board cache ──────────────────────────────────────
|
|
980
|
+
// `_graphqlCache` holds each repo's assembled board snapshot. As of #806 it is
|
|
981
|
+
// populated by the REST+ETag fetcher (githubStateFetcher / refreshRepoRest);
|
|
982
|
+
// the batched GraphQL query below (fetchAllProjectsGraphQL) remains ONLY as an
|
|
983
|
+
// emergency fallback, gated on the GraphQL budget. Name kept for minimal churn.
|
|
945
984
|
|
|
946
985
|
const _graphqlCache = new Map(); // repo → { ts, issues, prs, closedIssues, mergedPrs }
|
|
947
986
|
const GRAPHQL_CACHE_TTL = 60_000; // same as GH_ENDPOINT_CACHE_TTL
|
|
@@ -949,6 +988,676 @@ let _graphqlRefreshInFlight = false;
|
|
|
949
988
|
|
|
950
989
|
const RECENT_FETCH_LIMIT = 20;
|
|
951
990
|
const RECENT_DISPLAY_LIMIT = 5;
|
|
991
|
+
// #828 P3: max state=closed pages (×100) to scan for the latest merged PRs when
|
|
992
|
+
// a burst of recently-closed *unmerged* PRs pushes real merges past page 1.
|
|
993
|
+
const MERGED_PAGE_CAP = 3;
|
|
994
|
+
|
|
995
|
+
// ─── #806 (#805 step 1): REST + ETag GitHub state fetcher ──────────────────
|
|
996
|
+
// The dashboard board was fed by `gh pr list`/`gh issue list --json
|
|
997
|
+
// reviewDecision,statusCheckRollup,...`, which are GraphQL-backed under the
|
|
998
|
+
// hood — draining GitHub's GraphQL points budget on every poll. This fetcher
|
|
999
|
+
// acquires the same state via plain REST (`gh api repos/...`) with conditional
|
|
1000
|
+
// If-None-Match requests, so steady-state polling costs ~0 (304s are free and
|
|
1001
|
+
// don't count against the budget). It feeds the existing _ghEndpointCache /
|
|
1002
|
+
// _graphqlCache, and emits a single fully-assembled snapshot per pass.
|
|
1003
|
+
|
|
1004
|
+
// ETag store: etagKey → { etag, data } (the RAW REST JSON, re-mapped per pass).
|
|
1005
|
+
const _etagStore = new Map();
|
|
1006
|
+
// Per-repo in-flight refresh: repo → Promise. Concurrent callers AWAIT the same
|
|
1007
|
+
// refresh (not just skip it) so a cold first load — where GitHubPanel fires the
|
|
1008
|
+
// four list endpoints in parallel — never races ahead of the populated cache.
|
|
1009
|
+
const _restRefreshing = new Map();
|
|
1010
|
+
|
|
1011
|
+
// Split a `gh api -i` response into its header block and JSON body. gh emits
|
|
1012
|
+
// HTTP headers (CRLF) then a blank line then the body.
|
|
1013
|
+
function _parseGhInclude(raw) {
|
|
1014
|
+
const sep = raw.search(/\r?\n\r?\n/);
|
|
1015
|
+
if (sep === -1) return { headerBlock: raw, body: "" };
|
|
1016
|
+
return {
|
|
1017
|
+
headerBlock: raw.slice(0, sep),
|
|
1018
|
+
body: raw.slice(sep).replace(/^\r?\n\r?\n/, ""),
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// Extract the ETag header value (GitHub sends it as `Etag:`; match any case).
|
|
1023
|
+
function _extractEtag(headerBlock) {
|
|
1024
|
+
const m = headerBlock.match(/^etag:\s*(.+)$/im);
|
|
1025
|
+
return m ? m[1].trim() : null;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// gh exits non-zero on a 304; the marker lands in stderr ("gh: HTTP 304") and
|
|
1029
|
+
// the status line in stdout when `-i` is used.
|
|
1030
|
+
function _is304Error(err) {
|
|
1031
|
+
const blob = `${(err && err.stderr) || ""}${(err && err.stdout) || ""}`;
|
|
1032
|
+
return /HTTP 304|304 Not Modified/.test(blob);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Conditional `gh api` GET. Reuses the stored ETag; on 304 returns the cached
|
|
1036
|
+
// payload (status "unchanged", zero budget cost). Never throws — a hard
|
|
1037
|
+
// failure returns the last good payload (if any) with status "error".
|
|
1038
|
+
async function ghApiConditional(etagKey, apiPath) {
|
|
1039
|
+
const prev = _etagStore.get(etagKey);
|
|
1040
|
+
const args = ["api", apiPath, "-i"];
|
|
1041
|
+
if (prev && prev.etag) args.push("-H", `If-None-Match: ${prev.etag}`);
|
|
1042
|
+
try {
|
|
1043
|
+
const { stdout } = await _execFileAsync("gh", args, { encoding: "utf-8", timeout: 15000, maxBuffer: GH_LIST_MAX_BUFFER });
|
|
1044
|
+
const { headerBlock, body } = _parseGhInclude(stdout);
|
|
1045
|
+
const etag = _extractEtag(headerBlock);
|
|
1046
|
+
let data = null;
|
|
1047
|
+
try { data = body ? JSON.parse(body) : null; } catch { data = null; }
|
|
1048
|
+
_etagStore.set(etagKey, { etag, data });
|
|
1049
|
+
return { status: "ok", data, changed: true };
|
|
1050
|
+
} catch (err) {
|
|
1051
|
+
if (_is304Error(err) && prev) {
|
|
1052
|
+
return { status: "unchanged", data: prev.data, changed: false };
|
|
1053
|
+
}
|
|
1054
|
+
return { status: "error", data: prev ? prev.data : null, changed: false };
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// Coalesce concurrent async work by key: a caller arriving while a call for the
|
|
1059
|
+
// same key is in flight awaits the SAME promise (so it sees the result, not a
|
|
1060
|
+
// premature miss); a fresh call starts only after the prior one settles.
|
|
1061
|
+
function _coalesce(map, key, fn) {
|
|
1062
|
+
const inflight = map.get(key);
|
|
1063
|
+
if (inflight) return inflight;
|
|
1064
|
+
// Invoke eagerly so the work starts now; register before any await of fn can
|
|
1065
|
+
// resolve so same-tick concurrent callers join this exact promise.
|
|
1066
|
+
const p = (async () => fn())().finally(() => map.delete(key));
|
|
1067
|
+
map.set(key, p);
|
|
1068
|
+
return p;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// Run `fn` over `items` with at most `limit` concurrent in flight (keeps the
|
|
1072
|
+
// per-PR fan-out from draining the core budget). Preserves input order.
|
|
1073
|
+
async function _mapLimited(items, limit, fn) {
|
|
1074
|
+
const results = new Array(items.length);
|
|
1075
|
+
let next = 0;
|
|
1076
|
+
async function worker() {
|
|
1077
|
+
while (next < items.length) {
|
|
1078
|
+
const idx = next++;
|
|
1079
|
+
results[idx] = await fn(items[idx], idx);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
const n = Math.max(1, Math.min(limit, items.length));
|
|
1083
|
+
await Promise.all(Array.from({ length: n }, worker));
|
|
1084
|
+
return results;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// ── Canonical dashboard shape (pinned + tested; do NOT defer to "same as
|
|
1088
|
+
// GraphQL"). issue/PR `state` is UPPERCASE OPEN/CLOSED/MERGED — matches the
|
|
1089
|
+
// gh-CLI path and GitHubPanel.tsx's `state === "OPEN"` dot (the prior GraphQL
|
|
1090
|
+
// transform lowercased to "open", an inconsistency this pins). ──
|
|
1091
|
+
function restIssueToCanonical(it) {
|
|
1092
|
+
return {
|
|
1093
|
+
number: it.number,
|
|
1094
|
+
title: it.title,
|
|
1095
|
+
state: (it.state || "").toUpperCase(),
|
|
1096
|
+
url: it.html_url,
|
|
1097
|
+
labels: (it.labels || []).map((l) => ({ name: typeof l === "string" ? l : l.name })),
|
|
1098
|
+
assignees: (it.assignees || []).map((a) => ({ login: a.login })),
|
|
1099
|
+
createdAt: it.created_at,
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function restClosedIssueToCanonical(it) {
|
|
1104
|
+
return {
|
|
1105
|
+
number: it.number,
|
|
1106
|
+
title: it.title,
|
|
1107
|
+
state: (it.state || "closed").toUpperCase(),
|
|
1108
|
+
url: it.html_url,
|
|
1109
|
+
closedAt: it.closed_at,
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function restMergedPrToCanonical(p) {
|
|
1114
|
+
return {
|
|
1115
|
+
number: p.number,
|
|
1116
|
+
title: p.title,
|
|
1117
|
+
state: "MERGED",
|
|
1118
|
+
url: p.html_url,
|
|
1119
|
+
mergedAt: p.merged_at,
|
|
1120
|
+
author: p.user ? { login: p.user.login } : null,
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// Base open-PR row; reviews/reviewDecision/statusCheckRollup are attached after
|
|
1125
|
+
// the per-PR sub-fetches.
|
|
1126
|
+
function restPullBaseToCanonical(p) {
|
|
1127
|
+
return {
|
|
1128
|
+
number: p.number,
|
|
1129
|
+
title: p.title,
|
|
1130
|
+
state: (p.state || "").toUpperCase(),
|
|
1131
|
+
url: p.html_url,
|
|
1132
|
+
author: p.user ? { login: p.user.login } : null,
|
|
1133
|
+
assignees: (p.assignees || []).map((a) => ({ login: a.login })),
|
|
1134
|
+
createdAt: p.created_at,
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// Keep the FULL review list with bodies — re1/re2 share one GitHub author, so
|
|
1139
|
+
// #807/#810 attribute ROLE from body markers, not author-latest collapse.
|
|
1140
|
+
function mapReviews(restReviews) {
|
|
1141
|
+
return (restReviews || []).map((r) => ({
|
|
1142
|
+
state: r.state,
|
|
1143
|
+
author: r.user ? { login: r.user.login } : null,
|
|
1144
|
+
submittedAt: r.submitted_at,
|
|
1145
|
+
body: r.body || "",
|
|
1146
|
+
}));
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// Derive a reviewDecision from the raw review list (the per-PR reviews are
|
|
1150
|
+
// authoritative). Latest decision-affecting review per author wins; any
|
|
1151
|
+
// CHANGES_REQUESTED blocks, else any APPROVED approves, else REVIEW_REQUIRED.
|
|
1152
|
+
function deriveReviewDecision(reviews) {
|
|
1153
|
+
const byAuthor = new Map();
|
|
1154
|
+
const sorted = (reviews || []).slice().sort(
|
|
1155
|
+
(a, b) => (Date.parse(a && a.submittedAt) || 0) - (Date.parse(b && b.submittedAt) || 0),
|
|
1156
|
+
);
|
|
1157
|
+
for (const r of sorted) {
|
|
1158
|
+
const login = r && r.author && r.author.login;
|
|
1159
|
+
if (!login) continue;
|
|
1160
|
+
if (r.state === "APPROVED" || r.state === "CHANGES_REQUESTED" || r.state === "DISMISSED") {
|
|
1161
|
+
byAuthor.set(login, r.state);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
let approved = false;
|
|
1165
|
+
for (const s of byAuthor.values()) {
|
|
1166
|
+
if (s === "CHANGES_REQUESTED") return "CHANGES_REQUESTED";
|
|
1167
|
+
if (s === "APPROVED") approved = true;
|
|
1168
|
+
}
|
|
1169
|
+
return approved ? "APPROVED" : "REVIEW_REQUIRED";
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
function _normCheckRunState(run) {
|
|
1173
|
+
if (run.status && run.status !== "completed") return "PENDING";
|
|
1174
|
+
switch ((run.conclusion || "").toLowerCase()) {
|
|
1175
|
+
case "success": return "SUCCESS";
|
|
1176
|
+
case "failure": case "timed_out": case "startup_failure": return "FAILURE";
|
|
1177
|
+
case "cancelled": case "action_required": case "stale": return "ERROR";
|
|
1178
|
+
case "neutral": case "skipped": return "SUCCESS";
|
|
1179
|
+
default: return "PENDING";
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function _normStatusState(state) {
|
|
1184
|
+
switch ((state || "").toLowerCase()) {
|
|
1185
|
+
case "success": return "SUCCESS";
|
|
1186
|
+
case "failure": return "FAILURE";
|
|
1187
|
+
case "error": return "ERROR";
|
|
1188
|
+
default: return "PENDING";
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// statusCheckRollup = check-runs AND the legacy combined-status, normalized to
|
|
1193
|
+
// the { state } array GitHubPanel.tsx's ciColor/ciLabel expect (SUCCESS /
|
|
1194
|
+
// FAILURE / ERROR / PENDING).
|
|
1195
|
+
function buildStatusCheckRollup(checkRunsResp, statusResp) {
|
|
1196
|
+
const rollup = [];
|
|
1197
|
+
const runs = (checkRunsResp && checkRunsResp.check_runs) || [];
|
|
1198
|
+
for (const r of runs) rollup.push({ state: _normCheckRunState(r) });
|
|
1199
|
+
const statuses = (statusResp && statusResp.statuses) || [];
|
|
1200
|
+
for (const s of statuses) rollup.push({ state: _normStatusState(s.state) });
|
|
1201
|
+
return rollup;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// Fetch a repo's full board state via REST + ETag in one pass. Returns a single
|
|
1205
|
+
// fully-assembled snapshot (never section-by-section) plus a status the file
|
|
1206
|
+
// writer (#807) can trust: "ok" (something changed), "unchanged" (all 304s),
|
|
1207
|
+
// or "error" (could not assemble any list — never stamp this as fresh).
|
|
1208
|
+
// #828 P3: page through state=closed (newest-updated first) until we have
|
|
1209
|
+
// ≥RECENT_DISPLAY_LIMIT merged PRs or hit the page cap — a burst of recently-
|
|
1210
|
+
// closed *unmerged* PRs could otherwise push real merges past a single page and
|
|
1211
|
+
// under-report "Recently merged". Page 1 is fetched in parallel with the other
|
|
1212
|
+
// top lists and passed in; further pages are fetched on demand only when page 1
|
|
1213
|
+
// is FULL (more pages may exist) AND short on merges. `fetchPage(page)` returns
|
|
1214
|
+
// the same { status, data } shape as ghApiConditional. Pure control flow over
|
|
1215
|
+
// an injected fetcher → unit-testable without network.
|
|
1216
|
+
async function gatherClosedPrPages(firstPage, fetchPage) {
|
|
1217
|
+
const pages = [firstPage];
|
|
1218
|
+
const mergedCount = (r) => (Array.isArray(r && r.data) ? r.data : []).filter((p) => p.merged_at).length;
|
|
1219
|
+
let mergedSoFar = mergedCount(firstPage);
|
|
1220
|
+
let lastLen = Array.isArray(firstPage && firstPage.data) ? firstPage.data.length : 0;
|
|
1221
|
+
for (let page = 2; page <= MERGED_PAGE_CAP && mergedSoFar < RECENT_DISPLAY_LIMIT && lastLen === 100; page++) {
|
|
1222
|
+
const r = await fetchPage(page);
|
|
1223
|
+
pages.push(r);
|
|
1224
|
+
mergedSoFar += mergedCount(r);
|
|
1225
|
+
lastLen = Array.isArray(r && r.data) ? r.data.length : 0;
|
|
1226
|
+
}
|
|
1227
|
+
return pages;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// The latest RECENT_DISPLAY_LIMIT merged PRs across the gathered closed-PR
|
|
1231
|
+
// pages, newest merge first. Pure → unit-testable.
|
|
1232
|
+
function selectRecentMergedPrs(pages) {
|
|
1233
|
+
return pages
|
|
1234
|
+
.flatMap((r) => (Array.isArray(r && r.data) ? r.data : []))
|
|
1235
|
+
.filter((p) => p.merged_at)
|
|
1236
|
+
.sort((a, b) => (Date.parse(b && b.merged_at) || 0) - (Date.parse(a && a.merged_at) || 0))
|
|
1237
|
+
.slice(0, RECENT_DISPLAY_LIMIT)
|
|
1238
|
+
.map(restMergedPrToCanonical);
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// #834: whether the closed-PR pagination reached the ACTUAL end of the list —
|
|
1242
|
+
// the ONLY proof that no further closed/merged PR exists beyond what we scanned.
|
|
1243
|
+
// True iff the LAST fetched page came back reliably (not an "error") AND had
|
|
1244
|
+
// < 100 items (a genuine final page; a full 100 means more may exist). This is
|
|
1245
|
+
// deliberately stricter than "the loop stopped": stopping early because the
|
|
1246
|
+
// display need was met (≥5 merged) or hitting MERGED_PAGE_CAP while pages were
|
|
1247
|
+
// still full does NOT prove completeness — those leave a full (===100) last
|
|
1248
|
+
// page, so this returns false and the caller falls back to by-number. A 304
|
|
1249
|
+
// carries the unchanged real data, so a short (<100) 304 page is a genuine end
|
|
1250
|
+
// too; only "error" pages (possibly stale/null data) can't prove it. Pure.
|
|
1251
|
+
function closedPagesComplete(pages) {
|
|
1252
|
+
const last = pages[pages.length - 1];
|
|
1253
|
+
return !!last && last.status !== "error" && Array.isArray(last.data) && last.data.length < 100;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// #834: issue numbers linked by the team's `[#N]` PR-title convention across
|
|
1257
|
+
// ALL fetched closed-PR pages (not just the 5 displayed merged). Lets
|
|
1258
|
+
// progressFromSnapshot prove an OPEN issue has no closed/merged linked PR —
|
|
1259
|
+
// even one merged/closed outside the recent-5 window — before classifying it
|
|
1260
|
+
// queued. Pure → unit-testable.
|
|
1261
|
+
function closedPrIssueNumsFromPages(pages) {
|
|
1262
|
+
const re = /^\s*\[#(\d{1,7})\]/;
|
|
1263
|
+
const nums = [];
|
|
1264
|
+
for (const r of pages) {
|
|
1265
|
+
const arr = Array.isArray(r && r.data) ? r.data : [];
|
|
1266
|
+
for (const p of arr) {
|
|
1267
|
+
const m = ((p && p.title) || "").match(re);
|
|
1268
|
+
if (m) nums.push(parseInt(m[1], 10));
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
return nums;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
async function githubStateFetcher(repo) {
|
|
1275
|
+
const [owner, name] = (repo || "").split("/");
|
|
1276
|
+
if (!owner || !name) return { status: "error", data: null };
|
|
1277
|
+
const base = `repos/${owner}/${name}`;
|
|
1278
|
+
let changed = 0;
|
|
1279
|
+
|
|
1280
|
+
const [openPullsR, openIssuesR, closedIssuesR, closedPulls1R] = await Promise.all([
|
|
1281
|
+
ghApiConditional(`${repo}#pulls-open`, `${base}/pulls?state=open&per_page=50&sort=updated&direction=desc`),
|
|
1282
|
+
ghApiConditional(`${repo}#issues-open`, `${base}/issues?state=open&per_page=50&sort=updated&direction=desc`),
|
|
1283
|
+
ghApiConditional(`${repo}#issues-closed`, `${base}/issues?state=closed&per_page=100&sort=updated&direction=desc`),
|
|
1284
|
+
ghApiConditional(`${repo}#pulls-closed`, `${base}/pulls?state=closed&per_page=100&sort=updated&direction=desc`),
|
|
1285
|
+
]);
|
|
1286
|
+
|
|
1287
|
+
// #828 P3: extend the closed-PR window across pages only when needed (each
|
|
1288
|
+
// page conditional/ETag'd, so steady-state extra pages cost nothing on a 304).
|
|
1289
|
+
const closedPullPages = await gatherClosedPrPages(closedPulls1R, (page) =>
|
|
1290
|
+
ghApiConditional(`${repo}#pulls-closed-p${page}`, `${base}/pulls?state=closed&per_page=100&sort=updated&direction=desc&page=${page}`),
|
|
1291
|
+
);
|
|
1292
|
+
|
|
1293
|
+
let anyTopData = false;
|
|
1294
|
+
for (const r of [openPullsR, openIssuesR, closedIssuesR, ...closedPullPages]) {
|
|
1295
|
+
if (r.status === "ok") changed++;
|
|
1296
|
+
if (Array.isArray(r.data)) anyTopData = true;
|
|
1297
|
+
}
|
|
1298
|
+
// No list data at all (first run, every call failed) → don't fabricate a
|
|
1299
|
+
// snapshot; let callers keep serving whatever they already had.
|
|
1300
|
+
if (!anyTopData) return { status: "error", data: null };
|
|
1301
|
+
|
|
1302
|
+
// REST /issues includes PRs — drop anything carrying a pull_request key.
|
|
1303
|
+
const issues = (Array.isArray(openIssuesR.data) ? openIssuesR.data : [])
|
|
1304
|
+
.filter((it) => !it.pull_request)
|
|
1305
|
+
.map(restIssueToCanonical);
|
|
1306
|
+
|
|
1307
|
+
const closedIssues = (Array.isArray(closedIssuesR.data) ? closedIssuesR.data : [])
|
|
1308
|
+
.filter((it) => !it.pull_request)
|
|
1309
|
+
.sort((a, b) => (Date.parse(b && b.closed_at) || 0) - (Date.parse(a && a.closed_at) || 0))
|
|
1310
|
+
.slice(0, RECENT_DISPLAY_LIMIT)
|
|
1311
|
+
.map(restClosedIssueToCanonical);
|
|
1312
|
+
|
|
1313
|
+
const mergedPrs = selectRecentMergedPrs(closedPullPages);
|
|
1314
|
+
|
|
1315
|
+
// #828 P1: record whether the open-PR board window is PROVABLY complete — we
|
|
1316
|
+
// asked for 50 and got fewer, so we've seen EVERY open PR. progressFrom-
|
|
1317
|
+
// Snapshot relies on this to classify a no-matching-PR open issue as queued
|
|
1318
|
+
// without a by-number fetch; if exactly 50 came back the window may be
|
|
1319
|
+
// truncated and it must NOT guess.
|
|
1320
|
+
const openPrsWindowComplete = Array.isArray(openPullsR.data) && openPullsR.data.length < 50;
|
|
1321
|
+
// #834: queued-from-snapshot also requires proving NO closed/merged linked PR
|
|
1322
|
+
// exists. closedPrsWindowComplete = the closed-PR pagination reached an actual
|
|
1323
|
+
// <100 end (not just stopped early); closedPrIssueNums = every [#N] across ALL
|
|
1324
|
+
// scanned closed pages, so a PR merged/closed outside the recent-5 window
|
|
1325
|
+
// still blocks a false "queued". Both are consumed by progressFromSnapshot.
|
|
1326
|
+
const closedPrsWindowComplete = closedPagesComplete(closedPullPages);
|
|
1327
|
+
const closedPrIssueNums = closedPrIssueNumsFromPages(closedPullPages);
|
|
1328
|
+
|
|
1329
|
+
const openPullsRaw = Array.isArray(openPullsR.data) ? openPullsR.data : [];
|
|
1330
|
+
const prs = await _mapLimited(openPullsRaw, GH_MAX_CONCURRENT, async (p) => {
|
|
1331
|
+
const sha = p.head && p.head.sha;
|
|
1332
|
+
const [reviewsR, checkRunsR, statusR] = await Promise.all([
|
|
1333
|
+
ghApiConditional(`${repo}#reviews-${p.number}`, `${base}/pulls/${p.number}/reviews?per_page=100`),
|
|
1334
|
+
sha ? ghApiConditional(`${repo}#checkruns-${sha}`, `${base}/commits/${sha}/check-runs?per_page=100`) : Promise.resolve({ status: "error", data: null }),
|
|
1335
|
+
sha ? ghApiConditional(`${repo}#status-${sha}`, `${base}/commits/${sha}/status`) : Promise.resolve({ status: "error", data: null }),
|
|
1336
|
+
]);
|
|
1337
|
+
if (reviewsR.status === "ok" || checkRunsR.status === "ok" || statusR.status === "ok") changed++;
|
|
1338
|
+
const reviews = mapReviews(Array.isArray(reviewsR.data) ? reviewsR.data : []);
|
|
1339
|
+
return {
|
|
1340
|
+
...restPullBaseToCanonical(p),
|
|
1341
|
+
reviews,
|
|
1342
|
+
reviewDecision: deriveReviewDecision(reviews),
|
|
1343
|
+
statusCheckRollup: buildStatusCheckRollup(checkRunsR.data, statusR.data),
|
|
1344
|
+
};
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
return {
|
|
1348
|
+
status: changed > 0 ? "ok" : "unchanged",
|
|
1349
|
+
data: { issues, prs, closedIssues, mergedPrs, openPrsWindowComplete, closedPrsWindowComplete, closedPrIssueNums },
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// Refresh a single repo's REST snapshot into the shared caches. Coalesced: a
|
|
1354
|
+
// concurrent caller (e.g. the parallel cold-load of all four list endpoints, or
|
|
1355
|
+
// a background stale refresh overlapping an on-demand one) joins and awaits the
|
|
1356
|
+
// SAME in-flight pass, so by the time it resolves the slice caches are written.
|
|
1357
|
+
function refreshRepoRest(repo) {
|
|
1358
|
+
return _coalesce(_restRefreshing, repo, async () => {
|
|
1359
|
+
let result;
|
|
1360
|
+
try {
|
|
1361
|
+
const { status, data } = await githubStateFetcher(repo);
|
|
1362
|
+
if (data) {
|
|
1363
|
+
const now = Date.now();
|
|
1364
|
+
_graphqlCache.set(repo, { ts: now, ...data });
|
|
1365
|
+
_ghEndpointCache.set(`issues:${repo}`, { ts: now, data: data.issues, stale: false });
|
|
1366
|
+
_ghEndpointCache.set(`prs:${repo}`, { ts: now, data: data.prs, stale: false });
|
|
1367
|
+
_ghEndpointCache.set(`closed-issues:${repo}`, { ts: now, data: data.closedIssues, stale: false });
|
|
1368
|
+
_ghEndpointCache.set(`merged-prs:${repo}`, { ts: now, data: data.mergedPrs, stale: false });
|
|
1369
|
+
}
|
|
1370
|
+
result = { status, data };
|
|
1371
|
+
} catch {
|
|
1372
|
+
result = { status: "error", data: null };
|
|
1373
|
+
}
|
|
1374
|
+
// #807: the server is the sole author of GITHUB.md's machine sections —
|
|
1375
|
+
// regenerate it from this completed pass's snapshot (success or error so
|
|
1376
|
+
// staleCycles advances). Non-fatal; never affects the board cache result.
|
|
1377
|
+
try { syncGithubFilesForRepo(repo, result.status); } catch { /* non-fatal */ }
|
|
1378
|
+
return result;
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// ─── #807 (#805 step 2): server-authored GITHUB.md ────────────────────────
|
|
1383
|
+
// Per-project GITHUB.md mirrors OVERNIGHT-QUEUE.md: the server is the sole
|
|
1384
|
+
// author of the machine sections (built from #806's structured snapshot),
|
|
1385
|
+
// agents read the file for discovery. Section bodies are injection-safe and
|
|
1386
|
+
// strictly parseable (parseGithub). The `## Notes` block is human/HEAD-writable
|
|
1387
|
+
// and preserved across regeneration.
|
|
1388
|
+
|
|
1389
|
+
// Per-project freshness: projectId → { generatedAt(ms), staleCycles }.
|
|
1390
|
+
const _githubMeta = new Map();
|
|
1391
|
+
// #807: FIXED freshness window for the live `_stale` signal — must NOT use
|
|
1392
|
+
// adaptiveTTL, which returns Infinity under critical rate-limit (the exact case
|
|
1393
|
+
// where no sync pass runs and staleness is unbounded). ~5 poll cycles.
|
|
1394
|
+
const GITHUB_FRESH_TTL_MS = 5 * 60_000;
|
|
1395
|
+
|
|
1396
|
+
// Live staleness for /api/github-parsed: stale if the file says so, if we have
|
|
1397
|
+
// no generatedAt, or if the snapshot is older than the FIXED window. Pure so
|
|
1398
|
+
// the rate-limit edge case (unbounded age) is regression-tested directly.
|
|
1399
|
+
// #827: a NaN ageMs (Date.parse of a malformed/corrupted `generatedAt`) must
|
|
1400
|
+
// count as stale/unknown — NOT fresh. Without the guard, `NaN > window` is
|
|
1401
|
+
// false and staleness would be hidden exactly when the file is suspect.
|
|
1402
|
+
function _githubLiveStale(headerStale, ageMs) {
|
|
1403
|
+
return headerStale || ageMs == null || Number.isNaN(ageMs) || ageMs > GITHUB_FRESH_TTL_MS;
|
|
1404
|
+
}
|
|
1405
|
+
const GITHUB_SECTION_HEADERS = {
|
|
1406
|
+
openIssues: "Open Issues",
|
|
1407
|
+
openPRs: "Open PRs",
|
|
1408
|
+
closedIssues: "Recently Closed Issues",
|
|
1409
|
+
mergedPrs: "Recently Merged PRs",
|
|
1410
|
+
};
|
|
1411
|
+
|
|
1412
|
+
// Collapse untrusted text to a single safe line so it can NEVER begin a new
|
|
1413
|
+
// `## ` section marker or break the strict list-line grammar. Also strips the
|
|
1414
|
+
// field delimiter (·) we use, and `[`/`]` that bound the assignees field.
|
|
1415
|
+
function _sanitizeInline(s) {
|
|
1416
|
+
return String(s == null ? "" : s)
|
|
1417
|
+
.replace(/[\r\n]+/g, " ")
|
|
1418
|
+
.replace(/[·\[\]]/g, " ")
|
|
1419
|
+
.replace(/\s+/g, " ")
|
|
1420
|
+
.trim();
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
function _renderAssignees(assignees) {
|
|
1424
|
+
// Empty → "" so the rendered `[]` round-trips back to an empty array (rather
|
|
1425
|
+
// than a phantom assignee literally named "none").
|
|
1426
|
+
const logins = (assignees || []).map((a) => a && a.login).filter(Boolean).map(_sanitizeInline);
|
|
1427
|
+
return logins.map((l) => `@${l}`).join(", ");
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// One strict list line: `- [#N](url) · STATE · [assignees] · title`. The
|
|
1431
|
+
// free-text title is LAST (the parser reads it as the line tail), and url is
|
|
1432
|
+
// whitespace-free, so neither can corrupt the earlier fixed fields.
|
|
1433
|
+
function _renderListItem(item) {
|
|
1434
|
+
const url = _sanitizeInline(item.url).replace(/\s/g, "") || "-";
|
|
1435
|
+
const state = _sanitizeInline(item.state).toUpperCase() || "OPEN";
|
|
1436
|
+
return `- [#${item.number}](${url}) · ${state} · [${_renderAssignees(item.assignees)}] · ${_sanitizeInline(item.title)}`;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
function _renderSection(items, renderLine) {
|
|
1440
|
+
if (!items || items.length === 0) return "(none)";
|
|
1441
|
+
return items.map(renderLine).join("\n");
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
// Latest review per body-marker ROLE (re1/re2) across the shared author's full
|
|
1445
|
+
// review list — GitHub login can't distinguish the reviewers (same account), so
|
|
1446
|
+
// we match RE1/RE2/Reviewer1/Reviewer2/T2a/T2b (and `## Verdict`→re1) prefixes
|
|
1447
|
+
// in the review BODY. A marker-less/ambiguous review is UNCOUNTED (never
|
|
1448
|
+
// over-counts). Mirrors GitHubPanel.tsx's body-marker logic.
|
|
1449
|
+
function attributeReviewsByRole(reviews) {
|
|
1450
|
+
const sorted = (reviews || []).slice().sort(
|
|
1451
|
+
(a, b) => (Date.parse(a && a.submittedAt) || 0) - (Date.parse(b && b.submittedAt) || 0),
|
|
1452
|
+
);
|
|
1453
|
+
const out = { re1: null, re2: null };
|
|
1454
|
+
for (const r of sorted) {
|
|
1455
|
+
const body = ((r && r.body) || "").trim();
|
|
1456
|
+
let role = null;
|
|
1457
|
+
if (/^(?:RE2|Reviewer2|T2b)\b/i.test(body)) role = "re2";
|
|
1458
|
+
else if (/^(?:RE1|Reviewer1|T2a)\b/i.test(body) || /^##\s*Verdict/i.test(body)) role = "re1";
|
|
1459
|
+
if (!role) continue; // ambiguous → uncounted
|
|
1460
|
+
out[role] = { state: r.state, submittedAt: r.submittedAt || null };
|
|
1461
|
+
}
|
|
1462
|
+
return out;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
function _renderReviewDetail(prs) {
|
|
1466
|
+
const lines = [];
|
|
1467
|
+
for (const pr of prs || []) {
|
|
1468
|
+
const roles = attributeReviewsByRole(pr.reviews);
|
|
1469
|
+
for (const role of ["re1", "re2"]) {
|
|
1470
|
+
const rv = roles[role];
|
|
1471
|
+
if (!rv) continue;
|
|
1472
|
+
lines.push(`- #${pr.number} · ${role}:${_sanitizeInline(rv.state).toUpperCase()} · ${_sanitizeInline(rv.submittedAt) || "-"}`);
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
return lines.length ? lines.join("\n") : "(none)";
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// Build the full GITHUB.md from a structured snapshot. Pure (no I/O). `notesBody`
|
|
1479
|
+
// is the preserved `## Notes` text. `meta` = { generatedAt(ms), staleCycles }.
|
|
1480
|
+
function renderGithubMarkdown(projectName, repo, snapshot, meta, notesBody) {
|
|
1481
|
+
const snap = snapshot || {};
|
|
1482
|
+
const generatedAt = meta && meta.generatedAt ? new Date(meta.generatedAt).toISOString() : "never";
|
|
1483
|
+
const staleCycles = (meta && meta.staleCycles) || 0;
|
|
1484
|
+
const stale = staleCycles > 0 || !meta || !meta.generatedAt;
|
|
1485
|
+
return [
|
|
1486
|
+
`# ${projectName || ""} — GitHub State`,
|
|
1487
|
+
"",
|
|
1488
|
+
`> **Repo:** ${repo || ""}`,
|
|
1489
|
+
`> **Generated:** ${generatedAt} · staleCycles: ${staleCycles} · stale: ${stale}`,
|
|
1490
|
+
">",
|
|
1491
|
+
"> Machine-generated by QuadWork — do NOT edit the sections below (overwritten",
|
|
1492
|
+
"> on every sync). Edit only `## Notes`.",
|
|
1493
|
+
"",
|
|
1494
|
+
"---",
|
|
1495
|
+
"",
|
|
1496
|
+
`## ${GITHUB_SECTION_HEADERS.openIssues}`,
|
|
1497
|
+
"",
|
|
1498
|
+
_renderSection(snap.issues, _renderListItem),
|
|
1499
|
+
"",
|
|
1500
|
+
"---",
|
|
1501
|
+
"",
|
|
1502
|
+
`## ${GITHUB_SECTION_HEADERS.openPRs}`,
|
|
1503
|
+
"",
|
|
1504
|
+
_renderSection(snap.prs, _renderListItem),
|
|
1505
|
+
"",
|
|
1506
|
+
"---",
|
|
1507
|
+
"",
|
|
1508
|
+
`## ${GITHUB_SECTION_HEADERS.closedIssues}`,
|
|
1509
|
+
"",
|
|
1510
|
+
_renderSection(snap.closedIssues, _renderListItem),
|
|
1511
|
+
"",
|
|
1512
|
+
"---",
|
|
1513
|
+
"",
|
|
1514
|
+
`## ${GITHUB_SECTION_HEADERS.mergedPrs}`,
|
|
1515
|
+
"",
|
|
1516
|
+
_renderSection(snap.mergedPrs, _renderListItem),
|
|
1517
|
+
"",
|
|
1518
|
+
"---",
|
|
1519
|
+
"",
|
|
1520
|
+
"## Review Detail",
|
|
1521
|
+
"",
|
|
1522
|
+
_renderReviewDetail(snap.prs),
|
|
1523
|
+
"",
|
|
1524
|
+
"---",
|
|
1525
|
+
"",
|
|
1526
|
+
"## Notes",
|
|
1527
|
+
"",
|
|
1528
|
+
(notesBody && notesBody.trim()) ? notesBody.trim() : "(none)",
|
|
1529
|
+
"",
|
|
1530
|
+
].join("\n");
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
const _GITHUB_DEFAULT_NOTES =
|
|
1534
|
+
"_Advisory only. The Review Detail above is body-marker attribution (re1/re2),\n" +
|
|
1535
|
+
"not merge-authoritative — Head re-checks live before merging. This section is\n" +
|
|
1536
|
+
"human/Head-writable and is preserved across regeneration._";
|
|
1537
|
+
|
|
1538
|
+
// Extract the existing `## Notes` body (preserved verbatim across regeneration).
|
|
1539
|
+
// Anchored to line start (multiline) so it matches the real `## Notes` SECTION,
|
|
1540
|
+
// not the `` `## Notes` `` reference in the header instructions. Notes is the
|
|
1541
|
+
// last section, so we capture to EOF and do NOT strip trailing `## ` lines —
|
|
1542
|
+
// that lets a human note contain its own `## ` headings without truncation.
|
|
1543
|
+
function _extractNotesBody(text) {
|
|
1544
|
+
const m = (text || "").match(/^##\s+Notes\b[^\n]*\n([\s\S]*)$/im);
|
|
1545
|
+
if (!m) return _GITHUB_DEFAULT_NOTES;
|
|
1546
|
+
const body = m[1].trim();
|
|
1547
|
+
return body || _GITHUB_DEFAULT_NOTES;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// Write one project's GITHUB.md atomically (temp + rename) from a snapshot,
|
|
1551
|
+
// preserving its `## Notes`. status drives the freshness meta.
|
|
1552
|
+
function writeGithubFileFromSnapshot(projectId, projectName, repo, snapshot, status) {
|
|
1553
|
+
try {
|
|
1554
|
+
// #827: re-check idle here (reads config fresh) — syncGithubFilesForRepo
|
|
1555
|
+
// filtered on a config snapshot taken earlier, so an idle-flip concurrent
|
|
1556
|
+
// with a setup-seed/refresh pass could otherwise still write a parked
|
|
1557
|
+
// project's GITHUB.md. Defensive; idle projects must author nothing.
|
|
1558
|
+
if (isProjectIdle(projectId)) return;
|
|
1559
|
+
const dir = path.join(CONFIG_DIR, projectId);
|
|
1560
|
+
const filePath = path.join(dir, "GITHUB.md");
|
|
1561
|
+
// Cold + total failure with nothing cached: leave the seeded template be.
|
|
1562
|
+
if (!snapshot && status === "error") return;
|
|
1563
|
+
ensureSecureDir(dir);
|
|
1564
|
+
|
|
1565
|
+
let notesBody = _GITHUB_DEFAULT_NOTES;
|
|
1566
|
+
try { notesBody = _extractNotesBody(fs.readFileSync(filePath, "utf-8")); } catch { /* seed defaults */ }
|
|
1567
|
+
|
|
1568
|
+
const meta = _githubMeta.get(projectId) || { generatedAt: 0, staleCycles: 0 };
|
|
1569
|
+
if (status === "error") {
|
|
1570
|
+
meta.staleCycles += 1; // keep last generatedAt — never stamp a failed cycle fresh
|
|
1571
|
+
} else {
|
|
1572
|
+
meta.generatedAt = Date.now();
|
|
1573
|
+
meta.staleCycles = 0;
|
|
1574
|
+
}
|
|
1575
|
+
_githubMeta.set(projectId, meta);
|
|
1576
|
+
|
|
1577
|
+
const content = renderGithubMarkdown(projectName, repo, snapshot, meta, notesBody);
|
|
1578
|
+
const tmp = path.join(dir, `.GITHUB.md.tmp-${process.pid}`);
|
|
1579
|
+
fs.writeFileSync(tmp, content, { mode: 0o600 });
|
|
1580
|
+
fs.renameSync(tmp, filePath); // atomic — readers never see partial content
|
|
1581
|
+
} catch { /* non-fatal */ }
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// Regenerate GITHUB.md for every non-idle project bound to this repo, from the
|
|
1585
|
+
// last-good snapshot in _graphqlCache. Idle projects are never regenerated.
|
|
1586
|
+
function syncGithubFilesForRepo(repo, status) {
|
|
1587
|
+
let cfg;
|
|
1588
|
+
try { cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")); } catch { return; }
|
|
1589
|
+
const snapshot = _graphqlCache.get(repo) || null;
|
|
1590
|
+
for (const p of cfg.projects || []) {
|
|
1591
|
+
if (p.repo !== repo || p.idle) continue;
|
|
1592
|
+
writeGithubFileFromSnapshot(p.id, p.name || p.id, repo, snapshot, status);
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
// ── Strict GITHUB.md parser (mirrors parseActiveBatch). Pure; section-isolating
|
|
1597
|
+
// (stops at the next `## `); list lines must match the exact grammar so prose
|
|
1598
|
+
// `#N` can't leak. Distinguishes a parse error (not our file) from an
|
|
1599
|
+
// empty-but-valid section. Exported for unit tests. ──
|
|
1600
|
+
const _GH_ITEM_RE = /^-\s+\[#(\d{1,7})\]\((\S+)\)\s+·\s+(OPEN|CLOSED|MERGED)\s+·\s+\[([^\]]*)\]\s+·\s+(.*)$/;
|
|
1601
|
+
const _GH_REVIEW_RE = /^-\s+#(\d{1,7})\s+·\s+(re1|re2):([A-Z_]+)\s+·\s+(\S+)$/;
|
|
1602
|
+
const _GH_FRESH_RE = /\*\*Generated:\*\*\s*(\S+)\s*·\s*staleCycles:\s*(\d+)\s*·\s*stale:\s*(true|false)/i;
|
|
1603
|
+
|
|
1604
|
+
function _ghSection(text, header) {
|
|
1605
|
+
const re = new RegExp(`##\\s+${header.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}\\b[\\s\\S]*?(?=\\n##\\s|$)`, "i");
|
|
1606
|
+
const m = text.match(re);
|
|
1607
|
+
return m ? m[0] : null;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
function _parseGhList(text, header) {
|
|
1611
|
+
const section = _ghSection(text, header);
|
|
1612
|
+
if (section == null) return null; // header missing → distinct from empty
|
|
1613
|
+
const items = [];
|
|
1614
|
+
for (const line of section.split("\n")) {
|
|
1615
|
+
const m = line.match(_GH_ITEM_RE);
|
|
1616
|
+
if (!m) continue; // prose / "(none)" / blanks are skipped
|
|
1617
|
+
const assignees = m[4].split(",").map((s) => s.trim().replace(/^@/, "")).filter(Boolean).map((login) => ({ login }));
|
|
1618
|
+
items.push({ number: parseInt(m[1], 10), url: m[2], state: m[3], assignees, title: m[5].trim() });
|
|
1619
|
+
}
|
|
1620
|
+
return items;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
function parseGithub(text) {
|
|
1624
|
+
if (typeof text !== "string" || !text.trim()) {
|
|
1625
|
+
return { ok: false, error: "empty or non-string input" };
|
|
1626
|
+
}
|
|
1627
|
+
const openIssues = _parseGhList(text, GITHUB_SECTION_HEADERS.openIssues);
|
|
1628
|
+
const openPRs = _parseGhList(text, GITHUB_SECTION_HEADERS.openPRs);
|
|
1629
|
+
const closedIssues = _parseGhList(text, GITHUB_SECTION_HEADERS.closedIssues);
|
|
1630
|
+
const mergedPrs = _parseGhList(text, GITHUB_SECTION_HEADERS.mergedPrs);
|
|
1631
|
+
// If none of the required machine headers exist, this isn't a GITHUB.md.
|
|
1632
|
+
if (openIssues == null && openPRs == null && closedIssues == null && mergedPrs == null) {
|
|
1633
|
+
return { ok: false, error: "no recognizable GITHUB.md sections" };
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// Review Detail → { [prNumber]: { re1?: {state,submittedAt}, re2?: {...} } }.
|
|
1637
|
+
const reviewDetail = {};
|
|
1638
|
+
const reviewSection = _ghSection(text, "Review Detail");
|
|
1639
|
+
if (reviewSection) {
|
|
1640
|
+
for (const line of reviewSection.split("\n")) {
|
|
1641
|
+
const m = line.match(_GH_REVIEW_RE);
|
|
1642
|
+
if (!m) continue;
|
|
1643
|
+
const n = parseInt(m[1], 10);
|
|
1644
|
+
(reviewDetail[n] || (reviewDetail[n] = {}))[m[2]] = { state: m[3], submittedAt: m[4] };
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
const fresh = text.match(_GH_FRESH_RE);
|
|
1649
|
+
return {
|
|
1650
|
+
ok: true,
|
|
1651
|
+
generatedAt: fresh && fresh[1] !== "never" ? fresh[1] : null,
|
|
1652
|
+
staleCycles: fresh ? parseInt(fresh[2], 10) : 0,
|
|
1653
|
+
stale: fresh ? fresh[3].toLowerCase() === "true" : true,
|
|
1654
|
+
openIssues: openIssues || [],
|
|
1655
|
+
openPRs: openPRs || [],
|
|
1656
|
+
closedIssues: closedIssues || [],
|
|
1657
|
+
mergedPrs: mergedPrs || [],
|
|
1658
|
+
reviewDetail,
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
952
1661
|
|
|
953
1662
|
// Build and execute a batched GraphQL query for all configured projects.
|
|
954
1663
|
// Returns a Map of repo → { issues, prs, closedIssues, mergedPrs }.
|
|
@@ -959,7 +1668,7 @@ async function fetchAllProjectsGraphQL() {
|
|
|
959
1668
|
} catch {
|
|
960
1669
|
return null;
|
|
961
1670
|
}
|
|
962
|
-
const projects = (cfg.projects || []).filter((p) => p.repo && REPO_RE.test(p.repo));
|
|
1671
|
+
const projects = (cfg.projects || []).filter((p) => p.repo && REPO_RE.test(p.repo) && !p.idle); // #812: skip idle (parked) projects
|
|
963
1672
|
if (projects.length === 0) return null;
|
|
964
1673
|
|
|
965
1674
|
// Build aliased repository fields — one per project.
|
|
@@ -985,7 +1694,7 @@ fragment repoFields on Repository {
|
|
|
985
1694
|
nodes { number title url state closedAt }
|
|
986
1695
|
}
|
|
987
1696
|
openPRs: pullRequests(first: 50, states: OPEN, orderBy: {field: UPDATED_AT, direction: DESC}) {
|
|
988
|
-
nodes { number title url state author { login } reviews(last: 100) { nodes { state author { login } submittedAt } } createdAt }
|
|
1697
|
+
nodes { number title url state author { login } reviews(last: 100) { nodes { state author { login } submittedAt body } } createdAt }
|
|
989
1698
|
}
|
|
990
1699
|
mergedPRs: pullRequests(first: ${RECENT_FETCH_LIMIT}, states: MERGED, orderBy: {field: UPDATED_AT, direction: DESC}) {
|
|
991
1700
|
nodes { number title url state mergedAt author { login } }
|
|
@@ -995,7 +1704,7 @@ fragment repoFields on Repository {
|
|
|
995
1704
|
try {
|
|
996
1705
|
const { stdout } = await _execFileAsync("gh", [
|
|
997
1706
|
"api", "graphql", "-f", `query=${query}`,
|
|
998
|
-
], { encoding: "utf-8", timeout: 15000 });
|
|
1707
|
+
], { encoding: "utf-8", timeout: 15000, maxBuffer: GH_LIST_MAX_BUFFER });
|
|
999
1708
|
const data = JSON.parse(stdout).data;
|
|
1000
1709
|
if (!data) return null;
|
|
1001
1710
|
|
|
@@ -1006,10 +1715,13 @@ fragment repoFields on Repository {
|
|
|
1006
1715
|
if (!repoData) continue;
|
|
1007
1716
|
|
|
1008
1717
|
// Transform GraphQL nodes into the same shape as gh CLI JSON output.
|
|
1718
|
+
// #806: pin canonical uppercase state for emergency-fallback parity with
|
|
1719
|
+
// the REST path. reviewDecision/statusCheckRollup are absent here (the
|
|
1720
|
+
// GraphQL query doesn't fetch them) — acceptable for a degraded fallback.
|
|
1009
1721
|
const issues = (repoData.openIssues?.nodes || []).map((n) => ({
|
|
1010
1722
|
number: n.number,
|
|
1011
1723
|
title: n.title,
|
|
1012
|
-
state: n.state
|
|
1724
|
+
state: (n.state || "").toUpperCase(),
|
|
1013
1725
|
url: n.url,
|
|
1014
1726
|
labels: (n.labels?.nodes || []).map((l) => ({ name: l.name })),
|
|
1015
1727
|
assignees: (n.assignees?.nodes || []).map((a) => ({ login: a.login })),
|
|
@@ -1019,7 +1731,7 @@ fragment repoFields on Repository {
|
|
|
1019
1731
|
const prs = (repoData.openPRs?.nodes || []).map((n) => ({
|
|
1020
1732
|
number: n.number,
|
|
1021
1733
|
title: n.title,
|
|
1022
|
-
state: n.state
|
|
1734
|
+
state: (n.state || "").toUpperCase(),
|
|
1023
1735
|
url: n.url,
|
|
1024
1736
|
author: n.author ? { login: n.author.login } : null,
|
|
1025
1737
|
assignees: [],
|
|
@@ -1027,6 +1739,7 @@ fragment repoFields on Repository {
|
|
|
1027
1739
|
state: r.state,
|
|
1028
1740
|
author: r.author ? { login: r.author.login } : null,
|
|
1029
1741
|
submittedAt: r.submittedAt,
|
|
1742
|
+
body: r.body || "",
|
|
1030
1743
|
})),
|
|
1031
1744
|
createdAt: n.createdAt,
|
|
1032
1745
|
}));
|
|
@@ -1042,7 +1755,7 @@ fragment repoFields on Repository {
|
|
|
1042
1755
|
.map((n) => ({
|
|
1043
1756
|
number: n.number,
|
|
1044
1757
|
title: n.title,
|
|
1045
|
-
state: n.state
|
|
1758
|
+
state: (n.state || "CLOSED").toUpperCase(),
|
|
1046
1759
|
url: n.url,
|
|
1047
1760
|
closedAt: n.closedAt,
|
|
1048
1761
|
}));
|
|
@@ -1058,7 +1771,7 @@ fragment repoFields on Repository {
|
|
|
1058
1771
|
.map((n) => ({
|
|
1059
1772
|
number: n.number,
|
|
1060
1773
|
title: n.title,
|
|
1061
|
-
state: n.state
|
|
1774
|
+
state: (n.state || "MERGED").toUpperCase(),
|
|
1062
1775
|
url: n.url,
|
|
1063
1776
|
mergedAt: n.mergedAt,
|
|
1064
1777
|
author: n.author ? { login: n.author.login } : null,
|
|
@@ -1076,24 +1789,48 @@ fragment repoFields on Repository {
|
|
|
1076
1789
|
// and on demand when a per-project endpoint has no cached data.
|
|
1077
1790
|
async function refreshGraphQLCache() {
|
|
1078
1791
|
if (_graphqlRefreshInFlight) return;
|
|
1079
|
-
|
|
1792
|
+
// #806: the board state now comes from REST + ETag (githubStateFetcher), so
|
|
1793
|
+
// gate on the REST (core) budget. 304s are free, but the first/changed
|
|
1794
|
+
// fetches cost core — back off when REST is critically low and let consumers
|
|
1795
|
+
// serve stale. (Name kept so the module-scope auto-start stays untouched.)
|
|
1796
|
+
if (isRateLimited()) return;
|
|
1080
1797
|
_graphqlRefreshInFlight = true;
|
|
1081
1798
|
try {
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1799
|
+
let cfg;
|
|
1800
|
+
try { cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")); } catch { return; }
|
|
1801
|
+
const seen = new Set();
|
|
1802
|
+
const repos = [];
|
|
1803
|
+
for (const p of cfg.projects || []) {
|
|
1804
|
+
if (!p.repo || !REPO_RE.test(p.repo) || p.idle) continue; // #812: skip idle
|
|
1805
|
+
if (seen.has(p.repo)) continue;
|
|
1806
|
+
seen.add(p.repo);
|
|
1807
|
+
repos.push(p.repo);
|
|
1808
|
+
}
|
|
1809
|
+
const results = await _mapLimited(repos, GH_MAX_CONCURRENT, (repo) =>
|
|
1810
|
+
refreshRepoRest(repo).then((r) => ({ repo, status: r.status })),
|
|
1811
|
+
);
|
|
1812
|
+
|
|
1813
|
+
// #805/#806 emergency fallback: if REST could not assemble a snapshot for
|
|
1814
|
+
// some repos AND the GraphQL budget is healthy, backfill via the legacy
|
|
1815
|
+
// batched GraphQL query (gated on the GRAPHQL bucket, never REST).
|
|
1816
|
+
const failed = results.filter((r) => r.status === "error").map((r) => r.repo);
|
|
1817
|
+
if (failed.length > 0 && !isGraphqlRateLimited()) {
|
|
1818
|
+
const gql = await fetchAllProjectsGraphQL();
|
|
1819
|
+
if (gql) {
|
|
1820
|
+
const now = Date.now();
|
|
1821
|
+
for (const repo of failed) {
|
|
1822
|
+
const d = gql.get(repo);
|
|
1823
|
+
if (!d) continue;
|
|
1824
|
+
_graphqlCache.set(repo, { ts: now, ...d });
|
|
1825
|
+
_ghEndpointCache.set(`issues:${repo}`, { ts: now, data: d.issues, stale: false });
|
|
1826
|
+
_ghEndpointCache.set(`prs:${repo}`, { ts: now, data: d.prs, stale: false });
|
|
1827
|
+
_ghEndpointCache.set(`closed-issues:${repo}`, { ts: now, data: d.closedIssues, stale: false });
|
|
1828
|
+
_ghEndpointCache.set(`merged-prs:${repo}`, { ts: now, data: d.mergedPrs, stale: false });
|
|
1829
|
+
}
|
|
1093
1830
|
}
|
|
1094
1831
|
}
|
|
1095
1832
|
} catch {
|
|
1096
|
-
// Non-fatal —
|
|
1833
|
+
// Non-fatal — consumers serve last-known cache.
|
|
1097
1834
|
} finally {
|
|
1098
1835
|
_graphqlRefreshInFlight = false;
|
|
1099
1836
|
}
|
|
@@ -1103,128 +1840,110 @@ async function refreshGraphQLCache() {
|
|
|
1103
1840
|
let _graphqlPollTimer = null;
|
|
1104
1841
|
function startGraphQLPolling() {
|
|
1105
1842
|
if (_graphqlPollTimer) return;
|
|
1843
|
+
// #836: both the warm-up setTimeout and the steady-state setInterval are
|
|
1844
|
+
// .unref()'d so a test/import process can exit. The Express HTTP listener
|
|
1845
|
+
// keeps prod alive on its own; cadence is unchanged.
|
|
1106
1846
|
// Initial fetch after a short delay (let rate-limit poll run first).
|
|
1107
|
-
setTimeout(() => refreshGraphQLCache(), 2000);
|
|
1847
|
+
setTimeout(() => refreshGraphQLCache(), 2000).unref();
|
|
1108
1848
|
_graphqlPollTimer = setInterval(refreshGraphQLCache, GRAPHQL_CACHE_TTL);
|
|
1849
|
+
_graphqlPollTimer.unref();
|
|
1109
1850
|
}
|
|
1110
1851
|
|
|
1111
|
-
// #
|
|
1112
|
-
//
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
nodes { number state url merged reviews(last: 100) { nodes { state author { login } submittedAt } } }
|
|
1124
|
-
}
|
|
1125
|
-
}`
|
|
1126
|
-
).join("\n ");
|
|
1127
|
-
|
|
1128
|
-
const query = `query {
|
|
1129
|
-
repository(owner: "${owner}", name: "${name}") {
|
|
1130
|
-
${issueFields}
|
|
1131
|
-
}
|
|
1132
|
-
}`;
|
|
1133
|
-
|
|
1134
|
-
try {
|
|
1135
|
-
const { stdout } = await _execFileAsync("gh", [
|
|
1136
|
-
"api", "graphql", "-f", `query=${query}`,
|
|
1137
|
-
], { encoding: "utf-8", timeout: 15000 });
|
|
1138
|
-
const data = JSON.parse(stdout).data;
|
|
1139
|
-
if (!data?.repository) return null;
|
|
1140
|
-
return data.repository;
|
|
1141
|
-
} catch {
|
|
1142
|
-
return null; // fallback to individual gh CLI calls
|
|
1143
|
-
}
|
|
1852
|
+
// ─── #810: batch progress sourced from the REST snapshot ──────────────────
|
|
1853
|
+
// #806's server cache already holds open PRs (with full reviews + bodies),
|
|
1854
|
+
// open/closed issues, and merged PRs. Batch progress is computed from that
|
|
1855
|
+
// snapshot in steady state — no GraphQL — falling back to a targeted by-number
|
|
1856
|
+
// fetch only when an active-batch issue sits outside the board window.
|
|
1857
|
+
|
|
1858
|
+
// Per-ROLE approval count. re1/re2 share one GitHub login, so we attribute by
|
|
1859
|
+
// body-marker ROLE (attributeReviewsByRole, #807) and count roles whose LATEST
|
|
1860
|
+
// decision-affecting review is APPROVED — max 2 (re1, re2). NOT per-author.
|
|
1861
|
+
function countApprovedRoles(reviews) {
|
|
1862
|
+
const roles = attributeReviewsByRole(reviews);
|
|
1863
|
+
return ["re1", "re2"].filter((r) => roles[r] && roles[r].state === "APPROVED").length;
|
|
1144
1864
|
}
|
|
1145
1865
|
|
|
1146
|
-
//
|
|
1147
|
-
//
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1866
|
+
// Compute issue `n`'s progress row from the snapshot (no network). Links
|
|
1867
|
+
// issue↔PR by the team's `[#<issue>]` PR-title convention. Returns a row when
|
|
1868
|
+
// the snapshot can PROVE the state: a matching in-window PR (in_review/approved/
|
|
1869
|
+
// ready/merged), or — #828 P1 / #834 — a queued OPEN issue when BOTH windows are
|
|
1870
|
+
// provably complete: openPrsWindowComplete (<50 open PRs → no open PR exists)
|
|
1871
|
+
// AND closedPrsWindowComplete (the closed-PR pagination reached an actual <100
|
|
1872
|
+
// end) with the issue absent from closedPrIssueNums (every [#N] across ALL
|
|
1873
|
+
// scanned closed pages, so a PR merged/closed even OUTSIDE the recent-5 window
|
|
1874
|
+
// is accounted for). Returns null otherwise (either window unproven, issue
|
|
1875
|
+
// absent, CLOSED-with-no-in-window-PR, merged-but-open, or a linked closed/
|
|
1876
|
+
// merged PR exists) — a partial window can't prove absence of a linked PR, so
|
|
1877
|
+
// the caller does the authoritative by-number REST fetch (progressForItemRest).
|
|
1878
|
+
function progressFromSnapshot(snapshot, n) {
|
|
1879
|
+
if (!snapshot) return null;
|
|
1880
|
+
const titleRe = new RegExp(`^\\s*\\[#${n}\\]`);
|
|
1881
|
+
const openPr = (snapshot.prs || []).find((p) => titleRe.test(p.title || ""));
|
|
1882
|
+
const mergedPr = (snapshot.mergedPrs || []).find((p) => titleRe.test(p.title || ""));
|
|
1883
|
+
const openIssue = (snapshot.issues || []).find((i) => i.number === n);
|
|
1884
|
+
const closedIssue = (snapshot.closedIssues || []).find((i) => i.number === n);
|
|
1885
|
+
const title = (openIssue && openIssue.title) || (closedIssue && closedIssue.title) || `#${n}`;
|
|
1886
|
+
|
|
1887
|
+
// merged requires a matching merged PR in-window AND the issue CLOSED.
|
|
1888
|
+
if (mergedPr && closedIssue) {
|
|
1168
1889
|
return {
|
|
1169
|
-
issue_number:
|
|
1170
|
-
|
|
1171
|
-
url: pr.url || issueData.url,
|
|
1172
|
-
pr_number: pr.number,
|
|
1173
|
-
status: "merged",
|
|
1174
|
-
progress: 100,
|
|
1175
|
-
label: "Merged ✓",
|
|
1890
|
+
issue_number: n, title: closedIssue.title, url: mergedPr.url || closedIssue.url,
|
|
1891
|
+
pr_number: mergedPr.number, status: "merged", progress: 100, label: "Merged ✓",
|
|
1176
1892
|
};
|
|
1177
1893
|
}
|
|
1894
|
+
// Matching open PR in-window → confident in_review/approved/ready.
|
|
1895
|
+
if (openPr) {
|
|
1896
|
+
const approvals = countApprovedRoles(openPr.reviews);
|
|
1897
|
+
if (approvals >= 2) return { issue_number: n, title, url: openPr.url, pr_number: openPr.number, status: "ready", progress: 80, label: `PR #${openPr.number} · 2 approvals · ready` };
|
|
1898
|
+
if (approvals === 1) return { issue_number: n, title, url: openPr.url, pr_number: openPr.number, status: "approved1", progress: 50, label: `PR #${openPr.number} · 1 approval` };
|
|
1899
|
+
return { issue_number: n, title, url: openPr.url, pr_number: openPr.number, status: "in_review", progress: 20, label: `PR #${openPr.number} · waiting on review` };
|
|
1900
|
+
}
|
|
1901
|
+
// No matching open/in-window-merged PR. #828 P1 / #834: classify queued from
|
|
1902
|
+
// the snapshot ONLY when absence of BOTH an open AND a closed/merged linked PR
|
|
1903
|
+
// is proven — openPrsWindowComplete (no open PR) AND closedPrsWindowComplete
|
|
1904
|
+
// (the closed-PR window genuinely ended) AND `n` not in closedPrIssueNums
|
|
1905
|
+
// (no [#N] across ANY scanned closed page, incl. merges past the recent-5
|
|
1906
|
+
// display window). Then an OPEN issue is genuinely queued — NO network. Any
|
|
1907
|
+
// gap (either window unproven, or a linked closed/merged PR exists) → null,
|
|
1908
|
+
// and the by-number REST fetch resolves it authoritatively.
|
|
1909
|
+
if (
|
|
1910
|
+
snapshot.openPrsWindowComplete &&
|
|
1911
|
+
snapshot.closedPrsWindowComplete &&
|
|
1912
|
+
openIssue &&
|
|
1913
|
+
!openPr &&
|
|
1914
|
+
!(snapshot.closedPrIssueNums || []).includes(n)
|
|
1915
|
+
) {
|
|
1916
|
+
return buildNoPrRow({ number: n, title: openIssue.title, state: "OPEN", url: openIssue.url });
|
|
1917
|
+
}
|
|
1918
|
+
// Can't prove the issue has no linked PR (a window unproven / issue absent /
|
|
1919
|
+
// linked closed PR) — return null; the by-number REST fetch resolves it.
|
|
1920
|
+
return null;
|
|
1921
|
+
}
|
|
1178
1922
|
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
status: "ready",
|
|
1204
|
-
progress: 80,
|
|
1205
|
-
label: `PR #${pr.number} · 2 approvals · ready`,
|
|
1206
|
-
};
|
|
1207
|
-
}
|
|
1208
|
-
if (approvalCount === 1) {
|
|
1209
|
-
return {
|
|
1210
|
-
issue_number: issueData.number,
|
|
1211
|
-
title: issueData.title,
|
|
1212
|
-
url: pr.url || issueData.url,
|
|
1213
|
-
pr_number: pr.number,
|
|
1214
|
-
status: "approved1",
|
|
1215
|
-
progress: 50,
|
|
1216
|
-
label: `PR #${pr.number} · 1 approval`,
|
|
1217
|
-
};
|
|
1218
|
-
}
|
|
1219
|
-
return {
|
|
1220
|
-
issue_number: issueData.number,
|
|
1221
|
-
title: issueData.title,
|
|
1222
|
-
url: pr.url || issueData.url,
|
|
1223
|
-
pr_number: pr.number,
|
|
1224
|
-
status: "in_review",
|
|
1225
|
-
progress: 20,
|
|
1226
|
-
label: `PR #${pr.number} · waiting on review`,
|
|
1227
|
-
};
|
|
1923
|
+
// Confirmed-complete state machine, per project. `complete` must hold across
|
|
1924
|
+
// TWO consecutive SUCCESSFUL fetch cycles with DISTINCT generatedAt (the
|
|
1925
|
+
// snapshot ts, which only advances on a real #806 refresh) before we confirm.
|
|
1926
|
+
// A stale/served-expired cycle keeps the same ts, and an errored item flips
|
|
1927
|
+
// complete=false — so neither can confirm on its own. Consumers (server
|
|
1928
|
+
// auto-stop) gate stopTrigger/bridge-stop on the confirmed signal, never a
|
|
1929
|
+
// single transient `complete`.
|
|
1930
|
+
const _batchCompleteState = new Map(); // projectId -> { batchKey, ts, confirmed }
|
|
1931
|
+
// #828 P2: confirmation is BATCH-scoped, not just project-scoped. `batchKey`
|
|
1932
|
+
// identifies the batch (its number + issue set); when it changes, the streak
|
|
1933
|
+
// resets so a brand-new already-complete batch can never inherit the prior
|
|
1934
|
+
// batch's first cycle and confirm in a single tick (premature auto-stop). A new
|
|
1935
|
+
// batch must earn its own two distinct successful cycles.
|
|
1936
|
+
function evalBatchCompleteConfirmed(projectId, batchKey, complete, generatedAt) {
|
|
1937
|
+
let st = _batchCompleteState.get(projectId);
|
|
1938
|
+
if (!st || st.batchKey !== batchKey) {
|
|
1939
|
+
st = { batchKey, ts: null, confirmed: false };
|
|
1940
|
+
_batchCompleteState.set(projectId, st);
|
|
1941
|
+
}
|
|
1942
|
+
if (!complete) { st.ts = null; st.confirmed = false; return false; }
|
|
1943
|
+
if (generatedAt == null) return st.confirmed; // inconclusive cycle — no advance
|
|
1944
|
+
if (st.ts == null) { st.ts = generatedAt; st.confirmed = false; return false; }
|
|
1945
|
+
if (generatedAt !== st.ts) { st.ts = generatedAt; st.confirmed = true; return true; }
|
|
1946
|
+
return st.confirmed; // same snapshot re-served — no new cycle
|
|
1228
1947
|
}
|
|
1229
1948
|
|
|
1230
1949
|
// ─── /api/github/all — batched endpoint (#703) ────────────────────────────
|
|
@@ -1238,7 +1957,7 @@ router.get("/api/github/all", async (req, res) => {
|
|
|
1238
1957
|
const anyStale = (() => {
|
|
1239
1958
|
let cfg;
|
|
1240
1959
|
try { cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")); } catch { return true; }
|
|
1241
|
-
const projects = (cfg.projects || []).filter((p) => p.repo && REPO_RE.test(p.repo));
|
|
1960
|
+
const projects = (cfg.projects || []).filter((p) => p.repo && REPO_RE.test(p.repo) && !p.idle); // #812: skip idle (parked) projects
|
|
1242
1961
|
for (const p of projects) {
|
|
1243
1962
|
const cached = _graphqlCache.get(p.repo);
|
|
1244
1963
|
if (!cached || Date.now() - cached.ts > adaptiveTTL(GRAPHQL_CACHE_TTL)) return true;
|
|
@@ -1250,13 +1969,21 @@ router.get("/api/github/all", async (req, res) => {
|
|
|
1250
1969
|
// Build response.
|
|
1251
1970
|
let cfg;
|
|
1252
1971
|
try { cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")); } catch { return res.status(500).json({ error: "Config unreadable" }); }
|
|
1253
|
-
const projects = (cfg.projects || []).filter((p) => p.repo && REPO_RE.test(p.repo));
|
|
1972
|
+
const projects = (cfg.projects || []).filter((p) => p.repo && REPO_RE.test(p.repo)); // #812: include idle projects — served stale below, never fetched
|
|
1254
1973
|
|
|
1255
1974
|
const result = {};
|
|
1256
1975
|
const fallbackNeeded = [];
|
|
1257
1976
|
for (const p of projects) {
|
|
1258
1977
|
if (projectFilter && p.id !== projectFilter) continue;
|
|
1259
1978
|
const cached = _graphqlCache.get(p.repo);
|
|
1979
|
+
// #812: idle (parked) project — serve last-known data flagged _idle,
|
|
1980
|
+
// never trigger a fetch or fall back to gh.
|
|
1981
|
+
if (p.idle) {
|
|
1982
|
+
result[p.id] = cached
|
|
1983
|
+
? { issues: cached.issues, prs: cached.prs, closedIssues: cached.closedIssues, mergedPrs: cached.mergedPrs, _idle: true }
|
|
1984
|
+
: { issues: [], prs: [], closedIssues: [], mergedPrs: [], _idle: true };
|
|
1985
|
+
continue;
|
|
1986
|
+
}
|
|
1260
1987
|
if (cached) {
|
|
1261
1988
|
result[p.id] = {
|
|
1262
1989
|
issues: cached.issues,
|
|
@@ -1270,41 +1997,22 @@ router.get("/api/github/all", async (req, res) => {
|
|
|
1270
1997
|
}
|
|
1271
1998
|
}
|
|
1272
1999
|
|
|
1273
|
-
//
|
|
2000
|
+
// #806: fallback for still-missing repos goes through the REST+ETag snapshot
|
|
2001
|
+
// (refreshRepoRest), NOT `gh pr list`/`gh issue list`. Concurrency-limited so
|
|
2002
|
+
// a cold multi-project load doesn't drain the core budget.
|
|
1274
2003
|
if (fallbackNeeded.length > 0 && !isRateLimited()) {
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
_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 }) => {
|
|
1288
|
-
const items = JSON.parse(stdout);
|
|
1289
|
-
return Array.isArray(items)
|
|
1290
|
-
? items.sort((a, b) => (Date.parse(b?.mergedAt || 0)) - (Date.parse(a?.mergedAt || 0))).slice(0, RECENT_DISPLAY_LIMIT)
|
|
1291
|
-
: items;
|
|
1292
|
-
}),
|
|
1293
|
-
]);
|
|
1294
|
-
return {
|
|
1295
|
-
id: p.id,
|
|
1296
|
-
issues: issues.status === "fulfilled" ? issues.value : [],
|
|
1297
|
-
prs: prs.status === "fulfilled" ? prs.value : [],
|
|
1298
|
-
closedIssues: closedIssues.status === "fulfilled" ? closedIssues.value : [],
|
|
1299
|
-
mergedPrs: mergedPrs.status === "fulfilled" ? mergedPrs.value : [],
|
|
1300
|
-
_fallback: true,
|
|
1301
|
-
};
|
|
1302
|
-
}),
|
|
1303
|
-
);
|
|
1304
|
-
for (const r of fallbackResults) {
|
|
1305
|
-
if (r.status === "fulfilled") {
|
|
1306
|
-
result[r.value.id] = r.value;
|
|
1307
|
-
}
|
|
2004
|
+
await _mapLimited(fallbackNeeded, GH_MAX_CONCURRENT, (p) => refreshRepoRest(p.repo));
|
|
2005
|
+
for (const p of fallbackNeeded) {
|
|
2006
|
+
const cached = _graphqlCache.get(p.repo);
|
|
2007
|
+
result[p.id] = cached
|
|
2008
|
+
? {
|
|
2009
|
+
issues: cached.issues,
|
|
2010
|
+
prs: cached.prs,
|
|
2011
|
+
closedIssues: cached.closedIssues,
|
|
2012
|
+
mergedPrs: cached.mergedPrs,
|
|
2013
|
+
_fallback: true,
|
|
2014
|
+
}
|
|
2015
|
+
: { issues: [], prs: [], closedIssues: [], mergedPrs: [], _fallback: true };
|
|
1308
2016
|
}
|
|
1309
2017
|
}
|
|
1310
2018
|
|
|
@@ -1312,86 +2020,71 @@ router.get("/api/github/all", async (req, res) => {
|
|
|
1312
2020
|
});
|
|
1313
2021
|
|
|
1314
2022
|
// ─── Per-project endpoints (backward compat, served from shared cache) ────
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
2023
|
+
//
|
|
2024
|
+
// #806: all four lists are slices of the single REST+ETag snapshot built by
|
|
2025
|
+
// githubStateFetcher (closed/merged already sorted newest-first and capped to
|
|
2026
|
+
// RECENT_DISPLAY_LIMIT there). On a warm cache we serve instantly; cold misses
|
|
2027
|
+
// fetch the whole snapshot once via refreshRepoRest — no `gh pr list`/`gh issue
|
|
2028
|
+
// list` (those are GraphQL-backed). Idle projects never fetch.
|
|
2029
|
+
async function serveGithubList(req, res, kind) {
|
|
2030
|
+
const project = req.query.project || "";
|
|
2031
|
+
const repo = getRepo(project);
|
|
1318
2032
|
if (!repo) return res.status(400).json({ error: "No repo configured for project" });
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
["issue", "list", "-R", repo, "--json", "number,title,state,assignees,labels,createdAt,url", "--limit", "50"],
|
|
1322
|
-
res,
|
|
1323
|
-
);
|
|
1324
|
-
});
|
|
2033
|
+
const cacheKey = `${kind}:${repo}`;
|
|
2034
|
+
const cached = _ghEndpointCache.get(cacheKey);
|
|
1325
2035
|
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
["pr", "list", "-R", repo, "--json", "number,title,state,author,assignees,reviewDecision,reviews,statusCheckRollup,url,createdAt", "--limit", "50"],
|
|
1332
|
-
res,
|
|
1333
|
-
);
|
|
1334
|
-
});
|
|
2036
|
+
// #812: a parked (idle) project must never initiate a fetch.
|
|
2037
|
+
if (isProjectIdle(project)) {
|
|
2038
|
+
res.set("X-QuadWork-Idle", "1");
|
|
2039
|
+
return res.json(cached ? cached.data : []);
|
|
2040
|
+
}
|
|
1335
2041
|
|
|
1336
|
-
// #
|
|
1337
|
-
//
|
|
1338
|
-
//
|
|
1339
|
-
//
|
|
1340
|
-
//
|
|
1341
|
-
//
|
|
1342
|
-
//
|
|
1343
|
-
//
|
|
1344
|
-
//
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
}
|
|
2042
|
+
// #824: these four endpoints serve a BARE ARRAY (cached.data is an array).
|
|
2043
|
+
// Stale/rate-limited state must be signalled via a response header — mirroring
|
|
2044
|
+
// the X-QuadWork-Idle pattern above — never by spreading the array into an
|
|
2045
|
+
// object (which yields {"0":…,"_stale":true} and trips GitHubPanel's
|
|
2046
|
+
// Array.isArray guard, blanking the board exactly when it should show
|
|
2047
|
+
// last-known data).
|
|
2048
|
+
//
|
|
2049
|
+
// Freshness is measured against the BASE TTL, not adaptiveTTL: under critical
|
|
2050
|
+
// rate-limit adaptiveTTL is Infinity, so an age<ttl check would treat an
|
|
2051
|
+
// arbitrarily-old cache as fresh and skip the staleness signal entirely.
|
|
2052
|
+
const baseFresh = cached && Date.now() - cached.ts < GH_ENDPOINT_CACHE_TTL;
|
|
2053
|
+
|
|
2054
|
+
// Critically REST-rate-limited → serve whatever we have (even expired) without
|
|
2055
|
+
// revalidating. MUST precede the cache-hit branch below: adaptiveTTL is
|
|
2056
|
+
// Infinity here, so that branch would otherwise return first and an expired
|
|
2057
|
+
// array would go out with no X-QuadWork-Stale / X-QuadWork-Rate-Limited.
|
|
2058
|
+
if (isRateLimited() && cached) {
|
|
2059
|
+
res.set("X-QuadWork-Rate-Limited", "1");
|
|
2060
|
+
if (!baseFresh || cached.stale) res.set("X-QuadWork-Stale", "1");
|
|
2061
|
+
return res.json(cached.data);
|
|
2062
|
+
}
|
|
2063
|
+
// Fresh enough (within the adaptive window) → serve as-is.
|
|
2064
|
+
if (cached && Date.now() - cached.ts < adaptiveTTL(GH_ENDPOINT_CACHE_TTL)) {
|
|
2065
|
+
if (cached.stale) res.set("X-QuadWork-Stale", "1");
|
|
2066
|
+
return res.json(cached.data);
|
|
2067
|
+
}
|
|
2068
|
+
// Stale-while-revalidate: serve stale now, refresh the snapshot in background.
|
|
2069
|
+
if (cached) {
|
|
2070
|
+
refreshRepoRest(repo);
|
|
2071
|
+
res.set("X-QuadWork-Stale", "1");
|
|
2072
|
+
return res.json(cached.data);
|
|
2073
|
+
}
|
|
2074
|
+
// Cold: assemble the snapshot synchronously, then serve this slice.
|
|
2075
|
+
await refreshRepoRest(repo);
|
|
2076
|
+
const fresh = _ghEndpointCache.get(cacheKey);
|
|
2077
|
+
if (fresh) return res.json(fresh.data);
|
|
2078
|
+
return res.status(502).json({ error: "gh call failed" });
|
|
2079
|
+
}
|
|
1368
2080
|
|
|
1369
|
-
router.get("/api/github/
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
cachedGhEndpoint(
|
|
1377
|
-
`merged-prs:${repo}`,
|
|
1378
|
-
["pr", "list", "-R", repo, "--state", "merged", "--json", "number,title,state,url,mergedAt,author", "--limit", String(RECENT_FETCH_LIMIT)],
|
|
1379
|
-
res,
|
|
1380
|
-
{
|
|
1381
|
-
transform: (items) =>
|
|
1382
|
-
Array.isArray(items)
|
|
1383
|
-
? items
|
|
1384
|
-
.slice()
|
|
1385
|
-
.sort((a, b) => {
|
|
1386
|
-
const ta = a && a.mergedAt ? Date.parse(a.mergedAt) : 0;
|
|
1387
|
-
const tb = b && b.mergedAt ? Date.parse(b.mergedAt) : 0;
|
|
1388
|
-
return tb - ta;
|
|
1389
|
-
})
|
|
1390
|
-
.slice(0, RECENT_DISPLAY_LIMIT)
|
|
1391
|
-
: items,
|
|
1392
|
-
},
|
|
1393
|
-
);
|
|
1394
|
-
});
|
|
2081
|
+
router.get("/api/github/issues", (req, res) => serveGithubList(req, res, "issues"));
|
|
2082
|
+
router.get("/api/github/prs", (req, res) => serveGithubList(req, res, "prs"));
|
|
2083
|
+
// #411 / quadwork#281: "Recently closed" / "Recently merged" — newest-first,
|
|
2084
|
+
// capped to 5 in githubStateFetcher (REST default ordering isn't close/merge
|
|
2085
|
+
// time, so the snapshot re-sorts by closedAt/mergedAt before truncating).
|
|
2086
|
+
router.get("/api/github/closed-issues", (req, res) => serveGithubList(req, res, "closed-issues"));
|
|
2087
|
+
router.get("/api/github/merged-prs", (req, res) => serveGithubList(req, res, "merged-prs"));
|
|
1395
2088
|
|
|
1396
2089
|
// #413 / quadwork#282: Current Batch Progress panel.
|
|
1397
2090
|
//
|
|
@@ -1466,15 +2159,10 @@ async function checkBatchSnapshotFreshness(repo, snapshot) {
|
|
|
1466
2159
|
}
|
|
1467
2160
|
const first = snapshot.issueNumbers[0];
|
|
1468
2161
|
try {
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
"-R",
|
|
1474
|
-
repo,
|
|
1475
|
-
"--json",
|
|
1476
|
-
"number",
|
|
1477
|
-
]);
|
|
2162
|
+
// #828 P1: REST, not `gh issue view` (GraphQL). We only need existence, so
|
|
2163
|
+
// the cheap REST issue endpoint suffices and keeps the whole batch-progress
|
|
2164
|
+
// path off GraphQL.
|
|
2165
|
+
await ghJsonExecAsync(["api", `repos/${repo}/issues/${first}`, "--jq", ".number"]);
|
|
1478
2166
|
return "fresh";
|
|
1479
2167
|
} catch (err) {
|
|
1480
2168
|
// gh surfaces a 404 via stderr text on a non-zero exit. Only
|
|
@@ -1482,7 +2170,7 @@ async function checkBatchSnapshotFreshness(repo, snapshot) {
|
|
|
1482
2170
|
// count as genuinely gone; anything else (network, auth,
|
|
1483
2171
|
// timeout) is transient and must NOT delete the snapshot.
|
|
1484
2172
|
const msg = String((err && (err.stderr || err.message)) || "").toLowerCase();
|
|
1485
|
-
if (msg.includes("could not resolve") || msg.includes("not found") || msg.includes("
|
|
2173
|
+
if (msg.includes("could not resolve") || msg.includes("not found") || msg.includes("http 404")) {
|
|
1486
2174
|
return "gone";
|
|
1487
2175
|
}
|
|
1488
2176
|
return "unknown";
|
|
@@ -1586,22 +2274,22 @@ function parseActiveBatch(queueText) {
|
|
|
1586
2274
|
// progress fetcher. Wraps node's execFile in a promise.
|
|
1587
2275
|
//
|
|
1588
2276
|
// THROWS on subprocess failure (non-zero exit, timeout, JSON parse,
|
|
1589
|
-
// network) so
|
|
2277
|
+
// network) so progressForItemRest can decide which subset of
|
|
1590
2278
|
// failures should bubble up to the Promise.allSettled "fetch failed"
|
|
1591
2279
|
// row vs. which should fall through to a softer state. The previous
|
|
1592
2280
|
// catch-all-and-return-null contract collapsed real subprocess
|
|
1593
2281
|
// errors into the "not found" branch, making the new failure-row
|
|
1594
2282
|
// fallback unreachable for genuine command failures (t2a review).
|
|
1595
2283
|
async function ghJsonExecAsync(args) {
|
|
1596
|
-
const { stdout } = await _execFileAsync("gh", args, { encoding: "utf-8", timeout: 10000 });
|
|
2284
|
+
const { stdout } = await _execFileAsync("gh", args, { encoding: "utf-8", timeout: 10000, maxBuffer: GH_LIST_MAX_BUFFER });
|
|
1597
2285
|
return JSON.parse(stdout);
|
|
1598
2286
|
}
|
|
1599
2287
|
|
|
1600
2288
|
// #350: pure helper for the "no linked PR" branch of
|
|
1601
|
-
//
|
|
2289
|
+
// progressForItemRest. Takes the issue JSON (shape: { number,
|
|
1602
2290
|
// title, state, url, ... }) and returns the batch-progress row
|
|
1603
|
-
// for an item that has no
|
|
1604
|
-
//
|
|
2291
|
+
// for an item that has no linked PR. Exported from module.exports
|
|
2292
|
+
// below for unit tests — no other callers.
|
|
1605
2293
|
function buildNoPrRow(issue) {
|
|
1606
2294
|
if (issue && issue.state === "CLOSED") {
|
|
1607
2295
|
return {
|
|
@@ -1623,61 +2311,79 @@ function buildNoPrRow(issue) {
|
|
|
1623
2311
|
};
|
|
1624
2312
|
}
|
|
1625
2313
|
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
const
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
2314
|
+
// #828 P1: find the PR linked to issue `n` by the team's `[#N]` PR-title
|
|
2315
|
+
// convention, via the REST Search API (1 search point) — NOT GraphQL. gh's
|
|
2316
|
+
// `-X GET -f q=…` URL-encodes the query, so we never hand-concatenate the repo
|
|
2317
|
+
// or number into the URL. Search tokenises loosely (and `is:pr in:title N`
|
|
2318
|
+
// would also surface `[#807]` for n=80), so we re-apply the strict `^\s*\[#N\]`
|
|
2319
|
+
// regex the snapshot matcher uses. Returns the freshest matching PR number (or
|
|
2320
|
+
// null), treating a search failure as "no linked PR found".
|
|
2321
|
+
// Pure: pick the freshest PR (highest number) whose title matches the STRICT
|
|
2322
|
+
// `^\s*\[#N\]` convention from a set of REST search items. Guards `[#807]` from
|
|
2323
|
+
// matching n=80 even though Search's loose `in:title 80` would surface it.
|
|
2324
|
+
// Returns the PR number or null. Exported for unit tests.
|
|
2325
|
+
function pickLinkedPrFromSearch(items, n) {
|
|
2326
|
+
const titleRe = new RegExp(`^\\s*\\[#${n}\\]`);
|
|
2327
|
+
const matches = (Array.isArray(items) ? items : []).filter((it) => titleRe.test((it && it.title) || ""));
|
|
2328
|
+
if (matches.length === 0) return null;
|
|
2329
|
+
return matches.slice().sort((a, b) => (b.number || 0) - (a.number || 0))[0].number;
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
// The actual REST Search call, split out so findLinkedPrByTitle's
|
|
2333
|
+
// success-vs-failure distinction is unit-testable via an injected executor.
|
|
2334
|
+
// `gh api -X GET -f q=…` URL-encodes the query (we never hand-concatenate repo
|
|
2335
|
+
// or number into the URL). THROWS (propagates) on any Search failure.
|
|
2336
|
+
async function _searchLinkedPrItems(repo, n) {
|
|
2337
|
+
const res = await ghJsonExecAsync(["api", "-X", "GET", "search/issues", "-f", `q=repo:${repo} is:pr in:title ${n}`]);
|
|
2338
|
+
return Array.isArray(res && res.items) ? res.items : [];
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
// Find the PR linked to issue `n` by the strict `[#N]` title convention via the
|
|
2342
|
+
// REST Search API (1 search point) — NOT GraphQL. Returns the freshest matching
|
|
2343
|
+
// PR number, or null ONLY when Search SUCCEEDED with zero strict matches
|
|
2344
|
+
// (authoritative "no linked PR"). #828/RE1: a Search FAILURE (rate-limit /
|
|
2345
|
+
// network / parse) is NOT proof of no PR — it must NOT collapse to null, or an
|
|
2346
|
+
// out-of-window OPEN issue with a real PR would render queued (and a CLOSED one
|
|
2347
|
+
// with a merged PR would render closed). So failure THROWS, and the caller
|
|
2348
|
+
// (progressForItemRest, awaiting without a catch) drops the item to the
|
|
2349
|
+
// non-authoritative "fetch failed" row, retried on the next cache miss.
|
|
2350
|
+
async function findLinkedPrByTitle(repo, n, search = _searchLinkedPrItems) {
|
|
2351
|
+
const items = await search(repo, n);
|
|
2352
|
+
return pickLinkedPrFromSearch(items, n);
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
// Authoritative by-number resolution for an active-batch issue the board
|
|
2356
|
+
// snapshot can't prove (outside / truncated window, or absent). #828 P1: REST +
|
|
2357
|
+
// Search ONLY — zero `gh issue view` / `gh pr view` (GraphQL). Bucket mapping
|
|
2358
|
+
// matches progressFromSnapshot exactly.
|
|
2359
|
+
async function progressForItemRest(repo, issueNumber) {
|
|
2360
|
+
// Load-bearing call: the issue's own state via REST. If gh can't read it at
|
|
2361
|
+
// all (404, network, auth, timeout) we can't compute a meaningful row — let
|
|
2362
|
+
// the rejection propagate to the route's Promise.allSettled "fetch failed"
|
|
2363
|
+
// row instead of emitting a misleading "queued" entry.
|
|
2364
|
+
const raw = await ghJsonExecAsync(["api", `repos/${repo}/issues/${issueNumber}`]);
|
|
2365
|
+
const issue = {
|
|
2366
|
+
number: raw.number,
|
|
2367
|
+
title: raw.title,
|
|
2368
|
+
state: (raw.state || "").toUpperCase(),
|
|
2369
|
+
url: raw.html_url,
|
|
2370
|
+
};
|
|
2371
|
+
// Throws (→ route's "fetch failed" row) if Search itself fails, so a
|
|
2372
|
+
// could-not-determine is NEVER mistaken for proof of no linked PR. A null
|
|
2373
|
+
// here means Search SUCCEEDED with zero strict [#N] matches — authoritative.
|
|
2374
|
+
const prNumber = await findLinkedPrByTitle(repo, issueNumber);
|
|
2375
|
+
// No linked PR (authoritative). #350: honor the issue's own state — a CLOSED
|
|
2376
|
+
// issue with no linked PR is fully done (superseded/not-planned/runbook) →
|
|
2377
|
+
// 100% ✓; only a truly OPEN issue with no PR is queued.
|
|
2378
|
+
if (prNumber == null) {
|
|
1659
2379
|
return buildNoPrRow(issue);
|
|
1660
2380
|
}
|
|
1661
|
-
//
|
|
1662
|
-
//
|
|
1663
|
-
//
|
|
1664
|
-
// glitches on this single call we still know the PR exists (we
|
|
1665
|
-
// got the link from the issue) and can render a partial
|
|
1666
|
-
// "in_review" row, which is more useful than dropping the whole
|
|
1667
|
-
// item to "fetch failed". A persistent failure here will still
|
|
1668
|
-
// surface on the next cache miss because the issue fetch above
|
|
1669
|
-
// is the load-bearing one that controls the per-item rejection.
|
|
2381
|
+
// REST-fetch the PR for state + reviews. Soft: a glitch on these single calls
|
|
2382
|
+
// still lets us render a partial in_review row (we know the PR exists) rather
|
|
2383
|
+
// than dropping the whole item to "fetch failed".
|
|
1670
2384
|
let prData = null;
|
|
1671
2385
|
try {
|
|
1672
|
-
prData = await ghJsonExecAsync([
|
|
1673
|
-
"pr",
|
|
1674
|
-
"view",
|
|
1675
|
-
String(pr.number),
|
|
1676
|
-
"-R",
|
|
1677
|
-
repo,
|
|
1678
|
-
"--json",
|
|
1679
|
-
"number,state,url,reviewDecision,reviews",
|
|
1680
|
-
]);
|
|
2386
|
+
prData = await ghJsonExecAsync(["api", `repos/${repo}/pulls/${prNumber}`]);
|
|
1681
2387
|
} catch {
|
|
1682
2388
|
// soft fall-through to the in_review row below
|
|
1683
2389
|
}
|
|
@@ -1685,78 +2391,44 @@ async function progressForItemAsync(repo, issueNumber) {
|
|
|
1685
2391
|
return {
|
|
1686
2392
|
issue_number: issue.number,
|
|
1687
2393
|
title: issue.title,
|
|
1688
|
-
url:
|
|
1689
|
-
pr_number:
|
|
2394
|
+
url: issue.url,
|
|
2395
|
+
pr_number: prNumber,
|
|
1690
2396
|
status: "in_review",
|
|
1691
2397
|
progress: 20,
|
|
1692
|
-
label: `PR #${
|
|
2398
|
+
label: `PR #${prNumber} · waiting on review`,
|
|
1693
2399
|
};
|
|
1694
2400
|
}
|
|
1695
|
-
|
|
1696
|
-
|
|
2401
|
+
let reviews = [];
|
|
2402
|
+
try {
|
|
2403
|
+
const rv = await ghJsonExecAsync(["api", `repos/${repo}/pulls/${prNumber}/reviews?per_page=100`]);
|
|
2404
|
+
reviews = mapReviews(Array.isArray(rv) ? rv : []);
|
|
2405
|
+
} catch {
|
|
2406
|
+
// soft — reviews stay [], so the row degrades to in_review, never "failed"
|
|
2407
|
+
}
|
|
2408
|
+
const url = prData.html_url || issue.url;
|
|
2409
|
+
// REST: a merged PR is state "closed" with `merged: true`. merged requires
|
|
2410
|
+
// the issue CLOSED too (mirrors progressFromSnapshot and the prior path).
|
|
2411
|
+
if (prData.merged === true && issue.state === "CLOSED") {
|
|
1697
2412
|
return {
|
|
1698
2413
|
issue_number: issue.number,
|
|
1699
2414
|
title: issue.title,
|
|
1700
|
-
url
|
|
2415
|
+
url,
|
|
1701
2416
|
pr_number: prData.number,
|
|
1702
2417
|
status: "merged",
|
|
1703
2418
|
progress: 100,
|
|
1704
2419
|
label: "Merged ✓",
|
|
1705
2420
|
};
|
|
1706
2421
|
}
|
|
1707
|
-
//
|
|
1708
|
-
//
|
|
1709
|
-
|
|
1710
|
-
// genuinely lands on the freshest review per author — gh's
|
|
1711
|
-
// current ordering is chronological in practice but undocumented,
|
|
1712
|
-
// so the explicit sort keeps us safe if that ever changes.
|
|
1713
|
-
const reviews = Array.isArray(prData.reviews) ? prData.reviews.slice() : [];
|
|
1714
|
-
reviews.sort((a, b) => {
|
|
1715
|
-
const ta = (a && a.submittedAt) ? Date.parse(a.submittedAt) : 0;
|
|
1716
|
-
const tb = (b && b.submittedAt) ? Date.parse(b.submittedAt) : 0;
|
|
1717
|
-
return ta - tb;
|
|
1718
|
-
});
|
|
1719
|
-
const latestByAuthor = new Map();
|
|
1720
|
-
for (const r of reviews) {
|
|
1721
|
-
const author = (r && r.author && r.author.login) || "";
|
|
1722
|
-
if (!author) continue;
|
|
1723
|
-
latestByAuthor.set(author, r.state);
|
|
1724
|
-
}
|
|
1725
|
-
let approvalCount = 0;
|
|
1726
|
-
for (const state of latestByAuthor.values()) {
|
|
1727
|
-
if (state === "APPROVED") approvalCount++;
|
|
1728
|
-
}
|
|
2422
|
+
// #810: count APPROVED reviews per body-marker ROLE (re1/re2 share one login),
|
|
2423
|
+
// latest decision-affecting review per role — no stale double-count.
|
|
2424
|
+
const approvalCount = countApprovedRoles(reviews);
|
|
1729
2425
|
if (approvalCount >= 2) {
|
|
1730
|
-
return {
|
|
1731
|
-
issue_number: issue.number,
|
|
1732
|
-
title: issue.title,
|
|
1733
|
-
url: prData.url || issue.url,
|
|
1734
|
-
pr_number: prData.number,
|
|
1735
|
-
status: "ready",
|
|
1736
|
-
progress: 80,
|
|
1737
|
-
label: `PR #${prData.number} · 2 approvals · ready`,
|
|
1738
|
-
};
|
|
2426
|
+
return { issue_number: issue.number, title: issue.title, url, pr_number: prData.number, status: "ready", progress: 80, label: `PR #${prData.number} · 2 approvals · ready` };
|
|
1739
2427
|
}
|
|
1740
2428
|
if (approvalCount === 1) {
|
|
1741
|
-
return {
|
|
1742
|
-
issue_number: issue.number,
|
|
1743
|
-
title: issue.title,
|
|
1744
|
-
url: prData.url || issue.url,
|
|
1745
|
-
pr_number: prData.number,
|
|
1746
|
-
status: "approved1",
|
|
1747
|
-
progress: 50,
|
|
1748
|
-
label: `PR #${prData.number} · 1 approval`,
|
|
1749
|
-
};
|
|
2429
|
+
return { issue_number: issue.number, title: issue.title, url, pr_number: prData.number, status: "approved1", progress: 50, label: `PR #${prData.number} · 1 approval` };
|
|
1750
2430
|
}
|
|
1751
|
-
return {
|
|
1752
|
-
issue_number: issue.number,
|
|
1753
|
-
title: issue.title,
|
|
1754
|
-
url: prData.url || issue.url,
|
|
1755
|
-
pr_number: prData.number,
|
|
1756
|
-
status: "in_review",
|
|
1757
|
-
progress: 20,
|
|
1758
|
-
label: `PR #${prData.number} · waiting on review`,
|
|
1759
|
-
};
|
|
2431
|
+
return { issue_number: issue.number, title: issue.title, url, pr_number: prData.number, status: "in_review", progress: 20, label: `PR #${prData.number} · waiting on review` };
|
|
1760
2432
|
}
|
|
1761
2433
|
|
|
1762
2434
|
function summarizeItems(items) {
|
|
@@ -1784,37 +2456,104 @@ function summarizeItems(items) {
|
|
|
1784
2456
|
return parts.join(" · ");
|
|
1785
2457
|
}
|
|
1786
2458
|
|
|
1787
|
-
|
|
2459
|
+
// #839: derive the sidebar heartbeat-active flag from a batch-progress payload
|
|
2460
|
+
// so the pulse means "batch actively in progress", not just "Active Batch
|
|
2461
|
+
// section non-empty". Once `complete` is true (every item merged/closed —
|
|
2462
|
+
// see getOrComputeBatchProgress at items.every(...merged||closed)), the pulse
|
|
2463
|
+
// must stop even if Head leaves a parked/blocked ticket lingering in the
|
|
2464
|
+
// Active Batch section. Returns null when there's no progress payload (an
|
|
2465
|
+
// undefined input — current callers always pass a value), defensively
|
|
2466
|
+
// treated as "not active" at the route.
|
|
2467
|
+
function isBatchActiveFromProgress(progress) {
|
|
2468
|
+
if (!progress) return null;
|
|
2469
|
+
const items = Array.isArray(progress.items) ? progress.items : [];
|
|
2470
|
+
return items.length > 0 && !progress.complete;
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
router.get("/api/batch-active", async (req, res) => {
|
|
1788
2474
|
const projectId = req.query.project;
|
|
1789
2475
|
if (!projectId) return res.status(400).json({ error: "Missing project" });
|
|
1790
2476
|
if (!getRepo(projectId)) return res.status(400).json({ error: "No repo configured for project" });
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
2477
|
+
|
|
2478
|
+
// #839 (re1 follow-up on #844): share the same compute path that
|
|
2479
|
+
// /api/batch-progress uses so the completion-aware answer is available on
|
|
2480
|
+
// cache miss too, not only after the panel has been opened once. The
|
|
2481
|
+
// sidebar's 30s poll on all projects stays cheap because (a) the shared
|
|
2482
|
+
// BATCH_PROGRESS_TTL_MS cache absorbs most hits, (b) idle projects skip the
|
|
2483
|
+
// compute entirely, and (c) progressFromSnapshot resolves items from the
|
|
2484
|
+
// already-polled #806 GraphQL snapshot — REST fallback only fires for
|
|
2485
|
+
// items outside the recent window.
|
|
2486
|
+
const data = await getOrComputeBatchProgress(projectId);
|
|
2487
|
+
const active = isBatchActiveFromProgress(data);
|
|
2488
|
+
return res.json({ active: active === null ? false : active });
|
|
1799
2489
|
});
|
|
1800
2490
|
|
|
1801
|
-
|
|
2491
|
+
// #807: parsed view of the server-authored GITHUB.md. Single source of truth is
|
|
2492
|
+
// the file, so this endpoint and file-reading agents see identical data. Same
|
|
2493
|
+
// project/config guard as batch-active (a traversal id isn't in config →
|
|
2494
|
+
// getRepo null → 400). Surfaces freshness + a distinct parse-error result.
|
|
2495
|
+
router.get("/api/github-parsed", (req, res) => {
|
|
1802
2496
|
const projectId = req.query.project;
|
|
1803
2497
|
if (!projectId) return res.status(400).json({ error: "Missing project" });
|
|
2498
|
+
if (!getRepo(projectId)) return res.status(400).json({ error: "No repo configured for project" });
|
|
2499
|
+
const ghPath = path.join(CONFIG_DIR, projectId, "GITHUB.md");
|
|
2500
|
+
let text;
|
|
2501
|
+
try {
|
|
2502
|
+
text = fs.readFileSync(ghPath, "utf-8");
|
|
2503
|
+
} catch {
|
|
2504
|
+
return res.json({ ok: false, error: "GITHUB.md not found" });
|
|
2505
|
+
}
|
|
2506
|
+
const parsed = parseGithub(text);
|
|
2507
|
+
if (!parsed.ok) return res.json(parsed);
|
|
2508
|
+
// Live staleness: even when the file isn't regenerated (e.g. rate-limited →
|
|
2509
|
+
// no new sync pass), age must stay visible. Compare against a FIXED window,
|
|
2510
|
+
// never adaptiveTTL (which is Infinity under critical rate-limit and would
|
|
2511
|
+
// hide unbounded staleness exactly when it matters).
|
|
2512
|
+
// #827: a malformed `generatedAt` → Date.parse NaN. Normalize to null so the
|
|
2513
|
+
// response field is a clean "unknown age" (not NaN), and _githubLiveStale
|
|
2514
|
+
// also treats NaN/null as stale rather than hiding staleness on a suspect file.
|
|
2515
|
+
const rawAge = parsed.generatedAt ? Date.now() - Date.parse(parsed.generatedAt) : null;
|
|
2516
|
+
const ageMs = Number.isNaN(rawAge) ? null : rawAge;
|
|
2517
|
+
return res.json({
|
|
2518
|
+
...parsed,
|
|
2519
|
+
ageMs,
|
|
2520
|
+
_stale: _githubLiveStale(parsed.stale, ageMs),
|
|
2521
|
+
});
|
|
2522
|
+
});
|
|
1804
2523
|
|
|
2524
|
+
// #839 (re1 follow-up on #844): shared compute path for /api/batch-progress
|
|
2525
|
+
// and /api/batch-active. Returns the JSON body either route would send for a
|
|
2526
|
+
// successful response, or null to signal "no repo configured for project"
|
|
2527
|
+
// (caller's 400). Hits/populates _batchProgressCache so concurrent panel +
|
|
2528
|
+
// sidebar polls share the work via the BATCH_PROGRESS_TTL_MS cache.
|
|
2529
|
+
async function getOrComputeBatchProgress(projectId) {
|
|
1805
2530
|
const cached = _batchProgressCache.get(projectId);
|
|
2531
|
+
// #812: parked (idle) project — never run batch-progress gh/GraphQL calls,
|
|
2532
|
+
// and ALWAYS flag the payload _idle (even on a fresh cache hit), so the
|
|
2533
|
+
// endpoint contract is consistent regardless of cache freshness. This must
|
|
2534
|
+
// precede the fresh-cache and rate-limit returns below.
|
|
2535
|
+
if (isProjectIdle(projectId)) {
|
|
2536
|
+
if (cached) return { ...cached.data, _idle: true };
|
|
2537
|
+
return { batch_number: null, items: [], summary: "", complete: false, completeConfirmed: false, _idle: true };
|
|
2538
|
+
}
|
|
1806
2539
|
const batchTTL = adaptiveTTL(BATCH_PROGRESS_TTL_MS);
|
|
1807
2540
|
if (cached && Date.now() - cached.ts < batchTTL) {
|
|
1808
|
-
return
|
|
2541
|
+
return cached.data;
|
|
1809
2542
|
}
|
|
1810
2543
|
// #554: if critically rate-limited, serve stale cache instead of
|
|
1811
2544
|
// firing N gh calls per batch item.
|
|
1812
2545
|
if (isRateLimited() && cached) {
|
|
1813
|
-
return
|
|
2546
|
+
return { ...cached.data, _stale: true, _rateLimited: true };
|
|
2547
|
+
}
|
|
2548
|
+
// #802: GraphQL budget exhausted (separate from REST) — prefer serving the
|
|
2549
|
+
// existing cache (even stale) over the GraphQL query below. With no cache we
|
|
2550
|
+
// fall through to the REST gh-CLI fallback path (gated on its own bucket).
|
|
2551
|
+
if (isGraphqlRateLimited() && cached) {
|
|
2552
|
+
return { ...cached.data, _stale: true, _rateLimited: true };
|
|
1814
2553
|
}
|
|
1815
2554
|
|
|
1816
2555
|
const repo = getRepo(projectId);
|
|
1817
|
-
if (!repo) return
|
|
2556
|
+
if (!repo) return null;
|
|
1818
2557
|
|
|
1819
2558
|
const queuePath = path.join(CONFIG_DIR, projectId, "OVERNIGHT-QUEUE.md");
|
|
1820
2559
|
let queueText = "";
|
|
@@ -1853,55 +2592,56 @@ router.get("/api/batch-progress", async (req, res) => {
|
|
|
1853
2592
|
// snapshot-aware helper so merged items stay visible after Head
|
|
1854
2593
|
// moves them from Active Batch to Done, until a new batch starts.
|
|
1855
2594
|
const { batchNumber, issueNumbers } = resolveDisplayedBatch(queueText, projectId, { queueReadOk });
|
|
2595
|
+
// #828 P2: batch identity = number + issue set. evalBatchCompleteConfirmed
|
|
2596
|
+
// resets its streak when this changes, so a new already-complete batch can't
|
|
2597
|
+
// inherit the prior batch's first cycle and confirm in one tick.
|
|
2598
|
+
const batchKey = `${batchNumber}::${[...issueNumbers].sort((a, b) => a - b).join(",")}`;
|
|
1856
2599
|
if (issueNumbers.length === 0) {
|
|
1857
|
-
|
|
2600
|
+
evalBatchCompleteConfirmed(projectId, batchKey, false, null); // reset any complete streak
|
|
2601
|
+
const data = { batch_number: batchNumber, items: [], summary: "", complete: false, completeConfirmed: false };
|
|
1858
2602
|
_batchProgressCache.set(projectId, { ts: Date.now(), data });
|
|
1859
|
-
return
|
|
1860
|
-
}
|
|
1861
|
-
|
|
1862
|
-
// #703: Try batched GraphQL first — one query for all batch items.
|
|
1863
|
-
// Falls back to individual gh CLI calls (the #416 parallel approach)
|
|
1864
|
-
// if GraphQL fails.
|
|
1865
|
-
let items;
|
|
1866
|
-
const graphqlData = await fetchBatchProgressGraphQL(repo, issueNumbers);
|
|
1867
|
-
if (graphqlData) {
|
|
1868
|
-
items = issueNumbers.map((n) => {
|
|
1869
|
-
const issueNode = graphqlData[`issue${n}`];
|
|
1870
|
-
const row = issueNode ? graphqlIssueToProgressRow(issueNode) : null;
|
|
1871
|
-
return row || {
|
|
1872
|
-
issue_number: n,
|
|
1873
|
-
title: `#${n} (fetch failed)`,
|
|
1874
|
-
url: null,
|
|
1875
|
-
status: "unknown",
|
|
1876
|
-
progress: 0,
|
|
1877
|
-
label: "fetch failed",
|
|
1878
|
-
};
|
|
1879
|
-
});
|
|
1880
|
-
} else {
|
|
1881
|
-
// Fallback: #416 parallel individual gh CLI calls.
|
|
1882
|
-
const settled = await Promise.allSettled(
|
|
1883
|
-
issueNumbers.map((n) => progressForItemAsync(repo, n)),
|
|
1884
|
-
);
|
|
1885
|
-
items = settled.map((r, i) => {
|
|
1886
|
-
if (r.status === "fulfilled") return r.value;
|
|
1887
|
-
return {
|
|
1888
|
-
issue_number: issueNumbers[i],
|
|
1889
|
-
title: `#${issueNumbers[i]} (fetch failed)`,
|
|
1890
|
-
url: null,
|
|
1891
|
-
status: "unknown",
|
|
1892
|
-
progress: 0,
|
|
1893
|
-
label: "fetch failed",
|
|
1894
|
-
};
|
|
1895
|
-
});
|
|
2603
|
+
return data;
|
|
1896
2604
|
}
|
|
2605
|
+
|
|
2606
|
+
// #810: compute each item from the #806 REST snapshot (no GraphQL in steady
|
|
2607
|
+
// state); #828 P1: fall back to a targeted by-number REST/Search fetch
|
|
2608
|
+
// (progressForItemRest, no GraphQL) only for active-batch issues the snapshot
|
|
2609
|
+
// can't prove (outside / truncated window, or absent).
|
|
2610
|
+
const snapshot = _graphqlCache.get(repo) || null;
|
|
2611
|
+
const settled = await Promise.allSettled(
|
|
2612
|
+
issueNumbers.map(async (n) => progressFromSnapshot(snapshot, n) || progressForItemRest(repo, n)),
|
|
2613
|
+
);
|
|
2614
|
+
const items = settled.map((r, i) => {
|
|
2615
|
+
if (r.status === "fulfilled") return r.value;
|
|
2616
|
+
return {
|
|
2617
|
+
issue_number: issueNumbers[i],
|
|
2618
|
+
title: `#${issueNumbers[i]} (fetch failed)`,
|
|
2619
|
+
url: null,
|
|
2620
|
+
status: "unknown",
|
|
2621
|
+
progress: 0,
|
|
2622
|
+
label: "fetch failed",
|
|
2623
|
+
};
|
|
2624
|
+
});
|
|
1897
2625
|
const summary = summarizeItems(items);
|
|
1898
2626
|
// #350: treat CLOSED-without-PR items as complete alongside merged
|
|
1899
2627
|
// so batches that mix runbook/superseded closes with real PRs
|
|
1900
2628
|
// still flip to the COMPLETE state once everything is done.
|
|
1901
2629
|
const complete = items.length > 0 && items.every((it) => it.status === "merged" || it.status === "closed");
|
|
1902
|
-
|
|
2630
|
+
// #810: confirm `complete` across two distinct successful snapshot cycles
|
|
2631
|
+
// before any auto-stop consumer may act on it (never auto-stop on a single
|
|
2632
|
+
// transient/stale complete). Keyed on the snapshot's ts as the cycle marker.
|
|
2633
|
+
const completeConfirmed = evalBatchCompleteConfirmed(projectId, batchKey, complete, snapshot ? snapshot.ts : null);
|
|
2634
|
+
const data = { batch_number: batchNumber, items, summary, complete, completeConfirmed };
|
|
1903
2635
|
_batchProgressCache.set(projectId, { ts: Date.now(), data });
|
|
1904
|
-
|
|
2636
|
+
return data;
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
router.get("/api/batch-progress", async (req, res) => {
|
|
2640
|
+
const projectId = req.query.project;
|
|
2641
|
+
if (!projectId) return res.status(400).json({ error: "Missing project" });
|
|
2642
|
+
const data = await getOrComputeBatchProgress(projectId);
|
|
2643
|
+
if (data === null) return res.status(400).json({ error: "No repo configured for project" });
|
|
2644
|
+
return res.json(data);
|
|
1905
2645
|
});
|
|
1906
2646
|
|
|
1907
2647
|
// #445: Memory section (agent-memory butler integration) removed.
|
|
@@ -2171,6 +2911,7 @@ router.post("/api/setup", (req, res) => {
|
|
|
2171
2911
|
// below no-ops when the file is already present, so this
|
|
2172
2912
|
// can't clobber Head/user edits.
|
|
2173
2913
|
writeOvernightQueueFileSafe(id, cfg.projects.find((p) => p.id === id)?.name || id, cfg.projects.find((p) => p.id === id)?.repo || "");
|
|
2914
|
+
writeGithubFileSafe(id, cfg.projects.find((p) => p.id === id)?.name || id, cfg.projects.find((p) => p.id === id)?.repo || "");
|
|
2174
2915
|
return res.json({ ok: true, message: "Project already in config" });
|
|
2175
2916
|
}
|
|
2176
2917
|
// Match CLI wizard agent structure: { cwd, command, auto_approve, mcp_inject }
|
|
@@ -2210,6 +2951,8 @@ router.post("/api/setup", (req, res) => {
|
|
|
2210
2951
|
// Batch 25 / #204: seed the per-project OVERNIGHT-QUEUE.md at
|
|
2211
2952
|
// ~/.quadwork/{id}/OVERNIGHT-QUEUE.md.
|
|
2212
2953
|
writeOvernightQueueFileSafe(id, name || id, repo);
|
|
2954
|
+
// #807: seed the per-project GITHUB.md alongside it.
|
|
2955
|
+
writeGithubFileSafe(id, name || id, repo);
|
|
2213
2956
|
|
|
2214
2957
|
return res.json({ ok: true });
|
|
2215
2958
|
}
|
|
@@ -2218,6 +2961,399 @@ router.post("/api/setup", (req, res) => {
|
|
|
2218
2961
|
}
|
|
2219
2962
|
});
|
|
2220
2963
|
|
|
2964
|
+
// ─── Re-seed agents from current templates (#845) ──────────────────────────
|
|
2965
|
+
|
|
2966
|
+
// Split an AGENTS.md into the leading "intro" text (above the first H2) plus
|
|
2967
|
+
// one block per `## H2` heading. Lines that begin with `## ` (exactly two
|
|
2968
|
+
// hashes + whitespace) start a new block; H1 / H3+ stay inside the current
|
|
2969
|
+
// block so subsections nested under an H2 are not orphaned.
|
|
2970
|
+
function _splitAgentsMdSections(text) {
|
|
2971
|
+
const blocks = [];
|
|
2972
|
+
let intro = [];
|
|
2973
|
+
let current = null;
|
|
2974
|
+
for (const line of String(text || "").split("\n")) {
|
|
2975
|
+
const m = line.match(/^##\s+(.+?)\s*$/);
|
|
2976
|
+
if (m) {
|
|
2977
|
+
if (current) blocks.push(current);
|
|
2978
|
+
current = { heading: m[1], body: "" };
|
|
2979
|
+
} else if (current) {
|
|
2980
|
+
current.body += (current.body ? "\n" : "") + line;
|
|
2981
|
+
} else {
|
|
2982
|
+
intro.push(line);
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
if (current) blocks.push(current);
|
|
2986
|
+
return { intro: intro.join("\n"), blocks };
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
// Merge a fresh seed template with operator-added sections from the existing
|
|
2990
|
+
// worktree AGENTS.md. The fresh template wins for any H2 heading it defines
|
|
2991
|
+
// (so canonical role/workflow/communication sections always reflect current
|
|
2992
|
+
// rules), and any H2 block in the existing file whose heading is NOT in the
|
|
2993
|
+
// template is appended at the end under a marker comment. Heading comparison
|
|
2994
|
+
// is case-insensitive and whitespace-normalized.
|
|
2995
|
+
//
|
|
2996
|
+
// Returns { content, preservedHeadings }. If the existing content is empty
|
|
2997
|
+
// or has no custom sections, returns the fresh template verbatim.
|
|
2998
|
+
function reseedAgentsMd(existingContent, freshContent) {
|
|
2999
|
+
if (!existingContent || !existingContent.trim()) {
|
|
3000
|
+
return { content: freshContent, preservedHeadings: [] };
|
|
3001
|
+
}
|
|
3002
|
+
const norm = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
|
|
3003
|
+
const fresh = _splitAgentsMdSections(freshContent);
|
|
3004
|
+
const existing = _splitAgentsMdSections(existingContent);
|
|
3005
|
+
const freshHeadings = new Set(fresh.blocks.map((b) => norm(b.heading)));
|
|
3006
|
+
const preserved = existing.blocks.filter((b) => !freshHeadings.has(norm(b.heading)));
|
|
3007
|
+
if (preserved.length === 0) {
|
|
3008
|
+
return { content: freshContent, preservedHeadings: [] };
|
|
3009
|
+
}
|
|
3010
|
+
const trailer =
|
|
3011
|
+
"\n\n<!-- Operator notes preserved from prior AGENTS.md (#845) -->\n\n" +
|
|
3012
|
+
preserved
|
|
3013
|
+
.map((b) => `## ${b.heading}\n${b.body}`.replace(/\s+$/, ""))
|
|
3014
|
+
.join("\n\n");
|
|
3015
|
+
const base = freshContent.replace(/\s+$/, "");
|
|
3016
|
+
return {
|
|
3017
|
+
content: base + trailer + "\n",
|
|
3018
|
+
preservedHeadings: preserved.map((b) => b.heading),
|
|
3019
|
+
};
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
// #854: Extract the reviewer token path from a worktree's existing AGENTS.md
|
|
3023
|
+
// so the re-seed substitution preserves a custom path instead of resetting
|
|
3024
|
+
// it to the default. The re1/re2 seed renders:
|
|
3025
|
+
//
|
|
3026
|
+
// export GH_TOKEN=$(cat <path>)
|
|
3027
|
+
//
|
|
3028
|
+
// Common operator-edited variations are recognized:
|
|
3029
|
+
// • optional `export ` prefix
|
|
3030
|
+
// • optional outer `"$(cat …)"` double-quote wrapping
|
|
3031
|
+
// • optional inner quoting of the path (single or double quotes)
|
|
3032
|
+
// • whitespace tolerated between tokens
|
|
3033
|
+
//
|
|
3034
|
+
// Returns the raw extracted path (quotes stripped, whitespace trimmed) or
|
|
3035
|
+
// `null` when no GH_TOKEN line is present or the line doesn't use the
|
|
3036
|
+
// `$(cat …)` form. Pure — no I/O.
|
|
3037
|
+
function _extractReviewerTokenPath(existingContent) {
|
|
3038
|
+
if (!existingContent || typeof existingContent !== "string") return null;
|
|
3039
|
+
// First find a GH_TOKEN line. We anchor on the literal name so an operator
|
|
3040
|
+
// comment about token paths can't accidentally seed a stale path.
|
|
3041
|
+
const lineMatch = existingContent.match(/^[ \t]*(?:export[ \t]+)?GH_TOKEN[ \t]*=.*$/m);
|
|
3042
|
+
if (!lineMatch) return null;
|
|
3043
|
+
// Then pull the path out of `$(cat …)` on that line. Non-greedy capture
|
|
3044
|
+
// stops at the first `)` after `cat`, which is the correct boundary for
|
|
3045
|
+
// any well-formed command substitution.
|
|
3046
|
+
const catMatch = lineMatch[0].match(/\$\(\s*cat\s+(.+?)\s*\)/);
|
|
3047
|
+
if (!catMatch) return null;
|
|
3048
|
+
let raw = catMatch[1].trim();
|
|
3049
|
+
if (
|
|
3050
|
+
(raw.startsWith('"') && raw.endsWith('"')) ||
|
|
3051
|
+
(raw.startsWith("'") && raw.endsWith("'"))
|
|
3052
|
+
) {
|
|
3053
|
+
raw = raw.slice(1, -1);
|
|
3054
|
+
}
|
|
3055
|
+
return raw || null;
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
// #855: Map legacy agent config keys to the canonical seed-template names.
|
|
3059
|
+
// Older projects (and operator-renamed agents) may use keys like
|
|
3060
|
+
// `reviewer1` / `reviewer2` / `t1..t3`; the seed templates only exist for
|
|
3061
|
+
// the canonical `head/re1/re2/dev` slugs. Any key not in this table is
|
|
3062
|
+
// assumed canonical (so future agent slugs work without a code change).
|
|
3063
|
+
const LEGACY_AGENT_KEY_TO_CANONICAL = Object.freeze({
|
|
3064
|
+
t1: "head",
|
|
3065
|
+
t2a: "re1",
|
|
3066
|
+
t2b: "re2",
|
|
3067
|
+
t3: "dev",
|
|
3068
|
+
reviewer1: "re1",
|
|
3069
|
+
reviewer2: "re2",
|
|
3070
|
+
});
|
|
3071
|
+
|
|
3072
|
+
function _canonicalAgentSlug(agentKey) {
|
|
3073
|
+
return LEGACY_AGENT_KEY_TO_CANONICAL[agentKey] || agentKey;
|
|
3074
|
+
}
|
|
3075
|
+
|
|
3076
|
+
// #855: Resolve the per-agent re-seed targets for a project. Pulls the
|
|
3077
|
+
// worktree path from `project.agents[key].cwd` (the source of truth — the
|
|
3078
|
+
// agent process itself spawns there) rather than reconstructing
|
|
3079
|
+
// `${dirName}-${key}`, so legacy worktree names like `*-reviewer1` are
|
|
3080
|
+
// covered. When a project has no `agents` map at all (very old config),
|
|
3081
|
+
// falls back to the canonical sibling layout. Pure — no I/O.
|
|
3082
|
+
function _resolveReseedTargets(project) {
|
|
3083
|
+
const workingDir = project?.working_dir;
|
|
3084
|
+
if (!workingDir) return [];
|
|
3085
|
+
const dirName = path.basename(workingDir);
|
|
3086
|
+
const parentDir = path.dirname(workingDir);
|
|
3087
|
+
const agentsCfg = project.agents && typeof project.agents === "object" ? project.agents : null;
|
|
3088
|
+
const keys = agentsCfg ? Object.keys(agentsCfg) : ["head", "re1", "re2", "dev"];
|
|
3089
|
+
return keys.map((agentKey) => {
|
|
3090
|
+
const canonical = _canonicalAgentSlug(agentKey);
|
|
3091
|
+
const configuredCwd = agentsCfg?.[agentKey]?.cwd;
|
|
3092
|
+
const wtDir = configuredCwd || path.join(parentDir, `${dirName}-${canonical}`);
|
|
3093
|
+
return { agentKey, canonical, wtDir };
|
|
3094
|
+
});
|
|
3095
|
+
}
|
|
3096
|
+
|
|
3097
|
+
// Operator-triggered re-seed of a project's worktree AGENTS.md files from
|
|
3098
|
+
// the current `templates/seeds/*.AGENTS.md` templates, using the same
|
|
3099
|
+
// placeholder substitution as the setup wizard's `seed-files` step. New
|
|
3100
|
+
// seed content (e.g. the #809 GITHUB.md discovery instructions) takes
|
|
3101
|
+
// effect on the NEXT agent restart — this endpoint only rewrites the file.
|
|
3102
|
+
//
|
|
3103
|
+
// Guard: refuses to run while the project's batch is active, so a re-seed
|
|
3104
|
+
// can't drop new instructions on agents in the middle of a task. Pass
|
|
3105
|
+
// `{ force: true }` to override (operator escape hatch — the file is only
|
|
3106
|
+
// re-read on next spawn, so even a forced re-seed is harmless to a live
|
|
3107
|
+
// session, but the guard makes the safe path obvious).
|
|
3108
|
+
router.post("/api/projects/:project/reseed-agents", async (req, res) => {
|
|
3109
|
+
const projectId = req.params.project;
|
|
3110
|
+
const body = req.body || {};
|
|
3111
|
+
const force = body.force === true;
|
|
3112
|
+
let cfg;
|
|
3113
|
+
try { cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")); }
|
|
3114
|
+
catch { return res.status(500).json({ ok: false, error: "Failed to read config" }); }
|
|
3115
|
+
const project = cfg.projects?.find((p) => p.id === projectId);
|
|
3116
|
+
if (!project) return res.status(404).json({ ok: false, error: "Project not found" });
|
|
3117
|
+
const workingDir = project.working_dir;
|
|
3118
|
+
if (!workingDir) return res.status(400).json({ ok: false, error: "Project has no working_dir" });
|
|
3119
|
+
|
|
3120
|
+
if (!force) {
|
|
3121
|
+
// re1 review on #845: fail CLOSED when batch state can't be computed.
|
|
3122
|
+
// Falling through to a re-seed when the gate is uncertain re-introduces
|
|
3123
|
+
// the exact mid-batch rewrite the gate exists to prevent. Three cases
|
|
3124
|
+
// route to the same "unknown" 503 (operator can retry or pass force:true):
|
|
3125
|
+
// 1. getOrComputeBatchProgress throws (network/IO)
|
|
3126
|
+
// 2. getOrComputeBatchProgress returns null (no repo / no progress data)
|
|
3127
|
+
// 3. isBatchActiveFromProgress returns null (no items payload)
|
|
3128
|
+
let active;
|
|
3129
|
+
try {
|
|
3130
|
+
const progress = await getOrComputeBatchProgress(projectId);
|
|
3131
|
+
active = isBatchActiveFromProgress(progress);
|
|
3132
|
+
} catch {
|
|
3133
|
+
return res.status(503).json({
|
|
3134
|
+
ok: false,
|
|
3135
|
+
error: "Could not verify batch state — aborting to avoid a mid-batch re-seed. Retry, or pass force:true to override.",
|
|
3136
|
+
batchUnknown: true,
|
|
3137
|
+
});
|
|
3138
|
+
}
|
|
3139
|
+
if (active === null) {
|
|
3140
|
+
return res.status(503).json({
|
|
3141
|
+
ok: false,
|
|
3142
|
+
error: "Batch state unknown (no progress data) — aborting to avoid a mid-batch re-seed. Retry, or pass force:true to override.",
|
|
3143
|
+
batchUnknown: true,
|
|
3144
|
+
});
|
|
3145
|
+
}
|
|
3146
|
+
if (active) {
|
|
3147
|
+
return res.status(409).json({
|
|
3148
|
+
ok: false,
|
|
3149
|
+
error: "Batch is active — re-seed deferred to avoid mid-batch instruction drift. Pass force:true to override; new seed content only takes effect on next agent restart.",
|
|
3150
|
+
batchActive: true,
|
|
3151
|
+
});
|
|
3152
|
+
}
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3155
|
+
let result;
|
|
3156
|
+
try {
|
|
3157
|
+
result = _performReseedWrites(project, cfg, {
|
|
3158
|
+
reviewerUser: body.reviewerUser,
|
|
3159
|
+
reviewerTokenPath: body.reviewerTokenPath,
|
|
3160
|
+
});
|
|
3161
|
+
} catch (err) {
|
|
3162
|
+
return res.status(500).json({ ok: false, error: err.message });
|
|
3163
|
+
}
|
|
3164
|
+
return res.json({
|
|
3165
|
+
ok: true,
|
|
3166
|
+
reseeded: result.reseeded,
|
|
3167
|
+
skipped: result.skipped,
|
|
3168
|
+
preserved: result.preserved,
|
|
3169
|
+
note: "Re-seeded files take effect on next agent restart.",
|
|
3170
|
+
});
|
|
3171
|
+
});
|
|
3172
|
+
|
|
3173
|
+
// #856: Per-target write loop, extracted so the manual route handler and the
|
|
3174
|
+
// auto-reseed startup hook share one implementation. Throws on missing seed
|
|
3175
|
+
// template (the route handler maps that to a 500). Returns the same shape
|
|
3176
|
+
// the endpoint returns so the auto-reseed log can include per-project stats.
|
|
3177
|
+
//
|
|
3178
|
+
// `opts.reviewerUser` and `opts.reviewerTokenPath` are operator overrides:
|
|
3179
|
+
// when set they win over the per-agent extraction / cfg fallback. Auto-reseed
|
|
3180
|
+
// passes neither so each project uses the same priority chain manual re-seed
|
|
3181
|
+
// uses (per-agent extraction → cfg → default).
|
|
3182
|
+
function _performReseedWrites(project, cfg, opts = {}) {
|
|
3183
|
+
const workingDir = project.working_dir;
|
|
3184
|
+
const dirName = path.basename(workingDir);
|
|
3185
|
+
const reviewerUser = opts.reviewerUser ?? cfg.reviewer_github_user ?? "";
|
|
3186
|
+
const defaultReviewerTokenPath = path.join(os.homedir(), ".quadwork", "reviewer-token");
|
|
3187
|
+
|
|
3188
|
+
// #855: walk the project's configured agents (resolved by `cwd`) instead of
|
|
3189
|
+
// reconstructing sibling paths. Legacy keys (`reviewer1` etc.) still pull
|
|
3190
|
+
// the canonical seed template via `_canonicalAgentSlug`.
|
|
3191
|
+
const targets = _resolveReseedTargets(project);
|
|
3192
|
+
const reseeded = [];
|
|
3193
|
+
const preserved = {};
|
|
3194
|
+
const skipped = [];
|
|
3195
|
+
for (const { agentKey, canonical, wtDir } of targets) {
|
|
3196
|
+
if (!fs.existsSync(wtDir)) { skipped.push(`${agentKey} (no worktree)`); continue; }
|
|
3197
|
+
const seedSrc = path.join(TEMPLATES_DIR, "seeds", `${canonical}.AGENTS.md`);
|
|
3198
|
+
if (!fs.existsSync(seedSrc)) {
|
|
3199
|
+
throw new Error(`Missing seed template: templates/seeds/${canonical}.AGENTS.md`);
|
|
3200
|
+
}
|
|
3201
|
+
|
|
3202
|
+
// #854: Read the existing AGENTS.md FIRST so the per-agent token path
|
|
3203
|
+
// resolution can see what the operator currently has. Resolution order:
|
|
3204
|
+
// 1. explicit opts override (`opts.reviewerTokenPath`)
|
|
3205
|
+
// 2. extracted from this worktree's existing AGENTS.md
|
|
3206
|
+
// 3. project/global config (`cfg.reviewer_token_path`)
|
|
3207
|
+
// 4. default `~/.quadwork/reviewer-token`
|
|
3208
|
+
// Head/dev seeds have no GH_TOKEN line, so (2) is `null` for them and
|
|
3209
|
+
// they fall through to the default — the substituted placeholder is
|
|
3210
|
+
// unused in those templates anyway.
|
|
3211
|
+
const agentsMd = path.join(wtDir, "AGENTS.md");
|
|
3212
|
+
let existing = "";
|
|
3213
|
+
if (fs.existsSync(agentsMd)) {
|
|
3214
|
+
try { existing = fs.readFileSync(agentsMd, "utf-8"); } catch { existing = ""; }
|
|
3215
|
+
}
|
|
3216
|
+
const tokenPath =
|
|
3217
|
+
opts.reviewerTokenPath
|
|
3218
|
+
|| _extractReviewerTokenPath(existing)
|
|
3219
|
+
|| cfg.reviewer_token_path
|
|
3220
|
+
|| defaultReviewerTokenPath;
|
|
3221
|
+
|
|
3222
|
+
let freshContent = fs.readFileSync(seedSrc, "utf-8");
|
|
3223
|
+
freshContent = freshContent
|
|
3224
|
+
.replace(/\{\{reviewer_github_user\}\}/g, reviewerUser)
|
|
3225
|
+
.replace(/\{\{reviewer_token_path\}\}/g, tokenPath)
|
|
3226
|
+
.replace(/\{\{project_name\}\}/g, dirName);
|
|
3227
|
+
|
|
3228
|
+
const merged = reseedAgentsMd(existing, freshContent);
|
|
3229
|
+
fs.writeFileSync(agentsMd, merged.content);
|
|
3230
|
+
reseeded.push(`${agentKey}/AGENTS.md`);
|
|
3231
|
+
if (merged.preservedHeadings.length > 0) {
|
|
3232
|
+
preserved[agentKey] = merged.preservedHeadings;
|
|
3233
|
+
}
|
|
3234
|
+
}
|
|
3235
|
+
return { reseeded, skipped, preserved };
|
|
3236
|
+
}
|
|
3237
|
+
|
|
3238
|
+
// ─── #856: Auto-reseed on version upgrade ────────────────────────────────
|
|
3239
|
+
//
|
|
3240
|
+
// On every server startup we compare the current package version against a
|
|
3241
|
+
// per-project completion record in `~/.quadwork/reseed-state.json`. Any
|
|
3242
|
+
// project that hasn't been re-seeded for the current version gets re-seeded
|
|
3243
|
+
// from the current templates so existing users pick up new seed instructions
|
|
3244
|
+
// (e.g. GITHUB.md discovery) without ever clicking the manual button.
|
|
3245
|
+
//
|
|
3246
|
+
// The per-project state is load-bearing: a project deferred mid-batch stays
|
|
3247
|
+
// pending in the state file so the very next startup (or any future safe-
|
|
3248
|
+
// boundary hook) retries it. Using a single global "last run version"
|
|
3249
|
+
// marker would strand any deferred project — once the marker advanced past
|
|
3250
|
+
// the upgrade version, the deferred project would never be re-seeded.
|
|
3251
|
+
|
|
3252
|
+
const RESEED_STATE_PATH = path.join(os.homedir(), ".quadwork", "reseed-state.json");
|
|
3253
|
+
const PACKAGE_JSON_PATH = path.join(__dirname, "..", "package.json");
|
|
3254
|
+
|
|
3255
|
+
function _readPackageVersion(pkgPath = PACKAGE_JSON_PATH) {
|
|
3256
|
+
try {
|
|
3257
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
3258
|
+
return typeof pkg.version === "string" && pkg.version ? pkg.version : "0.0.0";
|
|
3259
|
+
} catch { return "0.0.0"; }
|
|
3260
|
+
}
|
|
3261
|
+
|
|
3262
|
+
function _loadReseedState(statePath = RESEED_STATE_PATH) {
|
|
3263
|
+
try {
|
|
3264
|
+
const parsed = JSON.parse(fs.readFileSync(statePath, "utf-8"));
|
|
3265
|
+
const map = parsed && typeof parsed === "object" && parsed !== null && parsed.completedByProjectVersion;
|
|
3266
|
+
const completed = map && typeof map === "object" && !Array.isArray(map) ? { ...map } : {};
|
|
3267
|
+
// Drop non-string version values so a corrupt write can't poison the
|
|
3268
|
+
// up-to-date check (which is a string equality).
|
|
3269
|
+
for (const k of Object.keys(completed)) {
|
|
3270
|
+
if (typeof completed[k] !== "string") delete completed[k];
|
|
3271
|
+
}
|
|
3272
|
+
return { completedByProjectVersion: completed };
|
|
3273
|
+
} catch { return { completedByProjectVersion: {} }; }
|
|
3274
|
+
}
|
|
3275
|
+
|
|
3276
|
+
function _saveReseedState(state, statePath = RESEED_STATE_PATH) {
|
|
3277
|
+
try {
|
|
3278
|
+
const dir = path.dirname(statePath);
|
|
3279
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
3280
|
+
} catch {}
|
|
3281
|
+
// Single fs.writeFileSync — write-replace is atomic enough for this
|
|
3282
|
+
// boot-time-only writer (no concurrent writer exists).
|
|
3283
|
+
fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n");
|
|
3284
|
+
}
|
|
3285
|
+
|
|
3286
|
+
async function autoReseedOnStartup(cfg, opts = {}) {
|
|
3287
|
+
const version = opts.version || _readPackageVersion();
|
|
3288
|
+
const statePath = opts.statePath || RESEED_STATE_PATH;
|
|
3289
|
+
const log = opts.log || ((m) => console.log(m));
|
|
3290
|
+
// Dependency-injected for tests so we don't have to spin up gh.
|
|
3291
|
+
const getProgress = opts.getProgress || getOrComputeBatchProgress;
|
|
3292
|
+
const isActiveFromProgress = opts.isActiveFromProgress || isBatchActiveFromProgress;
|
|
3293
|
+
const performWrites = opts.performWrites || _performReseedWrites;
|
|
3294
|
+
|
|
3295
|
+
const state = _loadReseedState(statePath);
|
|
3296
|
+
const decisions = [];
|
|
3297
|
+
const projects = (cfg?.projects || []).filter((p) => p && p.id && p.working_dir);
|
|
3298
|
+
|
|
3299
|
+
for (const project of projects) {
|
|
3300
|
+
if (state.completedByProjectVersion[project.id] === version) {
|
|
3301
|
+
decisions.push({ projectId: project.id, action: "skip", reason: "already current" });
|
|
3302
|
+
continue;
|
|
3303
|
+
}
|
|
3304
|
+
|
|
3305
|
+
// Fail-closed batch gate, same contract as the manual /reseed-agents
|
|
3306
|
+
// route (#853): a throw OR a null result both mean "we cannot prove the
|
|
3307
|
+
// batch is idle, so we MUST defer rather than risk mid-batch instruction
|
|
3308
|
+
// drift". The deferred state stays pending so the next startup retries.
|
|
3309
|
+
let active;
|
|
3310
|
+
try {
|
|
3311
|
+
const progress = await getProgress(project.id);
|
|
3312
|
+
active = isActiveFromProgress(progress);
|
|
3313
|
+
} catch (err) {
|
|
3314
|
+
decisions.push({ projectId: project.id, action: "deferred", reason: `batch-state check threw: ${err.message}` });
|
|
3315
|
+
log(`[reseed] ${project.id}: deferred — batch state unknown (${err.message}); will retry on next startup`);
|
|
3316
|
+
continue;
|
|
3317
|
+
}
|
|
3318
|
+
if (active === null) {
|
|
3319
|
+
decisions.push({ projectId: project.id, action: "deferred", reason: "batch state unknown" });
|
|
3320
|
+
log(`[reseed] ${project.id}: deferred — batch state unknown; will retry on next startup`);
|
|
3321
|
+
continue;
|
|
3322
|
+
}
|
|
3323
|
+
if (active === true) {
|
|
3324
|
+
decisions.push({ projectId: project.id, action: "deferred", reason: "batch active" });
|
|
3325
|
+
log(`[reseed] ${project.id}: deferred — batch active; will retry on next startup`);
|
|
3326
|
+
continue;
|
|
3327
|
+
}
|
|
3328
|
+
|
|
3329
|
+
let result;
|
|
3330
|
+
try {
|
|
3331
|
+
result = performWrites(project, cfg, {});
|
|
3332
|
+
} catch (err) {
|
|
3333
|
+
decisions.push({ projectId: project.id, action: "error", error: err.message });
|
|
3334
|
+
log(`[reseed] ${project.id}: ERROR — ${err.message}; state NOT advanced, will retry on next startup`);
|
|
3335
|
+
continue;
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
state.completedByProjectVersion[project.id] = version;
|
|
3339
|
+
try {
|
|
3340
|
+
_saveReseedState(state, statePath);
|
|
3341
|
+
} catch (err) {
|
|
3342
|
+
// If we can't persist, the project will re-seed again next boot.
|
|
3343
|
+
// Idempotent (file rewrite + #845 merger) so this is annoying-but-safe.
|
|
3344
|
+
log(`[reseed] ${project.id}: WARN — could not persist reseed-state (${err.message}); will re-seed next startup`);
|
|
3345
|
+
}
|
|
3346
|
+
decisions.push({
|
|
3347
|
+
projectId: project.id, action: "reseeded",
|
|
3348
|
+
reseeded: result.reseeded, skipped: result.skipped, preserved: result.preserved,
|
|
3349
|
+
});
|
|
3350
|
+
log(`[reseed] ${project.id}: reseeded for v${version} — wrote ${result.reseeded.length} file(s)` +
|
|
3351
|
+
(result.skipped.length ? `, skipped ${result.skipped.length}` : ""));
|
|
3352
|
+
}
|
|
3353
|
+
|
|
3354
|
+
return { version, decisions };
|
|
3355
|
+
}
|
|
3356
|
+
|
|
2221
3357
|
// ─── Rename ────────────────────────────────────────────────────────────────
|
|
2222
3358
|
|
|
2223
3359
|
function replaceInFile(filePath, oldStr, newStr) {
|
|
@@ -2749,9 +3885,91 @@ module.exports.parseActiveBatch = parseActiveBatch;
|
|
|
2749
3885
|
// summarizeItems for the batch-progress fixture test.
|
|
2750
3886
|
module.exports.buildNoPrRow = buildNoPrRow;
|
|
2751
3887
|
module.exports.summarizeItems = summarizeItems;
|
|
3888
|
+
// #810: expose batch-progress-from-snapshot helpers for unit tests.
|
|
3889
|
+
module.exports.progressFromSnapshot = progressFromSnapshot;
|
|
3890
|
+
module.exports.countApprovedRoles = countApprovedRoles;
|
|
3891
|
+
module.exports.evalBatchCompleteConfirmed = evalBatchCompleteConfirmed;
|
|
2752
3892
|
// #693: expose normalizeMentions for unit tests
|
|
2753
3893
|
module.exports.normalizeMentions = normalizeMentions;
|
|
2754
3894
|
// #714: expose for file-chat integration
|
|
2755
3895
|
module.exports.getProjectChatMode = getProjectChatMode;
|
|
2756
3896
|
// #730: PTY dispatch callback setter
|
|
2757
3897
|
module.exports.setPtyDispatchCallback = setPtyDispatchCallback;
|
|
3898
|
+
// #802: expose the GraphQL rate-limit bucket + predicates for unit tests.
|
|
3899
|
+
// No production callers outside this file.
|
|
3900
|
+
module.exports._graphqlRateLimit = _graphqlRateLimit;
|
|
3901
|
+
module.exports.isGraphqlRateLimited = isGraphqlRateLimited;
|
|
3902
|
+
module.exports.isGraphqlRateLow = isGraphqlRateLow;
|
|
3903
|
+
// #806: expose the canonical-shape transforms for unit tests + the fetcher
|
|
3904
|
+
// entry point (#807's file writer consumes the assembled snapshot).
|
|
3905
|
+
module.exports.githubStateFetcher = githubStateFetcher;
|
|
3906
|
+
module.exports._coalesce = _coalesce;
|
|
3907
|
+
// #837: expose ghApiConditional + the shared list-fetch maxBuffer constant for
|
|
3908
|
+
// the >1MB closed-PR page regression test.
|
|
3909
|
+
module.exports.ghApiConditional = ghApiConditional;
|
|
3910
|
+
module.exports.GH_LIST_MAX_BUFFER = GH_LIST_MAX_BUFFER;
|
|
3911
|
+
// #839: expose the completion-aware sidebar-heartbeat helper for unit tests.
|
|
3912
|
+
module.exports.isBatchActiveFromProgress = isBatchActiveFromProgress;
|
|
3913
|
+
// #845: expose the AGENTS.md re-seed merger so the placeholder substitution +
|
|
3914
|
+
// custom-section preservation contract is exercisable without spinning up a
|
|
3915
|
+
// real worktree. Pure function; no production callers outside this file.
|
|
3916
|
+
module.exports.reseedAgentsMd = reseedAgentsMd;
|
|
3917
|
+
// #855: expose the per-agent target resolver + legacy-key map so the legacy
|
|
3918
|
+
// config (`reviewer1` / `reviewer2` / `t1..t3`) → canonical-template mapping
|
|
3919
|
+
// is exercisable without spinning up a real worktree. Pure helpers; no
|
|
3920
|
+
// production callers outside this file.
|
|
3921
|
+
module.exports._resolveReseedTargets = _resolveReseedTargets;
|
|
3922
|
+
module.exports._canonicalAgentSlug = _canonicalAgentSlug;
|
|
3923
|
+
// #854: expose the GH_TOKEN path extractor so the parse forms (`export`,
|
|
3924
|
+
// double-quoted, inner-quoted, etc.) are exercisable without a temp fs.
|
|
3925
|
+
module.exports._extractReviewerTokenPath = _extractReviewerTokenPath;
|
|
3926
|
+
// #856: expose the auto-reseed startup hook + supporting state/version
|
|
3927
|
+
// helpers. server/index.js calls `autoReseedOnStartup` after config is
|
|
3928
|
+
// loaded; the helpers are exposed for unit tests + the integration test
|
|
3929
|
+
// (state corruption, version round-trip, per-project decision matrix).
|
|
3930
|
+
module.exports.autoReseedOnStartup = autoReseedOnStartup;
|
|
3931
|
+
module.exports._loadReseedState = _loadReseedState;
|
|
3932
|
+
module.exports._saveReseedState = _saveReseedState;
|
|
3933
|
+
module.exports._readPackageVersion = _readPackageVersion;
|
|
3934
|
+
module.exports._performReseedWrites = _performReseedWrites;
|
|
3935
|
+
module.exports.RESEED_STATE_PATH = RESEED_STATE_PATH;
|
|
3936
|
+
// #839 (re1 follow-up): expose the shared compute path plus the caches it
|
|
3937
|
+
// reads so the cache-miss completed-batch case is exercisable end-to-end.
|
|
3938
|
+
module.exports.getOrComputeBatchProgress = getOrComputeBatchProgress;
|
|
3939
|
+
module.exports._batchProgressCache = _batchProgressCache;
|
|
3940
|
+
module.exports._graphqlCache = _graphqlCache;
|
|
3941
|
+
module.exports.restIssueToCanonical = restIssueToCanonical;
|
|
3942
|
+
module.exports.restClosedIssueToCanonical = restClosedIssueToCanonical;
|
|
3943
|
+
module.exports.restMergedPrToCanonical = restMergedPrToCanonical;
|
|
3944
|
+
module.exports.restPullBaseToCanonical = restPullBaseToCanonical;
|
|
3945
|
+
module.exports.mapReviews = mapReviews;
|
|
3946
|
+
module.exports.deriveReviewDecision = deriveReviewDecision;
|
|
3947
|
+
module.exports.buildStatusCheckRollup = buildStatusCheckRollup;
|
|
3948
|
+
// #807: expose the GITHUB.md parser/renderer + role attribution for unit tests.
|
|
3949
|
+
// No production callers outside this file.
|
|
3950
|
+
module.exports.parseGithub = parseGithub;
|
|
3951
|
+
module.exports.renderGithubMarkdown = renderGithubMarkdown;
|
|
3952
|
+
module.exports.attributeReviewsByRole = attributeReviewsByRole;
|
|
3953
|
+
module.exports._githubLiveStale = _githubLiveStale;
|
|
3954
|
+
module.exports._extractNotesBody = _extractNotesBody;
|
|
3955
|
+
module.exports.GITHUB_FRESH_TTL_MS = GITHUB_FRESH_TTL_MS;
|
|
3956
|
+
// #824: expose the bare-array list handler + its cache and the REST rate-limit
|
|
3957
|
+
// bucket so the regression test can drive the stale/rate-limited paths and
|
|
3958
|
+
// assert the body stays a JSON array (never an object). No production callers
|
|
3959
|
+
// outside this file.
|
|
3960
|
+
module.exports.serveGithubList = serveGithubList;
|
|
3961
|
+
module.exports._ghEndpointCache = _ghEndpointCache;
|
|
3962
|
+
module.exports._rateLimit = _rateLimit;
|
|
3963
|
+
// #828: expose the merged-PR pagination helpers + the REST-search linked-PR
|
|
3964
|
+
// picker for unit tests. No production callers outside this file.
|
|
3965
|
+
module.exports.gatherClosedPrPages = gatherClosedPrPages;
|
|
3966
|
+
module.exports.selectRecentMergedPrs = selectRecentMergedPrs;
|
|
3967
|
+
// #834: closed-PR window completeness + linked-issue extraction, for the
|
|
3968
|
+
// queued-from-snapshot fix. No production callers outside this file.
|
|
3969
|
+
module.exports.closedPagesComplete = closedPagesComplete;
|
|
3970
|
+
module.exports.closedPrIssueNumsFromPages = closedPrIssueNumsFromPages;
|
|
3971
|
+
module.exports.pickLinkedPrFromSearch = pickLinkedPrFromSearch;
|
|
3972
|
+
module.exports.findLinkedPrByTitle = findLinkedPrByTitle;
|
|
3973
|
+
// #827: expose the GITHUB.md writer for the idle-no-op regression test. No
|
|
3974
|
+
// production callers outside this file.
|
|
3975
|
+
module.exports.writeGithubFileFromSnapshot = writeGithubFileFromSnapshot;
|