@lchase/kiln 0.1.0 → 0.3.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/dist/reconcile.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { INVESTIGATION_WORKFLOW } from "./findings.js";
1
2
  export function inFlightCount(observation) {
2
3
  return observation.jobs.filter((job) => inFlight(job, observation)).length;
3
4
  }
@@ -14,6 +15,7 @@ function inFlight(job, observation) {
14
15
  * `approved` is the approve decision while the run is still alive. It holds
15
16
  * a slot and does not unblock dependents. An ok run with no readable
16
17
  * decision is `failed`, so a missing artifact cannot unblock the next job.
18
+ * An investigation is `done` on approve and never `in-review`.
17
19
  */
18
20
  export function jobState(job, observation) {
19
21
  if (job.launchFailed && !job.run)
@@ -60,15 +62,22 @@ export function dependenciesMet(job, observation) {
60
62
  export function isLanded(job) {
61
63
  return job.run?.status === "ok" && job.run.gateDecision === "approve";
62
64
  }
63
- /** `deliver=local`: landed. `deliver=pr`: landed and the pull request has merged. */
65
+ /**
66
+ * `deliver=local`: landed. `deliver=pr`: landed and the pull request has merged.
67
+ * An investigation is done once the gate says approve. There is no pull request.
68
+ */
64
69
  export function isDone(job) {
65
70
  if (!isLanded(job))
66
71
  return false;
72
+ if (job.workflow === INVESTIGATION_WORKFLOW)
73
+ return true;
67
74
  if ((job.deliver ?? "local") !== "pr")
68
75
  return true;
69
76
  return job.delivery?.merged === true || job.delivery?.prState === "MERGED";
70
77
  }
71
78
  function landedState(job) {
79
+ if (job.workflow === INVESTIGATION_WORKFLOW)
80
+ return "done";
72
81
  if ((job.deliver ?? "local") !== "pr")
73
82
  return "done";
74
83
  const delivery = job.delivery;
@@ -85,6 +94,8 @@ function landedState(job) {
85
94
  function needsDelivery(job) {
86
95
  if (job.error)
87
96
  return false;
97
+ if (job.workflow === INVESTIGATION_WORKFLOW)
98
+ return false;
88
99
  if ((job.deliver ?? "local") !== "pr")
89
100
  return false;
90
101
  if (!isLanded(job))
@@ -107,6 +118,13 @@ export function decide(observation) {
107
118
  type: "clear-breadcrumb",
108
119
  path: crumb.path,
109
120
  }));
121
+ for (const job of observation.jobs) {
122
+ if (job.error || job.workflow !== INVESTIGATION_WORKFLOW)
123
+ continue;
124
+ if (!isLanded(job) || job.findingsPath)
125
+ continue;
126
+ actions.push({ type: "record-findings", jobId: job.jobId });
127
+ }
110
128
  for (const job of observation.jobs) {
111
129
  if (!needsDelivery(job))
112
130
  continue;
package/dist/report.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
  import { dirname, join } from "node:path";
4
+ import { INVESTIGATION_WORKFLOW } from "./findings.js";
4
5
  import { inFlightCount, isDone, isLanded, jobState, } from "./reconcile.js";
5
6
  export function shellQuote(value) {
6
7
  return `'${value.replaceAll("'", `'\\''`)}'`;
@@ -28,105 +29,195 @@ export function gateAnswerCommand(checkout, runId, gate, choice, title) {
28
29
  const titlePrefix = title ? `KILN_TASK_TITLE=${shellQuote(title)} ` : "";
29
30
  return `cd ${shellQuote(checkout)} && mkdir -p ${pendingDir} && printf '%s\\n' ${shellQuote(answer)} > ${answerFile} && ${titlePrefix}${yakInvocation("resume", runId)}`;
30
31
  }
31
- export function renderStatus(observation, jobId) {
32
- const jobs = jobId ? observation.jobs.filter((job) => job.jobId === jobId) : observation.jobs;
33
- if (jobId && jobs.length === 0) {
32
+ /**
33
+ * Derived state for every job, or for one id.
34
+ * Throws `No job <id>` when that id is not on disk, same as `kiln status`.
35
+ */
36
+ export function statusView(observation, jobId) {
37
+ const selected = jobId ? observation.jobs.filter((job) => job.jobId === jobId) : observation.jobs;
38
+ if (jobId && selected.length === 0) {
34
39
  throw new Error(`No job ${jobId}.`);
35
40
  }
36
- if (jobs.length === 0)
41
+ return { jobs: selected.map((job) => toStatusJob(job, observation)) };
42
+ }
43
+ export function renderStatus(view) {
44
+ if (view.jobs.length === 0)
37
45
  return "no jobs\n";
38
46
  const lines = [];
39
- for (const job of jobs) {
40
- if (job.error) {
41
- lines.push(`${job.jobId} invalid: ${job.error}`);
47
+ for (const job of view.jobs) {
48
+ lines.push(statusHeader(job));
49
+ if (job.state === "invalid")
42
50
  continue;
51
+ for (const detail of statusDetails(job))
52
+ lines.push(` ${detail}`);
53
+ }
54
+ return `${lines.join("\n")}\n`;
55
+ }
56
+ /** Extra lines `kiln status` prints under the job header, without the indent. */
57
+ export function statusDetails(job) {
58
+ const lines = [];
59
+ if (job.source)
60
+ lines.push(`source ${job.source.kind} ${job.source.ref}`);
61
+ if (job.findingsPath)
62
+ lines.push(`findings ${job.findingsPath}`);
63
+ else if (job.workflow === INVESTIGATION_WORKFLOW && job.state === "done")
64
+ lines.push("findings missing");
65
+ if (job.prPending)
66
+ lines.push("pull request not opened yet");
67
+ if (job.state === "failed" && job.deliveryError)
68
+ lines.push(job.deliveryError);
69
+ if (job.state === "failed" && job.prClosedUnmerged && job.prNumber) {
70
+ lines.push(`pull request #${job.prNumber} was closed without merging`);
71
+ }
72
+ if (job.viewError && (job.state === "in-review" || job.state === "done")) {
73
+ lines.push(`could not read pull request: ${job.viewError}`);
74
+ }
75
+ if (job.state === "failed" && job.launchFailed && job.logPath)
76
+ lines.push(`log ${job.logPath}`);
77
+ if (job.state === "failed" && job.launchError)
78
+ lines.push(job.launchError);
79
+ if (job.noReadableApproval)
80
+ lines.push("no readable approval decision");
81
+ if (job.cancelCommand)
82
+ lines.push(job.cancelCommand);
83
+ if (job.state === "blocked") {
84
+ for (const dep of job.dependsOn) {
85
+ if (dep.blockedDetail)
86
+ lines.push(dep.blockedDetail);
43
87
  }
44
- const state = jobState(job, observation);
45
- const run = job.run ? ` run ${job.run.runId}` : "";
46
- const branch = job.run && (state === "done" || state === "in-review") ? ` branch yak/${job.run.runId}` : "";
47
- const pr = prSuffix(job, state);
48
- lines.push(`${job.jobId} ${job.repo} ${job.workflow} ${state}${run}${branch}${pr}`);
49
- if (job.source)
50
- lines.push(` source ${job.source.kind} ${job.source.ref}`);
51
- if (state === "in-review" && !job.delivery?.prNumber) {
52
- lines.push(" pull request not opened yet");
53
- }
54
- if (state === "failed" && job.delivery?.error) {
55
- lines.push(` ${job.delivery.error}`);
56
- }
57
- if (state === "failed" && job.delivery?.prState === "CLOSED" && job.delivery.prNumber) {
58
- lines.push(` pull request #${job.delivery.prNumber} was closed without merging`);
59
- }
60
- if (job.delivery?.viewError && (state === "in-review" || state === "done")) {
61
- lines.push(` could not read pull request: ${job.delivery.viewError}`);
62
- }
63
- if (state === "failed" && job.launchFailed && job.logPath) {
64
- lines.push(` log ${job.logPath}`);
65
- }
66
- if (state === "failed" && job.launchError) {
67
- lines.push(` ${job.launchError}`);
68
- }
69
- if (state === "failed" && job.run?.status === "ok" && !job.run.gateDecision) {
70
- lines.push(" no readable approval decision");
71
- }
72
- if (state === "dead" && job.run) {
73
- lines.push(` fix: cd ${shellQuote(job.run.checkout)} && ${yakInvocation("cancel", job.run.runId)}`);
74
- }
75
- if (state === "blocked") {
76
- for (const line of blockedLines(job, observation))
77
- lines.push(line);
78
- }
79
- if (state === "needs-human" && job.run) {
80
- for (const gate of job.run.gates) {
81
- lines.push(` gate ${gate.stepId}: ${gate.prompt}`);
82
- appendGateCommands(lines, job, gate);
83
- }
88
+ }
89
+ if (job.state === "needs-human") {
90
+ for (const gate of job.gates) {
91
+ lines.push(`gate ${gate.stepId}: ${gate.prompt}`);
92
+ if (gate.choices.includes("approve"))
93
+ lines.push(`approve: kiln approve ${job.jobId}`);
94
+ if (gate.choices.includes("reject"))
95
+ lines.push(`reject: kiln reject ${job.jobId}`);
84
96
  }
85
97
  }
86
- return `${lines.join("\n")}\n`;
98
+ return lines;
87
99
  }
88
- function prSuffix(job, state) {
89
- const url = job.delivery?.prUrl;
90
- if (!url)
91
- return "";
92
- if (state !== "in-review" && state !== "done" && state !== "failed")
93
- return "";
94
- return ` pr ${url}`;
100
+ function statusHeader(job) {
101
+ if (job.state === "invalid")
102
+ return `${job.jobId} invalid: ${job.invalidReason ?? "invalid"}`;
103
+ const run = job.runId ? ` run ${job.runId}` : "";
104
+ const branch = job.branch ? ` branch ${job.branch}` : "";
105
+ const pr = job.prUrl && (job.state === "in-review" || job.state === "done" || job.state === "failed")
106
+ ? ` pr ${job.prUrl}`
107
+ : "";
108
+ return `${job.jobId} ${job.repo} ${job.workflow} ${job.state}${run}${branch}${pr}`;
95
109
  }
96
- function blockedLines(job, observation) {
97
- const lines = [];
110
+ function toStatusJob(job, observation) {
111
+ if (job.error) {
112
+ return {
113
+ jobId: job.jobId,
114
+ title: job.title,
115
+ repo: job.repo,
116
+ workflow: job.workflow,
117
+ state: "invalid",
118
+ dependsOn: dependencyViews(job, observation),
119
+ stack: job.stack === true,
120
+ ...(job.source ? { source: { kind: job.source.kind, ref: job.source.ref } } : {}),
121
+ ...(job.specPath ? { specPath: job.specPath } : {}),
122
+ ...(job.logPath ? { logPath: job.logPath } : {}),
123
+ ...(job.findingsPath ? { findingsPath: job.findingsPath } : {}),
124
+ launchFailed: job.launchFailed,
125
+ invalidReason: job.error,
126
+ prPending: false,
127
+ prClosedUnmerged: false,
128
+ noReadableApproval: false,
129
+ gates: [],
130
+ canApprove: false,
131
+ canReject: false,
132
+ };
133
+ }
134
+ const state = jobState(job, observation);
135
+ const gates = state === "needs-human" && job.run ? job.run.gates.map(toGateView) : [];
136
+ const prNumber = job.delivery?.prNumber;
137
+ const runId = job.run?.runId;
138
+ const showBranch = (state === "done" || state === "in-review") && runId !== undefined;
139
+ return {
140
+ jobId: job.jobId,
141
+ title: job.title,
142
+ repo: job.repo,
143
+ workflow: job.workflow,
144
+ state,
145
+ dependsOn: dependencyViews(job, observation),
146
+ stack: job.stack === true,
147
+ ...(job.source ? { source: { kind: job.source.kind, ref: job.source.ref } } : {}),
148
+ ...(runId ? { runId } : {}),
149
+ ...(showBranch ? { branch: `yak/${runId}` } : {}),
150
+ ...(job.delivery?.prUrl ? { prUrl: job.delivery.prUrl } : {}),
151
+ ...(prNumber ? { prNumber } : {}),
152
+ ...(job.logPath ? { logPath: job.logPath } : {}),
153
+ ...(job.specPath ? { specPath: job.specPath } : {}),
154
+ ...(job.findingsPath ? { findingsPath: job.findingsPath } : {}),
155
+ launchFailed: job.launchFailed,
156
+ ...(job.launchError ? { launchError: job.launchError } : {}),
157
+ ...(job.delivery?.error ? { deliveryError: job.delivery.error } : {}),
158
+ ...(job.delivery?.viewError ? { viewError: job.delivery.viewError } : {}),
159
+ prPending: state === "in-review" && !prNumber,
160
+ prClosedUnmerged: job.delivery?.prState === "CLOSED",
161
+ noReadableApproval: state === "failed" && job.run?.status === "ok" && !job.run.gateDecision,
162
+ ...(state === "dead" && job.run
163
+ ? { cancelCommand: `fix: cd ${shellQuote(job.run.checkout)} && ${yakInvocation("cancel", job.run.runId)}` }
164
+ : {}),
165
+ gates,
166
+ canApprove: gates.some((gate) => gate.choices.includes("approve")),
167
+ canReject: gates.some((gate) => gate.choices.includes("reject")),
168
+ };
169
+ }
170
+ function toGateView(gate) {
171
+ const choices = gate.choices?.length ? gate.choices : ["approve", "reject"];
172
+ return {
173
+ stepId: gate.stepId,
174
+ prompt: gate.prompt,
175
+ ...(gate.rendered ? { rendered: gate.rendered } : {}),
176
+ choices,
177
+ artifacts: gate.artifacts ?? [],
178
+ reviews: gate.reviews ?? [],
179
+ };
180
+ }
181
+ function dependencyViews(job, observation) {
182
+ const reasons = blockReasons(job, observation);
183
+ return job.dependsOn.map((dep) => {
184
+ const upstream = observation.jobs.find((candidate) => candidate.jobId === dep);
185
+ const state = !upstream ? "missing" : upstream.error ? "invalid" : jobState(upstream, observation);
186
+ const blockedDetail = reasons.find((reason) => reason.dep === dep)?.detail;
187
+ return {
188
+ jobId: dep,
189
+ state,
190
+ ...(blockedDetail ? { blockedDetail } : {}),
191
+ };
192
+ });
193
+ }
194
+ function blockReasons(job, observation) {
195
+ const reasons = [];
98
196
  for (const dep of job.dependsOn) {
99
197
  const upstream = observation.jobs.find((candidate) => candidate.jobId === dep);
100
198
  if (!upstream) {
101
- lines.push(` blocked on missing job ${dep}`);
199
+ reasons.push({ dep, detail: `blocked on missing job ${dep}` });
102
200
  continue;
103
201
  }
104
202
  if (job.stack) {
105
203
  if (!isLanded(upstream))
106
- lines.push(` waiting for ${dep} to land`);
204
+ reasons.push({ dep, detail: `waiting for ${dep} to land` });
107
205
  continue;
108
206
  }
109
207
  if (isDone(upstream))
110
208
  continue;
111
209
  const number = upstream.delivery?.prNumber;
112
210
  if (number && !upstream.delivery?.merged && upstream.delivery?.prState !== "CLOSED") {
113
- lines.push(` waiting for PR #${number} to merge`);
211
+ reasons.push({ dep, detail: `waiting for PR #${number} to merge` });
114
212
  continue;
115
213
  }
116
214
  if ((upstream.deliver ?? "local") === "pr" && isLanded(upstream)) {
117
- lines.push(` waiting for ${dep} to open a pull request`);
215
+ reasons.push({ dep, detail: `waiting for ${dep} to open a pull request` });
118
216
  }
119
217
  }
120
- return lines;
218
+ return reasons;
121
219
  }
122
- function appendGateCommands(lines, job, gate) {
123
- const choices = gate.choices?.length ? gate.choices : ["approve", "reject"];
124
- if (choices.includes("approve"))
125
- lines.push(` approve: kiln approve ${job.jobId}`);
126
- if (choices.includes("reject"))
127
- lines.push(` reject: kiln reject ${job.jobId}`);
128
- }
129
- export function renderTick(observation, actions, launches, delivers = []) {
220
+ export function renderTick(observation, actions, launches, delivers = [], findings = []) {
130
221
  const lines = [`tick inFlight=${inFlightCount(observation)} maxConcurrent=${observation.maxConcurrent}`];
131
222
  for (const job of observation.jobs) {
132
223
  if (job.error)
@@ -141,6 +232,11 @@ export function renderTick(observation, actions, launches, delivers = []) {
141
232
  ? `deliver ${deliver.jobId} ${deliver.repo} ${deliver.detail}`
142
233
  : `deliver ${deliver.jobId} ${deliver.repo} failed: ${deliver.detail}`);
143
234
  }
235
+ for (const recorded of findings) {
236
+ lines.push(recorded.status === "wrote"
237
+ ? `findings ${recorded.jobId} ${recorded.path}`
238
+ : `findings ${recorded.jobId} missing`);
239
+ }
144
240
  for (const launch of launches) {
145
241
  lines.push(`launch ${launch.jobId} ${launch.repo}`);
146
242
  for (const line of launch.output.split("\n")) {
@@ -150,7 +246,8 @@ export function renderTick(observation, actions, launches, delivers = []) {
150
246
  }
151
247
  if (!actions.some((action) => action.type === "launch") &&
152
248
  launches.length === 0 &&
153
- delivers.length === 0) {
249
+ delivers.length === 0 &&
250
+ findings.length === 0) {
154
251
  lines.push("nothing to launch");
155
252
  }
156
253
  return `${lines.join("\n")}\n`;
package/dist/schemas.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { z } from "zod";
2
2
  /**
3
3
  * Kiln config file. `repos` is an open map: any name, not a fixed set.
4
- * `path` is absolute, relative to the config file's directory, or `~`.
4
+ * `path` is absolute, `~`, or (in older files) relative to the config directory.
5
+ * `repo add` stores a relative path as absolute, resolved from the working directory.
5
6
  * `test` is the shell command a workflow runs as its verify step (`KILN_TEST`).
6
7
  */
7
8
  /**
@@ -59,6 +60,8 @@ export const JobSchema = z.object({
59
60
  * When true, this job starts once its single upstream has landed, on that
60
61
  * upstream's branch. Omitted means after-merged: wait until every upstream
61
62
  * is `done`. More than one `dependsOn` with `stack` is rejected at add time.
63
+ * Stacking onto an investigation is rejected: that workflow does not land
64
+ * a branch.
62
65
  */
63
66
  stack: z.boolean().optional(),
64
67
  source: JobSourceSchema.optional(),
@@ -71,10 +74,12 @@ export const JobSchema = z.object({
71
74
  * For `deliver=local`, `done` is a run that finished ok with an approve
72
75
  * decision. For `deliver=pr`, `done` is that landed run whose pull request
73
76
  * has merged (`state` or `mergedAt`, so a squash merge counts). `in-review`
74
- * is the landed run while the pull request is still open. A reject, a
75
- * failure, a dead process, and an ok run with no readable approval do not
76
- * unblock an after-merged dependent. A stacked job unblocks when its one
77
- * upstream has landed, which includes `in-review`.
77
+ * is the landed run while the pull request is still open. An investigation
78
+ * is `done` when the gate says approve, including when the repo is
79
+ * `deliver=pr`. It does not enter `in-review`. A reject, a failure, a dead
80
+ * process, and an ok run with no readable approval do not unblock an
81
+ * after-merged dependent. A stacked job unblocks when its one upstream has
82
+ * landed, which includes `in-review`.
78
83
  */
79
84
  export const JOB_STATES = [
80
85
  "queued",
@@ -0,0 +1,216 @@
1
+ import { INVESTIGATION_WORKFLOW } from "./findings.js";
2
+ import { statusDetails } from "./report.js";
3
+ import { JOB_STATES } from "./schemas.js";
4
+ const REFRESH_SECONDS = 3;
5
+ export function renderInbox(css, view) {
6
+ const waiting = view.jobs.filter((job) => job.state === "needs-human");
7
+ const body = waiting.length === 0 ? emptyInbox() : waiting.map(renderInboxCard).join("\n");
8
+ return document({
9
+ css,
10
+ title: "Kiln inbox",
11
+ refresh: true,
12
+ current: "inbox",
13
+ main: `<h1>Inbox</h1>
14
+ <p class="lede">Jobs waiting on a person. Approve and reject use the same gate as the CLI.</p>
15
+ ${body}`,
16
+ });
17
+ }
18
+ export function renderBoard(css, view) {
19
+ const groups = [];
20
+ const states = [...JOB_STATES, "invalid"];
21
+ for (const state of states) {
22
+ const jobs = view.jobs.filter((job) => job.state === state);
23
+ if (jobs.length === 0)
24
+ continue;
25
+ groups.push(`<section class="group" id="state-${escapeHtml(state)}">
26
+ <h2>${escapeHtml(state)} <span class="count">${jobs.length}</span></h2>
27
+ ${jobs.map(renderBoardJob).join("\n")}
28
+ </section>`);
29
+ }
30
+ const main = groups.length > 0
31
+ ? groups.join("\n")
32
+ : `<h1>Board</h1><p class="empty">No jobs.</p>`;
33
+ const titled = groups.length > 0 ? `<h1>Board</h1>\n${main}` : main;
34
+ return document({
35
+ css,
36
+ title: "Kiln board",
37
+ refresh: true,
38
+ current: "board",
39
+ main: titled,
40
+ });
41
+ }
42
+ export function renderTextPage(css, title, text) {
43
+ return document({
44
+ css,
45
+ title,
46
+ refresh: false,
47
+ current: "inbox",
48
+ main: `<a class="back" href="/">Inbox</a>
49
+ <h1>${escapeHtml(title)}</h1>
50
+ <pre class="text">${escapeHtml(text)}</pre>`,
51
+ });
52
+ }
53
+ export function renderMessagePage(css, title, message) {
54
+ return document({
55
+ css,
56
+ title,
57
+ refresh: false,
58
+ current: "inbox",
59
+ main: `<a class="back" href="/">Inbox</a>
60
+ <h1>${escapeHtml(title)}</h1>
61
+ <p class="empty">${escapeHtml(message)}</p>`,
62
+ });
63
+ }
64
+ function emptyInbox() {
65
+ return `<p class="empty">Nothing is waiting on a person. The <a href="/board">board</a> lists every job.</p>`;
66
+ }
67
+ function renderInboxCard(job) {
68
+ const gates = job.gates
69
+ .map((gate) => {
70
+ const asking = gate.rendered && gate.rendered !== gate.prompt ? gate.rendered : gate.prompt;
71
+ const artifacts = gate.artifacts.length === 0
72
+ ? ""
73
+ : `<ul class="facts">${gate.artifacts
74
+ .map((artifact) => `<li><span class="name">${escapeHtml(artifact.name)}</span> ${escapeHtml(artifact.summary)}</li>`)
75
+ .join("")}</ul>`;
76
+ const reviews = gate.reviews.length === 0
77
+ ? ""
78
+ : `<h4>Reviews</h4><ul class="facts">${gate.reviews
79
+ .map((review) => `<li><span class="name">${escapeHtml(review.name)}</span> ${escapeHtml(review.summary)}</li>`)
80
+ .join("")}</ul>`;
81
+ return `<p class="prompt">${escapeHtml(asking)}</p>${artifacts}${reviews}`;
82
+ })
83
+ .join("\n");
84
+ return `<article class="card" id="job-${escapeHtml(job.jobId)}">
85
+ <h2>${escapeHtml(job.title || job.jobId)}</h2>
86
+ <p class="meta">${escapeHtml(job.repo)} · ${escapeHtml(job.workflow)} · <code>${escapeHtml(job.jobId)}</code>${job.stack ? ' · <span class="chip">stacked</span>' : ""}</p>
87
+ ${gates}
88
+ ${resourceLinks(job)}
89
+ ${actionForms(job)}
90
+ </article>`;
91
+ }
92
+ function renderBoardJob(job) {
93
+ const deps = job.dependsOn.length === 0
94
+ ? ""
95
+ : `<p class="deps">depends on ${job.dependsOn
96
+ .map((dep) => {
97
+ const why = dep.blockedDetail ? ` <span class="why">${escapeHtml(dep.blockedDetail)}</span>` : "";
98
+ return `<a href="/board#job-${encodeURIComponent(dep.jobId)}">${escapeHtml(dep.jobId)}</a> <span class="chip">${escapeHtml(dep.state)}</span>${why}`;
99
+ })
100
+ .join(", ")}</p>`;
101
+ const pr = job.prUrl && (job.state === "in-review" || job.state === "done" || job.state === "failed")
102
+ ? renderPr(job.prUrl)
103
+ : "";
104
+ const details = statusDetails(job);
105
+ const detailList = details.length === 0
106
+ ? ""
107
+ : `<ul class="details">${details
108
+ .map((line) => `<li class="${detailClass(job.state, line)}">${escapeHtml(line)}</li>`)
109
+ .join("")}</ul>`;
110
+ return `<article class="job" id="job-${escapeHtml(job.jobId)}">
111
+ <h3>${escapeHtml(job.title || job.jobId)}</h3>
112
+ <p class="meta">${escapeHtml(job.repo)} · ${escapeHtml(job.workflow)} · <code>${escapeHtml(job.jobId)}</code></p>
113
+ <p class="chips"><span class="chip state-${escapeHtml(job.state)}">${escapeHtml(job.state)}</span>${job.stack ? '<span class="chip">stacked</span>' : ""}</p>
114
+ ${deps}
115
+ ${pr}
116
+ ${detailList}
117
+ ${resourceLinks(job)}
118
+ </article>`;
119
+ }
120
+ function detailClass(state, line) {
121
+ if (line.startsWith("source ") ||
122
+ line.startsWith("gate ") ||
123
+ line.startsWith("approve:") ||
124
+ line.startsWith("reject:") ||
125
+ line.startsWith("waiting ") ||
126
+ line.startsWith("blocked on ") ||
127
+ line.startsWith("fix:")) {
128
+ return "";
129
+ }
130
+ if (state === "failed" || state === "dead")
131
+ return "bad";
132
+ return "";
133
+ }
134
+ function renderPr(url) {
135
+ const safe = httpUrl(url);
136
+ if (!safe)
137
+ return `<p class="links">${escapeHtml(url)}</p>`;
138
+ return `<p class="links"><a href="${escapeHtml(safe)}">Pull request</a></p>`;
139
+ }
140
+ function resourceLinks(job) {
141
+ const links = [];
142
+ const id = encodeURIComponent(job.jobId);
143
+ if (job.specPath)
144
+ links.push(`<a href="/jobs/${id}/spec">Spec</a>`);
145
+ if (job.logPath)
146
+ links.push(`<a href="/jobs/${id}/log">Log</a>`);
147
+ if (job.workflow === INVESTIGATION_WORKFLOW)
148
+ links.push(`<a href="/jobs/${id}/findings">Findings</a>`);
149
+ if (links.length === 0)
150
+ return "";
151
+ return `<p class="links">${links.join(" ")}</p>`;
152
+ }
153
+ function actionForms(job) {
154
+ const forms = [];
155
+ const id = encodeURIComponent(job.jobId);
156
+ if (job.canApprove) {
157
+ forms.push(`<form method="post" action="/api/jobs/${id}/approve"><button class="approve" type="submit">Approve</button></form>`);
158
+ }
159
+ if (job.canReject) {
160
+ forms.push(`<form method="post" action="/api/jobs/${id}/reject"><button class="reject" type="submit">Reject</button></form>`);
161
+ }
162
+ if (forms.length === 0)
163
+ return "";
164
+ return `<div class="actions">${forms.join("")}</div>`;
165
+ }
166
+ function document(options) {
167
+ const refresh = options.refresh
168
+ ? `<meta http-equiv="refresh" content="${REFRESH_SECONDS}">`
169
+ : "";
170
+ return `<!DOCTYPE html>
171
+ <html lang="en">
172
+ <head>
173
+ <meta charset="utf-8">
174
+ <meta name="viewport" content="width=device-width, initial-scale=1">
175
+ <meta name="referrer" content="same-origin">
176
+ ${refresh}
177
+ <title>${escapeHtml(options.title)}</title>
178
+ <style>
179
+ ${options.css}
180
+ </style>
181
+ </head>
182
+ <body>
183
+ <header class="top">
184
+ <a class="kiln-mark" href="/">Kiln</a>
185
+ <nav>
186
+ <a href="/" ${options.current === "inbox" ? 'aria-current="page"' : ""}>Inbox</a>
187
+ <a href="/board" ${options.current === "board" ? 'aria-current="page"' : ""}>Board</a>
188
+ </nav>
189
+ ${options.refresh ? `<p class="refresh">Refreshes every ${REFRESH_SECONDS}s</p>` : ""}
190
+ </header>
191
+ <main>
192
+ ${options.main}
193
+ </main>
194
+ </body>
195
+ </html>
196
+ `;
197
+ }
198
+ export function escapeHtml(value) {
199
+ return value
200
+ .replaceAll("&", "&amp;")
201
+ .replaceAll("<", "&lt;")
202
+ .replaceAll(">", "&gt;")
203
+ .replaceAll('"', "&quot;")
204
+ .replaceAll("'", "&#39;");
205
+ }
206
+ function httpUrl(value) {
207
+ try {
208
+ const url = new URL(value);
209
+ if (url.protocol === "http:" || url.protocol === "https:")
210
+ return url.href;
211
+ }
212
+ catch {
213
+ return undefined;
214
+ }
215
+ return undefined;
216
+ }