@lchase/kiln 0.10.0 → 0.11.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/assets/serve.css +190 -480
- package/assets/serve.js +72 -3
- package/dist/board-view.js +688 -0
- package/dist/board.js +0 -97
- package/dist/serve-page.js +384 -324
- package/dist/serve.js +183 -11
- package/package.json +3 -2
package/dist/serve-page.js
CHANGED
|
@@ -1,18 +1,9 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import { formatSpend } from "./rails.js";
|
|
3
2
|
import { join } from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
import { readBoardFiles, stageOf, stagesFor } from "./board.js";
|
|
6
|
-
import { feedbackRoundLabel } from "./feedback.js";
|
|
7
|
-
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
3
|
+
import { buildBoard, } from "./board-view.js";
|
|
8
4
|
import { packageRoot } from "./paths.js";
|
|
5
|
+
import { formatSpend } from "./rails.js";
|
|
9
6
|
import { listWorkflowNames } from "./workflow.js";
|
|
10
|
-
const REVIEWERS = [
|
|
11
|
-
["security", "sec"],
|
|
12
|
-
["ux", "ux"],
|
|
13
|
-
["ui", "ui"],
|
|
14
|
-
["code", "code"],
|
|
15
|
-
];
|
|
16
7
|
const LIVE_SCRIPT = readFileSync(join(packageRoot(), "assets", "serve.js"), "utf8");
|
|
17
8
|
function kilnVersion() {
|
|
18
9
|
try {
|
|
@@ -25,62 +16,47 @@ function kilnVersion() {
|
|
|
25
16
|
}
|
|
26
17
|
const VERSION = kilnVersion();
|
|
27
18
|
export function renderConsole(css, attention, page) {
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
grouped.set(stage, list);
|
|
37
|
-
}
|
|
38
|
-
const tracks = stages
|
|
39
|
-
.map((stage) => ((grouped.get(stage.id)?.length ?? 0) === 0 ? "40px" : "minmax(0, 1fr)"))
|
|
40
|
-
.join(" ");
|
|
41
|
-
const columns = stages.map((stage) => column(stage, grouped.get(stage.id) ?? [])).join("\n");
|
|
42
|
-
const repos = [...new Set(items.map((item) => item.job.repo).filter((repo) => repo.length > 0))].sort();
|
|
43
|
-
const chips = [
|
|
44
|
-
chip("", "all", items.length, true),
|
|
45
|
-
...repos.map((repo) => chip(repo, repo, items.filter((item) => item.job.repo === repo).length, false)),
|
|
46
|
-
].join("");
|
|
19
|
+
const jobs = [...attention.needsYou, ...attention.inFlight, ...attention.doneRecently].map((item) => item.job);
|
|
20
|
+
const board = buildBoard(jobs, []);
|
|
21
|
+
const section = page?.section ?? "inbox";
|
|
22
|
+
return renderApp({ css, section, board, attention, ...(page ? { page, ...(page.repo ? { repo: page.repo } : {}) } : {}) });
|
|
23
|
+
}
|
|
24
|
+
export function renderApp(model) {
|
|
25
|
+
const page = model.page ?? { csrf: "", repos: [], workflows: listWorkflowNames() };
|
|
26
|
+
const paused = page.paused === true;
|
|
47
27
|
const version = VERSION ? `<span class="ver">v${escapeHtml(VERSION)}</span>` : "";
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
const body = `<
|
|
51
|
-
<
|
|
52
|
-
|
|
53
|
-
<
|
|
54
|
-
<
|
|
55
|
-
</
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
${
|
|
60
|
-
</
|
|
61
|
-
<
|
|
62
|
-
|
|
63
|
-
<
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
<span class="sep" aria-hidden="true"></span>
|
|
68
|
-
<button class="tool" type="button" id="compact" aria-pressed="true" data-tip="expand empty columns" aria-label="expand empty columns"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12h7m0 0-3-3m3 3-3 3M21 12h-7m0 0 3-3m-3 3 3 3M12 4v16"/></svg></button>
|
|
69
|
-
<span class="sep" aria-hidden="true"></span>
|
|
70
|
-
<button class="tool" type="button" id="open-settings" data-tip="Settings" aria-label="Settings"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 3v2.2M12 18.8V21M3 12h2.2M18.8 12H21M5.6 5.6l1.6 1.6M16.8 16.8l1.6 1.6M18.4 5.6l-1.6 1.6M7.2 16.8l-1.6 1.6"/></svg></button>
|
|
71
|
-
</nav>
|
|
28
|
+
const spend = page.spend ? `<span class="spend" id="spend">${escapeHtml(formatSpend(page.spend))}</span>` : `<span class="spend" id="spend"></span>`;
|
|
29
|
+
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
|
+
const body = `<div class="shell">
|
|
31
|
+
<aside class="nav" aria-label="Kiln">
|
|
32
|
+
<div class="brand"><span class="logo" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12V6l5-3 5 3v6l-5 3-5-3z"/><path d="M3 6l5 3 5-3M8 9v6"/></svg></span><div><h1>Kiln</h1>${version}</div></div>
|
|
33
|
+
<div class="runstate" id="runstate">
|
|
34
|
+
<span class="live-dot${paused ? " paused" : ""}" id="run-dot" aria-hidden="true"></span>
|
|
35
|
+
<span id="run-label">${paused ? "Paused" : "Running"}</span>
|
|
36
|
+
<form method="post" action="${paused ? "/api/resume" : "/api/pause"}" class="pause" id="pause-form"><input type="hidden" name="csrf" value="${escapeHtml(page.csrf)}"><button type="submit" id="pause-toggle" aria-pressed="${paused ? "true" : "false"}">${paused ? "Resume" : "Pause"}</button></form>
|
|
37
|
+
</div>
|
|
38
|
+
${spend}${capNote}
|
|
39
|
+
${navHtml(model)}
|
|
40
|
+
<button class="add" type="button" id="add-job">Add job</button>
|
|
41
|
+
<button class="tool-link" type="button" id="notify">Notify me</button>
|
|
42
|
+
</aside>
|
|
43
|
+
<div class="workspace${model.section === "job" ? " with-rail" : ""}">
|
|
44
|
+
${mainHtml(model, page)}
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
72
47
|
<div class="scrim" data-close></div>
|
|
73
|
-
<aside class="drawer" id="drawer" role="dialog" aria-modal="true" aria-label="
|
|
74
|
-
${intakeSheet(
|
|
75
|
-
${settingsSheet()}
|
|
76
|
-
${items.map((item) => sheet(item, intake.csrf)).join("\n")}
|
|
48
|
+
<aside class="drawer" id="drawer" role="dialog" aria-modal="true" aria-label="Add job">
|
|
49
|
+
${intakeSheet(page, model.attention)}
|
|
77
50
|
</aside>`;
|
|
51
|
+
const job = model.detail?.job;
|
|
78
52
|
return document({
|
|
79
|
-
css,
|
|
80
|
-
title: `(${
|
|
81
|
-
needsYou: attention.needsYouCount,
|
|
82
|
-
revision: attention.revision,
|
|
83
|
-
csrf:
|
|
53
|
+
css: model.css,
|
|
54
|
+
title: `(${model.board.inboxCount}) Kiln`,
|
|
55
|
+
needsYou: model.attention.needsYouCount,
|
|
56
|
+
revision: model.attention.revision,
|
|
57
|
+
csrf: page.csrf,
|
|
58
|
+
section: model.section,
|
|
59
|
+
...(job ? { jobId: job.jobId, latest: job.latestEventAt } : {}),
|
|
84
60
|
live: true,
|
|
85
61
|
body,
|
|
86
62
|
});
|
|
@@ -91,8 +67,9 @@ export function renderTextPage(css, title, text) {
|
|
|
91
67
|
title,
|
|
92
68
|
needsYou: 0,
|
|
93
69
|
revision: "",
|
|
70
|
+
section: "job",
|
|
94
71
|
live: false,
|
|
95
|
-
body: `<a class="back" href="/">
|
|
72
|
+
body: `<a class="back" href="/">Inbox</a>
|
|
96
73
|
<div class="page"><h1>${escapeHtml(title)}</h1>
|
|
97
74
|
<pre class="text">${escapeHtml(text)}</pre></div>`,
|
|
98
75
|
});
|
|
@@ -103,53 +80,347 @@ export function renderMessagePage(css, title, message) {
|
|
|
103
80
|
title,
|
|
104
81
|
needsYou: 0,
|
|
105
82
|
revision: "",
|
|
83
|
+
section: "job",
|
|
106
84
|
live: false,
|
|
107
|
-
body: `<a class="back" href="/">
|
|
85
|
+
body: `<a class="back" href="/">Inbox</a>
|
|
108
86
|
<div class="page"><h1>${escapeHtml(title)}</h1>
|
|
109
87
|
<p class="empty">${escapeHtml(message)}</p></div>`,
|
|
110
88
|
});
|
|
111
89
|
}
|
|
112
|
-
function
|
|
113
|
-
const
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
90
|
+
function navHtml(model) {
|
|
91
|
+
const board = model.board;
|
|
92
|
+
const item = (href, id, label, section, badge) => {
|
|
93
|
+
const current = model.section === section ? ` aria-current="page"` : "";
|
|
94
|
+
const count = badge !== undefined && badge > 0 ? `<span class="badge">${badge}</span>` : "";
|
|
95
|
+
const extra = id === "open-settings" ? ` id="open-settings"` : "";
|
|
96
|
+
return `<a class="nav-link" href="${href}"${current}${extra}>${escapeHtml(label)}${count}</a>`;
|
|
97
|
+
};
|
|
98
|
+
const repos = board.repos
|
|
99
|
+
.map((group) => {
|
|
100
|
+
const current = model.section === "repos" && model.repo === group.repo ? ` aria-current="page"` : "";
|
|
101
|
+
return `<a class="nav-sub" href="/repos/${encodeURIComponent(group.repo)}"${current}>${escapeHtml(group.repo)}<span class="badge">${group.jobs.length}</span></a>`;
|
|
102
|
+
})
|
|
103
|
+
.join("");
|
|
104
|
+
return `<nav class="side" aria-label="Sections">
|
|
105
|
+
${item("/inbox", "inbox", "Inbox", "inbox", board.inboxCount)}
|
|
106
|
+
<p class="unseen" id="inbox-unseen" hidden></p>
|
|
107
|
+
${item("/hand", "hand", "Needs a hand", "hand", board.handCount)}
|
|
108
|
+
${item("/repos", "repos", "Repos", "repos")}
|
|
109
|
+
${repos}
|
|
110
|
+
${item("/history", "history", "History", "history", board.history.length)}
|
|
111
|
+
${item("/stats", "stats", "Stats", "stats")}
|
|
112
|
+
${item("/report", "report", "Today", "report")}
|
|
113
|
+
${item("/settings", "open-settings", "Settings", "settings")}
|
|
114
|
+
</nav>`;
|
|
115
|
+
}
|
|
116
|
+
function mainHtml(model, page) {
|
|
117
|
+
const error = `<p class="meta config-error" id="config-error" role="status"${page.configError ? "" : " hidden"}>${escapeHtml(page.configError ?? "")}</p>`;
|
|
118
|
+
if (model.section === "settings") {
|
|
119
|
+
return `${error}<main class="main"><h2>Settings</h2><section id="settings" aria-label="Settings">${settingsForm()}</section></main>`;
|
|
120
|
+
}
|
|
121
|
+
if (model.section === "stats" && model.stats) {
|
|
122
|
+
return `${error}<main class="main">${statsHtml(model.stats)}</main>`;
|
|
123
|
+
}
|
|
124
|
+
if (model.section === "report" && model.report) {
|
|
125
|
+
return `${error}<main class="main">${reportHtml(model.report)}</main>`;
|
|
126
|
+
}
|
|
127
|
+
if (model.section === "job" && model.detail) {
|
|
128
|
+
return `${error}<main class="main detail">${detailHtml(model.detail, model.tab ?? "overview", page.csrf)}</main>`;
|
|
129
|
+
}
|
|
130
|
+
return `${error}<main class="main">${listHtml(model, page.csrf)}</main>`;
|
|
122
131
|
}
|
|
123
|
-
function
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
132
|
+
function listHtml(model, csrf) {
|
|
133
|
+
const board = model.board;
|
|
134
|
+
if (model.section === "inbox") {
|
|
135
|
+
return `<header class="list-head"><h2>Inbox</h2><p class="meta">Approve, reject, or revise. Kiln does not do this on its own.</p></header>${jobList(board.inbox, "inbox", "Nothing is waiting on a gate.", csrf)}`;
|
|
136
|
+
}
|
|
137
|
+
if (model.section === "hand") {
|
|
138
|
+
return `<header class="list-head"><h2>Needs a hand</h2><p class="meta">A breaker tripped, a cap was hit, or a run failed with no retry left.</p></header>${jobList(board.hand, "hand", "Nothing needs a person.", csrf)}`;
|
|
139
|
+
}
|
|
140
|
+
if (model.section === "history") {
|
|
141
|
+
return `<header class="list-head"><h2>History</h2></header>${jobList(board.history, "history", "No finished jobs.", csrf)}`;
|
|
142
|
+
}
|
|
143
|
+
const groups = model.repo ? board.repos.filter((group) => group.repo === model.repo) : board.repos;
|
|
144
|
+
if (groups.length === 0)
|
|
145
|
+
return `<header class="list-head"><h2>Repos</h2></header><p class="empty">No jobs.</p>`;
|
|
146
|
+
return `<header class="list-head"><h2>${model.repo ? escapeHtml(model.repo) : "Repos"}</h2></header>${groups
|
|
147
|
+
.map((group) => `<section class="repo-block"><h3>${escapeHtml(group.repo)}</h3>${jobList(group.jobs, "repos", "No jobs.", csrf)}</section>`)
|
|
148
|
+
.join("")}`;
|
|
149
|
+
}
|
|
150
|
+
function jobList(jobs, section, empty, csrf) {
|
|
151
|
+
if (jobs.length === 0)
|
|
152
|
+
return `<p class="empty">${escapeHtml(empty)}</p>`;
|
|
153
|
+
return `<div class="jobs">${jobs.map((job) => jobRow(job, section, csrf)).join("\n")}</div>`;
|
|
154
|
+
}
|
|
155
|
+
function jobRow(job, section, csrf) {
|
|
156
|
+
const id = encodeURIComponent(job.jobId);
|
|
157
|
+
const classes = ["job"];
|
|
158
|
+
if (job.scheduledRetry)
|
|
128
159
|
classes.push("retrying");
|
|
129
|
-
|
|
160
|
+
if (section === "inbox" || (section === "hand" && !job.scheduledRetry))
|
|
130
161
|
classes.push("human");
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
${
|
|
147
|
-
|
|
148
|
-
${
|
|
149
|
-
|
|
150
|
-
</div
|
|
151
|
-
}
|
|
152
|
-
|
|
162
|
+
const flag = classes.includes("human") ? `<span class="flag">your call</span>` : "";
|
|
163
|
+
const repo = section === "repos" ? "" : `<span class="meta">${escapeHtml(job.repo)}</span>`;
|
|
164
|
+
const sandbox = job.sandboxed ? `<span class="meta">sandbox · docker</span>` : "";
|
|
165
|
+
const pr = section === "repos" && job.state === "in-review" ? `<span class="meta">PR review</span>` : "";
|
|
166
|
+
const retrying = job.scheduledRetry && job.nextRetryAt
|
|
167
|
+
? `<div class="meta">retrying <time datetime="${escapeHtml(job.nextRetryAt)}" data-time="clock">${escapeHtml(job.nextRetryAt)}</time>${job.attemptLine ? ` · ${escapeHtml(job.attemptLine)}` : ""}</div>`
|
|
168
|
+
: "";
|
|
169
|
+
const attempt = !retrying && job.attemptLine && (section === "hand" || section === "inbox") ? `<div class="meta">${escapeHtml(job.attemptLine)}</div>` : "";
|
|
170
|
+
const history = job.historyLine ? `<div class="meta">${escapeHtml(job.historyLine)}</div>` : "";
|
|
171
|
+
const unsafe = job.prUrl && !safeUrl(job.prUrl) ? `<div class="branch">${escapeHtml(job.prUrl)}</div>` : "";
|
|
172
|
+
const deps = dependsHtml(job);
|
|
173
|
+
const evidence = section === "inbox" ? evidenceHtml(job) : "";
|
|
174
|
+
const feedback = feedbackHtml(job);
|
|
175
|
+
const tail = section !== "inbox" && job.logTail ? `<div class="note">${escapeHtml(clipTail(job.logTail))}</div>` : "";
|
|
176
|
+
const notes = job.notes.length ? `<div class="note">${job.notes.map((line) => escapeHtml(line)).join("<br>")}</div>` : "";
|
|
177
|
+
const unseen = `<p class="unseen" data-unseen-for="${escapeHtml(job.jobId)}" hidden></p>`;
|
|
178
|
+
const links = fileLinks(job);
|
|
179
|
+
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-deps="${escapeHtml(job.dependsOn.map((dep) => dep.jobId).join(" "))}"${job.latestEventAt ? ` data-latest="${escapeHtml(job.latestEventAt)}"` : ""}>
|
|
180
|
+
<div class="job-main">
|
|
181
|
+
<div class="job-top">${flag}<a class="job-title" href="/jobs/${id}">${escapeHtml(job.title)}</a></div>
|
|
182
|
+
<div class="job-meta"><span class="num">${escapeHtml(job.jobId)}</span>${repo}<span class="meta">${escapeHtml(job.state)}</span>${sandbox}${pr}</div>
|
|
183
|
+
<p class="reason">${escapeHtml(job.reason)}</p>
|
|
184
|
+
${history}${unsafe}${retrying}${attempt}${deps}${feedback}${evidence}${notes}${tail}${unseen}${links}
|
|
185
|
+
</div>
|
|
186
|
+
${actionsHtml(job, section, csrf)}
|
|
187
|
+
</article>`;
|
|
188
|
+
}
|
|
189
|
+
function clipTail(text) {
|
|
190
|
+
const lines = text.split("\n").filter((line) => line.trim().length > 0);
|
|
191
|
+
const tail = lines.slice(-8);
|
|
192
|
+
return tail.join("\n");
|
|
193
|
+
}
|
|
194
|
+
function fileLinks(job) {
|
|
195
|
+
const id = encodeURIComponent(job.jobId);
|
|
196
|
+
const links = [];
|
|
197
|
+
if (job.spec)
|
|
198
|
+
links.push(`<a href="/jobs/${id}/spec">spec</a>`);
|
|
199
|
+
if (job.log)
|
|
200
|
+
links.push(`<a href="/jobs/${id}/log">log</a>`);
|
|
201
|
+
if (job.findings)
|
|
202
|
+
links.push(`<a href="/jobs/${id}/findings">Findings</a>`);
|
|
203
|
+
if (links.length === 0)
|
|
204
|
+
return "";
|
|
205
|
+
return `<p class="links">${links.join(" ")}</p>`;
|
|
206
|
+
}
|
|
207
|
+
function evidenceHtml(job) {
|
|
208
|
+
if (job.evidence.length === 0)
|
|
209
|
+
return "";
|
|
210
|
+
const texts = job.evidence.map((text) => (text.length > 500 ? `${text.slice(0, 499)}…` : text));
|
|
211
|
+
return `<div class="evidence">${texts.map((text) => `<pre class="text">${escapeHtml(text)}</pre>`).join("")}</div>`;
|
|
212
|
+
}
|
|
213
|
+
function feedbackHtml(job) {
|
|
214
|
+
if (job.feedback.length === 0)
|
|
215
|
+
return "";
|
|
216
|
+
const rows = job.feedback
|
|
217
|
+
.map((line) => {
|
|
218
|
+
const href = line.url ? safeUrl(line.url) : undefined;
|
|
219
|
+
const body = href ? `<a href="${escapeHtml(href)}">${escapeHtml(line.label)}</a>` : escapeHtml(line.label);
|
|
220
|
+
return `<li>${body}</li>`;
|
|
221
|
+
})
|
|
222
|
+
.join("");
|
|
223
|
+
return `<ul class="feedback">${rows}</ul>`;
|
|
224
|
+
}
|
|
225
|
+
function dependsHtml(job) {
|
|
226
|
+
if (job.dependsOn.length === 0)
|
|
227
|
+
return "";
|
|
228
|
+
const links = job.dependsOn
|
|
229
|
+
.map((dep) => `<a href="/jobs/${encodeURIComponent(dep.jobId)}">${escapeHtml(dep.jobId)}</a>`)
|
|
230
|
+
.join(", ");
|
|
231
|
+
const detail = job.dependsOn
|
|
232
|
+
.map((dep) => dep.detail)
|
|
233
|
+
.filter((line) => Boolean(line))
|
|
234
|
+
.join("; ");
|
|
235
|
+
return `<p class="deps">depends on ${links}</p>${detail ? `<p class="meta">${escapeHtml(detail)}</p>` : ""}`;
|
|
236
|
+
}
|
|
237
|
+
function actionsHtml(job, section, csrf) {
|
|
238
|
+
const showGate = section === "inbox" || section === "job";
|
|
239
|
+
const showRetry = section === "hand" || section === "repos" || section === "job";
|
|
240
|
+
if (!showGate && !showRetry)
|
|
241
|
+
return "";
|
|
242
|
+
const id = encodeURIComponent(job.jobId);
|
|
243
|
+
const forms = [];
|
|
244
|
+
if (showGate && job.canApprove)
|
|
245
|
+
forms.push(postForm(`/api/jobs/${id}/approve`, "approve", "Approve", csrf));
|
|
246
|
+
if (showGate && job.canReject)
|
|
247
|
+
forms.push(postForm(`/api/jobs/${id}/reject`, "reject", "Reject", csrf));
|
|
248
|
+
if (showGate && job.canRevise) {
|
|
249
|
+
forms.push(`<form method="post" action="/api/jobs/${id}/revise" class="act revise"><input type="hidden" name="csrf" value="${escapeHtml(csrf)}"><input name="note" type="text" required maxlength="8000" placeholder="Note for the next round" autocomplete="off"><button type="submit">Revise</button></form>`);
|
|
250
|
+
}
|
|
251
|
+
if (showRetry && job.retry)
|
|
252
|
+
forms.push(postForm(`/api/jobs/${id}/retry`, "retry", "Retry", csrf));
|
|
253
|
+
if (forms.length === 0)
|
|
254
|
+
return "";
|
|
255
|
+
return `<div class="d-actions">${forms.join("")}</div>`;
|
|
256
|
+
}
|
|
257
|
+
function postForm(action, kind, label, csrf) {
|
|
258
|
+
return `<form method="post" action="${action}" class="act ${kind}"><input type="hidden" name="csrf" value="${escapeHtml(csrf)}"><button type="submit">${label}</button></form>`;
|
|
259
|
+
}
|
|
260
|
+
function detailHtml(detail, tab, _csrf) {
|
|
261
|
+
const job = detail.job;
|
|
262
|
+
const id = encodeURIComponent(job.jobId);
|
|
263
|
+
const tabs = ["overview", "changes", "timeline"]
|
|
264
|
+
.map((name) => {
|
|
265
|
+
const href = name === "overview" ? `/jobs/${id}` : `/jobs/${id}?tab=${name}`;
|
|
266
|
+
const current = name === tab ? ` aria-current="page"` : "";
|
|
267
|
+
const label = name[0]?.toUpperCase() + name.slice(1);
|
|
268
|
+
return `<a href="${href}"${current}>${label}</a>`;
|
|
269
|
+
})
|
|
270
|
+
.join("");
|
|
271
|
+
const panel = tab === "changes" ? changesHtml(detail) : tab === "timeline" ? timelineHtml(detail) : overviewHtml(detail);
|
|
272
|
+
return `<div class="detail-main">
|
|
273
|
+
<a class="back" href="/inbox">Inbox</a>
|
|
274
|
+
<header class="list-head"><h2>${escapeHtml(job.title)}</h2><p class="reason">${escapeHtml(job.reason)}</p><p class="unseen" data-unseen-for="${escapeHtml(job.jobId)}" hidden></p></header>
|
|
275
|
+
<nav class="tabs" aria-label="Job">${tabs}</nav>
|
|
276
|
+
${actionsHtml(job, "job", _csrf)}
|
|
277
|
+
${panel}
|
|
278
|
+
</div>
|
|
279
|
+
${railHtml(detail)}`;
|
|
280
|
+
}
|
|
281
|
+
function overviewHtml(detail) {
|
|
282
|
+
const cards = detail.overview;
|
|
283
|
+
if (cards.length === 0)
|
|
284
|
+
return `<p class="empty">No phases in the event log yet.</p>`;
|
|
285
|
+
return `<ol class="phases">${cards.map((card) => phaseHtml(card)).join("")}</ol>`;
|
|
286
|
+
}
|
|
287
|
+
function phaseHtml(card) {
|
|
288
|
+
const bits = [];
|
|
289
|
+
if (card.verdict)
|
|
290
|
+
bits.push(`<p class="verdict">${escapeHtml(card.verdict)}</p>`);
|
|
291
|
+
if (card.detail)
|
|
292
|
+
bits.push(`<pre class="text">${escapeHtml(card.detail)}</pre>`);
|
|
293
|
+
if (card.testCommand || card.exitCode !== undefined) {
|
|
294
|
+
const exit = card.exitCode === undefined ? "" : ` · exit ${card.exitCode}`;
|
|
295
|
+
bits.push(`<p class="meta">${escapeHtml(`${card.testCommand ?? "test"}${exit}`)}</p>`);
|
|
296
|
+
}
|
|
297
|
+
if (card.failureClass || card.fingerprint) {
|
|
298
|
+
bits.push(`<p class="meta">${escapeHtml([card.failureClass, card.fingerprint].filter(Boolean).join(" · "))}</p>`);
|
|
299
|
+
}
|
|
300
|
+
if (card.note)
|
|
301
|
+
bits.push(`<blockquote class="note">${escapeHtml(card.note)}</blockquote>`);
|
|
302
|
+
const iteration = card.iteration !== undefined ? ` <span class="meta">#${card.iteration}</span>` : "";
|
|
303
|
+
return `<li class="phase ${escapeHtml(card.outcome)}"><header><span class="step">${escapeHtml(card.label)}</span>${iteration}<span class="outcome">${escapeHtml(card.outcome)}</span><time datetime="${escapeHtml(card.ts)}">${escapeHtml(card.ts)}</time></header>${bits.join("")}</li>`;
|
|
304
|
+
}
|
|
305
|
+
function changesHtml(detail) {
|
|
306
|
+
const changes = detail.changes;
|
|
307
|
+
const branch = changes.branch ? `<p><span class="k">Branch</span> <span class="mono">${escapeHtml(changes.branch)}</span></p>` : `<p class="empty">No branch yet.</p>`;
|
|
308
|
+
const base = changes.base ? `<p><span class="k">Base</span> <span class="mono">${escapeHtml(changes.base)}</span></p>` : "";
|
|
309
|
+
const pr = prHtml(changes.prUrl, changes.prNumber);
|
|
310
|
+
const diff = changes.diffStat ? `<pre class="text">${escapeHtml(changes.diffStat)}</pre>` : "";
|
|
311
|
+
const commits = changes.commits.length === 0
|
|
312
|
+
? `<p class="empty">No commits read for this branch.</p>`
|
|
313
|
+
: `<ul class="commits">${changes.commits
|
|
314
|
+
.map((commit) => {
|
|
315
|
+
const round = commit.round !== undefined ? `<span class="meta">Kiln-Round ${commit.round}</span>` : "";
|
|
316
|
+
return `<li><code>${escapeHtml(commit.sha.slice(0, 12))}</code> ${escapeHtml(commit.subject)} ${round}</li>`;
|
|
317
|
+
})
|
|
318
|
+
.join("")}</ul>`;
|
|
319
|
+
return `<section class="changes">${branch}${base}${pr}${diff}<h3>Commits</h3>${commits}</section>`;
|
|
320
|
+
}
|
|
321
|
+
function timelineHtml(detail) {
|
|
322
|
+
if (detail.timeline.length === 0)
|
|
323
|
+
return `<p class="empty">No events.</p>`;
|
|
324
|
+
const rows = detail.timeline
|
|
325
|
+
.map((event) => {
|
|
326
|
+
const step = event.step ? `<span class="meta">${escapeHtml(event.step)}</span>` : "";
|
|
327
|
+
return `<li><time datetime="${escapeHtml(event.ts)}">${escapeHtml(event.ts)}</time> <span class="ev">${escapeHtml(event.event)}</span> ${step}<pre class="text">${escapeHtml(JSON.stringify(event.properties))}</pre></li>`;
|
|
328
|
+
})
|
|
329
|
+
.join("");
|
|
330
|
+
return `<ol class="timeline">${rows}</ol>`;
|
|
331
|
+
}
|
|
332
|
+
function railHtml(detail) {
|
|
333
|
+
const rail = detail.rail;
|
|
334
|
+
const rows = [];
|
|
335
|
+
const row = (label, value) => {
|
|
336
|
+
rows.push(`<div><dt>${escapeHtml(label)}</dt><dd>${value}</dd></div>`);
|
|
337
|
+
};
|
|
338
|
+
row("Repo", escapeHtml(rail.repo));
|
|
339
|
+
if (rail.issue)
|
|
340
|
+
row("Issue", `<span class="mono">${escapeHtml(rail.issue)}</span>`);
|
|
341
|
+
if (rail.branch)
|
|
342
|
+
row("Branch", `<span class="mono">${escapeHtml(rail.branch)}</span>`);
|
|
343
|
+
if (rail.prUrl || rail.prNumber)
|
|
344
|
+
row("Pull request", prHtml(rail.prUrl, rail.prNumber) || escapeHtml(rail.prUrl ?? ""));
|
|
345
|
+
if (rail.ci)
|
|
346
|
+
row("CI", escapeHtml(rail.ci));
|
|
347
|
+
if (rail.risk)
|
|
348
|
+
row("Risk", escapeHtml(rail.risk));
|
|
349
|
+
row("Cost", rail.costUsd === null ? "no cost recorded" : escapeHtml(formatDollars(rail.costUsd)));
|
|
350
|
+
row("Attempts", escapeHtml(`${rail.attempts.used} / ${rail.attempts.cap}`));
|
|
351
|
+
row("Fix rounds", escapeHtml(`${rail.fixRounds.used} / ${rail.fixRounds.cap}`));
|
|
352
|
+
if (rail.failureClass || rail.fingerprint) {
|
|
353
|
+
row("Failure", escapeHtml([rail.failureClass, rail.fingerprint].filter(Boolean).join(" · ")));
|
|
354
|
+
}
|
|
355
|
+
return `<aside class="rail" aria-label="Job facts"><h3>${escapeHtml(detail.job.jobId)}</h3><dl>${rows.join("")}</dl></aside>`;
|
|
356
|
+
}
|
|
357
|
+
function prHtml(url, number) {
|
|
358
|
+
const safe = url ? safeUrl(url) : undefined;
|
|
359
|
+
const text = number ? `PR #${number}` : (safe ?? url ?? "");
|
|
360
|
+
if (!text)
|
|
361
|
+
return "";
|
|
362
|
+
if (safe)
|
|
363
|
+
return `<a href="${escapeHtml(safe)}">${escapeHtml(text)}</a>`;
|
|
364
|
+
return escapeHtml(url ?? text);
|
|
365
|
+
}
|
|
366
|
+
function statsHtml(stats) {
|
|
367
|
+
const windows = ["today", "7d", "30d"]
|
|
368
|
+
.map((window) => {
|
|
369
|
+
const current = stats.window === window ? ` aria-current="page"` : "";
|
|
370
|
+
const label = window === "today" ? "Today" : window;
|
|
371
|
+
return `<a href="/stats?window=${window}"${current}>${label}</a>`;
|
|
372
|
+
})
|
|
373
|
+
.join("");
|
|
374
|
+
const cap = stats.spend.cap === null ? "no cap" : formatDollars(stats.spend.cap);
|
|
375
|
+
const repoRows = tableRows(stats.costByRepo.map((row) => [row.repo, formatDollars(row.usd)]), "No recorded cost in this window.");
|
|
376
|
+
const phaseRows = tableRows(stats.costByPhase.map((row) => [row.phase, formatDollars(row.usd)]), "No recorded cost in this window.");
|
|
377
|
+
const classRows = tableRows(stats.failuresByClass.map((row) => [row.class, String(row.count)]), "No failures in this window.");
|
|
378
|
+
const interventions = stats.interventions.total === 0
|
|
379
|
+
? `<p class="empty">0 interventions</p>`
|
|
380
|
+
: `<ul class="ledger">${stats.interventions.rows
|
|
381
|
+
.map((row) => {
|
|
382
|
+
const reasons = row.reasons.map((reason) => `<li>${escapeHtml(reason.reason)} ${reason.count}</li>`).join("");
|
|
383
|
+
return `<li><span class="mono">${escapeHtml(row.repo)} / ${escapeHtml(row.jobId)}</span> ${row.total}<ul>${reasons}</ul></li>`;
|
|
384
|
+
})
|
|
385
|
+
.join("")}</ul><p class="meta">${stats.interventions.total} ${stats.interventions.total === 1 ? "intervention" : "interventions"}</p>`;
|
|
386
|
+
return `<header class="list-head"><h2>Stats</h2><nav class="tabs" aria-label="Window">${windows}</nav></header>
|
|
387
|
+
<section class="stat"><h3>Spend</h3><p>Today ${escapeHtml(stats.spend.day)} ${escapeHtml(formatDollars(stats.spend.usd))} / ${escapeHtml(cap)}</p><p class="meta">This window ${escapeHtml(formatDollars(stats.windowUsd))}</p></section>
|
|
388
|
+
<section class="stat"><h3>Cost by repo</h3>${repoRows}</section>
|
|
389
|
+
<section class="stat"><h3>Cost by phase</h3>${phaseRows}</section>
|
|
390
|
+
<section class="stat"><h3>Runs</h3><p>${stats.runs.started} started · ${stats.runs.finished} finished · ${stats.runs.failed} failed</p>${classRows}</section>
|
|
391
|
+
<section class="stat"><h3>Interventions</h3>${interventions}</section>`;
|
|
392
|
+
}
|
|
393
|
+
function reportHtml(report) {
|
|
394
|
+
const cap = report.spend.cap === null ? "no cap" : formatDollars(report.spend.cap);
|
|
395
|
+
const runs = report.runs.length === 0
|
|
396
|
+
? `<p class="empty">Nothing ran today.</p>`
|
|
397
|
+
: `<ul class="runs">${report.runs
|
|
398
|
+
.map((run) => {
|
|
399
|
+
const cost = run.usd === null ? "" : ` · ${escapeHtml(formatDollars(run.usd))}`;
|
|
400
|
+
const klass = run.failureClass ? ` · ${escapeHtml(run.failureClass)}` : "";
|
|
401
|
+
return `<li><a href="/jobs/${encodeURIComponent(run.jobId)}">${escapeHtml(run.title)}</a> <span class="meta">${escapeHtml(run.repo)} · ${escapeHtml(run.outcome)}${klass}${cost}</span></li>`;
|
|
402
|
+
})
|
|
403
|
+
.join("")}</ul>`;
|
|
404
|
+
const interventions = report.interventions.total === 0
|
|
405
|
+
? `<p class="empty">0 interventions</p>`
|
|
406
|
+
: `<ul class="ledger">${report.interventions.rows
|
|
407
|
+
.map((row) => `<li><span class="mono">${escapeHtml(row.repo)} / ${escapeHtml(row.jobId)}</span> ${row.total}</li>`)
|
|
408
|
+
.join("")}</ul>`;
|
|
409
|
+
return `<header class="list-head"><h2>Today</h2><p class="meta">${escapeHtml(report.day)}</p></header>
|
|
410
|
+
<section class="stat"><h3>Spend</h3><p>${escapeHtml(formatDollars(report.spend.usd))} / ${escapeHtml(cap)}</p></section>
|
|
411
|
+
<section class="stat"><h3>Runs</h3>${runs}</section>
|
|
412
|
+
<section class="stat"><h3>Interventions</h3>${interventions}</section>`;
|
|
413
|
+
}
|
|
414
|
+
function tableRows(rows, empty) {
|
|
415
|
+
if (rows.length === 0)
|
|
416
|
+
return `<p class="empty">${escapeHtml(empty)}</p>`;
|
|
417
|
+
return `<table><tbody>${rows.map((row) => `<tr><td>${escapeHtml(row[0] ?? "")}</td><td class="num">${escapeHtml(row[1] ?? "")}</td></tr>`).join("")}</tbody></table>`;
|
|
418
|
+
}
|
|
419
|
+
function formatDollars(value) {
|
|
420
|
+
return `$${value.toFixed(2)}`;
|
|
421
|
+
}
|
|
422
|
+
function intakeSheet(intake, attention) {
|
|
423
|
+
const items = [...attention.needsYou, ...attention.inFlight, ...attention.doneRecently];
|
|
153
424
|
return `<section class="sheet intake" id="intake" aria-label="Add job" hidden>
|
|
154
425
|
<div class="d-head">
|
|
155
426
|
<div class="num">kiln add</div>
|
|
@@ -174,21 +445,15 @@ function intakeSheet(intake, items) {
|
|
|
174
445
|
<div data-pane="jira" hidden><label class="field"><span class="k">Jira key</span><input id="jira-key" type="text" placeholder="PROJ-123" autocomplete="off" spellcheck="false"></label></div>
|
|
175
446
|
<label class="field" id="issue-field"><span class="k">Issue</span><input id="job-issue" type="text" placeholder="ABC-123" autocomplete="off" spellcheck="false"></label>
|
|
176
447
|
<label class="field" id="title-field"><span class="k">Title</span><input id="job-title" type="text" autocomplete="off"></label>
|
|
177
|
-
<fieldset class="field" id="after-field"><legend class="k">After</legend><div id="after-list">${afterOptions(items)}</div></fieldset>
|
|
448
|
+
<fieldset class="field" id="after-field"><legend class="k">After</legend><div id="after-list">${afterOptions(items.map((item) => item.job))}</div></fieldset>
|
|
178
449
|
<pre class="text preview" id="intake-preview" hidden></pre>
|
|
179
450
|
<p class="form-error" id="intake-error" role="alert" hidden></p>
|
|
180
451
|
<div class="d-actions"><button type="button" id="preview-job" hidden>Preview</button><button type="submit">Add</button></div>
|
|
181
452
|
</form>
|
|
182
453
|
</section>`;
|
|
183
454
|
}
|
|
184
|
-
function
|
|
185
|
-
return `<
|
|
186
|
-
<div class="d-head">
|
|
187
|
-
<div class="num">kiln.config.json</div>
|
|
188
|
-
<h3>Settings</h3>
|
|
189
|
-
<button type="button" class="close" data-close aria-label="Close">×</button>
|
|
190
|
-
</div>
|
|
191
|
-
<form id="settings-form" class="intake-form">
|
|
455
|
+
function settingsForm() {
|
|
456
|
+
return `<form id="settings-form" class="intake-form">
|
|
192
457
|
<p class="meta" id="settings-path"></p>
|
|
193
458
|
<p class="saved" id="settings-saved" role="status" hidden>Saved</p>
|
|
194
459
|
<p class="form-error" id="settings-error" role="alert" hidden></p>
|
|
@@ -256,8 +521,7 @@ function settingsSheet() {
|
|
|
256
521
|
<p class="field-error" data-error="dailyDollarCap" hidden></p>
|
|
257
522
|
</div>
|
|
258
523
|
<div class="d-actions"><button type="submit">Save</button></div>
|
|
259
|
-
</form
|
|
260
|
-
</section>`;
|
|
524
|
+
</form>`;
|
|
261
525
|
}
|
|
262
526
|
function repoButtons(repos) {
|
|
263
527
|
if (repos.length === 0)
|
|
@@ -273,219 +537,13 @@ function workflowOptions(names) {
|
|
|
273
537
|
.map((name) => `<option value="${escapeHtml(name)}"${name === "feature" ? " selected" : ""}>${escapeHtml(name)}</option>`)
|
|
274
538
|
.join("");
|
|
275
539
|
}
|
|
276
|
-
function afterOptions(
|
|
277
|
-
const open =
|
|
540
|
+
function afterOptions(jobs) {
|
|
541
|
+
const open = jobs.filter((job) => job.state !== "done");
|
|
278
542
|
if (open.length === 0)
|
|
279
543
|
return `<p class="empty">No open jobs.</p>`;
|
|
280
544
|
return open
|
|
281
|
-
.map((
|
|
282
|
-
const job = item.job;
|
|
283
|
-
return `<label class="after"><input type="checkbox" name="after" value="${escapeHtml(job.jobId)}"><span class="num">${escapeHtml(job.jobId)}</span><span>${escapeHtml(job.title || job.jobId)}</span></label>`;
|
|
284
|
-
})
|
|
285
|
-
.join("");
|
|
286
|
-
}
|
|
287
|
-
function sheet(item, csrf) {
|
|
288
|
-
const job = item.job;
|
|
289
|
-
const files = displayFiles(job);
|
|
290
|
-
const round = lastRound(job);
|
|
291
|
-
const pr = safeUrl(job.prUrl);
|
|
292
|
-
const num = `${job.repo}/${job.jobId} · ${job.workflow} · ${job.state}${round ? ` · round ${round}` : ""}`;
|
|
293
|
-
const branch = branchOf(job);
|
|
294
|
-
return `<section class="sheet" data-job="${escapeHtml(job.jobId)}"${pr ? ` data-pr="${escapeHtml(pr)}"` : ""} hidden>
|
|
295
|
-
<div class="d-head">
|
|
296
|
-
<div class="num">${escapeHtml(num)}</div>
|
|
297
|
-
<h3>${escapeHtml(job.title || job.jobId)}</h3>
|
|
298
|
-
${branch ? `<div class="branch">${escapeHtml(branch)}</div>` : ""}
|
|
299
|
-
${sandboxHtml(job)}
|
|
300
|
-
${prLink(job)}
|
|
301
|
-
${actionsHtml(item, csrf)}
|
|
302
|
-
<button type="button" class="close" data-close aria-label="Close">×</button>
|
|
303
|
-
</div>
|
|
304
|
-
<div class="d-body">${filesNav(files, job)}</div>
|
|
305
|
-
</section>`;
|
|
306
|
-
}
|
|
307
|
-
function filesNav(files, job) {
|
|
308
|
-
let group = "";
|
|
309
|
-
const buttons = files
|
|
310
|
-
.map((file, index) => {
|
|
311
|
-
const head = file.group !== group ? `<div class="group">${escapeHtml(file.group)}</div>` : "";
|
|
312
|
-
group = file.group;
|
|
313
|
-
const dot = file.tone ? ` ${file.tone}` : "";
|
|
314
|
-
return `${head}<button type="button" class="file${index === 0 ? " on" : ""}" data-file="${escapeHtml(file.id)}"><span class="dot${dot}"></span>${escapeHtml(file.label)}</button>`;
|
|
315
|
-
})
|
|
316
|
-
.join("");
|
|
317
|
-
const sources = files
|
|
318
|
-
.map((file) => `<pre class="src" data-file="${escapeHtml(file.id)}" hidden>${escapeHtml(file.text)}</pre>`)
|
|
545
|
+
.map((job) => `<label class="after"><input type="checkbox" name="after" value="${escapeHtml(job.jobId)}"><span class="num">${escapeHtml(job.jobId)}</span><span>${escapeHtml(job.title || job.jobId)}</span></label>`)
|
|
319
546
|
.join("");
|
|
320
|
-
const empty = files.length === 0 ? '<div class="empty">Nothing written yet.</div>' : "";
|
|
321
|
-
return `<nav class="files">${buttons}${routeLinks(job)}${empty}</nav><article class="doc"><div class="md" data-view></div>${sources}</article>`;
|
|
322
|
-
}
|
|
323
|
-
function routeLinks(job) {
|
|
324
|
-
const id = encodeURIComponent(job.jobId);
|
|
325
|
-
const links = [];
|
|
326
|
-
if (job.specPath)
|
|
327
|
-
links.push(`<a class="file" href="/jobs/${id}/spec">spec</a>`);
|
|
328
|
-
if (job.logPath)
|
|
329
|
-
links.push(`<a class="file" href="/jobs/${id}/log">log</a>`);
|
|
330
|
-
if (job.findingsPath)
|
|
331
|
-
links.push(`<a class="file" href="/jobs/${id}/findings">Findings</a>`);
|
|
332
|
-
if (links.length === 0)
|
|
333
|
-
return "";
|
|
334
|
-
return `<div class="group">Open</div>${links.join("")}`;
|
|
335
|
-
}
|
|
336
|
-
function actionsHtml(item, csrf) {
|
|
337
|
-
const id = encodeURIComponent(item.job.jobId);
|
|
338
|
-
const token = `<input type="hidden" name="csrf" value="${escapeHtml(csrf)}">`;
|
|
339
|
-
const forms = [];
|
|
340
|
-
if (item.job.canApprove) {
|
|
341
|
-
forms.push(`<form method="post" action="/api/jobs/${id}/approve" class="act approve">${token}<button type="submit">Approve</button></form>`);
|
|
342
|
-
}
|
|
343
|
-
if (item.job.canReject) {
|
|
344
|
-
forms.push(`<form method="post" action="/api/jobs/${id}/reject" class="act reject">${token}<button type="submit">Reject</button></form>`);
|
|
345
|
-
}
|
|
346
|
-
if (item.job.canRevise) {
|
|
347
|
-
forms.push(`<form method="post" action="/api/jobs/${id}/revise" class="act revise">${token}<input name="note" type="text" required maxlength="8000" placeholder="Note for the next round" autocomplete="off"><button type="submit">Revise</button></form>`);
|
|
348
|
-
}
|
|
349
|
-
if (item.retry) {
|
|
350
|
-
forms.push(`<form method="post" action="/api/jobs/${id}/retry" class="act retry">${token}<button type="submit">Retry</button></form>`);
|
|
351
|
-
}
|
|
352
|
-
if (forms.length === 0)
|
|
353
|
-
return "";
|
|
354
|
-
return `<div class="d-actions">${forms.join("")}</div>`;
|
|
355
|
-
}
|
|
356
|
-
function sandboxHtml(job) {
|
|
357
|
-
if (!job.sandboxed)
|
|
358
|
-
return "";
|
|
359
|
-
return `<div class="meta">sandbox · docker</div>`;
|
|
360
|
-
}
|
|
361
|
-
function roundsHtml(job, stage) {
|
|
362
|
-
const rounds = job.rounds ?? [];
|
|
363
|
-
const last = rounds[rounds.length - 1]?.n;
|
|
364
|
-
const rows = rounds.map((round) => roundRow(round, stage === "review" && job.state === "running" && round.n === last));
|
|
365
|
-
for (const round of job.feedbackRounds ?? []) {
|
|
366
|
-
const tone = round.outcome ? " PASS" : " CHANGES";
|
|
367
|
-
rows.push(`<div class="round"><span class="r">R${round.round}</span><span class="chip${tone}" style="grid-column: 2 / -1">${escapeHtml(feedbackRoundLabel(round))}</span></div>`);
|
|
368
|
-
}
|
|
369
|
-
const inReview = job.state === "in-review" || (job.state === "done" && safeUrl(job.prUrl));
|
|
370
|
-
if (inReview && (job.feedbackRounds?.length ?? 0) === 0) {
|
|
371
|
-
const n = (last ?? 0) + 1;
|
|
372
|
-
const tone = job.state === "done" ? " PASS" : "";
|
|
373
|
-
rows.push(`<div class="round"><span class="r">R${n}</span><span class="chip${tone}" style="grid-column: 2 / -1">PR review</span></div>`);
|
|
374
|
-
}
|
|
375
|
-
if (rows.length === 0)
|
|
376
|
-
return "";
|
|
377
|
-
return `<div class="rounds">${rows.join("")}</div>`;
|
|
378
|
-
}
|
|
379
|
-
function roundRow(round, waiting) {
|
|
380
|
-
const cells = REVIEWERS.map(([key, label]) => {
|
|
381
|
-
const verdict = round[key];
|
|
382
|
-
const tone = verdict === "PASS" || verdict === "CHANGES" ? verdict : waiting && !verdict ? "wait" : "";
|
|
383
|
-
return `<span class="chip${tone ? ` ${tone}` : ""}" title="${escapeHtml(`${key}: ${verdict || "pending"}`)}">${label}</span>`;
|
|
384
|
-
}).join("");
|
|
385
|
-
return `<div class="round"><span class="r">R${round.n}</span>${cells}</div>`;
|
|
386
|
-
}
|
|
387
|
-
/**
|
|
388
|
-
* A scheduled retry is one quiet line in the stage column.
|
|
389
|
-
* A dead or non-retryable failure in Needs human keeps the attempt budget.
|
|
390
|
-
*/
|
|
391
|
-
function attemptHtml(job, stage) {
|
|
392
|
-
if (scheduledRetry(job) && job.nextRetryAt) {
|
|
393
|
-
const detail = [`attempt ${job.attemptCount}/${job.maxAttempts}`];
|
|
394
|
-
if (job.lastFailureReason)
|
|
395
|
-
detail.push(job.lastFailureReason);
|
|
396
|
-
return `<div class="meta">retrying <time datetime="${escapeHtml(job.nextRetryAt)}" data-time="clock">${escapeHtml(job.nextRetryAt)}</time> · ${escapeHtml(detail.join(" · "))}</div>`;
|
|
397
|
-
}
|
|
398
|
-
if (stage !== "needs-human" || (job.state !== "failed" && job.state !== "dead" && job.state !== "needs-human"))
|
|
399
|
-
return "";
|
|
400
|
-
if (job.attemptCount <= 0 && !job.lastFailureReason && !job.nextRetryAt)
|
|
401
|
-
return "";
|
|
402
|
-
const parts = [];
|
|
403
|
-
if (job.attemptCount > 0)
|
|
404
|
-
parts.push(`attempt ${job.attemptCount}/${job.maxAttempts}`);
|
|
405
|
-
if (job.lastFailureReason)
|
|
406
|
-
parts.push(job.lastFailureReason);
|
|
407
|
-
const line = parts.length > 0 ? `<div class="meta">${escapeHtml(parts.join(" · "))}</div>` : "";
|
|
408
|
-
const when = job.nextRetryAt
|
|
409
|
-
? `<div class="meta">next retry <time datetime="${escapeHtml(job.nextRetryAt)}">${escapeHtml(job.nextRetryAt)}</time></div>`
|
|
410
|
-
: "";
|
|
411
|
-
return `${line}${when}`;
|
|
412
|
-
}
|
|
413
|
-
function noteHtml(item) {
|
|
414
|
-
const lines = [noteText(item)];
|
|
415
|
-
if (item.errorTail) {
|
|
416
|
-
for (const line of item.errorTail.split("\n")) {
|
|
417
|
-
const trimmed = line.trim();
|
|
418
|
-
if (trimmed && !lines.some((existing) => existing.includes(trimmed)))
|
|
419
|
-
lines.push(trimmed);
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
return `<div class="note">${lines.map((line) => escapeHtml(line)).join("<br>")}</div>`;
|
|
423
|
-
}
|
|
424
|
-
function repoLabel(job) {
|
|
425
|
-
if (job.deliver === "local" || job.deliver === "pr")
|
|
426
|
-
return `${job.repo} · ${job.deliver}`;
|
|
427
|
-
return job.repo;
|
|
428
|
-
}
|
|
429
|
-
function noteText(item) {
|
|
430
|
-
const job = item.job;
|
|
431
|
-
if (job.state === "done") {
|
|
432
|
-
if (job.prNumber)
|
|
433
|
-
return `merged · PR #${job.prNumber}`;
|
|
434
|
-
if (safeUrl(job.prUrl))
|
|
435
|
-
return "merged · PR";
|
|
436
|
-
const branch = branchOf(job);
|
|
437
|
-
return branch ? `landed locally · ${branch}` : "landed locally";
|
|
438
|
-
}
|
|
439
|
-
return item.reason;
|
|
440
|
-
}
|
|
441
|
-
function dependsHtml(job) {
|
|
442
|
-
if (job.dependsOn.length === 0)
|
|
443
|
-
return "";
|
|
444
|
-
const links = job.dependsOn
|
|
445
|
-
.map((dep) => `<a href="#job-${encodeURIComponent(dep.jobId)}">${escapeHtml(dep.jobId)}</a>`)
|
|
446
|
-
.join(", ");
|
|
447
|
-
return `<p class="deps">depends on ${links}</p>`;
|
|
448
|
-
}
|
|
449
|
-
function prLink(job) {
|
|
450
|
-
const safe = safeUrl(job.prUrl);
|
|
451
|
-
if (safe) {
|
|
452
|
-
const text = job.prNumber ? `PR #${job.prNumber}` : safe;
|
|
453
|
-
return `<div class="branch"><a href="${escapeHtml(safe)}" target="_blank" rel="noopener">${escapeHtml(text)}</a></div>`;
|
|
454
|
-
}
|
|
455
|
-
if (job.prUrl)
|
|
456
|
-
return `<div class="branch">${escapeHtml(job.prUrl)}</div>`;
|
|
457
|
-
return "";
|
|
458
|
-
}
|
|
459
|
-
function branchOf(job) {
|
|
460
|
-
if (job.branch)
|
|
461
|
-
return job.branch;
|
|
462
|
-
if (job.runId)
|
|
463
|
-
return `yak/${job.runId}`;
|
|
464
|
-
return "";
|
|
465
|
-
}
|
|
466
|
-
function lastRound(job) {
|
|
467
|
-
const rounds = job.rounds ?? [];
|
|
468
|
-
return rounds.length > 0 ? rounds[rounds.length - 1]?.n : undefined;
|
|
469
|
-
}
|
|
470
|
-
function displayFiles(job) {
|
|
471
|
-
if (job.files && job.files.length > 0)
|
|
472
|
-
return job.files;
|
|
473
|
-
return readBoardFiles({
|
|
474
|
-
...(job.diffSummary && job.workflow !== INVESTIGATION_WORKFLOW ? { diffSummary: job.diffSummary } : {}),
|
|
475
|
-
...(job.logTail ? { logTail: job.logTail } : {}),
|
|
476
|
-
...(job.workflow ? { workflow: job.workflow } : {}),
|
|
477
|
-
gates: job.gates.map((gate) => ({
|
|
478
|
-
stepId: gate.stepId,
|
|
479
|
-
prompt: gate.prompt,
|
|
480
|
-
...(gate.rendered ? { rendered: gate.rendered } : {}),
|
|
481
|
-
choices: gate.choices,
|
|
482
|
-
artifacts: gate.artifacts,
|
|
483
|
-
reviews: gate.reviews,
|
|
484
|
-
})),
|
|
485
|
-
});
|
|
486
|
-
}
|
|
487
|
-
function chip(id, label, count, pressed) {
|
|
488
|
-
return `<button type="button" class="repo-f" aria-pressed="${pressed ? "true" : "false"}" data-repo="${escapeHtml(id)}">${escapeHtml(label)}<span class="n">${count}</span></button>`;
|
|
489
547
|
}
|
|
490
548
|
function safeUrl(url) {
|
|
491
549
|
if (!url)
|
|
@@ -501,6 +559,8 @@ function safeUrl(url) {
|
|
|
501
559
|
}
|
|
502
560
|
}
|
|
503
561
|
function document(options) {
|
|
562
|
+
const job = options.jobId ? ` data-job="${escapeHtml(options.jobId)}"` : "";
|
|
563
|
+
const latest = options.latest ? ` data-latest="${escapeHtml(options.latest)}"` : "";
|
|
504
564
|
return `<!DOCTYPE html>
|
|
505
565
|
<html lang="en">
|
|
506
566
|
<head>
|
|
@@ -512,7 +572,7 @@ function document(options) {
|
|
|
512
572
|
${options.css}
|
|
513
573
|
</style>
|
|
514
574
|
</head>
|
|
515
|
-
<body data-needs-you="${options.needsYou}" data-revision="${escapeHtml(options.revision)}" data-csrf="${escapeHtml(options.csrf ?? "")}">
|
|
575
|
+
<body data-section="${escapeHtml(options.section)}" data-needs-you="${options.needsYou}" data-revision="${escapeHtml(options.revision)}" data-csrf="${escapeHtml(options.csrf ?? "")}"${job}${latest}>
|
|
516
576
|
${options.body}
|
|
517
577
|
${options.live ? `<script>\n${LIVE_SCRIPT}\n</script>` : ""}
|
|
518
578
|
</body>
|