@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/README.md +51 -15
- package/assets/serve.css +233 -0
- package/dist/cli.js +79 -4
- package/dist/config.js +45 -27
- package/dist/driver.js +203 -12
- package/dist/findings.js +79 -0
- package/dist/intake/jira.js +4 -1
- package/dist/intake/tickets.js +23 -18
- package/dist/jobs.js +6 -1
- package/dist/reconcile.js +19 -1
- package/dist/report.js +168 -71
- package/dist/schemas.js +10 -5
- package/dist/serve-page.js +216 -0
- package/dist/serve.js +351 -0
- package/dist/workflow.js +28 -10
- package/package.json +2 -1
- package/prompts/kiln/investigation.md +31 -0
- package/workflows/investigation.yaml +67 -0
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
|
-
/**
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
98
|
+
return lines;
|
|
87
99
|
}
|
|
88
|
-
function
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
215
|
+
reasons.push({ dep, detail: `waiting for ${dep} to open a pull request` });
|
|
118
216
|
}
|
|
119
217
|
}
|
|
120
|
-
return
|
|
218
|
+
return reasons;
|
|
121
219
|
}
|
|
122
|
-
function
|
|
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
|
|
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.
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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("&", "&")
|
|
201
|
+
.replaceAll("<", "<")
|
|
202
|
+
.replaceAll(">", ">")
|
|
203
|
+
.replaceAll('"', """)
|
|
204
|
+
.replaceAll("'", "'");
|
|
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
|
+
}
|