@lchase/kiln 0.11.0 → 0.13.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 +20 -0
- package/assets/serve.js +53 -0
- package/dist/attention.js +2 -0
- package/dist/board-view.js +7 -0
- package/dist/cli.js +10 -2
- package/dist/config.js +5 -0
- package/dist/driver.js +67 -13
- package/dist/events.js +85 -2
- package/dist/host.js +106 -1
- package/dist/intake/jira.js +87 -0
- package/dist/jobs.js +4 -3
- package/dist/reconcile.js +7 -3
- package/dist/report.js +8 -0
- package/dist/review-post.js +269 -0
- package/dist/schemas.js +51 -3
- package/dist/serve-page.js +33 -2
- package/dist/serve.js +13 -4
- package/dist/settings.js +40 -1
- package/dist/triggers.js +797 -0
- package/dist/workflow.js +18 -15
- package/package.json +1 -1
- package/prompts/kiln/review.md +13 -0
- package/workflows/review.yaml +64 -0
package/dist/intake/jira.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
1
2
|
import { assertCanStack, listValidJobs, renderAddedJob, writeJob } from "../jobs.js";
|
|
2
3
|
import { ISSUE_KEY } from "../schemas.js";
|
|
3
4
|
import { assertWorkflowName } from "../workflow.js";
|
|
@@ -89,6 +90,92 @@ export async function addFromJira(config, input) {
|
|
|
89
90
|
warnings,
|
|
90
91
|
};
|
|
91
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Search Jira with the operator's token. The token stays in the child
|
|
95
|
+
* environment and is never written to stdout, a job, or an event.
|
|
96
|
+
* `POST /rest/api/3/search/jql` is the read. There is no webhook here.
|
|
97
|
+
*/
|
|
98
|
+
export function searchJiraIssues(config, jql, env = process.env) {
|
|
99
|
+
const query = jql.trim();
|
|
100
|
+
if (!query || query.length > 2_000)
|
|
101
|
+
throw new Error("Jira poll JQL is empty or too long.");
|
|
102
|
+
const credentials = resolveJiraCredentials(config, env);
|
|
103
|
+
const script = [
|
|
104
|
+
"const base = process.env.KILN_JIRA_BASE;",
|
|
105
|
+
"const email = process.env.KILN_JIRA_EMAIL;",
|
|
106
|
+
"const token = process.env.KILN_JIRA_TOKEN;",
|
|
107
|
+
"const jql = process.env.KILN_JIRA_JQL;",
|
|
108
|
+
"if (!base || !email || !token || !jql) { console.error('jira search is missing credentials'); process.exit(2); }",
|
|
109
|
+
"const auth = Buffer.from(email + ':' + token, 'utf8').toString('base64');",
|
|
110
|
+
"let url;",
|
|
111
|
+
"try { url = new URL('/rest/api/3/search/jql', base.endsWith('/') ? base : base + '/'); }",
|
|
112
|
+
"catch { console.error('jira base url is invalid'); process.exit(2); }",
|
|
113
|
+
"const response = await fetch(url, {",
|
|
114
|
+
" method: 'POST',",
|
|
115
|
+
" headers: { Authorization: 'Basic ' + auth, Accept: 'application/json', 'Content-Type': 'application/json' },",
|
|
116
|
+
" body: JSON.stringify({ jql, maxResults: 50, fields: ['summary', 'updated', 'description'] }),",
|
|
117
|
+
"});",
|
|
118
|
+
"const text = await response.text();",
|
|
119
|
+
"if (!response.ok) { console.error('jira search failed HTTP ' + response.status); process.exit(1); }",
|
|
120
|
+
"process.stdout.write(text);",
|
|
121
|
+
].join("\n");
|
|
122
|
+
const result = spawnSync(process.execPath, ["--input-type=module", "-e", script], {
|
|
123
|
+
encoding: "utf8",
|
|
124
|
+
timeout: 20_000,
|
|
125
|
+
env: {
|
|
126
|
+
PATH: env.PATH,
|
|
127
|
+
KILN_JIRA_BASE: credentials.baseUrl,
|
|
128
|
+
KILN_JIRA_EMAIL: credentials.email,
|
|
129
|
+
KILN_JIRA_TOKEN: credentials.apiToken,
|
|
130
|
+
KILN_JIRA_JQL: query,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
if (result.error || result.status !== 0) {
|
|
134
|
+
const detail = (result.stderr || result.error?.message || "jira search failed").trim().split("\n")[0] ?? "jira search failed";
|
|
135
|
+
throw new Error(redactSecrets(detail, credentials).slice(0, 300));
|
|
136
|
+
}
|
|
137
|
+
let parsed;
|
|
138
|
+
try {
|
|
139
|
+
parsed = JSON.parse(result.stdout || "{}");
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
throw new Error("Jira search returned data that is not JSON.");
|
|
143
|
+
}
|
|
144
|
+
return parseSearchIssues(parsed, credentials);
|
|
145
|
+
}
|
|
146
|
+
function parseSearchIssues(payload, credentials) {
|
|
147
|
+
const issues = typeof payload === "object" && payload !== null && "issues" in payload ? payload.issues : undefined;
|
|
148
|
+
if (!Array.isArray(issues))
|
|
149
|
+
throw new Error("Jira search returned data that is not a list of issues.");
|
|
150
|
+
const out = [];
|
|
151
|
+
for (const item of issues) {
|
|
152
|
+
if (typeof item !== "object" || item === null)
|
|
153
|
+
continue;
|
|
154
|
+
const record = item;
|
|
155
|
+
if (typeof record.key !== "string")
|
|
156
|
+
continue;
|
|
157
|
+
let key;
|
|
158
|
+
try {
|
|
159
|
+
key = normalizeIssueKey(record.key);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const fields = typeof record.fields === "object" && record.fields !== null ? record.fields : {};
|
|
165
|
+
const summary = typeof fields.summary === "string" ? fields.summary.trim().replace(/\s+/g, " ") : "";
|
|
166
|
+
if (!summary)
|
|
167
|
+
continue;
|
|
168
|
+
const updated = typeof fields.updated === "string" && fields.updated.trim() ? fields.updated.trim() : key;
|
|
169
|
+
const description = adfToMarkdown(fields.description).trim();
|
|
170
|
+
out.push({
|
|
171
|
+
key,
|
|
172
|
+
summary: redactSecrets(summary, credentials),
|
|
173
|
+
updated: redactSecrets(updated, credentials),
|
|
174
|
+
...(description ? { description: redactSecrets(description, credentials) } : {}),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
92
179
|
export function normalizeIssueKey(raw) {
|
|
93
180
|
const key = raw.trim().toUpperCase();
|
|
94
181
|
if (!ISSUE_KEY.test(key)) {
|
package/dist/jobs.js
CHANGED
|
@@ -5,7 +5,7 @@ import { appendEvent } from "./events.js";
|
|
|
5
5
|
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
6
6
|
import { jobBasename } from "./paths.js";
|
|
7
7
|
import { JobSchema, parseIssueKey } from "./schemas.js";
|
|
8
|
-
import { assertWorkflowName } from "./workflow.js";
|
|
8
|
+
import { assertWorkflowName, REVIEW_WORKFLOW } from "./workflow.js";
|
|
9
9
|
export function newJobId(now = new Date()) {
|
|
10
10
|
const stamp = now.toISOString().replace(/[-:TZ.]/g, "").slice(0, 14);
|
|
11
11
|
return `j${stamp}${randomBytes(2).toString("hex")}`;
|
|
@@ -149,8 +149,9 @@ export function assertCanStack(repo, dependsOn, stack, known = []) {
|
|
|
149
149
|
}
|
|
150
150
|
const upstreamId = dependsOn[0];
|
|
151
151
|
const upstream = known.find((job) => job.id === upstreamId);
|
|
152
|
-
if (upstream?.workflow === INVESTIGATION_WORKFLOW) {
|
|
153
|
-
|
|
152
|
+
if (upstream?.workflow === INVESTIGATION_WORKFLOW || upstream?.workflow === REVIEW_WORKFLOW) {
|
|
153
|
+
const kind = upstream.workflow === INVESTIGATION_WORKFLOW ? "investigation" : "review";
|
|
154
|
+
throw new Error(`stack cannot target ${kind} job ${upstreamId}. ${kind === "investigation" ? "An investigation" : "A review"} does not land a branch.`);
|
|
154
155
|
}
|
|
155
156
|
if (upstream && upstream.repo !== repo) {
|
|
156
157
|
throw new Error(`stack needs upstream ${upstreamId} to be in repo ${repo}. That job is in repo ${upstream.repo}.`);
|
package/dist/reconcile.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
2
|
+
import { REVIEW_WORKFLOW } from "./workflow.js";
|
|
2
3
|
/**
|
|
3
4
|
* Why an attempt failed. Written under `<kilnDir>/attempts`, not on the job.
|
|
4
5
|
* `auth-failed` is not an automatic retry: the job stays in Needs human.
|
|
@@ -118,17 +119,20 @@ export function isLanded(job) {
|
|
|
118
119
|
* `deliver=local`: landed. `deliver=pr`: landed and the pull request has merged.
|
|
119
120
|
* An investigation is done once the gate says approve. There is no pull request.
|
|
120
121
|
*/
|
|
122
|
+
function noPullRequest(workflow) {
|
|
123
|
+
return workflow === INVESTIGATION_WORKFLOW || workflow === REVIEW_WORKFLOW;
|
|
124
|
+
}
|
|
121
125
|
export function isDone(job) {
|
|
122
126
|
if (!isLanded(job))
|
|
123
127
|
return false;
|
|
124
|
-
if (job.workflow
|
|
128
|
+
if (noPullRequest(job.workflow))
|
|
125
129
|
return true;
|
|
126
130
|
if ((job.deliver ?? "local") !== "pr")
|
|
127
131
|
return true;
|
|
128
132
|
return job.delivery?.merged === true || job.delivery?.prState === "MERGED";
|
|
129
133
|
}
|
|
130
134
|
function landedState(job) {
|
|
131
|
-
if (job.workflow
|
|
135
|
+
if (noPullRequest(job.workflow))
|
|
132
136
|
return "done";
|
|
133
137
|
if ((job.deliver ?? "local") !== "pr")
|
|
134
138
|
return "done";
|
|
@@ -146,7 +150,7 @@ function landedState(job) {
|
|
|
146
150
|
function needsDelivery(job) {
|
|
147
151
|
if (job.error)
|
|
148
152
|
return false;
|
|
149
|
-
if (job.workflow
|
|
153
|
+
if (noPullRequest(job.workflow))
|
|
150
154
|
return false;
|
|
151
155
|
if ((job.deliver ?? "local") !== "pr")
|
|
152
156
|
return false;
|
package/dist/report.js
CHANGED
|
@@ -132,6 +132,10 @@ export function statusDetails(job) {
|
|
|
132
132
|
lines.push(`findings ${job.findingsPath}`);
|
|
133
133
|
else if (job.workflow === INVESTIGATION_WORKFLOW && job.state === "done")
|
|
134
134
|
lines.push("findings missing");
|
|
135
|
+
if (job.reviewUrl)
|
|
136
|
+
lines.push(`review ${job.reviewUrl}`);
|
|
137
|
+
if (job.reviewPostError)
|
|
138
|
+
lines.push(job.reviewPostError);
|
|
135
139
|
if (job.prPending)
|
|
136
140
|
lines.push("pull request not opened yet");
|
|
137
141
|
if (job.state === "failed" && job.deliveryError)
|
|
@@ -222,6 +226,8 @@ function toStatusJob(job, observation) {
|
|
|
222
226
|
...(job.specPath ? { specPath: job.specPath } : {}),
|
|
223
227
|
...(job.logPath ? { logPath: job.logPath } : {}),
|
|
224
228
|
...(job.findingsPath ? { findingsPath: job.findingsPath } : {}),
|
|
229
|
+
...(job.reviewPostError ? { reviewPostError: job.reviewPostError } : {}),
|
|
230
|
+
...(job.reviewUrl ? { reviewUrl: job.reviewUrl } : {}),
|
|
225
231
|
launchFailed: job.launchFailed,
|
|
226
232
|
attemptCount: job.attemptCount ?? 0,
|
|
227
233
|
maxAttempts: job.attemptCap ?? observation.maxAttempts,
|
|
@@ -270,6 +276,8 @@ function toStatusJob(job, observation) {
|
|
|
270
276
|
...(job.logPath ? { logPath: job.logPath } : {}),
|
|
271
277
|
...(job.specPath ? { specPath: job.specPath } : {}),
|
|
272
278
|
...(job.findingsPath ? { findingsPath: job.findingsPath } : {}),
|
|
279
|
+
...(job.reviewPostError ? { reviewPostError: job.reviewPostError } : {}),
|
|
280
|
+
...(job.reviewUrl ? { reviewUrl: job.reviewUrl } : {}),
|
|
273
281
|
launchFailed: job.launchFailed,
|
|
274
282
|
attemptCount: job.attemptCount ?? 0,
|
|
275
283
|
maxAttempts: job.attemptCap ?? observation.maxAttempts,
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { appendEvent, readEvents } from "./events.js";
|
|
4
|
+
import { classifyFailureText } from "./failure.js";
|
|
5
|
+
import { realGitHub } from "./host.js";
|
|
6
|
+
import { redact } from "./redact.js";
|
|
7
|
+
import { parseReviewSource } from "./triggers.js";
|
|
8
|
+
/**
|
|
9
|
+
* A review draft the human has not posted yet, and the COMMENT review Kiln
|
|
10
|
+
* writes after they approve the Inbox gate.
|
|
11
|
+
*
|
|
12
|
+
* The review event is always COMMENT. Kiln does not approve, request
|
|
13
|
+
* changes, or merge. A second approve at the same `review:<repo>:<number>:<sha>`
|
|
14
|
+
* does not post again.
|
|
15
|
+
*/
|
|
16
|
+
export const REVIEW_SEVERITIES = ["note", "nit", "warning", "blocking"];
|
|
17
|
+
const NEEDS_HUMAN_REASON = "review-post";
|
|
18
|
+
/** Target of a review-request job, or undefined when the draft is not tied to a pull request. */
|
|
19
|
+
export function reviewTargetFrom(repo, sourceRef) {
|
|
20
|
+
if (!sourceRef)
|
|
21
|
+
return undefined;
|
|
22
|
+
const parsed = parseReviewSource(sourceRef);
|
|
23
|
+
if (!parsed || parsed.repo !== repo)
|
|
24
|
+
return undefined;
|
|
25
|
+
return {
|
|
26
|
+
repo: parsed.repo,
|
|
27
|
+
number: parsed.number,
|
|
28
|
+
sha: parsed.sha,
|
|
29
|
+
dedupe: `review:${parsed.repo}:${parsed.number}:${parsed.sha}`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function reviewAlreadyPosted(events, dedupe) {
|
|
33
|
+
return events.some((event) => event.event === "review.posted" && event.properties.dedupe === dedupe);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Latest post for this job. A `review.posted` for the same dedupe, including
|
|
37
|
+
* one recorded on another job, counts as posted.
|
|
38
|
+
*/
|
|
39
|
+
export function reviewPostForJob(events, job) {
|
|
40
|
+
const dedupe = job.source?.kind === "trigger" ? reviewTargetFrom(job.repo, job.source.ref)?.dedupe : undefined;
|
|
41
|
+
let url;
|
|
42
|
+
let error;
|
|
43
|
+
for (const event of events) {
|
|
44
|
+
const posted = event.event === "review.posted" &&
|
|
45
|
+
(event.job_id === job.jobId || (dedupe !== undefined && event.properties.dedupe === dedupe));
|
|
46
|
+
if (posted) {
|
|
47
|
+
url = event.properties.url;
|
|
48
|
+
error = undefined;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (event.event === "review.failed" && event.job_id === job.jobId) {
|
|
52
|
+
url = undefined;
|
|
53
|
+
error = `Could not post the review (${event.properties.class}). ${event.properties.detail}`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { ...(url ? { url } : {}), ...(error ? { error } : {}) };
|
|
57
|
+
}
|
|
58
|
+
/** The draft step's artifact. Missing or unreadable is undefined. Extra fields are ignored. */
|
|
59
|
+
export function readReviewDraft(checkout, runId) {
|
|
60
|
+
const path = join(checkout, ".runs", runId, "artifacts", "draft.json");
|
|
61
|
+
if (!existsSync(path))
|
|
62
|
+
return undefined;
|
|
63
|
+
try {
|
|
64
|
+
return parseReviewDraft(JSON.parse(readFileSync(path, "utf8")));
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export function parseReviewDraft(value) {
|
|
71
|
+
if (typeof value !== "object" || value === null)
|
|
72
|
+
return undefined;
|
|
73
|
+
const markdown = value.markdown;
|
|
74
|
+
if (typeof markdown !== "string" || !markdown.trim())
|
|
75
|
+
return undefined;
|
|
76
|
+
const raw = value.findings;
|
|
77
|
+
const findings = [];
|
|
78
|
+
if (Array.isArray(raw)) {
|
|
79
|
+
for (const item of raw) {
|
|
80
|
+
const finding = parseFinding(item);
|
|
81
|
+
if (finding)
|
|
82
|
+
findings.push(finding);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { markdown: markdown.trim(), findings };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Body is the draft markdown. Findings with a real file and line become
|
|
89
|
+
* inline comments. Anything else is folded into the body when the markdown
|
|
90
|
+
* does not already contain it. Line numbers are never invented.
|
|
91
|
+
*/
|
|
92
|
+
export function buildCommentReview(draft) {
|
|
93
|
+
const comments = [];
|
|
94
|
+
const skipped = [];
|
|
95
|
+
for (const finding of draft.findings) {
|
|
96
|
+
const path = commentPath(finding.file);
|
|
97
|
+
const line = commentLine(finding.line);
|
|
98
|
+
if (!path || line === undefined) {
|
|
99
|
+
skipped.push(finding);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
comments.push({ path, line, body: commentText(finding) });
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
body: redact(foldSkipped(draft.markdown, skipped)),
|
|
106
|
+
comments: comments.map((comment) => ({ ...comment, body: redact(comment.body) })),
|
|
107
|
+
findings: draft.findings.length,
|
|
108
|
+
inline: comments.length,
|
|
109
|
+
skipped: skipped.length,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Post the approved draft, or do nothing when that head SHA was already
|
|
114
|
+
* posted. Throws without calling gh when the factory is paused, the daily
|
|
115
|
+
* cap is blocking, or the post fails. The caller leaves the gate open on throw.
|
|
116
|
+
*/
|
|
117
|
+
export function postApprovedReview(input) {
|
|
118
|
+
const target = reviewTargetFrom(input.repo, input.sourceRef);
|
|
119
|
+
if (!target) {
|
|
120
|
+
recordNeedsHuman(input);
|
|
121
|
+
throw new Error("This review draft is not tied to a pull request, so Kiln will not post it.");
|
|
122
|
+
}
|
|
123
|
+
const events = readEvents(input.kilnDir, { warn: () => { } });
|
|
124
|
+
if (reviewAlreadyPosted(events, target.dedupe))
|
|
125
|
+
return;
|
|
126
|
+
if (input.paused) {
|
|
127
|
+
throw new Error("Paused. Kiln will not post this review until kiln resume.");
|
|
128
|
+
}
|
|
129
|
+
if (input.spendBlocked) {
|
|
130
|
+
throw new Error("Daily cap reached. Kiln will not post this review until the local day rolls over.");
|
|
131
|
+
}
|
|
132
|
+
const draft = readReviewDraft(input.checkout, input.runId);
|
|
133
|
+
if (!draft) {
|
|
134
|
+
failPost(input, target, "deterministic", "The draft artifact is missing or unreadable.");
|
|
135
|
+
}
|
|
136
|
+
const review = buildCommentReview(draft);
|
|
137
|
+
const poster = input.host.github.postCommentReview ?? realGitHub.postCommentReview;
|
|
138
|
+
if (!poster) {
|
|
139
|
+
failPost(input, target, "deterministic", "gh cannot post a review from this host.");
|
|
140
|
+
}
|
|
141
|
+
let posted;
|
|
142
|
+
try {
|
|
143
|
+
posted = poster.call(input.host.github, {
|
|
144
|
+
checkout: input.checkout,
|
|
145
|
+
number: target.number,
|
|
146
|
+
commitId: target.sha,
|
|
147
|
+
body: review.body,
|
|
148
|
+
comments: review.comments,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
const detail = publicDetail(error);
|
|
153
|
+
failPost(input, target, classifyFailureText(detail), detail);
|
|
154
|
+
}
|
|
155
|
+
appendEvent(input.kilnDir, {
|
|
156
|
+
job_id: input.jobId,
|
|
157
|
+
run_id: input.runId,
|
|
158
|
+
event: "review.posted",
|
|
159
|
+
step: "approve",
|
|
160
|
+
properties: {
|
|
161
|
+
repo: target.repo,
|
|
162
|
+
number: target.number,
|
|
163
|
+
sha: target.sha,
|
|
164
|
+
review_id: posted.id,
|
|
165
|
+
url: posted.url,
|
|
166
|
+
findings: review.findings,
|
|
167
|
+
inline: review.inline,
|
|
168
|
+
skipped: review.skipped,
|
|
169
|
+
dedupe: target.dedupe,
|
|
170
|
+
once: `posted:${target.dedupe}`,
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
function failPost(input, target, failureClass, detail) {
|
|
175
|
+
const clipped = detail.trim() || "The review was not posted.";
|
|
176
|
+
appendEvent(input.kilnDir, {
|
|
177
|
+
job_id: input.jobId,
|
|
178
|
+
run_id: input.runId,
|
|
179
|
+
event: "review.failed",
|
|
180
|
+
step: "approve",
|
|
181
|
+
properties: {
|
|
182
|
+
repo: target.repo,
|
|
183
|
+
number: target.number,
|
|
184
|
+
sha: target.sha,
|
|
185
|
+
class: failureClass,
|
|
186
|
+
detail: clipped,
|
|
187
|
+
dedupe: target.dedupe,
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
recordNeedsHuman(input);
|
|
191
|
+
throw new Error(`Could not post the review (${failureClass}). ${clipped}`);
|
|
192
|
+
}
|
|
193
|
+
function recordNeedsHuman(input) {
|
|
194
|
+
appendEvent(input.kilnDir, {
|
|
195
|
+
job_id: input.jobId,
|
|
196
|
+
run_id: input.runId,
|
|
197
|
+
event: "needs-human",
|
|
198
|
+
step: "approve",
|
|
199
|
+
properties: { reason: NEEDS_HUMAN_REASON, once: `needs-human:${NEEDS_HUMAN_REASON}` },
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
function publicDetail(error) {
|
|
203
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
204
|
+
const cleaned = redact(message).replace(/\s+/g, " ").trim();
|
|
205
|
+
if (cleaned.length > 500)
|
|
206
|
+
return `${cleaned.slice(0, 499)}…`;
|
|
207
|
+
return cleaned || "The review was not posted.";
|
|
208
|
+
}
|
|
209
|
+
function parseFinding(value) {
|
|
210
|
+
if (typeof value !== "object" || value === null)
|
|
211
|
+
return undefined;
|
|
212
|
+
const record = value;
|
|
213
|
+
if (typeof record.comment !== "string" || !record.comment.trim())
|
|
214
|
+
return undefined;
|
|
215
|
+
const severity = REVIEW_SEVERITIES.find((item) => item === record.severity) ?? "note";
|
|
216
|
+
const file = typeof record.file === "string" ? record.file : undefined;
|
|
217
|
+
const line = commentLine(record.line);
|
|
218
|
+
return {
|
|
219
|
+
severity,
|
|
220
|
+
comment: record.comment.trim(),
|
|
221
|
+
...(file !== undefined ? { file } : {}),
|
|
222
|
+
...(line !== undefined ? { line } : {}),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
/** A relative path inside the repo. Absolute paths and `..` are not locations. A leading `./` is a normal relative path. */
|
|
226
|
+
export function commentPath(file) {
|
|
227
|
+
if (!file)
|
|
228
|
+
return undefined;
|
|
229
|
+
const raw = file.trim().replace(/\\/g, "/");
|
|
230
|
+
if (!raw || raw.startsWith("/") || raw.startsWith("~") || /^[A-Za-z]:/.test(raw))
|
|
231
|
+
return undefined;
|
|
232
|
+
if (/[\0\r\n]/.test(raw))
|
|
233
|
+
return undefined;
|
|
234
|
+
const parts = raw.split("/");
|
|
235
|
+
if (parts.some((part) => part === "" || part === ".."))
|
|
236
|
+
return undefined;
|
|
237
|
+
const cleaned = parts.filter((part) => part !== ".");
|
|
238
|
+
if (cleaned.length === 0)
|
|
239
|
+
return undefined;
|
|
240
|
+
return cleaned.join("/");
|
|
241
|
+
}
|
|
242
|
+
function commentLine(line) {
|
|
243
|
+
if (typeof line !== "number" || !Number.isInteger(line) || line < 1)
|
|
244
|
+
return undefined;
|
|
245
|
+
return line;
|
|
246
|
+
}
|
|
247
|
+
function commentText(finding) {
|
|
248
|
+
const text = finding.comment.trim();
|
|
249
|
+
if (finding.severity === "nit")
|
|
250
|
+
return `**nit** (not blocking)\n\n${text}`;
|
|
251
|
+
return `**${finding.severity}**\n\n${text}`;
|
|
252
|
+
}
|
|
253
|
+
function foldSkipped(markdown, skipped) {
|
|
254
|
+
const base = markdown.trim();
|
|
255
|
+
const extra = skipped.filter((finding) => !base.includes(finding.comment.trim()));
|
|
256
|
+
if (extra.length === 0)
|
|
257
|
+
return `${base}\n`;
|
|
258
|
+
const lines = [
|
|
259
|
+
base,
|
|
260
|
+
"",
|
|
261
|
+
"## Not posted on a line",
|
|
262
|
+
"",
|
|
263
|
+
"These findings had no file and line Kiln could check, so they are only in this review.",
|
|
264
|
+
"",
|
|
265
|
+
];
|
|
266
|
+
for (const finding of extra)
|
|
267
|
+
lines.push(`- **${finding.severity}** ${finding.comment.trim()}`);
|
|
268
|
+
return `${lines.join("\n")}\n`;
|
|
269
|
+
}
|
package/dist/schemas.js
CHANGED
|
@@ -127,8 +127,45 @@ const maxFixRounds = z
|
|
|
127
127
|
/**
|
|
128
128
|
* The subset `kiln serve` settings can edit. The file schema below uses these
|
|
129
129
|
* same field validators. Settings does not accept jira, tokens, adapter,
|
|
130
|
-
* workflows, or sandbox image.
|
|
130
|
+
* workflows, or sandbox image. Trigger edits are enable/disable only.
|
|
131
131
|
*/
|
|
132
|
+
const TRIGGER_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
133
|
+
/**
|
|
134
|
+
* One trigger in `kiln.config.json`. `enabled` defaults to true.
|
|
135
|
+
* `schedule` uses `cron` (5 fields, local time). `jira` uses `jql` or
|
|
136
|
+
* `label` / `status` (compiled to JQL) and `every` (default `5m`).
|
|
137
|
+
* `pr` is the built-in Kiln-owned pull request source.
|
|
138
|
+
*/
|
|
139
|
+
export const TriggerConfigSchema = z
|
|
140
|
+
.object({
|
|
141
|
+
id: z.string().regex(TRIGGER_ID, "Trigger id must start with a letter or digit."),
|
|
142
|
+
enabled: z.boolean().optional(),
|
|
143
|
+
kind: z.enum(["schedule", "jira", "pr"]),
|
|
144
|
+
cron: z.string().min(1).optional(),
|
|
145
|
+
every: z.string().min(1).optional(),
|
|
146
|
+
repo: z.string().regex(REPO_NAME).optional(),
|
|
147
|
+
jql: z.string().min(1).optional(),
|
|
148
|
+
label: z.string().min(1).optional(),
|
|
149
|
+
status: z.string().min(1).optional(),
|
|
150
|
+
})
|
|
151
|
+
.strict();
|
|
152
|
+
/** Maps a trigger id to create a job or attach to one. Hot-reloaded with the config. */
|
|
153
|
+
export const RouteConfigSchema = z
|
|
154
|
+
.object({
|
|
155
|
+
id: z.string().regex(TRIGGER_ID, "Route id must start with a letter or digit."),
|
|
156
|
+
trigger: z.string().regex(TRIGGER_ID),
|
|
157
|
+
action: z.enum(["create", "attach"]),
|
|
158
|
+
workflow: z.string().min(1).optional(),
|
|
159
|
+
repo: z.string().regex(REPO_NAME).optional(),
|
|
160
|
+
})
|
|
161
|
+
.strict();
|
|
162
|
+
/** Settings API body for enable/disable. Other trigger fields stay in the file. */
|
|
163
|
+
export const TriggerToggleSchema = z
|
|
164
|
+
.object({
|
|
165
|
+
id: z.string().regex(TRIGGER_ID),
|
|
166
|
+
enabled: z.boolean(),
|
|
167
|
+
})
|
|
168
|
+
.strict();
|
|
132
169
|
export const EditableKilnSettingsSchema = z.object({
|
|
133
170
|
repos: KilnReposSchema,
|
|
134
171
|
stallTimeoutMs,
|
|
@@ -137,6 +174,7 @@ export const EditableKilnSettingsSchema = z.object({
|
|
|
137
174
|
maxSameFingerprint,
|
|
138
175
|
dailyDollarCap,
|
|
139
176
|
sandbox: EditableSandboxSchema.optional(),
|
|
177
|
+
triggers: z.array(TriggerToggleSchema).optional(),
|
|
140
178
|
});
|
|
141
179
|
/** PUT /api/settings body. `revision` is the sha256 of the file the client loaded. */
|
|
142
180
|
export const SettingsWriteSchema = EditableKilnSettingsSchema.extend({
|
|
@@ -156,14 +194,24 @@ export const KilnConfigFileSchema = z.object({
|
|
|
156
194
|
workflows: z.record(z.string().regex(WORKFLOW_KEY), z.object({ sandbox: SandboxSettingsSchema.optional() })).optional(),
|
|
157
195
|
repos: KilnReposSchema,
|
|
158
196
|
jira: JiraConfigSchema.optional(),
|
|
197
|
+
/** Clock, Jira poll, and pull-request sources. Omitted entries use the built-ins. */
|
|
198
|
+
triggers: z.array(TriggerConfigSchema).optional(),
|
|
199
|
+
/** Trigger id → create a job or attach to an existing one. */
|
|
200
|
+
routes: z.array(RouteConfigSchema).optional(),
|
|
201
|
+
/**
|
|
202
|
+
* Reviewer prompt file. Relative paths resolve from the config directory.
|
|
203
|
+
* Omitted uses the packaged `prompts/kiln/review.md`.
|
|
204
|
+
*/
|
|
205
|
+
reviewPrompt: z.string().min(1).optional(),
|
|
159
206
|
});
|
|
160
207
|
/**
|
|
161
208
|
* Where a job came from. Adapters set this; tick and reconcile do not branch on it.
|
|
162
209
|
* `ref` is an absolute spec path (`file`), `inline` or `stdin` (`prompt`), the
|
|
163
|
-
* absolute ticket path (`tickets`),
|
|
210
|
+
* absolute ticket path (`tickets`), the issue key (`jira`, such as `SAMPLE-1`),
|
|
211
|
+
* or the trigger dedupe key (`trigger`).
|
|
164
212
|
*/
|
|
165
213
|
export const JobSourceSchema = z.object({
|
|
166
|
-
kind: z.enum(["file", "prompt", "tickets", "jira"]),
|
|
214
|
+
kind: z.enum(["file", "prompt", "tickets", "jira", "trigger"]),
|
|
167
215
|
ref: z.string().min(1),
|
|
168
216
|
});
|
|
169
217
|
/**
|
package/dist/serve-page.js
CHANGED
|
@@ -110,6 +110,7 @@ ${repos}
|
|
|
110
110
|
${item("/history", "history", "History", "history", board.history.length)}
|
|
111
111
|
${item("/stats", "stats", "Stats", "stats")}
|
|
112
112
|
${item("/report", "report", "Today", "report")}
|
|
113
|
+
${item("/triggers", "triggers", "Triggers", "triggers")}
|
|
113
114
|
${item("/settings", "open-settings", "Settings", "settings")}
|
|
114
115
|
</nav>`;
|
|
115
116
|
}
|
|
@@ -127,15 +128,45 @@ function mainHtml(model, page) {
|
|
|
127
128
|
if (model.section === "job" && model.detail) {
|
|
128
129
|
return `${error}<main class="main detail">${detailHtml(model.detail, model.tab ?? "overview", page.csrf)}</main>`;
|
|
129
130
|
}
|
|
131
|
+
if (model.section === "triggers") {
|
|
132
|
+
return `${error}<main class="main">${triggersHtml(model.triggers ?? [])}</main>`;
|
|
133
|
+
}
|
|
130
134
|
return `${error}<main class="main">${listHtml(model, page.csrf)}</main>`;
|
|
131
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. A review is posted only after you approve it. Kiln does not 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
|
+
}
|
|
132
163
|
function listHtml(model, csrf) {
|
|
133
164
|
const board = model.board;
|
|
134
165
|
if (model.section === "inbox") {
|
|
135
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)}`;
|
|
136
167
|
}
|
|
137
168
|
if (model.section === "hand") {
|
|
138
|
-
return `<header class="list-head"><h2>Needs a hand</h2><p class="meta">A breaker tripped, a cap was hit, or a run failed with no retry left.</p></header>${jobList(board.hand, "hand", "Nothing needs a person.", csrf)}`;
|
|
169
|
+
return `<header class="list-head"><h2>Needs a hand</h2><p class="meta">A breaker tripped, a cap was hit, a review could not be posted, or a run failed with no retry left.</p></header>${jobList(board.hand, "hand", "Nothing needs a person.", csrf)}`;
|
|
139
170
|
}
|
|
140
171
|
if (model.section === "history") {
|
|
141
172
|
return `<header class="list-head"><h2>History</h2></header>${jobList(board.history, "history", "No finished jobs.", csrf)}`;
|
|
@@ -235,7 +266,7 @@ function dependsHtml(job) {
|
|
|
235
266
|
return `<p class="deps">depends on ${links}</p>${detail ? `<p class="meta">${escapeHtml(detail)}</p>` : ""}`;
|
|
236
267
|
}
|
|
237
268
|
function actionsHtml(job, section, csrf) {
|
|
238
|
-
const showGate = section === "inbox" || section === "job";
|
|
269
|
+
const showGate = section === "inbox" || section === "job" || (section === "hand" && Boolean(job.reviewPostError));
|
|
239
270
|
const showRetry = section === "hand" || section === "repos" || section === "job";
|
|
240
271
|
if (!showGate && !showRetry)
|
|
241
272
|
return "";
|
package/dist/serve.js
CHANGED
|
@@ -10,13 +10,14 @@ import { browse } from "./fs-browse.js";
|
|
|
10
10
|
import { branchOf, buildBoard, dayReport, jobDetail, parseSeenQuery, parseStatsWindow, readBranchCommits, readDiffStat, statsFor, unseenChanges, } from "./board-view.js";
|
|
11
11
|
import { appendEvent, eventsForJob, readEvents } from "./events.js";
|
|
12
12
|
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
13
|
-
import {
|
|
13
|
+
import { productionHost } from "./host.js";
|
|
14
14
|
import { intakeJobs } from "./intake-http.js";
|
|
15
15
|
import { jobBasename, packageRoot } from "./paths.js";
|
|
16
16
|
import { SANDBOX_PREFLIGHT_PREFIX } from "./sandbox.js";
|
|
17
17
|
import { renderApp, renderMessagePage, renderTextPage } from "./serve-page.js";
|
|
18
18
|
import { isPaused, pauseFactory, resumeFactory, spendFor } from "./rails.js";
|
|
19
19
|
import { readSettings, saveSettings } from "./settings.js";
|
|
20
|
+
import { triggerRows } from "./triggers.js";
|
|
20
21
|
import { listWorkflowNames } from "./workflow.js";
|
|
21
22
|
const BODY_LIMIT = 65_536;
|
|
22
23
|
const TEXT_LIMIT = 100_000;
|
|
@@ -43,6 +44,7 @@ export function startServe(options) {
|
|
|
43
44
|
let stopped = false;
|
|
44
45
|
let timer;
|
|
45
46
|
let firstTick;
|
|
47
|
+
let triggerStartup = true;
|
|
46
48
|
const shutdown = () => {
|
|
47
49
|
if (stopped)
|
|
48
50
|
return;
|
|
@@ -68,8 +70,10 @@ export function startServe(options) {
|
|
|
68
70
|
const tick = () => {
|
|
69
71
|
if (stopped)
|
|
70
72
|
return;
|
|
73
|
+
const startup = triggerStartup;
|
|
74
|
+
triggerStartup = false;
|
|
71
75
|
refreshLiveConfig(state);
|
|
72
|
-
safeTick(state.config, state.secrets,
|
|
76
|
+
safeTick(state.config, state.secrets, startup);
|
|
73
77
|
};
|
|
74
78
|
firstTick = setTimeout(tick, 0);
|
|
75
79
|
timer = setInterval(tick, options.tickSeconds * 1000);
|
|
@@ -83,9 +87,9 @@ export function loadServeCss() {
|
|
|
83
87
|
}
|
|
84
88
|
return readFileSync(path, "utf8");
|
|
85
89
|
}
|
|
86
|
-
function safeTick(config, secrets,
|
|
90
|
+
function safeTick(config, secrets, triggerStartup) {
|
|
87
91
|
try {
|
|
88
|
-
const report = runTick(config,
|
|
92
|
+
const report = runTick(config, productionHost(), { startup: false, triggerStartup });
|
|
89
93
|
if (report.startsWith(SANDBOX_PREFLIGHT_PREFIX) || report.startsWith(AUTH_PREFLIGHT_PREFIX)) {
|
|
90
94
|
const tickAt = report.indexOf("\ntick ");
|
|
91
95
|
const head = tickAt === -1 ? report : report.slice(0, tickAt);
|
|
@@ -734,6 +738,8 @@ function listSection(path) {
|
|
|
734
738
|
return "report";
|
|
735
739
|
if (path === "/settings")
|
|
736
740
|
return "settings";
|
|
741
|
+
if (path === "/triggers")
|
|
742
|
+
return "triggers";
|
|
737
743
|
return undefined;
|
|
738
744
|
}
|
|
739
745
|
function renderShell(state, css, csrf, section, extra = {}) {
|
|
@@ -751,6 +757,9 @@ function renderShell(state, css, csrf, section, extra = {}) {
|
|
|
751
757
|
...(section === "stats" && extra.window ? { stats: statsDocument(state, extra.window) } : {}),
|
|
752
758
|
...(section === "report" ? { report: reportDocument(state) } : {}),
|
|
753
759
|
...(extra.detail ? { detail: extra.detail, tab: extra.tab ?? "overview" } : {}),
|
|
760
|
+
...(section === "triggers"
|
|
761
|
+
? { triggers: triggerRows(state.config, readEvents(state.config.kilnDir, { warn: () => { } })) }
|
|
762
|
+
: {}),
|
|
754
763
|
});
|
|
755
764
|
}
|
|
756
765
|
function boardPayload(state) {
|