@lchase/kiln 0.13.0 → 0.14.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/serve-page.js
CHANGED
|
@@ -1,20 +1,13 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { buildBoard, } from "./board-view.js";
|
|
4
3
|
import { packageRoot } from "./paths.js";
|
|
4
|
+
import { buildBoard, } from "./board-view.js";
|
|
5
|
+
import { COLUMN_LABEL, PIPELINE_COLUMNS, formatCost, formatDuration, formatLocal, formatRelative, shortSha, stateTone, } from "./job-view.js";
|
|
6
|
+
import { renderMarkdown } from "./markdown.js";
|
|
7
|
+
import { kilnVersion } from "./project.js";
|
|
5
8
|
import { formatSpend } from "./rails.js";
|
|
6
9
|
import { listWorkflowNames } from "./workflow.js";
|
|
7
10
|
const LIVE_SCRIPT = readFileSync(join(packageRoot(), "assets", "serve.js"), "utf8");
|
|
8
|
-
function kilnVersion() {
|
|
9
|
-
try {
|
|
10
|
-
const parsed = JSON.parse(readFileSync(join(packageRoot(), "package.json"), "utf8"));
|
|
11
|
-
return typeof parsed.version === "string" ? parsed.version : "";
|
|
12
|
-
}
|
|
13
|
-
catch {
|
|
14
|
-
return "";
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
const VERSION = kilnVersion();
|
|
18
11
|
export function renderConsole(css, attention, page) {
|
|
19
12
|
const jobs = [...attention.needsYou, ...attention.inFlight, ...attention.doneRecently].map((item) => item.job);
|
|
20
13
|
const board = buildBoard(jobs, []);
|
|
@@ -24,7 +17,8 @@ export function renderConsole(css, attention, page) {
|
|
|
24
17
|
export function renderApp(model) {
|
|
25
18
|
const page = model.page ?? { csrf: "", repos: [], workflows: listWorkflowNames() };
|
|
26
19
|
const paused = page.paused === true;
|
|
27
|
-
const
|
|
20
|
+
const versionText = kilnVersion();
|
|
21
|
+
const version = versionText && versionText !== "unknown" ? `<span class="ver">v${escapeHtml(versionText)}</span>` : "";
|
|
28
22
|
const spend = page.spend ? `<span class="spend" id="spend">${escapeHtml(formatSpend(page.spend))}</span>` : `<span class="spend" id="spend"></span>`;
|
|
29
23
|
const capNote = page.spend?.blocked ? `<span class="cap" id="cap-note">cap hit</span>` : `<span class="cap" id="cap-note" hidden>cap hit</span>`;
|
|
30
24
|
const body = `<div class="shell">
|
|
@@ -69,7 +63,7 @@ export function renderTextPage(css, title, text) {
|
|
|
69
63
|
revision: "",
|
|
70
64
|
section: "job",
|
|
71
65
|
live: false,
|
|
72
|
-
body: `<a class="back" href="/">
|
|
66
|
+
body: `<a class="back" href="/">Pipeline</a>
|
|
73
67
|
<div class="page"><h1>${escapeHtml(title)}</h1>
|
|
74
68
|
<pre class="text">${escapeHtml(text)}</pre></div>`,
|
|
75
69
|
});
|
|
@@ -82,7 +76,7 @@ export function renderMessagePage(css, title, message) {
|
|
|
82
76
|
revision: "",
|
|
83
77
|
section: "job",
|
|
84
78
|
live: false,
|
|
85
|
-
body: `<a class="back" href="/">
|
|
79
|
+
body: `<a class="back" href="/">Pipeline</a>
|
|
86
80
|
<div class="page"><h1>${escapeHtml(title)}</h1>
|
|
87
81
|
<p class="empty">${escapeHtml(message)}</p></div>`,
|
|
88
82
|
});
|
|
@@ -102,6 +96,7 @@ function navHtml(model) {
|
|
|
102
96
|
})
|
|
103
97
|
.join("");
|
|
104
98
|
return `<nav class="side" aria-label="Sections">
|
|
99
|
+
${item("/", "pipeline", "Pipeline", "pipeline")}
|
|
105
100
|
${item("/inbox", "inbox", "Inbox", "inbox", board.inboxCount)}
|
|
106
101
|
<p class="unseen" id="inbox-unseen" hidden></p>
|
|
107
102
|
${item("/hand", "hand", "Needs a hand", "hand", board.handCount)}
|
|
@@ -131,6 +126,9 @@ function mainHtml(model, page) {
|
|
|
131
126
|
if (model.section === "triggers") {
|
|
132
127
|
return `${error}<main class="main">${triggersHtml(model.triggers ?? [])}</main>`;
|
|
133
128
|
}
|
|
129
|
+
if (model.section === "pipeline") {
|
|
130
|
+
return `${error}<main class="main">${pipelineHtml(allJobs(model.board))}</main>`;
|
|
131
|
+
}
|
|
134
132
|
return `${error}<main class="main">${listHtml(model, page.csrf)}</main>`;
|
|
135
133
|
}
|
|
136
134
|
function triggersHtml(rows) {
|
|
@@ -142,6 +140,15 @@ function triggersHtml(rows) {
|
|
|
142
140
|
function triggerCard(row) {
|
|
143
141
|
const on = row.enabled;
|
|
144
142
|
const bits = [row.kind, row.schedule, on ? "enabled" : "disabled"];
|
|
143
|
+
if (row.label)
|
|
144
|
+
bits.push(`label ${row.label}`);
|
|
145
|
+
if (row.maxNew !== undefined)
|
|
146
|
+
bits.push(`${row.maxNew} new/fire`);
|
|
147
|
+
if (row.maxQueued !== undefined)
|
|
148
|
+
bits.push(`${row.maxQueued} queued or running`);
|
|
149
|
+
if (row.repoLabels && row.repoLabels.length > 0) {
|
|
150
|
+
bits.push(row.repoLabels.map((item) => `${item.repo} label ${item.label}`).join(", "));
|
|
151
|
+
}
|
|
145
152
|
const next = row.nextFire ? `<p class="meta">next <time datetime="${escapeHtml(row.nextFire)}" data-time>${escapeHtml(row.nextFire)}</time></p>` : "";
|
|
146
153
|
const last = row.lastFire ? `<p class="meta">last fire <time datetime="${escapeHtml(row.lastFire)}" data-time>${escapeHtml(row.lastFire)}</time></p>` : `<p class="meta">last fire none</p>`;
|
|
147
154
|
const error = row.lastError
|
|
@@ -160,10 +167,13 @@ ${next}${last}${error}
|
|
|
160
167
|
${recent}
|
|
161
168
|
</article>`;
|
|
162
169
|
}
|
|
170
|
+
function allJobs(board) {
|
|
171
|
+
return board.repos.flatMap((group) => group.jobs);
|
|
172
|
+
}
|
|
163
173
|
function listHtml(model, csrf) {
|
|
164
174
|
const board = model.board;
|
|
165
175
|
if (model.section === "inbox") {
|
|
166
|
-
return `<header class="list-head"><h2>Inbox</h2><p class="meta">Approve, reject, or revise. Kiln does not
|
|
176
|
+
return `<header class="list-head"><h2>Inbox</h2><p class="meta">Approve, reject, or revise. Approving a review posts the draft as a comment. Kiln does not approve or merge.</p></header>${jobList(board.inbox, "inbox", "Nothing is waiting on a gate.", csrf)}`;
|
|
167
177
|
}
|
|
168
178
|
if (model.section === "hand") {
|
|
169
179
|
return `<header class="list-head"><h2>Needs a hand</h2><p class="meta">A breaker tripped, a cap was hit, a review could not be posted, or a run failed with no retry left.</p></header>${jobList(board.hand, "hand", "Nothing needs a person.", csrf)}`;
|
|
@@ -174,10 +184,71 @@ function listHtml(model, csrf) {
|
|
|
174
184
|
const groups = model.repo ? board.repos.filter((group) => group.repo === model.repo) : board.repos;
|
|
175
185
|
if (groups.length === 0)
|
|
176
186
|
return `<header class="list-head"><h2>Repos</h2></header><p class="empty">No jobs.</p>`;
|
|
177
|
-
|
|
178
|
-
|
|
187
|
+
const heading = model.repo ? escapeHtml(model.repo) : "Repos";
|
|
188
|
+
return `<header class="list-head"><h2>${heading}</h2></header>${groups
|
|
189
|
+
.map((group) => {
|
|
190
|
+
const label = model.repo ? "" : `<h3>${escapeHtml(group.repo)}</h3>`;
|
|
191
|
+
return `<section class="repo-block">${label}${groupedJobs(group.jobs, "repos", csrf)}</section>`;
|
|
192
|
+
})
|
|
179
193
|
.join("")}`;
|
|
180
194
|
}
|
|
195
|
+
function pipelineHtml(jobs) {
|
|
196
|
+
const columns = PIPELINE_COLUMNS.map((column) => {
|
|
197
|
+
const cards = jobs.filter((job) => job.column === column).map((job) => pipeCard(job)).join("");
|
|
198
|
+
return `<section class="col" data-column="${column}">
|
|
199
|
+
<header class="full"><h2>${escapeHtml(COLUMN_LABEL[column])}</h2><span class="count"></span></header>
|
|
200
|
+
<div class="slim-face"><span>${escapeHtml(COLUMN_LABEL[column])}</span><span class="count"></span></div>
|
|
201
|
+
${cards}<p class="empty">${escapeHtml(emptyColumn(column))}</p>
|
|
202
|
+
</section>`;
|
|
203
|
+
}).join("");
|
|
204
|
+
return `<header class="list-head"><div class="head-row"><h2>Pipeline</h2><button type="button" id="compact" aria-pressed="true" aria-label="collapse empty columns"></button></div><p class="meta">Jobs moving through queued, running, needs you, needs a hand, and done.</p></header>${filtersHtml(jobs)}<div class="board-scroll"><div class="board" id="board">${columns}</div></div>`;
|
|
205
|
+
}
|
|
206
|
+
function emptyColumn(column) {
|
|
207
|
+
switch (column) {
|
|
208
|
+
case "queued":
|
|
209
|
+
return "Nothing queued.";
|
|
210
|
+
case "running":
|
|
211
|
+
return "Nothing running.";
|
|
212
|
+
case "needs-you":
|
|
213
|
+
return "Nothing needs you.";
|
|
214
|
+
case "needs-hand":
|
|
215
|
+
return "Nothing needs a hand.";
|
|
216
|
+
case "done":
|
|
217
|
+
return "Nothing finished.";
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function filtersHtml(jobs) {
|
|
221
|
+
const repos = [...new Set(jobs.map((job) => job.repo))].sort((left, right) => left.localeCompare(right));
|
|
222
|
+
const workflows = [...new Set(jobs.map((job) => job.workflow))].sort((left, right) => left.localeCompare(right));
|
|
223
|
+
const repoButtons = [`<button type="button" class="repo-f" data-repo="" aria-pressed="true">All</button>`]
|
|
224
|
+
.concat(repos.map((repo) => `<button type="button" class="repo-f" data-repo="${escapeHtml(repo)}">${escapeHtml(repo)}</button>`))
|
|
225
|
+
.join("");
|
|
226
|
+
const workflowButtons = [`<button type="button" class="wf-f" data-workflow="" aria-pressed="true">All</button>`]
|
|
227
|
+
.concat(workflows.map((workflow) => `<button type="button" class="wf-f" data-workflow="${escapeHtml(workflow)}">${escapeHtml(workflow)}</button>`))
|
|
228
|
+
.join("");
|
|
229
|
+
return `<div class="filters"><div class="filter"><span class="k">Repo</span>${repoButtons}</div><div class="filter"><span class="k">Workflow</span>${workflowButtons}</div></div>`;
|
|
230
|
+
}
|
|
231
|
+
function pipeCard(job) {
|
|
232
|
+
const id = encodeURIComponent(job.jobId);
|
|
233
|
+
const cost = formatCost(job.costUsd);
|
|
234
|
+
const age = job.since ? timeHtml(job.since, "rel") : "";
|
|
235
|
+
const phase = job.currentPhase ? `<span class="meta">${escapeHtml(job.currentPhase)}</span>` : "";
|
|
236
|
+
return `<article class="card" id="job-${escapeHtml(job.jobId)}" data-job="${escapeHtml(job.jobId)}" data-state="${escapeHtml(job.state)}" data-repo="${escapeHtml(job.repo)}" data-workflow="${escapeHtml(job.workflow)}"${job.latestEventAt ? ` data-latest="${escapeHtml(job.latestEventAt)}"` : ""}>
|
|
237
|
+
<a class="job-title" href="/jobs/${id}">${escapeHtml(job.title)}</a>
|
|
238
|
+
<div class="job-meta"><span class="meta">${escapeHtml(job.repo)}</span><span class="chip">${escapeHtml(job.workflow)}</span>${age}${cost ? `<span class="meta">${escapeHtml(cost)}</span>` : ""}${phase}</div>
|
|
239
|
+
</article>`;
|
|
240
|
+
}
|
|
241
|
+
function groupedJobs(jobs, section, csrf) {
|
|
242
|
+
const blocks = PIPELINE_COLUMNS.flatMap((column) => {
|
|
243
|
+
const rows = jobs.filter((job) => job.column === column);
|
|
244
|
+
if (rows.length === 0)
|
|
245
|
+
return [];
|
|
246
|
+
return [
|
|
247
|
+
`<section class="state-group"><h3><span class="chip ${column}">${escapeHtml(COLUMN_LABEL[column])}</span><span class="badge">${rows.length}</span></h3><div class="jobs">${rows.map((job) => jobRow(job, section, csrf)).join("\n")}</div></section>`,
|
|
248
|
+
];
|
|
249
|
+
});
|
|
250
|
+
return blocks.length === 0 ? `<p class="empty">No jobs.</p>` : blocks.join("");
|
|
251
|
+
}
|
|
181
252
|
function jobList(jobs, section, empty, csrf) {
|
|
182
253
|
if (jobs.length === 0)
|
|
183
254
|
return `<p class="empty">${escapeHtml(empty)}</p>`;
|
|
@@ -186,6 +257,8 @@ function jobList(jobs, section, empty, csrf) {
|
|
|
186
257
|
function jobRow(job, section, csrf) {
|
|
187
258
|
const id = encodeURIComponent(job.jobId);
|
|
188
259
|
const classes = ["job"];
|
|
260
|
+
if (section === "inbox")
|
|
261
|
+
classes.push("decision");
|
|
189
262
|
if (job.scheduledRetry)
|
|
190
263
|
classes.push("retrying");
|
|
191
264
|
if (section === "inbox" || (section === "hand" && !job.scheduledRetry))
|
|
@@ -195,28 +268,141 @@ function jobRow(job, section, csrf) {
|
|
|
195
268
|
const sandbox = job.sandboxed ? `<span class="meta">sandbox · docker</span>` : "";
|
|
196
269
|
const pr = section === "repos" && job.state === "in-review" ? `<span class="meta">PR review</span>` : "";
|
|
197
270
|
const retrying = job.scheduledRetry && job.nextRetryAt
|
|
198
|
-
? `<div class="meta">retrying <time datetime="${escapeHtml(job.nextRetryAt)}" data-time="clock">${escapeHtml(job.nextRetryAt)}</time>${job.attemptLine ? ` · ${escapeHtml(job.attemptLine)}` : ""}</div>`
|
|
271
|
+
? `<div class="meta">retrying <time datetime="${escapeHtml(job.nextRetryAt)}" data-time="clock">${escapeHtml(formatRelative(job.nextRetryAt))}</time>${job.attemptLine ? ` · ${escapeHtml(job.attemptLine)}` : ""}</div>`
|
|
199
272
|
: "";
|
|
200
273
|
const attempt = !retrying && job.attemptLine && (section === "hand" || section === "inbox") ? `<div class="meta">${escapeHtml(job.attemptLine)}</div>` : "";
|
|
201
274
|
const history = job.historyLine ? `<div class="meta">${escapeHtml(job.historyLine)}</div>` : "";
|
|
202
275
|
const unsafe = job.prUrl && !safeUrl(job.prUrl) ? `<div class="branch">${escapeHtml(job.prUrl)}</div>` : "";
|
|
203
276
|
const deps = dependsHtml(job);
|
|
204
|
-
const
|
|
277
|
+
const body = section === "inbox" ? decisionBody(job) : "";
|
|
278
|
+
const reason = section === "inbox" && !showReason(job) ? "" : `<p class="reason">${escapeHtml(job.reason)}</p>`;
|
|
205
279
|
const feedback = feedbackHtml(job);
|
|
206
|
-
const tail = section !== "inbox" && job.logTail ?
|
|
207
|
-
const notes = job
|
|
280
|
+
const tail = section !== "inbox" && job.logTail ? logDisclosure(job.logTail) : "";
|
|
281
|
+
const notes = visibleNotes(job, section);
|
|
208
282
|
const unseen = `<p class="unseen" data-unseen-for="${escapeHtml(job.jobId)}" hidden></p>`;
|
|
209
283
|
const links = fileLinks(job);
|
|
210
|
-
|
|
284
|
+
const age = job.since ? timeHtml(job.since, "rel") : "";
|
|
285
|
+
const cost = formatCost(job.costUsd);
|
|
286
|
+
return `<article class="${classes.join(" ")}" id="job-${escapeHtml(job.jobId)}" data-job="${escapeHtml(job.jobId)}" data-state="${escapeHtml(job.state)}" data-repo="${escapeHtml(job.repo)}" data-workflow="${escapeHtml(job.workflow)}" data-deps="${escapeHtml(job.dependsOn.map((dep) => dep.jobId).join(" "))}"${job.latestEventAt ? ` data-latest="${escapeHtml(job.latestEventAt)}"` : ""}>
|
|
211
287
|
<div class="job-main">
|
|
212
288
|
<div class="job-top">${flag}<a class="job-title" href="/jobs/${id}">${escapeHtml(job.title)}</a></div>
|
|
213
|
-
<div class="job-meta"><span class="num">${escapeHtml(job.jobId)}</span>${repo}<span class="
|
|
214
|
-
|
|
215
|
-
${history}${unsafe}${retrying}${attempt}${deps}${feedback}${evidence}${notes}${tail}${unseen}${links}
|
|
289
|
+
<div class="job-meta"><span class="num">${escapeHtml(job.jobId)}</span>${repo}<span class="chip ${stateTone(job.state)}">${escapeHtml(job.state)}</span><span class="chip">${escapeHtml(job.workflow)}</span>${age}${cost ? `<span class="meta">${escapeHtml(cost)}</span>` : ""}${sandbox}${pr}</div>
|
|
290
|
+
${reason}${section === "inbox" ? ranHtml(job) : ""}${history}${unsafe}${retrying}${attempt}${deps}${feedback}${body}${notes}${tail}${unseen}${links}
|
|
216
291
|
</div>
|
|
217
292
|
${actionsHtml(job, section, csrf)}
|
|
218
293
|
</article>`;
|
|
219
294
|
}
|
|
295
|
+
function showReason(job) {
|
|
296
|
+
if (!job.ask)
|
|
297
|
+
return true;
|
|
298
|
+
const norm = (value) => value.replace(/^Gate:\s*/i, "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
299
|
+
const ask = norm(job.ask);
|
|
300
|
+
const reason = norm(job.reason);
|
|
301
|
+
return reason !== ask && !reason.endsWith(ask);
|
|
302
|
+
}
|
|
303
|
+
function visibleNotes(job, section) {
|
|
304
|
+
const lines = job.notes.filter((line) => section === "inbox" || !looksLikeLog(line));
|
|
305
|
+
if (lines.length === 0)
|
|
306
|
+
return "";
|
|
307
|
+
return `<div class="note">${lines.map((line) => escapeHtml(line)).join("<br>")}</div>`;
|
|
308
|
+
}
|
|
309
|
+
function decisionBody(job) {
|
|
310
|
+
const ask = job.ask ? `<p class="ask">${escapeHtml(job.ask)}</p>` : "";
|
|
311
|
+
const policy = job.workflow === "review" && job.canApprove
|
|
312
|
+
? `<p class="meta">Approving posts this draft as a pull request comment. Kiln does not approve the pull request or merge it.</p>`
|
|
313
|
+
: "";
|
|
314
|
+
return `${ask}${policy}${ticksHtml(job.phases)}${deliverableHtml(job)}`;
|
|
315
|
+
}
|
|
316
|
+
function ranHtml(job) {
|
|
317
|
+
if (job.phases.length === 0 && job.costUsd === null)
|
|
318
|
+
return "";
|
|
319
|
+
const bits = [job.workflow];
|
|
320
|
+
if (job.phases.length > 0)
|
|
321
|
+
bits.push(job.phases.map((phase) => `${phase.label} ${phase.status}`).join(", "));
|
|
322
|
+
const durations = job.phases.flatMap((phase) => (phase.durationMs === undefined ? [] : [phase.durationMs]));
|
|
323
|
+
if (durations.length > 0)
|
|
324
|
+
bits.push(formatDuration(durations.reduce((sum, ms) => sum + ms, 0)));
|
|
325
|
+
const cost = formatCost(job.costUsd);
|
|
326
|
+
if (cost)
|
|
327
|
+
bits.push(cost);
|
|
328
|
+
return `<p class="ran">${escapeHtml(bits.join(" · "))}</p>`;
|
|
329
|
+
}
|
|
330
|
+
function ticksHtml(phases) {
|
|
331
|
+
if (phases.length === 0)
|
|
332
|
+
return "";
|
|
333
|
+
const items = phases
|
|
334
|
+
.map((phase) => {
|
|
335
|
+
const mark = phase.status === "done" ? "✓" : phase.status === "failed" ? "×" : phase.status === "running" ? "●" : "○";
|
|
336
|
+
return `<li class="tick ${phase.status}"><span class="mark" aria-hidden="true">${mark}</span>${escapeHtml(phase.label)}</li>`;
|
|
337
|
+
})
|
|
338
|
+
.join("");
|
|
339
|
+
return `<ol class="ticks">${items}</ol>`;
|
|
340
|
+
}
|
|
341
|
+
function deliverableHtml(job, skipPr = false) {
|
|
342
|
+
const deliverable = job.deliverable;
|
|
343
|
+
if (!deliverable)
|
|
344
|
+
return "";
|
|
345
|
+
if (deliverable.kind === "review")
|
|
346
|
+
return reviewHtml(job, deliverable, skipPr);
|
|
347
|
+
const prose = deliverable.markdown ? `<div class="prose">${renderMarkdown(deliverable.markdown)}</div>` : "";
|
|
348
|
+
const notes = deliverable.notes.length
|
|
349
|
+
? `<ul class="notes">${deliverable.notes.map((note) => `<li>${escapeHtml(note)}</li>`).join("")}</ul>`
|
|
350
|
+
: "";
|
|
351
|
+
return `${prose}${notes}`;
|
|
352
|
+
}
|
|
353
|
+
function reviewHtml(job, review, skipPr = false) {
|
|
354
|
+
const link = skipPr ? "" : prLinkHtml(job.repo, review.prNumber ?? job.prNumber, review.prUrl ?? job.prUrl, review.headSha ?? job.headSha);
|
|
355
|
+
const verdict = review.verdict ? `<p class="verdict">${escapeHtml(review.verdict)}</p>` : "";
|
|
356
|
+
const prose = review.markdown ? `<div class="prose">${renderMarkdown(review.markdown)}</div>` : "";
|
|
357
|
+
const raw = review.raw ? `<details class="raw"><summary>Raw output</summary><pre class="text">${escapeHtml(review.raw)}</pre></details>` : "";
|
|
358
|
+
return `${link}${verdict}${prose}${findingsHtml(review.findings)}${raw}`;
|
|
359
|
+
}
|
|
360
|
+
function findingsHtml(findings) {
|
|
361
|
+
if (findings.length === 0)
|
|
362
|
+
return `<p class="empty">No findings.</p>`;
|
|
363
|
+
const groups = [];
|
|
364
|
+
let severity = "";
|
|
365
|
+
let items = [];
|
|
366
|
+
const flush = () => {
|
|
367
|
+
if (!severity)
|
|
368
|
+
return;
|
|
369
|
+
groups.push(`<section class="finding-group"><h3>${escapeHtml(severity)}</h3><ul>${items.join("")}</ul></section>`);
|
|
370
|
+
};
|
|
371
|
+
for (const finding of findings) {
|
|
372
|
+
if (finding.severity !== severity) {
|
|
373
|
+
flush();
|
|
374
|
+
severity = finding.severity;
|
|
375
|
+
items = [];
|
|
376
|
+
}
|
|
377
|
+
const loc = finding.file && finding.line !== undefined
|
|
378
|
+
? `<code>${escapeHtml(`${finding.file}:${finding.line}`)}</code>`
|
|
379
|
+
: finding.file
|
|
380
|
+
? `<code>${escapeHtml(finding.file)}</code>`
|
|
381
|
+
: "";
|
|
382
|
+
items.push(`<li>${loc}<span>${escapeHtml(finding.comment)}</span></li>`);
|
|
383
|
+
}
|
|
384
|
+
flush();
|
|
385
|
+
return `<div class="findings">${groups.join("")}</div>`;
|
|
386
|
+
}
|
|
387
|
+
function prLinkHtml(repo, number, url, sha) {
|
|
388
|
+
if (!number && !url && !sha)
|
|
389
|
+
return "";
|
|
390
|
+
const label = number ? `${repo}#${number}` : "pull request";
|
|
391
|
+
const safe = url ? safeUrl(url) : undefined;
|
|
392
|
+
const link = safe ? `<a href="${escapeHtml(safe)}">${escapeHtml(label)}</a>` : escapeHtml(label);
|
|
393
|
+
const short = sha ? ` <code>${escapeHtml(shortSha(sha))}</code>` : "";
|
|
394
|
+
return `<p class="pr-line">${link}${short}</p>`;
|
|
395
|
+
}
|
|
396
|
+
function logDisclosure(text) {
|
|
397
|
+
return `<details class="log"><summary>Log</summary><pre class="text">${escapeHtml(clipTail(text))}</pre></details>`;
|
|
398
|
+
}
|
|
399
|
+
function looksLikeLog(text) {
|
|
400
|
+
return /yak resume|worktree branch|suspended —/.test(text) || text.split("\n").length > 8;
|
|
401
|
+
}
|
|
402
|
+
function timeHtml(iso, mode) {
|
|
403
|
+
const text = mode === "rel" ? formatRelative(iso) : mode === "clock" ? formatLocal(iso) : formatLocal(iso);
|
|
404
|
+
return `<time datetime="${escapeHtml(iso)}" data-time="${mode}">${escapeHtml(text)}</time>`;
|
|
405
|
+
}
|
|
220
406
|
function clipTail(text) {
|
|
221
407
|
const lines = text.split("\n").filter((line) => line.trim().length > 0);
|
|
222
408
|
const tail = lines.slice(-8);
|
|
@@ -235,12 +421,6 @@ function fileLinks(job) {
|
|
|
235
421
|
return "";
|
|
236
422
|
return `<p class="links">${links.join(" ")}</p>`;
|
|
237
423
|
}
|
|
238
|
-
function evidenceHtml(job) {
|
|
239
|
-
if (job.evidence.length === 0)
|
|
240
|
-
return "";
|
|
241
|
-
const texts = job.evidence.map((text) => (text.length > 500 ? `${text.slice(0, 499)}…` : text));
|
|
242
|
-
return `<div class="evidence">${texts.map((text) => `<pre class="text">${escapeHtml(text)}</pre>`).join("")}</div>`;
|
|
243
|
-
}
|
|
244
424
|
function feedbackHtml(job) {
|
|
245
425
|
if (job.feedback.length === 0)
|
|
246
426
|
return "";
|
|
@@ -261,7 +441,7 @@ function dependsHtml(job) {
|
|
|
261
441
|
.join(", ");
|
|
262
442
|
const detail = job.dependsOn
|
|
263
443
|
.map((dep) => dep.detail)
|
|
264
|
-
.filter((line) => Boolean(line))
|
|
444
|
+
.filter((line) => Boolean(line) && line !== job.reason)
|
|
265
445
|
.join("; ");
|
|
266
446
|
return `<p class="deps">depends on ${links}</p>${detail ? `<p class="meta">${escapeHtml(detail)}</p>` : ""}`;
|
|
267
447
|
}
|
|
@@ -300,9 +480,18 @@ function detailHtml(detail, tab, _csrf) {
|
|
|
300
480
|
})
|
|
301
481
|
.join("");
|
|
302
482
|
const panel = tab === "changes" ? changesHtml(detail) : tab === "timeline" ? timelineHtml(detail) : overviewHtml(detail);
|
|
483
|
+
const back = job.inbox ? "/inbox" : "/";
|
|
484
|
+
const backLabel = job.inbox ? "Inbox" : "Pipeline";
|
|
303
485
|
return `<div class="detail-main">
|
|
304
|
-
<a class="back" href="
|
|
305
|
-
<header class="list-head"
|
|
486
|
+
<a class="back" href="${back}">${backLabel}</a>
|
|
487
|
+
<header class="list-head">
|
|
488
|
+
<div class="job-top"><h2>${escapeHtml(job.title)}</h2><span class="chip ${stateTone(job.state)}">${escapeHtml(job.state)}</span></div>
|
|
489
|
+
<div class="job-meta"><a href="/repos/${encodeURIComponent(job.repo)}">${escapeHtml(job.repo)}</a><span class="chip">${escapeHtml(job.workflow)}</span>${job.branch ? `<span class="mono">${escapeHtml(job.branch)}</span>` : ""}${prLinkHtml(job.repo, job.prNumber, job.prUrl, job.headSha)}</div>
|
|
490
|
+
${job.ask ? `<p class="ask">${escapeHtml(job.ask)}</p>` : ""}
|
|
491
|
+
${job.workflow === "review" && job.canApprove ? `<p class="meta">Approving posts this draft as a pull request comment. Kiln does not approve the pull request or merge it.</p>` : ""}
|
|
492
|
+
${showReason(job) ? `<p class="reason">${escapeHtml(job.reason)}</p>` : ""}
|
|
493
|
+
<p class="unseen" data-unseen-for="${escapeHtml(job.jobId)}" hidden></p>
|
|
494
|
+
</header>
|
|
306
495
|
<nav class="tabs" aria-label="Job">${tabs}</nav>
|
|
307
496
|
${actionsHtml(job, "job", _csrf)}
|
|
308
497
|
${panel}
|
|
@@ -310,28 +499,40 @@ ${panel}
|
|
|
310
499
|
${railHtml(detail)}`;
|
|
311
500
|
}
|
|
312
501
|
function overviewHtml(detail) {
|
|
313
|
-
const
|
|
314
|
-
if (
|
|
502
|
+
const phases = detail.job.phases;
|
|
503
|
+
if (phases.length === 0)
|
|
315
504
|
return `<p class="empty">No phases in the event log yet.</p>`;
|
|
316
|
-
|
|
505
|
+
const cost = formatCost(detail.job.costUsd);
|
|
506
|
+
const summary = cost ? `<p class="meta">Cost ${escapeHtml(cost)}</p>` : "";
|
|
507
|
+
return `${summary}${deliverableHtml(detail.job, true)}<ol class="phases">${phases.map((phase) => phaseHtml(phase)).join("")}</ol>`;
|
|
317
508
|
}
|
|
318
|
-
function phaseHtml(
|
|
509
|
+
function phaseHtml(phase) {
|
|
319
510
|
const bits = [];
|
|
320
|
-
if (
|
|
321
|
-
bits.push(`<p class="verdict">${escapeHtml(
|
|
322
|
-
if (
|
|
323
|
-
bits.push(
|
|
324
|
-
if (
|
|
325
|
-
const exit =
|
|
326
|
-
bits.push(`<p class="meta">${escapeHtml(`${
|
|
511
|
+
if (phase.verdict)
|
|
512
|
+
bits.push(`<p class="verdict">${escapeHtml(phase.verdict)}</p>`);
|
|
513
|
+
if (phase.detail)
|
|
514
|
+
bits.push(phaseDetailHtml(phase.detail));
|
|
515
|
+
if (phase.testCommand || phase.exitCode !== undefined) {
|
|
516
|
+
const exit = phase.exitCode === undefined ? "" : ` · exit ${phase.exitCode}`;
|
|
517
|
+
bits.push(`<p class="meta">${escapeHtml(`${phase.testCommand ?? "test"}${exit}`)}</p>`);
|
|
327
518
|
}
|
|
328
|
-
if (
|
|
329
|
-
bits.push(`<p class="meta">${escapeHtml([
|
|
519
|
+
if (phase.failureClass || phase.fingerprint) {
|
|
520
|
+
bits.push(`<p class="meta">${escapeHtml([phase.failureClass, phase.fingerprint].filter(Boolean).join(" · "))}</p>`);
|
|
330
521
|
}
|
|
331
|
-
if (
|
|
332
|
-
bits.push(`<blockquote class="note">${escapeHtml(
|
|
333
|
-
const iteration =
|
|
334
|
-
|
|
522
|
+
if (phase.note)
|
|
523
|
+
bits.push(`<blockquote class="note">${escapeHtml(phase.note)}</blockquote>`);
|
|
524
|
+
const iteration = phase.iteration !== undefined ? ` <span class="meta">#${phase.iteration}</span>` : "";
|
|
525
|
+
const duration = phase.durationMs !== undefined ? `<span class="meta">${escapeHtml(formatDuration(phase.durationMs))}</span>` : "";
|
|
526
|
+
const when = phase.finishedAt ?? phase.startedAt;
|
|
527
|
+
const clock = when ? timeHtml(when, "local") + timeHtml(when, "rel") : "";
|
|
528
|
+
return `<li class="phase ${escapeHtml(phase.status)}"><header><span class="mark" aria-hidden="true">${phase.status === "done" ? "✓" : phase.status === "failed" ? "×" : phase.status === "running" ? "●" : "○"}</span><span class="step">${escapeHtml(phase.label)}</span>${iteration}<span class="outcome">${escapeHtml(phase.status)}</span>${duration}${clock}</header>${bits.join("")}</li>`;
|
|
529
|
+
}
|
|
530
|
+
function phaseDetailHtml(detail) {
|
|
531
|
+
if (looksLikeLog(detail))
|
|
532
|
+
return logDisclosure(detail);
|
|
533
|
+
if (detail.includes("\n") || detail.length > 280)
|
|
534
|
+
return `<div class="prose">${renderMarkdown(detail)}</div>`;
|
|
535
|
+
return `<p>${escapeHtml(detail)}</p>`;
|
|
335
536
|
}
|
|
336
537
|
function changesHtml(detail) {
|
|
337
538
|
const changes = detail.changes;
|
|
@@ -350,15 +551,18 @@ function changesHtml(detail) {
|
|
|
350
551
|
return `<section class="changes">${branch}${base}${pr}${diff}<h3>Commits</h3>${commits}</section>`;
|
|
351
552
|
}
|
|
352
553
|
function timelineHtml(detail) {
|
|
353
|
-
|
|
554
|
+
const runs = detail.job.timeline;
|
|
555
|
+
if (runs.length === 0)
|
|
354
556
|
return `<p class="empty">No events.</p>`;
|
|
355
|
-
|
|
356
|
-
.map((
|
|
357
|
-
const
|
|
358
|
-
|
|
557
|
+
return runs
|
|
558
|
+
.map((run) => {
|
|
559
|
+
const label = run.runId ? `Run ${run.runId}` : "Job";
|
|
560
|
+
const rows = run.entries
|
|
561
|
+
.map((entry) => `<li>${timeHtml(entry.ts, "local")}${timeHtml(entry.ts, "rel")}<span>${escapeHtml(entry.summary)}</span></li>`)
|
|
562
|
+
.join("");
|
|
563
|
+
return `<section class="run-group"><h3>${escapeHtml(label)}</h3><ol class="timeline">${rows}</ol></section>`;
|
|
359
564
|
})
|
|
360
565
|
.join("");
|
|
361
|
-
return `<ol class="timeline">${rows}</ol>`;
|
|
362
566
|
}
|
|
363
567
|
function railHtml(detail) {
|
|
364
568
|
const rail = detail.rail;
|
|
@@ -414,12 +618,14 @@ function statsHtml(stats) {
|
|
|
414
618
|
return `<li><span class="mono">${escapeHtml(row.repo)} / ${escapeHtml(row.jobId)}</span> ${row.total}<ul>${reasons}</ul></li>`;
|
|
415
619
|
})
|
|
416
620
|
.join("")}</ul><p class="meta">${stats.interventions.total} ${stats.interventions.total === 1 ? "intervention" : "interventions"}</p>`;
|
|
417
|
-
return `<header class="list-head"><h2>Stats</h2><nav class="tabs" aria-label="Window">${windows}</nav></header>
|
|
418
|
-
<
|
|
621
|
+
return `<header class="list-head"><h2>Stats</h2><nav class="tabs" aria-label="Window">${windows}</nav><p class="meta">Since ${timeHtml(stats.since, "local")}</p></header>
|
|
622
|
+
<div class="stat-grid">
|
|
623
|
+
<section class="stat"><h3>Spend</h3><p class="stat-num">${escapeHtml(formatDollars(stats.spend.usd))}</p><p class="meta">Today ${escapeHtml(stats.spend.day)} / ${escapeHtml(cap)}</p><p class="meta">This window ${escapeHtml(formatDollars(stats.windowUsd))}</p></section>
|
|
624
|
+
<section class="stat"><h3>Runs</h3><p class="stat-num">${stats.runs.finished}</p><p class="meta">${stats.runs.started} started · ${stats.runs.finished} finished · ${stats.runs.failed} failed</p>${classRows}</section>
|
|
625
|
+
<section class="stat"><h3>Interventions</h3>${interventions}</section>
|
|
626
|
+
</div>
|
|
419
627
|
<section class="stat"><h3>Cost by repo</h3>${repoRows}</section>
|
|
420
|
-
<section class="stat"><h3>Cost by phase</h3>${phaseRows}</section
|
|
421
|
-
<section class="stat"><h3>Runs</h3><p>${stats.runs.started} started · ${stats.runs.finished} finished · ${stats.runs.failed} failed</p>${classRows}</section>
|
|
422
|
-
<section class="stat"><h3>Interventions</h3>${interventions}</section>`;
|
|
628
|
+
<section class="stat"><h3>Cost by phase</h3>${phaseRows}</section>`;
|
|
423
629
|
}
|
|
424
630
|
function reportHtml(report) {
|
|
425
631
|
const cap = report.spend.cap === null ? "no cap" : formatDollars(report.spend.cap);
|
|
@@ -429,7 +635,7 @@ function reportHtml(report) {
|
|
|
429
635
|
.map((run) => {
|
|
430
636
|
const cost = run.usd === null ? "" : ` · ${escapeHtml(formatDollars(run.usd))}`;
|
|
431
637
|
const klass = run.failureClass ? ` · ${escapeHtml(run.failureClass)}` : "";
|
|
432
|
-
return `<li><a href="/jobs/${encodeURIComponent(run.jobId)}">${escapeHtml(run.title)}</a> <span class="
|
|
638
|
+
return `<li><a href="/jobs/${encodeURIComponent(run.jobId)}">${escapeHtml(run.title)}</a> <span class="chip ${escapeHtml(run.outcome)}">${escapeHtml(run.outcome)}</span> <span class="meta">${escapeHtml(run.repo)}${klass}${cost}</span> ${timeHtml(run.at, "rel")}</li>`;
|
|
433
639
|
})
|
|
434
640
|
.join("")}</ul>`;
|
|
435
641
|
const interventions = report.interventions.total === 0
|
|
@@ -438,9 +644,12 @@ function reportHtml(report) {
|
|
|
438
644
|
.map((row) => `<li><span class="mono">${escapeHtml(row.repo)} / ${escapeHtml(row.jobId)}</span> ${row.total}</li>`)
|
|
439
645
|
.join("")}</ul>`;
|
|
440
646
|
return `<header class="list-head"><h2>Today</h2><p class="meta">${escapeHtml(report.day)}</p></header>
|
|
441
|
-
<
|
|
442
|
-
<section class="stat"><h3>
|
|
443
|
-
<section class="stat"><h3>
|
|
647
|
+
<div class="stat-grid">
|
|
648
|
+
<section class="stat"><h3>Spend</h3><p class="stat-num">${escapeHtml(formatDollars(report.spend.usd))}</p><p class="meta">of ${escapeHtml(cap)}</p></section>
|
|
649
|
+
<section class="stat"><h3>Runs</h3><p class="stat-num">${report.runs.length}</p></section>
|
|
650
|
+
<section class="stat"><h3>Interventions</h3>${interventions}</section>
|
|
651
|
+
</div>
|
|
652
|
+
<section class="stat"><h3>What ran</h3>${runs}</section>`;
|
|
444
653
|
}
|
|
445
654
|
function tableRows(rows, empty) {
|
|
446
655
|
if (rows.length === 0)
|
package/dist/serve.js
CHANGED
|
@@ -5,7 +5,7 @@ import { homedir } from "node:os";
|
|
|
5
5
|
import { join, resolve, sep } from "node:path";
|
|
6
6
|
import { AUTH_PREFLIGHT_PREFIX } from "./auth.js";
|
|
7
7
|
import { loadKilnConfig } from "./config.js";
|
|
8
|
-
import { answerJob, readStatus, retryJob, reviseJob, runTick, startupRecovery } from "./driver.js";
|
|
8
|
+
import { answerJob, cancelQueuedJobs, readStatus, retryJob, reviseJob, runTick, startupRecovery } from "./driver.js";
|
|
9
9
|
import { browse } from "./fs-browse.js";
|
|
10
10
|
import { branchOf, buildBoard, dayReport, jobDetail, parseSeenQuery, parseStatsWindow, readBranchCommits, readDiffStat, statsFor, unseenChanges, } from "./board-view.js";
|
|
11
11
|
import { appendEvent, eventsForJob, readEvents } from "./events.js";
|
|
@@ -167,6 +167,10 @@ async function handle(req, res, state, boundPort, css, csrf) {
|
|
|
167
167
|
await createJob(req, res, state, raw);
|
|
168
168
|
return;
|
|
169
169
|
}
|
|
170
|
+
if (path === "/api/jobs/cancel") {
|
|
171
|
+
await cancelQueued(res, config, secrets, raw);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
170
174
|
if (path === "/api/pause" || path === "/api/resume") {
|
|
171
175
|
const pausing = path === "/api/pause";
|
|
172
176
|
const changed = pausing ? pauseFactory(config.kilnDir) : resumeFactory(config.kilnDir);
|
|
@@ -341,6 +345,44 @@ async function handle(req, res, state, boundPort, css, csrf) {
|
|
|
341
345
|
else
|
|
342
346
|
sendHtml(res, 404, renderMessagePage(css, "Not found", "Not found."), secrets);
|
|
343
347
|
}
|
|
348
|
+
async function cancelQueued(res, config, secrets, raw) {
|
|
349
|
+
let parsed;
|
|
350
|
+
try {
|
|
351
|
+
parsed = JSON.parse(raw || "{}");
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
sendJson(res, 400, { error: "Request body is not JSON." }, secrets);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (!isRecord(parsed) || parsed.queued !== true) {
|
|
358
|
+
sendJson(res, 400, { error: "Cancel requires queued: true." }, secrets);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const repo = parsed.repo;
|
|
362
|
+
const workflow = parsed.workflow;
|
|
363
|
+
if (repo !== undefined && typeof repo !== "string") {
|
|
364
|
+
sendJson(res, 400, { error: "repo must be a string." }, secrets);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (workflow !== undefined && typeof workflow !== "string") {
|
|
368
|
+
sendJson(res, 400, { error: "workflow must be a string." }, secrets);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
const result = cancelQueuedJobs(config, {
|
|
373
|
+
...(typeof repo === "string" && repo.length > 0 ? { repo } : {}),
|
|
374
|
+
...(typeof workflow === "string" && workflow.length > 0 ? { workflow } : {}),
|
|
375
|
+
});
|
|
376
|
+
sendJson(res, 200, { ok: true, cancelled: result.cancelled }, secrets);
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
380
|
+
sendJson(res, 400, { error: message }, secrets);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
function isRecord(value) {
|
|
384
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
385
|
+
}
|
|
344
386
|
async function answer(req, res, config, css, secrets, jobId, choice) {
|
|
345
387
|
try {
|
|
346
388
|
answerJob(config, jobId, choice);
|
|
@@ -724,7 +766,9 @@ function statusDocument(state) {
|
|
|
724
766
|
};
|
|
725
767
|
}
|
|
726
768
|
function listSection(path) {
|
|
727
|
-
if (path === "/" || path === "" || path === "/board" || path === "/
|
|
769
|
+
if (path === "/" || path === "" || path === "/board" || path === "/pipeline")
|
|
770
|
+
return "pipeline";
|
|
771
|
+
if (path === "/inbox")
|
|
728
772
|
return "inbox";
|
|
729
773
|
if (path === "/hand")
|
|
730
774
|
return "hand";
|
package/dist/settings.js
CHANGED
|
@@ -5,7 +5,7 @@ import { basename, dirname, join } from "node:path";
|
|
|
5
5
|
import { assertBranchName, assertCheckoutDirectory } from "./config.js";
|
|
6
6
|
import { DEFAULT_BRANCH_TEMPLATE, DEFAULT_PR_TITLE } from "./pr-names.js";
|
|
7
7
|
import { DEFAULT_MAX_ATTEMPTS, DEFAULT_MAX_FIX_ROUNDS, DEFAULT_MAX_SAME_FINGERPRINT, DEFAULT_STALL_TIMEOUT_MS, KilnConfigFileSchema, SettingsWriteSchema, TriggerConfigSchema, } from "./schemas.js";
|
|
8
|
-
import { applyTriggerToggles, effectiveTriggers, knownTrigger } from "./triggers.js";
|
|
8
|
+
import { applyTriggerToggles, effectiveTriggers, knownTrigger, REVIEW_REQUESTS_TRIGGER, reviewMaxNew, reviewMaxQueued, reviewRequestLabel, } from "./triggers.js";
|
|
9
9
|
const DEFAULTS = {
|
|
10
10
|
deliver: "local",
|
|
11
11
|
prTitle: DEFAULT_PR_TITLE,
|
|
@@ -129,6 +129,7 @@ function mergeSettings(raw, settings) {
|
|
|
129
129
|
return next;
|
|
130
130
|
}
|
|
131
131
|
function triggerView(trigger) {
|
|
132
|
+
const review = trigger.id === REVIEW_REQUESTS_TRIGGER;
|
|
132
133
|
return {
|
|
133
134
|
id: trigger.id,
|
|
134
135
|
kind: trigger.kind,
|
|
@@ -137,8 +138,9 @@ function triggerView(trigger) {
|
|
|
137
138
|
...(trigger.every ? { every: trigger.every } : {}),
|
|
138
139
|
...(trigger.repo ? { repo: trigger.repo } : {}),
|
|
139
140
|
...(trigger.jql ? { jql: trigger.jql } : {}),
|
|
140
|
-
...(trigger.label ? { label: trigger.label } : {}),
|
|
141
|
+
...(trigger.label || review ? { label: review ? reviewRequestLabel(trigger) : trigger.label } : {}),
|
|
141
142
|
...(trigger.status ? { status: trigger.status } : {}),
|
|
143
|
+
...(review ? { maxNew: reviewMaxNew(trigger), maxQueued: reviewMaxQueued(trigger) } : {}),
|
|
142
144
|
};
|
|
143
145
|
}
|
|
144
146
|
function storedTriggers(value) {
|