great-cto 3.75.10 → 3.76.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/board/.claude-plugin/plugin.json +1 -1
- package/board/agents/architect.md +4 -0
- package/board/agents/ci-resolver.md +1 -0
- package/board/agents/design-advisor.md +28 -0
- package/board/agents/devops.md +1 -0
- package/board/agents/product-owner.md +13 -1
- package/board/agents/senior-dev.md +25 -9
- package/board/packages/board/lib/alerts.mjs +46 -1
- package/board/packages/board/lib/gate-run.sh +30 -0
- package/board/packages/board/lib/instant-alerts.mjs +159 -0
- package/board/packages/board/lib/merge-readiness.mjs +315 -0
- package/board/packages/board/lib/routes.mjs +29 -3
- package/board/packages/board/openapi.json +2996 -0
- package/board/packages/board/public/assets/icon-192.png +0 -0
- package/board/packages/board/public/assets/icon-512.png +0 -0
- package/board/packages/board/public/assets/icon-maskable-512.png +0 -0
- package/board/packages/board/public/assets/work-view.js +83 -3
- package/board/packages/board/public/index.html +112 -1
- package/board/packages/board/public/manifest.webmanifest +16 -0
- package/board/packages/board/public/sw.js +1 -1
- package/board/packages/board/route-coverage-baseline.json +25 -0
- package/board/packages/board/server.mjs +8 -1
- package/board/scripts/lib/gate-run-record.mjs +75 -0
- package/board/scripts/lib/handoff-state.mjs +478 -0
- package/board/scripts/lib/workspace.mjs +277 -0
- package/board/scripts/lib/worktree-state.mjs +128 -0
- package/board/shared/pipeline.toml +29 -0
- package/package.json +1 -1
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is this task's branch ready to merge? Seven rows, read from what is actually
|
|
3
|
+
* recorded (owner-approved design v12, ldws.3). The board never merges: when every
|
|
4
|
+
* row passes it offers the command to copy.
|
|
5
|
+
*
|
|
6
|
+
* A row that could not be read is `not-read` and counts as a blocker, never as a
|
|
7
|
+
* pass. A task with no known branch gets no card at all: there is nothing to merge.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { execFile, spawn } from 'node:child_process';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { validTaskId } from '../../../scripts/lib/workspace.mjs';
|
|
14
|
+
|
|
15
|
+
const SAFE_BRANCH = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,200}$/;
|
|
16
|
+
const REVIEWERS = Object.freeze(['code-reviewer', 'qa-engineer']);
|
|
17
|
+
const APPROVING = new Set(['APPROVED', 'PASS', 'DONE']);
|
|
18
|
+
const DESIGN_PATHS = /^packages\/board\/public\//;
|
|
19
|
+
const DESIGN_LINK = /^Design-Approved:\s*(https:\/\/claude\.ai\/\S+)/m;
|
|
20
|
+
const CACHE_MS = 30_000;
|
|
21
|
+
|
|
22
|
+
const THREADS_QUERY = 'query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100){pageInfo{hasNextPage} nodes{isResolved}}}}}';
|
|
23
|
+
|
|
24
|
+
/** Runs a command with a deadline. Rejects with { stderr, code } as execFile does. */
|
|
25
|
+
export function run(cmd, args, { cwd, timeout = 8000 } = {}) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
execFile(cmd, args, { cwd, timeout, maxBuffer: 4 * 1024 * 1024, encoding: 'utf8' }, (err, stdout, stderr) => {
|
|
28
|
+
if (err) { err.stderr = stderr; reject(err); } else resolve(stdout);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const attempt = async (fn) => { try { return await fn(); } catch { return null; } };
|
|
34
|
+
|
|
35
|
+
/** The branch a Work entry stands for, or null: a recorded publication, then ADR-047's task/<id>. */
|
|
36
|
+
export async function resolveBranch(entry, { root, exec = run } = {}) {
|
|
37
|
+
const recorded = entry?.publication?.branch;
|
|
38
|
+
if (typeof recorded === 'string' && SAFE_BRANCH.test(recorded)) return recorded;
|
|
39
|
+
for (const id of entry?.issueIds || []) {
|
|
40
|
+
const candidate = `task/${id}`;
|
|
41
|
+
if (!SAFE_BRANCH.test(candidate)) continue;
|
|
42
|
+
const found = await attempt(() => exec('git', ['-C', root, 'show-ref', '--verify', '--quiet', `refs/heads/${candidate}`]));
|
|
43
|
+
if (found !== null) return candidate;
|
|
44
|
+
const adopted = await adoptedBranch(root, id, exec);
|
|
45
|
+
if (adopted) return adopted;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The branch of a workspace a host made under its own name (ADR-047 section 4), if it still exists. */
|
|
51
|
+
async function adoptedBranch(root, id, exec) {
|
|
52
|
+
if (!validTaskId(id)) return null;
|
|
53
|
+
const common = await attempt(async () => String(await exec('git', ['-C', root, 'rev-parse', '--path-format=absolute', '--git-common-dir'])).trim());
|
|
54
|
+
if (!common) return null;
|
|
55
|
+
let branch;
|
|
56
|
+
try { branch = JSON.parse(fs.readFileSync(path.join(common, 'great-cto-workspaces', id + '.json'), 'utf8')).branch; } catch { return null; } // no record is "not adopted"; the caller then says "no branch"
|
|
57
|
+
if (typeof branch !== 'string' || !SAFE_BRANCH.test(branch)) return null;
|
|
58
|
+
const found = await attempt(() => exec('git', ['-C', root, 'show-ref', '--verify', '--quiet', 'refs/heads/' + branch]));
|
|
59
|
+
return found !== null ? branch : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** JSON lines of a verdict log. Older plain-text lines carry no head and are skipped. */
|
|
63
|
+
function verdictRecords(file) {
|
|
64
|
+
let raw;
|
|
65
|
+
try { raw = fs.readFileSync(file, 'utf8'); } catch (e) { return e.code === 'ENOENT' ? [] : null; }
|
|
66
|
+
const out = [];
|
|
67
|
+
for (const line of raw.split('\n')) {
|
|
68
|
+
if (!line.startsWith('{')) continue;
|
|
69
|
+
try { out.push(JSON.parse(line)); } catch { /* one bad line does not hide the rest */ }
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Latest verdict per reviewer bound to this exact head, from the project and the branch's worktree. */
|
|
75
|
+
export function readReviewerVerdicts(dirs, head) {
|
|
76
|
+
const found = {};
|
|
77
|
+
for (const reviewer of REVIEWERS) {
|
|
78
|
+
const records = [];
|
|
79
|
+
for (const dir of dirs) {
|
|
80
|
+
for (const name of [`${reviewer}.log`, `great-cto:${reviewer}.log`]) {
|
|
81
|
+
const got = verdictRecords(path.join(dir, '.great_cto', 'verdicts', name));
|
|
82
|
+
if (got === null) return null;
|
|
83
|
+
records.push(...got);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const bound = records.filter((r) => r?.receipt?.head === head).sort((a, b) => String(a.ts).localeCompare(String(b.ts)));
|
|
87
|
+
found[reviewer] = bound.at(-1)?.verdict ?? null;
|
|
88
|
+
}
|
|
89
|
+
return found;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Records of <root>/.great_cto/gate-runs.jsonl, or null when it exists and cannot be read. */
|
|
93
|
+
export function readGateHistory(root) {
|
|
94
|
+
let raw;
|
|
95
|
+
try { raw = fs.readFileSync(path.join(root, '.great_cto', 'gate-runs.jsonl'), 'utf8'); }
|
|
96
|
+
catch (e) { return e.code === 'ENOENT' ? [] : null; }
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const line of raw.split('\n')) {
|
|
99
|
+
if (!line.trim()) continue;
|
|
100
|
+
try { out.push(JSON.parse(line)); } catch { /* skip the line */ }
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function readPullRequest(root, branch, exec) {
|
|
106
|
+
let raw;
|
|
107
|
+
try {
|
|
108
|
+
raw = await exec('gh', ['pr', 'view', branch, '--json', 'number,state,isDraft,mergeable,url,headRefOid,body'], { cwd: root });
|
|
109
|
+
} catch (e) {
|
|
110
|
+
return /no pull requests? found/i.test(String(e.stderr || '')) ? { missing: true } : null;
|
|
111
|
+
}
|
|
112
|
+
try { return JSON.parse(raw); } catch { return null; }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function unresolvedThreads(root, pr, exec) {
|
|
116
|
+
const m = /github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/.exec(pr.url || '');
|
|
117
|
+
if (!m) return null;
|
|
118
|
+
const raw = await attempt(() => exec('gh', ['api', 'graphql', '-f', `query=${THREADS_QUERY}`, '-F', `owner=${m[1]}`, '-F', `name=${m[2]}`, '-F', `number=${m[3]}`], { cwd: root }));
|
|
119
|
+
if (raw === null) return null;
|
|
120
|
+
try {
|
|
121
|
+
const threads = JSON.parse(raw).data.repository.pullRequest.reviewThreads;
|
|
122
|
+
if (threads.pageInfo.hasNextPage) return null; // a partial count would understate it
|
|
123
|
+
return threads.nodes.filter((t) => !t.isResolved).length;
|
|
124
|
+
} catch { return null; }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Everything the rows are judged on. A value of null means it could not be read. */
|
|
128
|
+
export async function readFacts(root, { branch, base = null, issueIds = [], tasks = null, gateQueue = null, exec = run, now = Date.now() }) {
|
|
129
|
+
const git = (args) => attempt(async () => String(await exec('git', ['-C', root, ...args])).trim());
|
|
130
|
+
const originHead = await git(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']);
|
|
131
|
+
const integration = base ? `origin/${base}` : (originHead ? originHead.replace('refs/remotes/', '') : 'origin/main');
|
|
132
|
+
const head = await git(['rev-parse', `refs/heads/${branch}`]);
|
|
133
|
+
const aheadRaw = await git(['rev-list', '--count', `${integration}..refs/heads/${branch}`]);
|
|
134
|
+
const list = await git(['worktree', 'list', '--porcelain']);
|
|
135
|
+
let worktree = null;
|
|
136
|
+
if (list) {
|
|
137
|
+
for (const block of list.split('\n\n')) {
|
|
138
|
+
if (block.includes(`branch refs/heads/${branch}`)) worktree = /^worktree (.+)$/m.exec(block)?.[1] || null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
let dirty = worktree ? null : 0;
|
|
142
|
+
if (worktree) {
|
|
143
|
+
const status = await attempt(async () => String(await exec('git', ['-C', worktree, 'status', '--porcelain'])));
|
|
144
|
+
if (status !== null) dirty = status.split('\n').filter((l) => l.trim() && !/^\?\? (node_modules|packages\/cli\/node_modules)$/.test(l.trim())).length;
|
|
145
|
+
}
|
|
146
|
+
const pr = await readPullRequest(root, branch, exec);
|
|
147
|
+
const threads = pr && !pr.missing ? await unresolvedThreads(root, pr, exec) : null;
|
|
148
|
+
const filesRaw = await git(['diff', '--name-only', `${integration}...refs/heads/${branch}`]);
|
|
149
|
+
const messages = await git(['log', '--format=%B', `${integration}..refs/heads/${branch}`]);
|
|
150
|
+
const subtasks = !issueIds.length ? [] : tasks === null ? null : tasks
|
|
151
|
+
.filter((t) => issueIds.some((id) => String(t.id).startsWith(`${id}.`)))
|
|
152
|
+
.filter((t) => !['closed', 'done'].includes(String(t.raw_status || t.status || '').toLowerCase()))
|
|
153
|
+
.map((t) => t.id);
|
|
154
|
+
return {
|
|
155
|
+
branch, integration, head, ahead: aheadRaw === null ? null : Number(aheadRaw), worktree, dirty, pr, threads,
|
|
156
|
+
files: filesRaw === null ? null : filesRaw.split('\n').filter(Boolean),
|
|
157
|
+
designLink: DESIGN_LINK.exec(`${messages || ''}\n${pr?.body || ''}`)?.[1] || null,
|
|
158
|
+
gateRuns: readGateHistory(root),
|
|
159
|
+
gateQueue,
|
|
160
|
+
gateRunnable: !!worktree && fs.existsSync(path.join(worktree, 'scripts', 'ci-local.sh')),
|
|
161
|
+
verdicts: head ? readReviewerVerdicts([root, ...(worktree ? [worktree] : [])], head) : null,
|
|
162
|
+
linkedIssues: issueIds.length > 0,
|
|
163
|
+
subtasks,
|
|
164
|
+
checkedAt: new Date(now).toISOString(),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const row = (id, label, state, detail, action = null) => ({ id, label, state, detail, action });
|
|
169
|
+
const NOT_READ = 'Not read';
|
|
170
|
+
const short = (sha) => String(sha || '').slice(0, 7);
|
|
171
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
172
|
+
|
|
173
|
+
function branchRow(f) {
|
|
174
|
+
const main = f.integration.replace(/^origin\//, '');
|
|
175
|
+
if (f.ahead === null || f.dirty === null) return row('branch', 'Branch', 'not-read', NOT_READ);
|
|
176
|
+
if (f.dirty > 0) return row('branch', 'Branch', 'fail', `${plural(f.dirty, 'uncommitted change')} in the worktree`);
|
|
177
|
+
if (f.ahead === 0) return row('branch', 'Branch', 'fail', `No commits ahead of ${main}`);
|
|
178
|
+
return row('branch', 'Branch', 'pass', `${plural(f.ahead, 'commit')} ahead of ${main} · ${f.worktree ? 'working tree clean' : 'no local worktree'}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function prRow(f) {
|
|
182
|
+
const main = f.integration.replace(/^origin\//, '');
|
|
183
|
+
const link = f.pr?.url ? { kind: 'link', label: 'Open PR', href: f.pr.url } : null;
|
|
184
|
+
if (f.pr === null) return row('pr', 'Pull request', 'not-read', NOT_READ);
|
|
185
|
+
if (f.pr.missing) return row('pr', 'Pull request', 'fail', 'No pull request for this branch');
|
|
186
|
+
const n = `#${f.pr.number}`;
|
|
187
|
+
if (f.pr.state !== 'OPEN') return row('pr', 'Pull request', 'fail', `${n} is ${String(f.pr.state).toLowerCase()}`, link);
|
|
188
|
+
if (f.head && f.pr.headRefOid !== f.head) return row('pr', 'Pull request', 'fail', `${n} is on ${short(f.pr.headRefOid)}, the branch is on ${short(f.head)}`, link);
|
|
189
|
+
if (f.pr.isDraft) return row('pr', 'Pull request', 'fail', `${n} is a draft`, link);
|
|
190
|
+
if (f.pr.mergeable === 'CONFLICTING') return row('pr', 'Pull request', 'fail', `${n} open · conflicts with ${main}`, link);
|
|
191
|
+
if (f.pr.mergeable !== 'MERGEABLE') return row('pr', 'Pull request', 'not-read', `${n} open · ${NOT_READ}: GitHub has not computed conflicts`, link);
|
|
192
|
+
return row('pr', 'Pull request', 'pass', `${n} open · no conflicts with ${main}`, link);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function queueText(q) {
|
|
196
|
+
if (q.state === 'running') return 'running now';
|
|
197
|
+
const why = Number.isFinite(q.idle) ? ` (CPU ${q.idle}% idle${q.ahead ? `, ${plural(q.ahead, 'gate')} ahead` : ''})` : '';
|
|
198
|
+
return `queued — waiting for a quiet machine${why}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function gateRow(f) {
|
|
202
|
+
const q = f.gateQueue;
|
|
203
|
+
const live = q && q.branch === f.branch ? q : null;
|
|
204
|
+
// "Run gate" appears only when no run is queued or running, on any branch.
|
|
205
|
+
const action = live ? { kind: 'disabled', label: live.state === 'running' ? 'Running' : 'Queued' }
|
|
206
|
+
: (f.gateRunnable && !q ? { kind: 'run-gate', label: 'Run gate' } : null);
|
|
207
|
+
if (f.gateRuns === null || !f.head) return row('gate', 'Local gate', 'not-read', NOT_READ, action);
|
|
208
|
+
const onHead = (r) => r?.sha && f.head.startsWith(r.sha);
|
|
209
|
+
const last = f.gateRuns.filter(onHead).at(-1);
|
|
210
|
+
const withQueue = (r) => (live ? { ...r, queue: queueText(live), queueBrief: queueText(live).replace(/ \(.*\)$/, '') } : r);
|
|
211
|
+
if (last?.result === 'green') return withQueue(row('gate', 'Local gate', 'pass', 'Green on this head.', live ? action : null));
|
|
212
|
+
if (last?.result === 'skipped') return withQueue(row('gate', 'Local gate', 'pass', 'Green on this head, some tests skipped.', live ? action : null));
|
|
213
|
+
if (last?.result === 'red') {
|
|
214
|
+
const steps = Array.isArray(last.failed) && last.failed.length ? `: ${last.failed.slice(0, 3).join(', ')}` : '';
|
|
215
|
+
return withQueue(row('gate', 'Local gate', 'fail', `Red on this head${steps}.`, action));
|
|
216
|
+
}
|
|
217
|
+
const older = f.gateRuns.filter((r) => r?.branch === f.branch && ['green', 'skipped'].includes(r.result)).at(-1);
|
|
218
|
+
const prior = older ? `Last green run is on an older head (${short(older.sha)}).` : 'No run on this head.';
|
|
219
|
+
return withQueue({ ...row('gate', 'Local gate', 'fail', prior, action), ...(older ? { brief: 'Green only on an older head.' } : {}) });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function reviewersRow(f) {
|
|
223
|
+
if (f.verdicts === null) return row('reviewers', 'Reviewers', 'not-read', NOT_READ);
|
|
224
|
+
const parts = REVIEWERS.map((r) => `${r}: ${f.verdicts[r] ? f.verdicts[r].toLowerCase() : 'no verdict on this head'}`);
|
|
225
|
+
const ok = REVIEWERS.every((r) => APPROVING.has(f.verdicts[r]));
|
|
226
|
+
return row('reviewers', 'Reviewers', ok ? 'pass' : 'fail', parts.join(' · '), { kind: 'tab', label: 'Verdicts', tab: 'evidence' });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function commentsRow(f) {
|
|
230
|
+
if (f.pr === null) return row('comments', 'Review comments', 'not-read', NOT_READ);
|
|
231
|
+
if (f.pr.missing) return row('comments', 'Review comments', 'na', 'No pull request yet');
|
|
232
|
+
if (f.threads === null) return row('comments', 'Review comments', 'not-read', NOT_READ);
|
|
233
|
+
if (f.threads === 0) return row('comments', 'Review comments', 'pass', 'None unresolved');
|
|
234
|
+
return row('comments', 'Review comments', 'fail', `${f.threads} unresolved on the PR`, {
|
|
235
|
+
kind: 'copy', label: 'Copy for agent',
|
|
236
|
+
text: `Resolve the ${plural(f.threads, 'unresolved review thread')} on ${f.pr.url}: read each one, fix the code or answer it, and push to ${f.branch}. Do not merge.`,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function subtasksRow(f) {
|
|
241
|
+
if (!f.linkedIssues) return row('subtasks', 'Open subtasks', 'na', 'Not linked to a Beads task');
|
|
242
|
+
if (f.subtasks === null) return row('subtasks', 'Open subtasks', 'not-read', NOT_READ);
|
|
243
|
+
if (!f.subtasks.length) return row('subtasks', 'Open subtasks', 'pass', 'None open in Beads');
|
|
244
|
+
return { ...row('subtasks', 'Open subtasks', 'fail', `${f.subtasks.length} open in Beads: ${f.subtasks.slice(0, 3).join(', ')}`, { kind: 'issue', label: 'Show', narrowLabel: 'Show subtask', issue: f.subtasks[0] }), brief: `${f.subtasks.length} open in Beads` };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function designRow(f) {
|
|
248
|
+
if (f.files === null) return row('design', 'Design approval', 'not-read', NOT_READ);
|
|
249
|
+
if (!f.files.some((p) => DESIGN_PATHS.test(p))) return row('design', 'Design approval', 'na', 'Not required: no board, admin or landing files changed');
|
|
250
|
+
if (f.designLink) return row('design', 'Design approval', 'pass', 'Approved in Claude Design', { kind: 'link', label: 'Open', href: f.designLink });
|
|
251
|
+
return row('design', 'Design approval', 'fail', 'Board files changed; no Design-Approved link in the commits or the PR');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The run the board started, if its process is still alive. A display hint and the single-run check. */
|
|
255
|
+
export function readGateQueue(root) {
|
|
256
|
+
try {
|
|
257
|
+
const q = JSON.parse(fs.readFileSync(path.join(root, '.great_cto', 'gate-queue.json'), 'utf8'));
|
|
258
|
+
process.kill(q.pid, 0);
|
|
259
|
+
return q;
|
|
260
|
+
} catch { return null; } // display-only: no file or a dead pid means nothing is queued; nothing is written from this
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The one-line form a passed row takes on a phone. */
|
|
264
|
+
function briefOf(r, f) {
|
|
265
|
+
if (r.state === 'na') return { comments: 'no PR yet', subtasks: 'not linked', design: 'not required' }[r.id] || '';
|
|
266
|
+
return {
|
|
267
|
+
branch: `${f.ahead} ahead, ${f.worktree ? 'clean' : 'no worktree'}`, pr: 'no conflicts', gate: 'green on this head',
|
|
268
|
+
reviewers: `${REVIEWERS.length} approve`, comments: 'none unresolved', subtasks: 'none open', design: 'approved',
|
|
269
|
+
}[r.id] || '';
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Pure: the card from the facts. */
|
|
273
|
+
export function evaluate(f) {
|
|
274
|
+
const rows = [branchRow(f), prRow(f), gateRow(f), reviewersRow(f), commentsRow(f), subtasksRow(f), designRow(f)]
|
|
275
|
+
.map((r) => (r.state === 'pass' || r.state === 'na' ? { ...r, brief: briefOf(r, f) } : r));
|
|
276
|
+
const blockers = rows.filter((r) => r.state === 'fail' || r.state === 'not-read').length;
|
|
277
|
+
const ready = blockers === 0;
|
|
278
|
+
return {
|
|
279
|
+
branch: f.branch, head: f.head, shortHead: short(f.head), checkedAt: f.checkedAt, blockers, ready, rows,
|
|
280
|
+
mergeCommand: ready && f.pr && !f.pr.missing ? `gh pr merge ${f.pr.number} --merge --match-head-commit ${f.head}` : null,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const cache = new Map();
|
|
285
|
+
|
|
286
|
+
/** The card for one Work entry, or { branch: null } when it has no branch. Cached briefly: gh is slow. */
|
|
287
|
+
export async function readinessFor(root, entry, { tasks = null, gateQueue = null, exec = run, now = Date.now() } = {}) {
|
|
288
|
+
const branch = await resolveBranch(entry, { root, exec });
|
|
289
|
+
if (!branch) return { branch: null };
|
|
290
|
+
const key = `${root}\0${branch}\0${gateQueue?.state || ''}`;
|
|
291
|
+
const hit = cache.get(key);
|
|
292
|
+
if (hit && now - hit.at < CACHE_MS) return hit.value;
|
|
293
|
+
const facts = await readFacts(root, { branch, base: entry.publication?.base || null, issueIds: entry.issueIds || [], tasks, gateQueue, exec, now });
|
|
294
|
+
const value = evaluate(facts);
|
|
295
|
+
cache.set(key, { at: now, value });
|
|
296
|
+
return value;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const GATE_SCRIPT = fileURLToPath(new URL('./gate-run.sh', import.meta.url));
|
|
300
|
+
|
|
301
|
+
/** Starts one detached gate on the entry's own worktree. No path comes from the request. */
|
|
302
|
+
export async function startGate(root, entry, { exec = run, spawnFn = spawn } = {}) {
|
|
303
|
+
const branch = await resolveBranch(entry, { root, exec });
|
|
304
|
+
if (!branch) return { status: 404, error: 'This task has no branch' };
|
|
305
|
+
const list = await attempt(async () => String(await exec('git', ['-C', root, 'worktree', 'list', '--porcelain'])));
|
|
306
|
+
const block = list?.split('\n\n').find((b) => b.includes(`branch refs/heads/${branch}`));
|
|
307
|
+
const worktree = block ? /^worktree (.+)$/m.exec(block)?.[1] : null;
|
|
308
|
+
if (!worktree || !fs.existsSync(path.join(worktree, 'scripts', 'ci-local.sh'))) return { status: 409, error: 'This branch has no worktree with a local gate' };
|
|
309
|
+
if (readGateQueue(root)) return { status: 409, error: 'A gate is already queued or running' };
|
|
310
|
+
const child = spawnFn('bash', [GATE_SCRIPT, worktree, branch, root], { detached: true, stdio: 'ignore', cwd: worktree });
|
|
311
|
+
child.unref?.();
|
|
312
|
+
fs.mkdirSync(path.join(root, '.great_cto'), { recursive: true });
|
|
313
|
+
fs.writeFileSync(path.join(root, '.great_cto', 'gate-queue.json'), `${JSON.stringify({ branch, state: 'queued', pid: child.pid, ahead: 0 })}\n`);
|
|
314
|
+
return { status: 202, state: 'queued', branch };
|
|
315
|
+
}
|
|
@@ -19,6 +19,7 @@ import { usageReports } from './usage-report.mjs';
|
|
|
19
19
|
import { outcomes as computeOutcomes } from '../../../scripts/lib/outcomes.mjs';
|
|
20
20
|
import { reviewerStatus } from '../../../scripts/lib/required-reviewers.mjs';
|
|
21
21
|
import { readSessionStatus } from '../../../scripts/lib/session-status.mjs';
|
|
22
|
+
import { INSTANT_TRIGGERS } from './instant-alerts.mjs';
|
|
22
23
|
import { readScores, summarizeScores } from '../../../scripts/lib/scores.mjs';
|
|
23
24
|
import { status as routerKeyStatus, writeKey as writeRouterKey, verifyKey as verifyRouterKey } from '../../../scripts/lib/router-key.mjs';
|
|
24
25
|
|
|
@@ -46,6 +47,7 @@ import { parseAgentBudgets, upsertAgentBudget, removeAgentBudget } from '../../.
|
|
|
46
47
|
import { resolveSecondOpinion, SECOND_OPINION_PROVIDERS } from '../../../scripts/lib/second-opinion.mjs';
|
|
47
48
|
import { detectCodex } from '../../../scripts/lib/codex-exec.mjs';
|
|
48
49
|
import { getWork, getHostGraphPage } from './work.mjs';
|
|
50
|
+
import { readinessFor, readGateQueue, startGate } from './merge-readiness.mjs';
|
|
49
51
|
let publicationPreviewBusy = false;
|
|
50
52
|
let publicationWriteBusy = false;
|
|
51
53
|
import { publicationTickets, localPublicationOrigin } from './publication-tickets.mjs';
|
|
@@ -135,7 +137,7 @@ async function dispatch(req, res, url, cwd) {
|
|
|
135
137
|
|
|
136
138
|
// Existing synchronous projections may consume a completed snapshot, but
|
|
137
139
|
// HTTP cold reads must await async I/O instead of freezing unrelated clients.
|
|
138
|
-
const taskRead = ['/api/work', '/api/sse', '/api/tasks', '/api/metrics',
|
|
140
|
+
const taskRead = ['/api/work', '/api/work/readiness', '/api/sse', '/api/tasks', '/api/metrics',
|
|
139
141
|
'/api/memory', '/api/pipeline', '/api/heartbeat', '/api/share', '/api/cost', '/api/resume'].includes(pathname)
|
|
140
142
|
|| /^\/api\/tasks\/[^/]+\/history$/.test(pathname);
|
|
141
143
|
const unknownWork = pathname === '/api/work' && requestedProject
|
|
@@ -251,6 +253,24 @@ async function dispatch(req, res, url, cwd) {
|
|
|
251
253
|
}
|
|
252
254
|
|
|
253
255
|
// New read model refuses project fallback rather than relabeling another project.
|
|
256
|
+
// The merge-readiness card (design v12). `task` is a Work entry key; the branch and
|
|
257
|
+
// its worktree are resolved from that entry, never taken from the request.
|
|
258
|
+
if (pathname === '/api/work/readiness' || pathname === '/api/work/readiness/run-gate') {
|
|
259
|
+
const send = (code, body) => { res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)); };
|
|
260
|
+
if (requestedProject && resolveProjectInfo(requestedProject).resolved === 'fallback') { send(404, { error: 'Unknown project' }); return true; }
|
|
261
|
+
const running = pathname.endsWith('/run-gate');
|
|
262
|
+
if (req.method !== (running ? 'POST' : 'GET')) { send(405, { error: 'Method not allowed' }); return true; }
|
|
263
|
+
if (running && !originAllowed(req)) { send(403, { error: 'origin not allowed' }); return true; }
|
|
264
|
+
const entry = getWork(cwd).entries.find((e) => e.key === url.searchParams.get('task'));
|
|
265
|
+
if (!entry) { send(404, { error: 'Unknown task' }); return true; }
|
|
266
|
+
try {
|
|
267
|
+
if (running) { const { status, ...body } = await startGate(cwd, entry); send(status, body); return true; }
|
|
268
|
+
const tasks = getTasks(cwd);
|
|
269
|
+
send(200, await readinessFor(cwd, entry, { tasks: Array.isArray(tasks) ? tasks : null, gateQueue: readGateQueue(cwd) }));
|
|
270
|
+
} catch (err) { send(500, { error: String(err?.message || err) }); }
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
|
|
254
274
|
if (pathname === '/api/work') {
|
|
255
275
|
if (requestedProject && resolveProjectInfo(requestedProject).resolved === 'fallback') {
|
|
256
276
|
res.writeHead(404, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
@@ -326,8 +346,14 @@ async function dispatch(req, res, url, cwd) {
|
|
|
326
346
|
let body = '';
|
|
327
347
|
req.on('data', c => body += c);
|
|
328
348
|
req.on('end', () => {
|
|
349
|
+
let parsed;
|
|
350
|
+
try { parsed = JSON.parse(body || '{}'); } catch (e) {
|
|
351
|
+
// A body that is not JSON is the caller's mistake: 400, not a server crash.
|
|
352
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
353
|
+
return res.end(JSON.stringify({ error: 'invalid_json', message: String(e.message || e) }));
|
|
354
|
+
}
|
|
329
355
|
try {
|
|
330
|
-
const
|
|
356
|
+
const projPath = parsed && parsed.path;
|
|
331
357
|
if (!projPath || typeof projPath !== 'string') {
|
|
332
358
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
333
359
|
return res.end(JSON.stringify({ error: 'path missing' }));
|
|
@@ -459,7 +485,7 @@ async function dispatch(req, res, url, cwd) {
|
|
|
459
485
|
if (pathname === '/api/notifications') {
|
|
460
486
|
const NOTIFY_RELAY = process.env.GREATCTO_NOTIFY_URL || 'https://greatcto.systems';
|
|
461
487
|
const stateFile = path.join(GREAT_CTO_DIR, 'notifications.json');
|
|
462
|
-
const KNOWN_TRIGGERS = ['incident.p0', 'gate.stale', 'gate.blocked', 'cost.threshold', 'digest.weekly'];
|
|
488
|
+
const KNOWN_TRIGGERS = ['incident.p0', 'gate.stale', 'gate.blocked', 'cost.threshold', 'digest.weekly', ...INSTANT_TRIGGERS];
|
|
463
489
|
let state = { to: '', verified: false, enabled: false, triggers: [] };
|
|
464
490
|
try { Object.assign(state, JSON.parse(fs.readFileSync(stateFile, 'utf8'))); } catch {}
|
|
465
491
|
|