@lchase/kiln 0.13.0 → 0.15.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/README.md +14 -3
- package/assets/serve.css +136 -11
- package/assets/serve.js +44 -5
- package/dist/attention.js +3 -1
- package/dist/board-view.js +4 -169
- package/dist/cli.js +48 -2
- package/dist/config.js +1 -0
- package/dist/driver.js +44 -9
- package/dist/events.js +10 -1
- package/dist/host.js +45 -2
- package/dist/job-view.js +755 -0
- package/dist/jobs.js +9 -0
- package/dist/markdown.js +91 -0
- package/dist/project.js +26 -1
- package/dist/reconcile.js +5 -2
- package/dist/schemas.js +21 -1
- package/dist/serve-page.js +275 -66
- package/dist/serve.js +46 -2
- package/dist/settings.js +4 -2
- package/dist/triggers.js +220 -21
- package/package.json +1 -1
package/dist/job-view.js
ADDED
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
import { scheduledRetry, attentionReason } from "./attention.js";
|
|
2
|
+
import { feedbackRoundLabel } from "./feedback.js";
|
|
3
|
+
import { parseReviewDraft } from "./review-post.js";
|
|
4
|
+
import { parseReviewSource } from "./triggers.js";
|
|
5
|
+
/**
|
|
6
|
+
* One projection of a job. Cards, the pipeline, the repo page, and the job
|
|
7
|
+
* page all render this. They do not format events or gate text on their own.
|
|
8
|
+
*
|
|
9
|
+
* A review gate contributes one ask, one markdown body, one findings list,
|
|
10
|
+
* and the draft JSON only as `raw` for a collapsed disclosure.
|
|
11
|
+
*/
|
|
12
|
+
const PHASE_LABEL = {
|
|
13
|
+
spec: "Spec",
|
|
14
|
+
build: "Build",
|
|
15
|
+
verify: "Verify",
|
|
16
|
+
security: "Security",
|
|
17
|
+
ux: "UX",
|
|
18
|
+
ui: "UI",
|
|
19
|
+
code: "Code",
|
|
20
|
+
approver: "Approve",
|
|
21
|
+
approve: "Approve",
|
|
22
|
+
land: "Land",
|
|
23
|
+
explore: "Explore",
|
|
24
|
+
draft: "Draft",
|
|
25
|
+
fix: "Fix",
|
|
26
|
+
};
|
|
27
|
+
const SEVERITY_ORDER = ["blocking", "warning", "nit", "note"];
|
|
28
|
+
const BOILERPLATE = /^Approving (?:keeps this draft|posts this draft)\b/i;
|
|
29
|
+
const FINDING_LINE = /^(?:[-*+]|\d+\.)?\s*\*\*(note|nit|warning|blocking)\*\*\s+`?([A-Za-z0-9_./\\-]+):(\d+)`?\s*(?:[—–:-]+\s*)?(.+)$/i;
|
|
30
|
+
const VERDICT_LINE = /^(?:\*\*)?Verdict:(?:\*\*)?\s*(.+)$/i;
|
|
31
|
+
export const PIPELINE_COLUMNS = ["queued", "running", "needs-you", "needs-hand", "done"];
|
|
32
|
+
export const COLUMN_LABEL = {
|
|
33
|
+
queued: "Queued",
|
|
34
|
+
running: "Running",
|
|
35
|
+
"needs-you": "Needs you",
|
|
36
|
+
"needs-hand": "Needs a hand",
|
|
37
|
+
done: "Done",
|
|
38
|
+
};
|
|
39
|
+
export function isInbox(job) {
|
|
40
|
+
if (job.reviewPostError)
|
|
41
|
+
return false;
|
|
42
|
+
if (job.canApprove || job.canReject || job.canRevise)
|
|
43
|
+
return true;
|
|
44
|
+
return job.state === "needs-human" && job.gates.length > 0 && !job.stopReason && !job.feedbackReason;
|
|
45
|
+
}
|
|
46
|
+
/** Needs a person, and not an open gate. A scheduled retry stays in flight. */
|
|
47
|
+
export function isHand(job) {
|
|
48
|
+
if (isInbox(job) || scheduledRetry(job))
|
|
49
|
+
return false;
|
|
50
|
+
return job.state === "needs-human" || job.state === "failed" || job.state === "dead" || job.state === "invalid";
|
|
51
|
+
}
|
|
52
|
+
export function isFinished(job) {
|
|
53
|
+
return job.state === "done" || job.state === "rejected" || job.state === "cancelled";
|
|
54
|
+
}
|
|
55
|
+
export function columnOf(job) {
|
|
56
|
+
if (isFinished(job))
|
|
57
|
+
return "done";
|
|
58
|
+
if (isInbox(job) || job.state === "in-review")
|
|
59
|
+
return "needs-you";
|
|
60
|
+
if (isHand(job))
|
|
61
|
+
return "needs-hand";
|
|
62
|
+
if (job.state === "queued" || job.state === "blocked")
|
|
63
|
+
return "queued";
|
|
64
|
+
return "running";
|
|
65
|
+
}
|
|
66
|
+
export function projectJob(job, events) {
|
|
67
|
+
const inbox = isInbox(job);
|
|
68
|
+
const hand = isHand(job);
|
|
69
|
+
const finished = isFinished(job);
|
|
70
|
+
const own = events.filter((event) => event.job_id === job.jobId);
|
|
71
|
+
const failure = latestFailure(own);
|
|
72
|
+
const activity = jobActivity(own);
|
|
73
|
+
const waiting = scheduledRetry(job);
|
|
74
|
+
const reason = hand ? handLine(job, failure) : attentionReason(job);
|
|
75
|
+
const phases = phasesOf(job, own);
|
|
76
|
+
const target = reviewTarget(job, own);
|
|
77
|
+
const deliverable = deliverableOf(job, target);
|
|
78
|
+
const ask = askOf(job);
|
|
79
|
+
const notes = noteLines(job, deliverable);
|
|
80
|
+
const feedback = [];
|
|
81
|
+
for (const round of job.feedbackRounds ?? []) {
|
|
82
|
+
const url = round.items.find((item) => item.url)?.url;
|
|
83
|
+
feedback.push({ label: feedbackRoundLabel(round), ...(url ? { url } : {}) });
|
|
84
|
+
}
|
|
85
|
+
const historyLine = finished ? finishedLine(job) : undefined;
|
|
86
|
+
const attempt = attemptLine(job);
|
|
87
|
+
const current = currentPhase(phases);
|
|
88
|
+
const since = job.startedAt ?? job.createdAt ?? activity.latest;
|
|
89
|
+
return {
|
|
90
|
+
jobId: job.jobId,
|
|
91
|
+
title: job.title || job.jobId,
|
|
92
|
+
repo: job.repo,
|
|
93
|
+
workflow: job.workflow,
|
|
94
|
+
state: job.state,
|
|
95
|
+
column: columnOf(job),
|
|
96
|
+
reason,
|
|
97
|
+
...(ask ? { ask } : {}),
|
|
98
|
+
...(since ? { since } : {}),
|
|
99
|
+
costUsd: jobCost(own),
|
|
100
|
+
...(current ? { currentPhase: current } : {}),
|
|
101
|
+
phases,
|
|
102
|
+
...(deliverable ? { deliverable } : {}),
|
|
103
|
+
...(target.headSha ? { headSha: target.headSha } : {}),
|
|
104
|
+
...(job.branch ? { branch: job.branch } : {}),
|
|
105
|
+
...(target.reviewUrl || job.reviewUrl ? { reviewUrl: target.reviewUrl ?? job.reviewUrl } : {}),
|
|
106
|
+
timeline: timelineOf(own),
|
|
107
|
+
inbox,
|
|
108
|
+
hand,
|
|
109
|
+
finished,
|
|
110
|
+
retry: (job.state === "failed" || job.state === "dead" || (job.state === "needs-human" && Boolean(job.stopReason))) && job.gates.length === 0,
|
|
111
|
+
sandboxed: job.sandboxed === true,
|
|
112
|
+
canApprove: job.canApprove,
|
|
113
|
+
canReject: job.canReject,
|
|
114
|
+
canRevise: job.canRevise,
|
|
115
|
+
attemptCount: job.attemptCount,
|
|
116
|
+
maxAttempts: job.maxAttempts,
|
|
117
|
+
scheduledRetry: waiting,
|
|
118
|
+
...(waiting && job.nextRetryAt ? { nextRetryAt: job.nextRetryAt } : {}),
|
|
119
|
+
...(attempt ? { attemptLine: attempt } : {}),
|
|
120
|
+
...(failure?.class ? { failureClass: failure.class } : {}),
|
|
121
|
+
...(failure?.fingerprint ? { fingerprint: shortFingerprint(failure.fingerprint) } : {}),
|
|
122
|
+
...(activity.latest ? { latestEventAt: activity.latest } : {}),
|
|
123
|
+
eventCount: activity.count,
|
|
124
|
+
...(target.prUrl || job.prUrl ? { prUrl: target.prUrl ?? job.prUrl } : {}),
|
|
125
|
+
...(target.prNumber || job.prNumber ? { prNumber: target.prNumber ?? job.prNumber } : {}),
|
|
126
|
+
dependsOn: job.dependsOn.map((dep) => ({
|
|
127
|
+
jobId: dep.jobId,
|
|
128
|
+
state: dep.state,
|
|
129
|
+
...(dep.blockedDetail ? { detail: dep.blockedDetail } : {}),
|
|
130
|
+
})),
|
|
131
|
+
evidence: evidenceLines(deliverable, notes),
|
|
132
|
+
feedback,
|
|
133
|
+
notes,
|
|
134
|
+
...(historyLine ? { historyLine } : {}),
|
|
135
|
+
findings: Boolean(job.findingsPath),
|
|
136
|
+
spec: Boolean(job.specPath),
|
|
137
|
+
log: Boolean(job.logPath),
|
|
138
|
+
...(job.logTail?.trim() ? { logTail: job.logTail.trim() } : {}),
|
|
139
|
+
...(job.reviewPostError ? { reviewPostError: job.reviewPostError } : {}),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
export function formatDuration(ms) {
|
|
143
|
+
if (ms < 1000)
|
|
144
|
+
return `${ms}ms`;
|
|
145
|
+
const sec = Math.round(ms / 1000);
|
|
146
|
+
if (sec < 60)
|
|
147
|
+
return `${sec}s`;
|
|
148
|
+
const min = Math.floor(sec / 60);
|
|
149
|
+
const rem = sec % 60;
|
|
150
|
+
if (min < 60)
|
|
151
|
+
return rem ? `${min}m ${rem}s` : `${min}m`;
|
|
152
|
+
const hr = Math.floor(min / 60);
|
|
153
|
+
const leftover = min % 60;
|
|
154
|
+
return leftover ? `${hr}h ${leftover}m` : `${hr}h`;
|
|
155
|
+
}
|
|
156
|
+
export function formatCost(usd) {
|
|
157
|
+
if (usd === null || usd === undefined || !Number.isFinite(usd))
|
|
158
|
+
return undefined;
|
|
159
|
+
return `$${usd.toFixed(2)}`;
|
|
160
|
+
}
|
|
161
|
+
/** Relative label from `now`. The page script replaces it with the viewer's clock. */
|
|
162
|
+
export function formatRelative(iso, now = Date.now()) {
|
|
163
|
+
const ts = Date.parse(iso);
|
|
164
|
+
if (!Number.isFinite(ts))
|
|
165
|
+
return iso;
|
|
166
|
+
const delta = now - ts;
|
|
167
|
+
const future = delta < 0;
|
|
168
|
+
const abs = Math.abs(delta);
|
|
169
|
+
const sec = Math.round(abs / 1000);
|
|
170
|
+
if (sec < 45)
|
|
171
|
+
return future ? "in a moment" : "just now";
|
|
172
|
+
const min = Math.round(sec / 60);
|
|
173
|
+
const suffix = future ? "" : " ago";
|
|
174
|
+
const prefix = future ? "in " : "";
|
|
175
|
+
if (min < 60)
|
|
176
|
+
return `${prefix}${min}m${suffix}`;
|
|
177
|
+
const hr = Math.round(min / 60);
|
|
178
|
+
if (hr < 36)
|
|
179
|
+
return `${prefix}${hr}h${suffix}`;
|
|
180
|
+
const day = Math.round(hr / 24);
|
|
181
|
+
if (day < 14)
|
|
182
|
+
return `${prefix}${day}d${suffix}`;
|
|
183
|
+
return formatLocal(iso);
|
|
184
|
+
}
|
|
185
|
+
/** Readable clock time in the server's zone. The browser rewrites it locally. */
|
|
186
|
+
export function formatLocal(iso) {
|
|
187
|
+
const date = new Date(iso);
|
|
188
|
+
if (!Number.isFinite(date.getTime()))
|
|
189
|
+
return iso;
|
|
190
|
+
return date.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
|
191
|
+
}
|
|
192
|
+
export function shortSha(sha) {
|
|
193
|
+
return sha.slice(0, 7);
|
|
194
|
+
}
|
|
195
|
+
export function stateTone(state) {
|
|
196
|
+
switch (state) {
|
|
197
|
+
case "running":
|
|
198
|
+
case "approved":
|
|
199
|
+
return "running";
|
|
200
|
+
case "queued":
|
|
201
|
+
case "blocked":
|
|
202
|
+
return "queued";
|
|
203
|
+
case "needs-human":
|
|
204
|
+
case "in-review":
|
|
205
|
+
return "needs-you";
|
|
206
|
+
case "failed":
|
|
207
|
+
case "dead":
|
|
208
|
+
case "invalid":
|
|
209
|
+
return "needs-hand";
|
|
210
|
+
case "done":
|
|
211
|
+
case "cancelled":
|
|
212
|
+
return "done";
|
|
213
|
+
case "rejected":
|
|
214
|
+
return "failed";
|
|
215
|
+
default:
|
|
216
|
+
return "queued";
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function phasesOf(job, events) {
|
|
220
|
+
const planned = new Set(job.steps.map((step) => step.id));
|
|
221
|
+
const byStep = new Map();
|
|
222
|
+
const order = [];
|
|
223
|
+
const ensure = (id, label) => {
|
|
224
|
+
const existing = byStep.get(id);
|
|
225
|
+
if (existing)
|
|
226
|
+
return existing;
|
|
227
|
+
const phase = { id, label: label ?? labelOf(id), status: "pending" };
|
|
228
|
+
byStep.set(id, phase);
|
|
229
|
+
order.push(id);
|
|
230
|
+
return phase;
|
|
231
|
+
};
|
|
232
|
+
for (const step of job.steps) {
|
|
233
|
+
ensure(step.id).status = stepStatus(step.phase);
|
|
234
|
+
}
|
|
235
|
+
for (const event of events) {
|
|
236
|
+
if (event.event === "phase.started" && event.step) {
|
|
237
|
+
const phase = ensure(event.step);
|
|
238
|
+
phase.startedAt = event.ts;
|
|
239
|
+
if (!planned.has(event.step) && phase.status === "pending")
|
|
240
|
+
phase.status = "running";
|
|
241
|
+
if (event.properties.iteration !== undefined)
|
|
242
|
+
phase.iteration = event.properties.iteration;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (event.event === "phase.finished" && event.step) {
|
|
246
|
+
const phase = ensure(event.step);
|
|
247
|
+
phase.finishedAt = event.ts;
|
|
248
|
+
if (!planned.has(event.step)) {
|
|
249
|
+
phase.status = event.properties.outcome === "failed" ? "failed" : event.properties.skipped ? "skipped" : "done";
|
|
250
|
+
}
|
|
251
|
+
if (event.properties.iteration !== undefined)
|
|
252
|
+
phase.iteration = event.properties.iteration;
|
|
253
|
+
if (event.properties.verdict)
|
|
254
|
+
phase.verdict = event.properties.verdict;
|
|
255
|
+
if (event.properties.detail)
|
|
256
|
+
phase.detail = event.properties.detail;
|
|
257
|
+
if (event.properties.test_command)
|
|
258
|
+
phase.testCommand = event.properties.test_command;
|
|
259
|
+
if (event.properties.exit_code !== undefined)
|
|
260
|
+
phase.exitCode = event.properties.exit_code;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (event.event === "gate.resolved" && event.properties.decision === "revise" && event.properties.note) {
|
|
264
|
+
const phase = ensure(event.step ?? `revise-${event.ts}`, "Revise");
|
|
265
|
+
phase.status = "done";
|
|
266
|
+
phase.finishedAt = event.ts;
|
|
267
|
+
phase.note = event.properties.note;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (event.event === "pr.feedback") {
|
|
271
|
+
const phase = ensure(`fix-${event.properties.round}`, `Fix ${event.properties.round}`);
|
|
272
|
+
phase.status = event.properties.outcome ? "done" : "running";
|
|
273
|
+
phase.finishedAt = event.ts;
|
|
274
|
+
phase.iteration = event.properties.round;
|
|
275
|
+
phase.detail = event.properties.outcome ?? event.properties.trigger;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (event.event === "run.failed") {
|
|
279
|
+
const phase = [...byStep.values()].reverse().find((item) => item.status === "failed") ?? [...byStep.values()].at(-1);
|
|
280
|
+
const fingerprint = shortFingerprint(event.properties.fingerprint);
|
|
281
|
+
if (phase) {
|
|
282
|
+
if (event.properties.class)
|
|
283
|
+
phase.failureClass = event.properties.class;
|
|
284
|
+
if (fingerprint)
|
|
285
|
+
phase.fingerprint = fingerprint;
|
|
286
|
+
if (event.properties.detail && !phase.detail)
|
|
287
|
+
phase.detail = event.properties.detail;
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
const failed = ensure("run", "Run");
|
|
291
|
+
failed.status = "failed";
|
|
292
|
+
failed.finishedAt = event.ts;
|
|
293
|
+
if (event.properties.class)
|
|
294
|
+
failed.failureClass = event.properties.class;
|
|
295
|
+
if (fingerprint)
|
|
296
|
+
failed.fingerprint = fingerprint;
|
|
297
|
+
if (event.properties.detail)
|
|
298
|
+
failed.detail = event.properties.detail;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
for (const phase of byStep.values()) {
|
|
303
|
+
if (!phase.startedAt || !phase.finishedAt)
|
|
304
|
+
continue;
|
|
305
|
+
const ms = Date.parse(phase.finishedAt) - Date.parse(phase.startedAt);
|
|
306
|
+
if (Number.isFinite(ms) && ms >= 0)
|
|
307
|
+
phase.durationMs = ms;
|
|
308
|
+
}
|
|
309
|
+
return order.map((id) => byStep.get(id)).filter((phase) => Boolean(phase));
|
|
310
|
+
}
|
|
311
|
+
function timelineOf(events) {
|
|
312
|
+
const runs = [];
|
|
313
|
+
const index = new Map();
|
|
314
|
+
for (const event of events) {
|
|
315
|
+
const key = event.run_id ?? "";
|
|
316
|
+
let run = index.get(key);
|
|
317
|
+
if (!run) {
|
|
318
|
+
run = { runId: event.run_id, entries: [] };
|
|
319
|
+
index.set(key, run);
|
|
320
|
+
runs.push(run);
|
|
321
|
+
}
|
|
322
|
+
run.entries.push({ ts: event.ts, summary: summarizeEvent(event) });
|
|
323
|
+
}
|
|
324
|
+
return runs;
|
|
325
|
+
}
|
|
326
|
+
function summarizeEvent(event) {
|
|
327
|
+
switch (event.event) {
|
|
328
|
+
case "job.added":
|
|
329
|
+
return `Added · ${event.properties.workflow}`;
|
|
330
|
+
case "job.retried":
|
|
331
|
+
return "Retried";
|
|
332
|
+
case "run.started":
|
|
333
|
+
return `Run started · ${event.properties.workflow}`;
|
|
334
|
+
case "phase.started":
|
|
335
|
+
return `${labelOf(event.step ?? "step")} started`;
|
|
336
|
+
case "phase.finished": {
|
|
337
|
+
const outcome = event.properties.outcome === "failed" ? "failed" : "finished";
|
|
338
|
+
const verdict = event.properties.verdict ? ` · ${event.properties.verdict}` : "";
|
|
339
|
+
return `${labelOf(event.step ?? "step")} ${outcome}${verdict}`;
|
|
340
|
+
}
|
|
341
|
+
case "gate.opened":
|
|
342
|
+
return event.properties.prompt ? `Gate opened · ${oneLine(event.properties.prompt, 120)}` : "Gate opened";
|
|
343
|
+
case "gate.resolved":
|
|
344
|
+
return `Gate ${event.properties.decision}`;
|
|
345
|
+
case "run.failed":
|
|
346
|
+
return event.properties.class ? `Run failed · ${event.properties.class}` : "Run failed";
|
|
347
|
+
case "run.finished":
|
|
348
|
+
return "Run finished";
|
|
349
|
+
case "pr.opened":
|
|
350
|
+
return `Opened pull request #${event.properties.number}`;
|
|
351
|
+
case "pr.feedback":
|
|
352
|
+
return `Feedback round ${event.properties.round} · ${event.properties.trigger}`;
|
|
353
|
+
case "worktree.cleaned":
|
|
354
|
+
return "Worktree cleaned";
|
|
355
|
+
case "worktree.kept":
|
|
356
|
+
return "Worktree kept";
|
|
357
|
+
case "needs-human":
|
|
358
|
+
return `Needs a person · ${event.properties.reason}`;
|
|
359
|
+
case "job.cancelled":
|
|
360
|
+
return "Cancelled before it started";
|
|
361
|
+
case "review.posted":
|
|
362
|
+
return `Posted the review on ${event.properties.repo}#${event.properties.number}`;
|
|
363
|
+
case "review.failed":
|
|
364
|
+
return `Could not post the review · ${event.properties.class}`;
|
|
365
|
+
default:
|
|
366
|
+
return event.event;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function reviewTarget(job, events) {
|
|
370
|
+
const parsed = job.source?.ref ? parseReviewSource(job.source.ref) : undefined;
|
|
371
|
+
const target = {};
|
|
372
|
+
if (parsed && parsed.repo === job.repo) {
|
|
373
|
+
target.prNumber = parsed.number;
|
|
374
|
+
target.headSha = parsed.sha;
|
|
375
|
+
}
|
|
376
|
+
for (const event of events) {
|
|
377
|
+
if (event.event === "pr.opened") {
|
|
378
|
+
target.prUrl = event.properties.url;
|
|
379
|
+
target.prNumber = event.properties.number;
|
|
380
|
+
}
|
|
381
|
+
if (event.event === "review.posted") {
|
|
382
|
+
target.prNumber = event.properties.number;
|
|
383
|
+
target.headSha = event.properties.sha.toLowerCase();
|
|
384
|
+
target.reviewUrl = event.properties.url;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const texts = collectedTexts(job);
|
|
388
|
+
for (const text of texts) {
|
|
389
|
+
const url = /^Pull request:\s*(https?:\/\/\S+)/m.exec(text)?.[1];
|
|
390
|
+
const number = /^Number:\s*(\d+)/m.exec(text)?.[1];
|
|
391
|
+
const head = /^Head:\s*([0-9a-f]{7,64})/im.exec(text)?.[1];
|
|
392
|
+
if (url && !target.prUrl)
|
|
393
|
+
target.prUrl = url;
|
|
394
|
+
if (number && !target.prNumber)
|
|
395
|
+
target.prNumber = Number(number);
|
|
396
|
+
if (head && !target.headSha)
|
|
397
|
+
target.headSha = head.toLowerCase();
|
|
398
|
+
}
|
|
399
|
+
if (job.prUrl && !target.prUrl)
|
|
400
|
+
target.prUrl = job.prUrl;
|
|
401
|
+
if (job.prNumber && !target.prNumber)
|
|
402
|
+
target.prNumber = job.prNumber;
|
|
403
|
+
if (job.reviewUrl && !target.reviewUrl)
|
|
404
|
+
target.reviewUrl = job.reviewUrl;
|
|
405
|
+
return target;
|
|
406
|
+
}
|
|
407
|
+
function deliverableOf(job, target) {
|
|
408
|
+
const gate = gateParts(job);
|
|
409
|
+
const draft = draftFrom(collectedTexts(job));
|
|
410
|
+
const review = job.workflow === "review" || Boolean(draft) || Boolean(job.source?.ref && parseReviewSource(job.source.ref));
|
|
411
|
+
if (review && (draft || gate.markdown || gate.ask)) {
|
|
412
|
+
const markdown = longer(draft?.markdown, gate.markdown);
|
|
413
|
+
const fromJson = draft?.findings ?? [];
|
|
414
|
+
const findings = sortFindings(fromJson.length > 0 ? fromJson : findingsFromMarkdown(markdown));
|
|
415
|
+
const stripped = presentMarkdown(markdown, job.title, findings.length > 0);
|
|
416
|
+
return {
|
|
417
|
+
kind: "review",
|
|
418
|
+
markdown: stripped.markdown,
|
|
419
|
+
findings,
|
|
420
|
+
...(stripped.verdict ? { verdict: stripped.verdict } : {}),
|
|
421
|
+
...(target.prUrl ? { prUrl: target.prUrl } : {}),
|
|
422
|
+
...(target.prNumber ? { prNumber: target.prNumber } : {}),
|
|
423
|
+
...(target.headSha ? { headSha: target.headSha } : {}),
|
|
424
|
+
...(draft?.raw ? { raw: draft.raw } : {}),
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
const notes = proseNotes(job, gate.markdown);
|
|
428
|
+
if (!gate.markdown && notes.length === 0)
|
|
429
|
+
return undefined;
|
|
430
|
+
return { kind: "prose", ...(gate.markdown ? { markdown: gate.markdown } : {}), notes };
|
|
431
|
+
}
|
|
432
|
+
function askOf(job) {
|
|
433
|
+
const gate = job.gates[0];
|
|
434
|
+
if (!gate)
|
|
435
|
+
return undefined;
|
|
436
|
+
const first = gate.rendered?.split("\n")[0]?.trim();
|
|
437
|
+
const ask = first || gate.prompt.trim();
|
|
438
|
+
return ask || undefined;
|
|
439
|
+
}
|
|
440
|
+
function gateParts(job) {
|
|
441
|
+
const gate = job.gates.find((item) => item.rendered?.includes("\n")) ?? job.gates[0];
|
|
442
|
+
if (!gate?.rendered?.includes("\n"))
|
|
443
|
+
return { ...(gate ? { ask: gate.prompt.trim() } : {}), markdown: "" };
|
|
444
|
+
const ask = gate.rendered.split("\n")[0]?.trim() || gate.prompt.trim();
|
|
445
|
+
const parts = gate.rendered.split("\n").slice(1).join("\n").trim().split(/\n\s*\n/);
|
|
446
|
+
if (parts[0] && BOILERPLATE.test(parts[0].trim()))
|
|
447
|
+
parts.shift();
|
|
448
|
+
return { ...(ask ? { ask } : {}), markdown: parts.join("\n\n").trim() };
|
|
449
|
+
}
|
|
450
|
+
function draftFrom(texts) {
|
|
451
|
+
let best;
|
|
452
|
+
for (const text of texts) {
|
|
453
|
+
const start = text.indexOf("{");
|
|
454
|
+
if (start < 0 || !text.includes('"markdown"'))
|
|
455
|
+
continue;
|
|
456
|
+
const raw = text.slice(start).trim();
|
|
457
|
+
const parsed = parseObject(raw);
|
|
458
|
+
if (parsed) {
|
|
459
|
+
const draft = parseReviewDraft(parsed);
|
|
460
|
+
if (!draft)
|
|
461
|
+
continue;
|
|
462
|
+
const findings = draft.findings.flatMap((finding) => {
|
|
463
|
+
const view = findingView(finding);
|
|
464
|
+
return view ? [view] : [];
|
|
465
|
+
});
|
|
466
|
+
if (!best || draft.markdown.length >= best.markdown.length)
|
|
467
|
+
best = { markdown: draft.markdown, findings, raw };
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
const match = /"markdown"\s*:\s*"((?:\\.|[^"\\])*)"/.exec(raw);
|
|
471
|
+
if (!match?.[1])
|
|
472
|
+
continue;
|
|
473
|
+
const markdown = unescapeJson(match[1]);
|
|
474
|
+
if (!markdown.trim())
|
|
475
|
+
continue;
|
|
476
|
+
const findings = findingsFromMarkdown(markdown);
|
|
477
|
+
if (!best || markdown.length > best.markdown.length)
|
|
478
|
+
best = { markdown, findings, raw };
|
|
479
|
+
}
|
|
480
|
+
return best;
|
|
481
|
+
}
|
|
482
|
+
function presentMarkdown(markdown, title, dropFindingLines) {
|
|
483
|
+
let body = markdown.trim();
|
|
484
|
+
const verdict = verdictOf(body);
|
|
485
|
+
body = body
|
|
486
|
+
.split("\n")
|
|
487
|
+
.filter((line) => !VERDICT_LINE.test(line.trim()))
|
|
488
|
+
.join("\n");
|
|
489
|
+
if (dropFindingLines) {
|
|
490
|
+
body = body
|
|
491
|
+
.split("\n")
|
|
492
|
+
.filter((line) => !FINDING_LINE.test(line.trim()))
|
|
493
|
+
.join("\n");
|
|
494
|
+
}
|
|
495
|
+
body = body.replace(/\n{3,}/g, "\n\n").trim();
|
|
496
|
+
const lines = body.split("\n");
|
|
497
|
+
const heading = lines[0]?.trim().replace(/^#+\s*/, "") ?? "";
|
|
498
|
+
if (heading && (heading === title || heading.endsWith(title))) {
|
|
499
|
+
body = lines.slice(1).join("\n").trim();
|
|
500
|
+
}
|
|
501
|
+
return { markdown: body, ...(verdict ? { verdict } : {}) };
|
|
502
|
+
}
|
|
503
|
+
function findingsFromMarkdown(markdown) {
|
|
504
|
+
const found = [];
|
|
505
|
+
for (const line of markdown.split("\n")) {
|
|
506
|
+
const match = FINDING_LINE.exec(line.trim());
|
|
507
|
+
if (!match?.[1] || !match[2] || !match[3] || !match[4])
|
|
508
|
+
continue;
|
|
509
|
+
const severity = SEVERITY_ORDER.find((item) => item === match[1].toLowerCase());
|
|
510
|
+
if (!severity)
|
|
511
|
+
continue;
|
|
512
|
+
found.push({ severity, file: match[2], line: Number(match[3]), comment: match[4].trim() });
|
|
513
|
+
}
|
|
514
|
+
return found;
|
|
515
|
+
}
|
|
516
|
+
function proseNotes(job, markdown) {
|
|
517
|
+
const notes = [];
|
|
518
|
+
const push = (text) => {
|
|
519
|
+
const trimmed = text?.trim();
|
|
520
|
+
if (!trimmed || notes.includes(trimmed))
|
|
521
|
+
return;
|
|
522
|
+
if (markdown && (trimmed === markdown || markdown.includes(trimmed)))
|
|
523
|
+
return;
|
|
524
|
+
if (/^title:\s*/i.test(trimmed))
|
|
525
|
+
return;
|
|
526
|
+
if (trimmed.startsWith("{") && trimmed.includes('"markdown"'))
|
|
527
|
+
return;
|
|
528
|
+
notes.push(trimmed);
|
|
529
|
+
};
|
|
530
|
+
if (job.workflow !== "investigation")
|
|
531
|
+
push(job.diffSummary);
|
|
532
|
+
for (const gate of job.gates) {
|
|
533
|
+
for (const review of gate.reviews)
|
|
534
|
+
push(reviewLabel(review.name, review.summary));
|
|
535
|
+
for (const artifact of gate.artifacts) {
|
|
536
|
+
if (artifact.name === "input" || artifact.name === "draft")
|
|
537
|
+
continue;
|
|
538
|
+
push(artifact.summary);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return notes;
|
|
542
|
+
}
|
|
543
|
+
function noteLines(job, deliverable) {
|
|
544
|
+
const covered = deliverable?.kind === "prose" ? deliverable.notes : [];
|
|
545
|
+
const notes = [];
|
|
546
|
+
const push = (text) => {
|
|
547
|
+
const trimmed = text?.trim();
|
|
548
|
+
if (!trimmed || notes.includes(trimmed) || covered.includes(trimmed) || isDump(trimmed))
|
|
549
|
+
return;
|
|
550
|
+
notes.push(trimmed);
|
|
551
|
+
};
|
|
552
|
+
push(job.launchError);
|
|
553
|
+
push(job.failureDetail);
|
|
554
|
+
push(job.waitReason);
|
|
555
|
+
push(job.reviewPostError);
|
|
556
|
+
return notes;
|
|
557
|
+
}
|
|
558
|
+
function evidenceLines(deliverable, notes) {
|
|
559
|
+
const lines = [...notes];
|
|
560
|
+
if (deliverable?.kind === "review" && deliverable.markdown)
|
|
561
|
+
lines.unshift(deliverable.markdown);
|
|
562
|
+
if (deliverable?.kind === "prose" && deliverable.markdown)
|
|
563
|
+
lines.unshift(deliverable.markdown);
|
|
564
|
+
return lines;
|
|
565
|
+
}
|
|
566
|
+
function isDump(text) {
|
|
567
|
+
if (/^title:\s*/i.test(text))
|
|
568
|
+
return true;
|
|
569
|
+
if (text.startsWith("{") && text.includes('"markdown"'))
|
|
570
|
+
return true;
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
function collectedTexts(job) {
|
|
574
|
+
const texts = [];
|
|
575
|
+
for (const gate of job.gates) {
|
|
576
|
+
if (gate.rendered)
|
|
577
|
+
texts.push(gate.rendered);
|
|
578
|
+
for (const artifact of gate.artifacts)
|
|
579
|
+
texts.push(artifact.summary);
|
|
580
|
+
for (const review of gate.reviews)
|
|
581
|
+
texts.push(review.summary);
|
|
582
|
+
}
|
|
583
|
+
for (const file of job.files ?? [])
|
|
584
|
+
texts.push(file.text);
|
|
585
|
+
return texts;
|
|
586
|
+
}
|
|
587
|
+
function finishedLine(job) {
|
|
588
|
+
if (job.state === "cancelled")
|
|
589
|
+
return "Cancelled";
|
|
590
|
+
if (job.state === "rejected")
|
|
591
|
+
return "Rejected";
|
|
592
|
+
if (job.workflow === "review")
|
|
593
|
+
return job.reviewUrl ? "comment posted" : "approved";
|
|
594
|
+
if (job.prNumber)
|
|
595
|
+
return `merged · PR #${job.prNumber}`;
|
|
596
|
+
if (job.prUrl && httpUrl(job.prUrl))
|
|
597
|
+
return "merged · PR";
|
|
598
|
+
return job.branch ? `landed locally · ${job.branch}` : "landed locally";
|
|
599
|
+
}
|
|
600
|
+
function handLine(job, failure) {
|
|
601
|
+
if (job.reviewPostError)
|
|
602
|
+
return job.reviewPostError;
|
|
603
|
+
let label = "Needs a person";
|
|
604
|
+
if (job.stopReason === "attempt-cap")
|
|
605
|
+
label = "Attempt cap";
|
|
606
|
+
else if (job.stopReason === "fingerprint")
|
|
607
|
+
label = "Same failure";
|
|
608
|
+
else if (job.feedbackReason === "feedback-exhausted")
|
|
609
|
+
label = "Fix round cap";
|
|
610
|
+
else if (job.feedbackReason === "feedback-ambiguous")
|
|
611
|
+
label = "Feedback unclear";
|
|
612
|
+
else if (job.state === "failed" || job.state === "dead" || job.state === "invalid")
|
|
613
|
+
label = attentionReason(job);
|
|
614
|
+
const parts = [label];
|
|
615
|
+
if (failure?.class)
|
|
616
|
+
parts.push(failure.class);
|
|
617
|
+
const fingerprint = shortFingerprint(failure?.fingerprint);
|
|
618
|
+
if (fingerprint)
|
|
619
|
+
parts.push(fingerprint);
|
|
620
|
+
return parts.join(" · ");
|
|
621
|
+
}
|
|
622
|
+
function attemptLine(job) {
|
|
623
|
+
if (job.attemptCount <= 0 && !job.lastFailureReason)
|
|
624
|
+
return undefined;
|
|
625
|
+
const parts = [];
|
|
626
|
+
if (job.attemptCount > 0)
|
|
627
|
+
parts.push(`attempt ${job.attemptCount}/${job.maxAttempts}`);
|
|
628
|
+
if (job.lastFailureReason)
|
|
629
|
+
parts.push(job.lastFailureReason);
|
|
630
|
+
return parts.join(" · ");
|
|
631
|
+
}
|
|
632
|
+
function jobActivity(events) {
|
|
633
|
+
let count = 0;
|
|
634
|
+
let latest;
|
|
635
|
+
for (const event of events) {
|
|
636
|
+
count += 1;
|
|
637
|
+
if (!latest || event.ts > latest)
|
|
638
|
+
latest = event.ts;
|
|
639
|
+
}
|
|
640
|
+
return { count, ...(latest ? { latest } : {}) };
|
|
641
|
+
}
|
|
642
|
+
function latestFailure(events) {
|
|
643
|
+
let found;
|
|
644
|
+
for (const event of events) {
|
|
645
|
+
if (event.event !== "run.failed")
|
|
646
|
+
continue;
|
|
647
|
+
found = {
|
|
648
|
+
...(event.properties.class ? { class: event.properties.class } : {}),
|
|
649
|
+
...(event.properties.fingerprint ? { fingerprint: event.properties.fingerprint } : {}),
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
return found;
|
|
653
|
+
}
|
|
654
|
+
function jobCost(events) {
|
|
655
|
+
let usd = 0;
|
|
656
|
+
let saw = false;
|
|
657
|
+
for (const event of events) {
|
|
658
|
+
if (event.event !== "run.finished" && event.event !== "run.failed")
|
|
659
|
+
continue;
|
|
660
|
+
const amount = event.properties.usd;
|
|
661
|
+
if (typeof amount !== "number" || !Number.isFinite(amount) || amount < 0)
|
|
662
|
+
continue;
|
|
663
|
+
usd += amount;
|
|
664
|
+
saw = true;
|
|
665
|
+
}
|
|
666
|
+
return saw ? usd : null;
|
|
667
|
+
}
|
|
668
|
+
function findingView(finding) {
|
|
669
|
+
if (!finding.comment.trim())
|
|
670
|
+
return undefined;
|
|
671
|
+
return {
|
|
672
|
+
severity: finding.severity,
|
|
673
|
+
comment: finding.comment.trim(),
|
|
674
|
+
...(finding.file ? { file: finding.file } : {}),
|
|
675
|
+
...(finding.line !== undefined ? { line: finding.line } : {}),
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
function sortFindings(findings) {
|
|
679
|
+
return [...findings].sort((left, right) => SEVERITY_ORDER.indexOf(left.severity) - SEVERITY_ORDER.indexOf(right.severity));
|
|
680
|
+
}
|
|
681
|
+
function verdictOf(markdown) {
|
|
682
|
+
for (const line of markdown.split("\n")) {
|
|
683
|
+
const match = VERDICT_LINE.exec(line.trim());
|
|
684
|
+
if (match?.[1]?.trim())
|
|
685
|
+
return match[1].trim();
|
|
686
|
+
}
|
|
687
|
+
return undefined;
|
|
688
|
+
}
|
|
689
|
+
function reviewLabel(name, summary) {
|
|
690
|
+
const text = summary.trim();
|
|
691
|
+
if (!text)
|
|
692
|
+
return "";
|
|
693
|
+
if (text.toLowerCase().startsWith(name.toLowerCase()))
|
|
694
|
+
return text;
|
|
695
|
+
return `${name}: ${text}`;
|
|
696
|
+
}
|
|
697
|
+
function labelOf(id) {
|
|
698
|
+
return PHASE_LABEL[id] ?? id.replace(/[-_]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
|
699
|
+
}
|
|
700
|
+
function stepStatus(phase) {
|
|
701
|
+
if (phase === "done")
|
|
702
|
+
return "done";
|
|
703
|
+
if (phase === "current")
|
|
704
|
+
return "running";
|
|
705
|
+
if (phase === "failed")
|
|
706
|
+
return "failed";
|
|
707
|
+
return "pending";
|
|
708
|
+
}
|
|
709
|
+
function currentPhase(phases) {
|
|
710
|
+
return (phases.find((phase) => phase.status === "running")?.label ??
|
|
711
|
+
[...phases].reverse().find((phase) => phase.status === "failed")?.label ??
|
|
712
|
+
[...phases].reverse().find((phase) => phase.status === "done")?.label);
|
|
713
|
+
}
|
|
714
|
+
function longer(left, right) {
|
|
715
|
+
const a = left?.trim() ?? "";
|
|
716
|
+
const b = right.trim();
|
|
717
|
+
return a.length >= b.length ? a : b;
|
|
718
|
+
}
|
|
719
|
+
function parseObject(text) {
|
|
720
|
+
const slice = text.replace(/\.\.\.$/, "").trim();
|
|
721
|
+
try {
|
|
722
|
+
return JSON.parse(slice);
|
|
723
|
+
}
|
|
724
|
+
catch {
|
|
725
|
+
return undefined;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
function unescapeJson(value) {
|
|
729
|
+
try {
|
|
730
|
+
return JSON.parse(`"${value}"`);
|
|
731
|
+
}
|
|
732
|
+
catch {
|
|
733
|
+
return value.replace(/\\n/g, "\n").replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
function oneLine(text, max) {
|
|
737
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
738
|
+
if (flat.length <= max)
|
|
739
|
+
return flat;
|
|
740
|
+
return `${flat.slice(0, max - 1)}…`;
|
|
741
|
+
}
|
|
742
|
+
function shortFingerprint(value) {
|
|
743
|
+
if (!value)
|
|
744
|
+
return undefined;
|
|
745
|
+
return value.slice(0, 12);
|
|
746
|
+
}
|
|
747
|
+
function httpUrl(url) {
|
|
748
|
+
try {
|
|
749
|
+
const parsed = new URL(url);
|
|
750
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
751
|
+
}
|
|
752
|
+
catch {
|
|
753
|
+
return false;
|
|
754
|
+
}
|
|
755
|
+
}
|