@lchase/kiln 0.10.0 → 0.12.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 +27 -0
- package/assets/serve.css +210 -480
- package/assets/serve.js +125 -3
- package/dist/board-view.js +688 -0
- package/dist/board.js +0 -97
- package/dist/cli.js +7 -1
- package/dist/config.js +5 -0
- package/dist/driver.js +48 -13
- package/dist/events.js +59 -2
- package/dist/host.js +53 -0
- package/dist/intake/jira.js +87 -0
- package/dist/jobs.js +4 -3
- package/dist/reconcile.js +7 -3
- package/dist/schemas.js +51 -3
- package/dist/serve-page.js +415 -324
- package/dist/serve.js +196 -15
- package/dist/settings.js +40 -1
- package/dist/triggers.js +787 -0
- package/dist/workflow.js +18 -15
- package/package.json +3 -2
- package/prompts/kiln/review.md +13 -0
- package/workflows/review.yaml +64 -0
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,378 @@ 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("/triggers", "triggers", "Triggers", "triggers")}
|
|
114
|
+
${item("/settings", "open-settings", "Settings", "settings")}
|
|
115
|
+
</nav>`;
|
|
116
|
+
}
|
|
117
|
+
function mainHtml(model, page) {
|
|
118
|
+
const error = `<p class="meta config-error" id="config-error" role="status"${page.configError ? "" : " hidden"}>${escapeHtml(page.configError ?? "")}</p>`;
|
|
119
|
+
if (model.section === "settings") {
|
|
120
|
+
return `${error}<main class="main"><h2>Settings</h2><section id="settings" aria-label="Settings">${settingsForm()}</section></main>`;
|
|
121
|
+
}
|
|
122
|
+
if (model.section === "stats" && model.stats) {
|
|
123
|
+
return `${error}<main class="main">${statsHtml(model.stats)}</main>`;
|
|
124
|
+
}
|
|
125
|
+
if (model.section === "report" && model.report) {
|
|
126
|
+
return `${error}<main class="main">${reportHtml(model.report)}</main>`;
|
|
127
|
+
}
|
|
128
|
+
if (model.section === "job" && model.detail) {
|
|
129
|
+
return `${error}<main class="main detail">${detailHtml(model.detail, model.tab ?? "overview", page.csrf)}</main>`;
|
|
130
|
+
}
|
|
131
|
+
if (model.section === "triggers") {
|
|
132
|
+
return `${error}<main class="main">${triggersHtml(model.triggers ?? [])}</main>`;
|
|
133
|
+
}
|
|
134
|
+
return `${error}<main class="main">${listHtml(model, page.csrf)}</main>`;
|
|
135
|
+
}
|
|
136
|
+
function triggersHtml(rows) {
|
|
137
|
+
const body = rows.length === 0
|
|
138
|
+
? `<p class="empty">No triggers.</p>`
|
|
139
|
+
: rows.map((row) => triggerCard(row)).join("\n");
|
|
140
|
+
return `<header class="list-head"><h2>Triggers</h2><p class="meta">The clock, Jira, and pull request activity can start work. Kiln does not post, approve, or merge.</p><p class="form-error" id="trigger-error" role="alert" hidden></p></header>${body}`;
|
|
141
|
+
}
|
|
142
|
+
function triggerCard(row) {
|
|
143
|
+
const on = row.enabled;
|
|
144
|
+
const bits = [row.kind, row.schedule, on ? "enabled" : "disabled"];
|
|
145
|
+
const next = row.nextFire ? `<p class="meta">next <time datetime="${escapeHtml(row.nextFire)}" data-time>${escapeHtml(row.nextFire)}</time></p>` : "";
|
|
146
|
+
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
|
+
const error = row.lastError
|
|
148
|
+
? `<p class="err">last error${row.lastErrorAt ? ` <time datetime="${escapeHtml(row.lastErrorAt)}" data-time>${escapeHtml(row.lastErrorAt)}</time>` : ""}: ${escapeHtml(row.lastError)}</p>`
|
|
149
|
+
: "";
|
|
150
|
+
const recent = row.recent.length === 0
|
|
151
|
+
? `<p class="empty">No routed events yet.</p>`
|
|
152
|
+
: `<ul class="recent">${row.recent
|
|
153
|
+
.map((item) => `<li><time datetime="${escapeHtml(item.ts)}" data-time>${escapeHtml(item.ts)}</time> <span class="ev">${escapeHtml(item.event)}</span> ${escapeHtml(item.summary)}</li>`)
|
|
154
|
+
.join("")}</ul>`;
|
|
155
|
+
return `<article class="trigger" id="trigger-${escapeHtml(row.id)}">
|
|
156
|
+
<h3>${escapeHtml(row.id)}</h3>
|
|
157
|
+
<p class="meta">${escapeHtml(bits.join(" · "))}</p>
|
|
158
|
+
${next}${last}${error}
|
|
159
|
+
<button type="button" data-trigger-toggle="${escapeHtml(row.id)}" data-enabled="${on ? "true" : "false"}">${on ? "Disable" : "Enable"}</button>
|
|
160
|
+
${recent}
|
|
161
|
+
</article>`;
|
|
162
|
+
}
|
|
163
|
+
function listHtml(model, csrf) {
|
|
164
|
+
const board = model.board;
|
|
165
|
+
if (model.section === "inbox") {
|
|
166
|
+
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)}`;
|
|
167
|
+
}
|
|
168
|
+
if (model.section === "hand") {
|
|
169
|
+
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)}`;
|
|
170
|
+
}
|
|
171
|
+
if (model.section === "history") {
|
|
172
|
+
return `<header class="list-head"><h2>History</h2></header>${jobList(board.history, "history", "No finished jobs.", csrf)}`;
|
|
173
|
+
}
|
|
174
|
+
const groups = model.repo ? board.repos.filter((group) => group.repo === model.repo) : board.repos;
|
|
175
|
+
if (groups.length === 0)
|
|
176
|
+
return `<header class="list-head"><h2>Repos</h2></header><p class="empty">No jobs.</p>`;
|
|
177
|
+
return `<header class="list-head"><h2>${model.repo ? escapeHtml(model.repo) : "Repos"}</h2></header>${groups
|
|
178
|
+
.map((group) => `<section class="repo-block"><h3>${escapeHtml(group.repo)}</h3>${jobList(group.jobs, "repos", "No jobs.", csrf)}</section>`)
|
|
179
|
+
.join("")}`;
|
|
180
|
+
}
|
|
181
|
+
function jobList(jobs, section, empty, csrf) {
|
|
182
|
+
if (jobs.length === 0)
|
|
183
|
+
return `<p class="empty">${escapeHtml(empty)}</p>`;
|
|
184
|
+
return `<div class="jobs">${jobs.map((job) => jobRow(job, section, csrf)).join("\n")}</div>`;
|
|
122
185
|
}
|
|
123
|
-
function
|
|
124
|
-
const
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
if (waiting)
|
|
186
|
+
function jobRow(job, section, csrf) {
|
|
187
|
+
const id = encodeURIComponent(job.jobId);
|
|
188
|
+
const classes = ["job"];
|
|
189
|
+
if (job.scheduledRetry)
|
|
128
190
|
classes.push("retrying");
|
|
129
|
-
|
|
191
|
+
if (section === "inbox" || (section === "hand" && !job.scheduledRetry))
|
|
130
192
|
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
|
-
|
|
193
|
+
const flag = classes.includes("human") ? `<span class="flag">your call</span>` : "";
|
|
194
|
+
const repo = section === "repos" ? "" : `<span class="meta">${escapeHtml(job.repo)}</span>`;
|
|
195
|
+
const sandbox = job.sandboxed ? `<span class="meta">sandbox · docker</span>` : "";
|
|
196
|
+
const pr = section === "repos" && job.state === "in-review" ? `<span class="meta">PR review</span>` : "";
|
|
197
|
+
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>`
|
|
199
|
+
: "";
|
|
200
|
+
const attempt = !retrying && job.attemptLine && (section === "hand" || section === "inbox") ? `<div class="meta">${escapeHtml(job.attemptLine)}</div>` : "";
|
|
201
|
+
const history = job.historyLine ? `<div class="meta">${escapeHtml(job.historyLine)}</div>` : "";
|
|
202
|
+
const unsafe = job.prUrl && !safeUrl(job.prUrl) ? `<div class="branch">${escapeHtml(job.prUrl)}</div>` : "";
|
|
203
|
+
const deps = dependsHtml(job);
|
|
204
|
+
const evidence = section === "inbox" ? evidenceHtml(job) : "";
|
|
205
|
+
const feedback = feedbackHtml(job);
|
|
206
|
+
const tail = section !== "inbox" && job.logTail ? `<div class="note">${escapeHtml(clipTail(job.logTail))}</div>` : "";
|
|
207
|
+
const notes = job.notes.length ? `<div class="note">${job.notes.map((line) => escapeHtml(line)).join("<br>")}</div>` : "";
|
|
208
|
+
const unseen = `<p class="unseen" data-unseen-for="${escapeHtml(job.jobId)}" hidden></p>`;
|
|
209
|
+
const links = fileLinks(job);
|
|
210
|
+
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)}"` : ""}>
|
|
211
|
+
<div class="job-main">
|
|
212
|
+
<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="meta">${escapeHtml(job.state)}</span>${sandbox}${pr}</div>
|
|
214
|
+
<p class="reason">${escapeHtml(job.reason)}</p>
|
|
215
|
+
${history}${unsafe}${retrying}${attempt}${deps}${feedback}${evidence}${notes}${tail}${unseen}${links}
|
|
216
|
+
</div>
|
|
217
|
+
${actionsHtml(job, section, csrf)}
|
|
218
|
+
</article>`;
|
|
219
|
+
}
|
|
220
|
+
function clipTail(text) {
|
|
221
|
+
const lines = text.split("\n").filter((line) => line.trim().length > 0);
|
|
222
|
+
const tail = lines.slice(-8);
|
|
223
|
+
return tail.join("\n");
|
|
224
|
+
}
|
|
225
|
+
function fileLinks(job) {
|
|
226
|
+
const id = encodeURIComponent(job.jobId);
|
|
227
|
+
const links = [];
|
|
228
|
+
if (job.spec)
|
|
229
|
+
links.push(`<a href="/jobs/${id}/spec">spec</a>`);
|
|
230
|
+
if (job.log)
|
|
231
|
+
links.push(`<a href="/jobs/${id}/log">log</a>`);
|
|
232
|
+
if (job.findings)
|
|
233
|
+
links.push(`<a href="/jobs/${id}/findings">Findings</a>`);
|
|
234
|
+
if (links.length === 0)
|
|
235
|
+
return "";
|
|
236
|
+
return `<p class="links">${links.join(" ")}</p>`;
|
|
237
|
+
}
|
|
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
|
+
function feedbackHtml(job) {
|
|
245
|
+
if (job.feedback.length === 0)
|
|
246
|
+
return "";
|
|
247
|
+
const rows = job.feedback
|
|
248
|
+
.map((line) => {
|
|
249
|
+
const href = line.url ? safeUrl(line.url) : undefined;
|
|
250
|
+
const body = href ? `<a href="${escapeHtml(href)}">${escapeHtml(line.label)}</a>` : escapeHtml(line.label);
|
|
251
|
+
return `<li>${body}</li>`;
|
|
252
|
+
})
|
|
253
|
+
.join("");
|
|
254
|
+
return `<ul class="feedback">${rows}</ul>`;
|
|
255
|
+
}
|
|
256
|
+
function dependsHtml(job) {
|
|
257
|
+
if (job.dependsOn.length === 0)
|
|
258
|
+
return "";
|
|
259
|
+
const links = job.dependsOn
|
|
260
|
+
.map((dep) => `<a href="/jobs/${encodeURIComponent(dep.jobId)}">${escapeHtml(dep.jobId)}</a>`)
|
|
261
|
+
.join(", ");
|
|
262
|
+
const detail = job.dependsOn
|
|
263
|
+
.map((dep) => dep.detail)
|
|
264
|
+
.filter((line) => Boolean(line))
|
|
265
|
+
.join("; ");
|
|
266
|
+
return `<p class="deps">depends on ${links}</p>${detail ? `<p class="meta">${escapeHtml(detail)}</p>` : ""}`;
|
|
267
|
+
}
|
|
268
|
+
function actionsHtml(job, section, csrf) {
|
|
269
|
+
const showGate = section === "inbox" || section === "job";
|
|
270
|
+
const showRetry = section === "hand" || section === "repos" || section === "job";
|
|
271
|
+
if (!showGate && !showRetry)
|
|
272
|
+
return "";
|
|
273
|
+
const id = encodeURIComponent(job.jobId);
|
|
274
|
+
const forms = [];
|
|
275
|
+
if (showGate && job.canApprove)
|
|
276
|
+
forms.push(postForm(`/api/jobs/${id}/approve`, "approve", "Approve", csrf));
|
|
277
|
+
if (showGate && job.canReject)
|
|
278
|
+
forms.push(postForm(`/api/jobs/${id}/reject`, "reject", "Reject", csrf));
|
|
279
|
+
if (showGate && job.canRevise) {
|
|
280
|
+
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>`);
|
|
281
|
+
}
|
|
282
|
+
if (showRetry && job.retry)
|
|
283
|
+
forms.push(postForm(`/api/jobs/${id}/retry`, "retry", "Retry", csrf));
|
|
284
|
+
if (forms.length === 0)
|
|
285
|
+
return "";
|
|
286
|
+
return `<div class="d-actions">${forms.join("")}</div>`;
|
|
287
|
+
}
|
|
288
|
+
function postForm(action, kind, label, csrf) {
|
|
289
|
+
return `<form method="post" action="${action}" class="act ${kind}"><input type="hidden" name="csrf" value="${escapeHtml(csrf)}"><button type="submit">${label}</button></form>`;
|
|
290
|
+
}
|
|
291
|
+
function detailHtml(detail, tab, _csrf) {
|
|
292
|
+
const job = detail.job;
|
|
293
|
+
const id = encodeURIComponent(job.jobId);
|
|
294
|
+
const tabs = ["overview", "changes", "timeline"]
|
|
295
|
+
.map((name) => {
|
|
296
|
+
const href = name === "overview" ? `/jobs/${id}` : `/jobs/${id}?tab=${name}`;
|
|
297
|
+
const current = name === tab ? ` aria-current="page"` : "";
|
|
298
|
+
const label = name[0]?.toUpperCase() + name.slice(1);
|
|
299
|
+
return `<a href="${href}"${current}>${label}</a>`;
|
|
300
|
+
})
|
|
301
|
+
.join("");
|
|
302
|
+
const panel = tab === "changes" ? changesHtml(detail) : tab === "timeline" ? timelineHtml(detail) : overviewHtml(detail);
|
|
303
|
+
return `<div class="detail-main">
|
|
304
|
+
<a class="back" href="/inbox">Inbox</a>
|
|
305
|
+
<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>
|
|
306
|
+
<nav class="tabs" aria-label="Job">${tabs}</nav>
|
|
307
|
+
${actionsHtml(job, "job", _csrf)}
|
|
308
|
+
${panel}
|
|
309
|
+
</div>
|
|
310
|
+
${railHtml(detail)}`;
|
|
311
|
+
}
|
|
312
|
+
function overviewHtml(detail) {
|
|
313
|
+
const cards = detail.overview;
|
|
314
|
+
if (cards.length === 0)
|
|
315
|
+
return `<p class="empty">No phases in the event log yet.</p>`;
|
|
316
|
+
return `<ol class="phases">${cards.map((card) => phaseHtml(card)).join("")}</ol>`;
|
|
317
|
+
}
|
|
318
|
+
function phaseHtml(card) {
|
|
319
|
+
const bits = [];
|
|
320
|
+
if (card.verdict)
|
|
321
|
+
bits.push(`<p class="verdict">${escapeHtml(card.verdict)}</p>`);
|
|
322
|
+
if (card.detail)
|
|
323
|
+
bits.push(`<pre class="text">${escapeHtml(card.detail)}</pre>`);
|
|
324
|
+
if (card.testCommand || card.exitCode !== undefined) {
|
|
325
|
+
const exit = card.exitCode === undefined ? "" : ` · exit ${card.exitCode}`;
|
|
326
|
+
bits.push(`<p class="meta">${escapeHtml(`${card.testCommand ?? "test"}${exit}`)}</p>`);
|
|
327
|
+
}
|
|
328
|
+
if (card.failureClass || card.fingerprint) {
|
|
329
|
+
bits.push(`<p class="meta">${escapeHtml([card.failureClass, card.fingerprint].filter(Boolean).join(" · "))}</p>`);
|
|
330
|
+
}
|
|
331
|
+
if (card.note)
|
|
332
|
+
bits.push(`<blockquote class="note">${escapeHtml(card.note)}</blockquote>`);
|
|
333
|
+
const iteration = card.iteration !== undefined ? ` <span class="meta">#${card.iteration}</span>` : "";
|
|
334
|
+
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>`;
|
|
335
|
+
}
|
|
336
|
+
function changesHtml(detail) {
|
|
337
|
+
const changes = detail.changes;
|
|
338
|
+
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>`;
|
|
339
|
+
const base = changes.base ? `<p><span class="k">Base</span> <span class="mono">${escapeHtml(changes.base)}</span></p>` : "";
|
|
340
|
+
const pr = prHtml(changes.prUrl, changes.prNumber);
|
|
341
|
+
const diff = changes.diffStat ? `<pre class="text">${escapeHtml(changes.diffStat)}</pre>` : "";
|
|
342
|
+
const commits = changes.commits.length === 0
|
|
343
|
+
? `<p class="empty">No commits read for this branch.</p>`
|
|
344
|
+
: `<ul class="commits">${changes.commits
|
|
345
|
+
.map((commit) => {
|
|
346
|
+
const round = commit.round !== undefined ? `<span class="meta">Kiln-Round ${commit.round}</span>` : "";
|
|
347
|
+
return `<li><code>${escapeHtml(commit.sha.slice(0, 12))}</code> ${escapeHtml(commit.subject)} ${round}</li>`;
|
|
348
|
+
})
|
|
349
|
+
.join("")}</ul>`;
|
|
350
|
+
return `<section class="changes">${branch}${base}${pr}${diff}<h3>Commits</h3>${commits}</section>`;
|
|
351
|
+
}
|
|
352
|
+
function timelineHtml(detail) {
|
|
353
|
+
if (detail.timeline.length === 0)
|
|
354
|
+
return `<p class="empty">No events.</p>`;
|
|
355
|
+
const rows = detail.timeline
|
|
356
|
+
.map((event) => {
|
|
357
|
+
const step = event.step ? `<span class="meta">${escapeHtml(event.step)}</span>` : "";
|
|
358
|
+
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>`;
|
|
359
|
+
})
|
|
360
|
+
.join("");
|
|
361
|
+
return `<ol class="timeline">${rows}</ol>`;
|
|
362
|
+
}
|
|
363
|
+
function railHtml(detail) {
|
|
364
|
+
const rail = detail.rail;
|
|
365
|
+
const rows = [];
|
|
366
|
+
const row = (label, value) => {
|
|
367
|
+
rows.push(`<div><dt>${escapeHtml(label)}</dt><dd>${value}</dd></div>`);
|
|
368
|
+
};
|
|
369
|
+
row("Repo", escapeHtml(rail.repo));
|
|
370
|
+
if (rail.issue)
|
|
371
|
+
row("Issue", `<span class="mono">${escapeHtml(rail.issue)}</span>`);
|
|
372
|
+
if (rail.branch)
|
|
373
|
+
row("Branch", `<span class="mono">${escapeHtml(rail.branch)}</span>`);
|
|
374
|
+
if (rail.prUrl || rail.prNumber)
|
|
375
|
+
row("Pull request", prHtml(rail.prUrl, rail.prNumber) || escapeHtml(rail.prUrl ?? ""));
|
|
376
|
+
if (rail.ci)
|
|
377
|
+
row("CI", escapeHtml(rail.ci));
|
|
378
|
+
if (rail.risk)
|
|
379
|
+
row("Risk", escapeHtml(rail.risk));
|
|
380
|
+
row("Cost", rail.costUsd === null ? "no cost recorded" : escapeHtml(formatDollars(rail.costUsd)));
|
|
381
|
+
row("Attempts", escapeHtml(`${rail.attempts.used} / ${rail.attempts.cap}`));
|
|
382
|
+
row("Fix rounds", escapeHtml(`${rail.fixRounds.used} / ${rail.fixRounds.cap}`));
|
|
383
|
+
if (rail.failureClass || rail.fingerprint) {
|
|
384
|
+
row("Failure", escapeHtml([rail.failureClass, rail.fingerprint].filter(Boolean).join(" · ")));
|
|
385
|
+
}
|
|
386
|
+
return `<aside class="rail" aria-label="Job facts"><h3>${escapeHtml(detail.job.jobId)}</h3><dl>${rows.join("")}</dl></aside>`;
|
|
387
|
+
}
|
|
388
|
+
function prHtml(url, number) {
|
|
389
|
+
const safe = url ? safeUrl(url) : undefined;
|
|
390
|
+
const text = number ? `PR #${number}` : (safe ?? url ?? "");
|
|
391
|
+
if (!text)
|
|
392
|
+
return "";
|
|
393
|
+
if (safe)
|
|
394
|
+
return `<a href="${escapeHtml(safe)}">${escapeHtml(text)}</a>`;
|
|
395
|
+
return escapeHtml(url ?? text);
|
|
396
|
+
}
|
|
397
|
+
function statsHtml(stats) {
|
|
398
|
+
const windows = ["today", "7d", "30d"]
|
|
399
|
+
.map((window) => {
|
|
400
|
+
const current = stats.window === window ? ` aria-current="page"` : "";
|
|
401
|
+
const label = window === "today" ? "Today" : window;
|
|
402
|
+
return `<a href="/stats?window=${window}"${current}>${label}</a>`;
|
|
403
|
+
})
|
|
404
|
+
.join("");
|
|
405
|
+
const cap = stats.spend.cap === null ? "no cap" : formatDollars(stats.spend.cap);
|
|
406
|
+
const repoRows = tableRows(stats.costByRepo.map((row) => [row.repo, formatDollars(row.usd)]), "No recorded cost in this window.");
|
|
407
|
+
const phaseRows = tableRows(stats.costByPhase.map((row) => [row.phase, formatDollars(row.usd)]), "No recorded cost in this window.");
|
|
408
|
+
const classRows = tableRows(stats.failuresByClass.map((row) => [row.class, String(row.count)]), "No failures in this window.");
|
|
409
|
+
const interventions = stats.interventions.total === 0
|
|
410
|
+
? `<p class="empty">0 interventions</p>`
|
|
411
|
+
: `<ul class="ledger">${stats.interventions.rows
|
|
412
|
+
.map((row) => {
|
|
413
|
+
const reasons = row.reasons.map((reason) => `<li>${escapeHtml(reason.reason)} ${reason.count}</li>`).join("");
|
|
414
|
+
return `<li><span class="mono">${escapeHtml(row.repo)} / ${escapeHtml(row.jobId)}</span> ${row.total}<ul>${reasons}</ul></li>`;
|
|
415
|
+
})
|
|
416
|
+
.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
|
+
<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>
|
|
419
|
+
<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>`;
|
|
423
|
+
}
|
|
424
|
+
function reportHtml(report) {
|
|
425
|
+
const cap = report.spend.cap === null ? "no cap" : formatDollars(report.spend.cap);
|
|
426
|
+
const runs = report.runs.length === 0
|
|
427
|
+
? `<p class="empty">Nothing ran today.</p>`
|
|
428
|
+
: `<ul class="runs">${report.runs
|
|
429
|
+
.map((run) => {
|
|
430
|
+
const cost = run.usd === null ? "" : ` · ${escapeHtml(formatDollars(run.usd))}`;
|
|
431
|
+
const klass = run.failureClass ? ` · ${escapeHtml(run.failureClass)}` : "";
|
|
432
|
+
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>`;
|
|
433
|
+
})
|
|
434
|
+
.join("")}</ul>`;
|
|
435
|
+
const interventions = report.interventions.total === 0
|
|
436
|
+
? `<p class="empty">0 interventions</p>`
|
|
437
|
+
: `<ul class="ledger">${report.interventions.rows
|
|
438
|
+
.map((row) => `<li><span class="mono">${escapeHtml(row.repo)} / ${escapeHtml(row.jobId)}</span> ${row.total}</li>`)
|
|
439
|
+
.join("")}</ul>`;
|
|
440
|
+
return `<header class="list-head"><h2>Today</h2><p class="meta">${escapeHtml(report.day)}</p></header>
|
|
441
|
+
<section class="stat"><h3>Spend</h3><p>${escapeHtml(formatDollars(report.spend.usd))} / ${escapeHtml(cap)}</p></section>
|
|
442
|
+
<section class="stat"><h3>Runs</h3>${runs}</section>
|
|
443
|
+
<section class="stat"><h3>Interventions</h3>${interventions}</section>`;
|
|
444
|
+
}
|
|
445
|
+
function tableRows(rows, empty) {
|
|
446
|
+
if (rows.length === 0)
|
|
447
|
+
return `<p class="empty">${escapeHtml(empty)}</p>`;
|
|
448
|
+
return `<table><tbody>${rows.map((row) => `<tr><td>${escapeHtml(row[0] ?? "")}</td><td class="num">${escapeHtml(row[1] ?? "")}</td></tr>`).join("")}</tbody></table>`;
|
|
449
|
+
}
|
|
450
|
+
function formatDollars(value) {
|
|
451
|
+
return `$${value.toFixed(2)}`;
|
|
452
|
+
}
|
|
453
|
+
function intakeSheet(intake, attention) {
|
|
454
|
+
const items = [...attention.needsYou, ...attention.inFlight, ...attention.doneRecently];
|
|
153
455
|
return `<section class="sheet intake" id="intake" aria-label="Add job" hidden>
|
|
154
456
|
<div class="d-head">
|
|
155
457
|
<div class="num">kiln add</div>
|
|
@@ -174,21 +476,15 @@ function intakeSheet(intake, items) {
|
|
|
174
476
|
<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
477
|
<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
478
|
<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>
|
|
479
|
+
<fieldset class="field" id="after-field"><legend class="k">After</legend><div id="after-list">${afterOptions(items.map((item) => item.job))}</div></fieldset>
|
|
178
480
|
<pre class="text preview" id="intake-preview" hidden></pre>
|
|
179
481
|
<p class="form-error" id="intake-error" role="alert" hidden></p>
|
|
180
482
|
<div class="d-actions"><button type="button" id="preview-job" hidden>Preview</button><button type="submit">Add</button></div>
|
|
181
483
|
</form>
|
|
182
484
|
</section>`;
|
|
183
485
|
}
|
|
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">
|
|
486
|
+
function settingsForm() {
|
|
487
|
+
return `<form id="settings-form" class="intake-form">
|
|
192
488
|
<p class="meta" id="settings-path"></p>
|
|
193
489
|
<p class="saved" id="settings-saved" role="status" hidden>Saved</p>
|
|
194
490
|
<p class="form-error" id="settings-error" role="alert" hidden></p>
|
|
@@ -256,8 +552,7 @@ function settingsSheet() {
|
|
|
256
552
|
<p class="field-error" data-error="dailyDollarCap" hidden></p>
|
|
257
553
|
</div>
|
|
258
554
|
<div class="d-actions"><button type="submit">Save</button></div>
|
|
259
|
-
</form
|
|
260
|
-
</section>`;
|
|
555
|
+
</form>`;
|
|
261
556
|
}
|
|
262
557
|
function repoButtons(repos) {
|
|
263
558
|
if (repos.length === 0)
|
|
@@ -273,219 +568,13 @@ function workflowOptions(names) {
|
|
|
273
568
|
.map((name) => `<option value="${escapeHtml(name)}"${name === "feature" ? " selected" : ""}>${escapeHtml(name)}</option>`)
|
|
274
569
|
.join("");
|
|
275
570
|
}
|
|
276
|
-
function afterOptions(
|
|
277
|
-
const open =
|
|
571
|
+
function afterOptions(jobs) {
|
|
572
|
+
const open = jobs.filter((job) => job.state !== "done");
|
|
278
573
|
if (open.length === 0)
|
|
279
574
|
return `<p class="empty">No open jobs.</p>`;
|
|
280
575
|
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
|
-
})
|
|
576
|
+
.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>`)
|
|
316
577
|
.join("");
|
|
317
|
-
const sources = files
|
|
318
|
-
.map((file) => `<pre class="src" data-file="${escapeHtml(file.id)}" hidden>${escapeHtml(file.text)}</pre>`)
|
|
319
|
-
.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
578
|
}
|
|
490
579
|
function safeUrl(url) {
|
|
491
580
|
if (!url)
|
|
@@ -501,6 +590,8 @@ function safeUrl(url) {
|
|
|
501
590
|
}
|
|
502
591
|
}
|
|
503
592
|
function document(options) {
|
|
593
|
+
const job = options.jobId ? ` data-job="${escapeHtml(options.jobId)}"` : "";
|
|
594
|
+
const latest = options.latest ? ` data-latest="${escapeHtml(options.latest)}"` : "";
|
|
504
595
|
return `<!DOCTYPE html>
|
|
505
596
|
<html lang="en">
|
|
506
597
|
<head>
|
|
@@ -512,7 +603,7 @@ function document(options) {
|
|
|
512
603
|
${options.css}
|
|
513
604
|
</style>
|
|
514
605
|
</head>
|
|
515
|
-
<body data-needs-you="${options.needsYou}" data-revision="${escapeHtml(options.revision)}" data-csrf="${escapeHtml(options.csrf ?? "")}">
|
|
606
|
+
<body data-section="${escapeHtml(options.section)}" data-needs-you="${options.needsYou}" data-revision="${escapeHtml(options.revision)}" data-csrf="${escapeHtml(options.csrf ?? "")}"${job}${latest}>
|
|
516
607
|
${options.body}
|
|
517
608
|
${options.live ? `<script>\n${LIVE_SCRIPT}\n</script>` : ""}
|
|
518
609
|
</body>
|