@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/triggers.js
ADDED
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { appendEvent, readEvents } from "./events.js";
|
|
5
|
+
import { isIgnoredAuthor } from "./feedback.js";
|
|
6
|
+
import { searchJiraIssues } from "./intake/jira.js";
|
|
7
|
+
import { listValidJobs, writeJob } from "./jobs.js";
|
|
8
|
+
import { pausedFrom, spendSnapshot } from "./rails.js";
|
|
9
|
+
import { redact } from "./redact.js";
|
|
10
|
+
import { ISSUE_KEY } from "./schemas.js";
|
|
11
|
+
import { REVIEW_WORKFLOW } from "./workflow.js";
|
|
12
|
+
/**
|
|
13
|
+
* Triggers and the one router that turns them into jobs.
|
|
14
|
+
* A trigger writes a typed event. The router creates a job or attaches to
|
|
15
|
+
* one. The dedupe key lives on that event, so a restart does not create
|
|
16
|
+
* the job twice. A failed fire records `last_error` and waits for the next
|
|
17
|
+
* slot. Pause and the daily cap record a skip. Nothing is queued.
|
|
18
|
+
*
|
|
19
|
+
* Jira is polled. `jiraIssueEvent` is the seam a webhook can call later.
|
|
20
|
+
* Pull-request activity reuses the feedback loop's snapshot. It does not
|
|
21
|
+
* poll a second time.
|
|
22
|
+
*/
|
|
23
|
+
export const REVIEW_REQUESTS_TRIGGER = "review-requests";
|
|
24
|
+
export const PR_ACTIVITY_TRIGGER = "pr-activity";
|
|
25
|
+
/** Minute 0 of every hour, local time. */
|
|
26
|
+
export const DEFAULT_REVIEW_CRON = "0 * * * *";
|
|
27
|
+
export const DEFAULT_JIRA_EVERY = "5m";
|
|
28
|
+
const BUILTIN_TRIGGERS = [
|
|
29
|
+
{ id: REVIEW_REQUESTS_TRIGGER, kind: "schedule", cron: DEFAULT_REVIEW_CRON, enabled: true },
|
|
30
|
+
{ id: PR_ACTIVITY_TRIGGER, kind: "pr", enabled: true },
|
|
31
|
+
];
|
|
32
|
+
const BUILTIN_ROUTES = [
|
|
33
|
+
{ id: REVIEW_REQUESTS_TRIGGER, trigger: REVIEW_REQUESTS_TRIGGER, action: "create", workflow: REVIEW_WORKFLOW },
|
|
34
|
+
{ id: PR_ACTIVITY_TRIGGER, trigger: PR_ACTIVITY_TRIGGER, action: "attach" },
|
|
35
|
+
];
|
|
36
|
+
const REVIEW_REF = /^review:([^:]+):(\d+):([0-9a-f]{7,64})$/i;
|
|
37
|
+
export function effectiveTriggers(file) {
|
|
38
|
+
const byId = new Map();
|
|
39
|
+
for (const trigger of BUILTIN_TRIGGERS)
|
|
40
|
+
byId.set(trigger.id, { ...trigger });
|
|
41
|
+
for (const trigger of file ?? []) {
|
|
42
|
+
const prior = byId.get(trigger.id);
|
|
43
|
+
byId.set(trigger.id, {
|
|
44
|
+
...(prior ?? {}),
|
|
45
|
+
...trigger,
|
|
46
|
+
enabled: trigger.enabled ?? prior?.enabled ?? true,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return [...byId.values()];
|
|
50
|
+
}
|
|
51
|
+
export function effectiveRoutes(file) {
|
|
52
|
+
const byId = new Map();
|
|
53
|
+
for (const route of BUILTIN_ROUTES)
|
|
54
|
+
byId.set(route.id, { ...route });
|
|
55
|
+
for (const route of file ?? [])
|
|
56
|
+
byId.set(route.id, { ...route });
|
|
57
|
+
return [...byId.values()];
|
|
58
|
+
}
|
|
59
|
+
export function knownTrigger(id, file) {
|
|
60
|
+
return effectiveTriggers(file).some((trigger) => trigger.id === id);
|
|
61
|
+
}
|
|
62
|
+
/** Enable/disable writes `enabled` and leaves cron, JQL, and repo on the file. */
|
|
63
|
+
export function applyTriggerToggles(file, toggles) {
|
|
64
|
+
const list = (file ?? []).map((trigger) => ({ ...trigger }));
|
|
65
|
+
for (const toggle of toggles) {
|
|
66
|
+
const index = list.findIndex((trigger) => trigger.id === toggle.id);
|
|
67
|
+
if (index >= 0) {
|
|
68
|
+
const current = list[index];
|
|
69
|
+
if (current)
|
|
70
|
+
list[index] = { ...current, enabled: toggle.enabled };
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const builtin = BUILTIN_TRIGGERS.find((trigger) => trigger.id === toggle.id);
|
|
74
|
+
if (!builtin)
|
|
75
|
+
continue;
|
|
76
|
+
list.push({ ...builtin, enabled: toggle.enabled });
|
|
77
|
+
}
|
|
78
|
+
return list;
|
|
79
|
+
}
|
|
80
|
+
/** `label` / `status` compile to JQL. An explicit `jql` wins. */
|
|
81
|
+
export function compileJql(trigger) {
|
|
82
|
+
if (trigger.jql?.trim())
|
|
83
|
+
return trigger.jql.trim();
|
|
84
|
+
const parts = [];
|
|
85
|
+
if (trigger.label?.trim())
|
|
86
|
+
parts.push(`labels = ${jqlQuote(trigger.label.trim())}`);
|
|
87
|
+
if (trigger.status?.trim())
|
|
88
|
+
parts.push(`status = ${jqlQuote(trigger.status.trim())}`);
|
|
89
|
+
if (parts.length === 0)
|
|
90
|
+
throw new Error("Jira trigger needs jql, or a label or status.");
|
|
91
|
+
return parts.join(" AND ");
|
|
92
|
+
}
|
|
93
|
+
/** `30s`, `5m`, or `1h`. Omitted means {@link DEFAULT_JIRA_EVERY}. */
|
|
94
|
+
export function parseEvery(raw) {
|
|
95
|
+
const text = (raw ?? DEFAULT_JIRA_EVERY).trim();
|
|
96
|
+
const match = /^(\d+)(s|m|h)$/.exec(text);
|
|
97
|
+
if (!match?.[1] || !match[2])
|
|
98
|
+
throw new Error(`Poll interval ${JSON.stringify(text)} must look like 30s, 5m, or 1h.`);
|
|
99
|
+
const count = Number(match[1]);
|
|
100
|
+
if (!Number.isInteger(count) || count < 1) {
|
|
101
|
+
throw new Error(`Poll interval ${JSON.stringify(text)} must look like 30s, 5m, or 1h.`);
|
|
102
|
+
}
|
|
103
|
+
const unit = match[2];
|
|
104
|
+
const ms = unit === "s" ? count * 1_000 : unit === "m" ? count * 60_000 : count * 3_600_000;
|
|
105
|
+
if (ms < 1_000)
|
|
106
|
+
throw new Error(`Poll interval ${JSON.stringify(text)} must be at least 1s.`);
|
|
107
|
+
return ms;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Latest cron minute at or before `now`, in local time.
|
|
111
|
+
* Walking back stops after a year so an impossible expression cannot spin.
|
|
112
|
+
*/
|
|
113
|
+
export function previousCronSlot(expr, now) {
|
|
114
|
+
const cron = parseCron(expr);
|
|
115
|
+
const cursor = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), now.getMinutes(), 0, 0);
|
|
116
|
+
const limit = cursor.getTime() - 366 * 24 * 60 * 60 * 1000;
|
|
117
|
+
while (cursor.getTime() >= limit) {
|
|
118
|
+
if (cronMatches(cron, cursor))
|
|
119
|
+
return new Date(cursor.getTime());
|
|
120
|
+
cursor.setMinutes(cursor.getMinutes() - 1);
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
export function nextCronSlot(expr, after) {
|
|
125
|
+
const cron = parseCron(expr);
|
|
126
|
+
const cursor = new Date(after.getFullYear(), after.getMonth(), after.getDate(), after.getHours(), after.getMinutes(), 0, 0);
|
|
127
|
+
cursor.setMinutes(cursor.getMinutes() + 1);
|
|
128
|
+
const limit = cursor.getTime() + 366 * 24 * 60 * 60 * 1000;
|
|
129
|
+
while (cursor.getTime() <= limit) {
|
|
130
|
+
if (cronMatches(cron, cursor))
|
|
131
|
+
return new Date(cursor.getTime());
|
|
132
|
+
cursor.setMinutes(cursor.getMinutes() + 1);
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* The one slot this evaluation may fire.
|
|
138
|
+
* A running tick fires only the slot that contains `now`.
|
|
139
|
+
* Startup fires the latest missed slot once, never the ones behind it.
|
|
140
|
+
*/
|
|
141
|
+
export function dueCronSlot(expr, lastFire, now, startup) {
|
|
142
|
+
const slot = previousCronSlot(expr, now);
|
|
143
|
+
if (!slot)
|
|
144
|
+
return null;
|
|
145
|
+
if (lastFire && Number.isFinite(Date.parse(lastFire)) && slot.getTime() <= Date.parse(lastFire))
|
|
146
|
+
return null;
|
|
147
|
+
if (!startup && !sameMinute(slot, now))
|
|
148
|
+
return null;
|
|
149
|
+
return slot;
|
|
150
|
+
}
|
|
151
|
+
/** Kiln's own comment or commit: author `kiln` / `[bot]`, a `kiln:` subject, or a `Kiln-Job` trailer. */
|
|
152
|
+
export function isKilnOwnActivity(input) {
|
|
153
|
+
if (input.author && isIgnoredAuthor(input.author))
|
|
154
|
+
return true;
|
|
155
|
+
const text = input.body ?? "";
|
|
156
|
+
if (!text.trim())
|
|
157
|
+
return false;
|
|
158
|
+
const subject = text.split("\n")[0]?.trim() ?? "";
|
|
159
|
+
if (subject.startsWith("kiln:"))
|
|
160
|
+
return true;
|
|
161
|
+
return text.split("\n").some((line) => line.trim().startsWith("Kiln-Job:"));
|
|
162
|
+
}
|
|
163
|
+
/** Pull-request activity worth routing. Kiln's own comments and commits are left out. */
|
|
164
|
+
export function prTriggerEvents(input) {
|
|
165
|
+
const events = [];
|
|
166
|
+
const push = (id, title, author, body) => {
|
|
167
|
+
if (isKilnOwnActivity({ ...(author ? { author } : {}), body }))
|
|
168
|
+
return;
|
|
169
|
+
events.push({
|
|
170
|
+
key: `pr:${input.jobId}:${id}`,
|
|
171
|
+
source: "pr",
|
|
172
|
+
triggerId: PR_ACTIVITY_TRIGGER,
|
|
173
|
+
id,
|
|
174
|
+
revision: input.headSha,
|
|
175
|
+
repo: input.repo,
|
|
176
|
+
title: oneLine(title),
|
|
177
|
+
body,
|
|
178
|
+
...(author ? { author } : {}),
|
|
179
|
+
jobId: input.jobId,
|
|
180
|
+
});
|
|
181
|
+
};
|
|
182
|
+
for (const comment of input.comments ?? []) {
|
|
183
|
+
if (comment.resolved)
|
|
184
|
+
continue;
|
|
185
|
+
push(`comment:${comment.id}`, comment.body, comment.author, comment.body);
|
|
186
|
+
}
|
|
187
|
+
for (const review of input.reviews ?? []) {
|
|
188
|
+
if (!review.body.trim())
|
|
189
|
+
continue;
|
|
190
|
+
push(`review:${review.id}`, review.body, review.author, review.body);
|
|
191
|
+
}
|
|
192
|
+
for (const commit of input.commits ?? []) {
|
|
193
|
+
push(`commit:${commit.sha}`, commit.message, undefined, commit.message);
|
|
194
|
+
}
|
|
195
|
+
return events;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* One function from a trigger event to a create or an attach.
|
|
199
|
+
* `routed` is the set of keys already stored on `trigger.routed` events.
|
|
200
|
+
*/
|
|
201
|
+
export function routeTrigger(event, routes, routed) {
|
|
202
|
+
if (event.kilnOwn || isKilnOwnActivity({ ...(event.author ? { author: event.author } : {}), ...(event.body ? { body: event.body } : {}) })) {
|
|
203
|
+
return { type: "ignore", reason: "kiln" };
|
|
204
|
+
}
|
|
205
|
+
if (routed.has(event.key))
|
|
206
|
+
return { type: "deduped", key: event.key };
|
|
207
|
+
const route = routes.find((candidate) => candidate.trigger === event.triggerId);
|
|
208
|
+
if (!route)
|
|
209
|
+
return { type: "unrouted", key: event.key };
|
|
210
|
+
if (route.action === "attach") {
|
|
211
|
+
if (!event.jobId)
|
|
212
|
+
return { type: "unrouted", key: event.key };
|
|
213
|
+
return { type: "attach", key: event.key, jobId: event.jobId };
|
|
214
|
+
}
|
|
215
|
+
const repo = event.repo ?? route.repo;
|
|
216
|
+
const workflow = event.workflow ?? route.workflow;
|
|
217
|
+
if (!repo || !workflow)
|
|
218
|
+
return { type: "unrouted", key: event.key };
|
|
219
|
+
return { type: "create", key: event.key, workflow, repo };
|
|
220
|
+
}
|
|
221
|
+
/** A Jira issue, from a poll or (later) a webhook. The dedupe key is the issue, not each edit. */
|
|
222
|
+
export function jiraIssueEvent(triggerId, repo, issue) {
|
|
223
|
+
return {
|
|
224
|
+
key: `jira:${triggerId}:${issue.key}`,
|
|
225
|
+
source: "jira",
|
|
226
|
+
triggerId,
|
|
227
|
+
id: issue.key,
|
|
228
|
+
revision: issue.updated,
|
|
229
|
+
repo,
|
|
230
|
+
title: oneLine(issue.summary),
|
|
231
|
+
body: issue.description,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
export function reviewHeadOf(job) {
|
|
235
|
+
if (job.workflow !== REVIEW_WORKFLOW || job.source?.kind !== "trigger")
|
|
236
|
+
return undefined;
|
|
237
|
+
return REVIEW_REF.exec(job.source.ref)?.[3];
|
|
238
|
+
}
|
|
239
|
+
/** Download the pull request head when the checkout does not have that commit yet. */
|
|
240
|
+
export function ensureReviewHead(checkout, prNumber, sha) {
|
|
241
|
+
if (hasCommit(checkout, sha))
|
|
242
|
+
return;
|
|
243
|
+
const fetched = spawnSync("git", ["fetch", "--quiet", "origin", `pull/${prNumber}/head`], {
|
|
244
|
+
cwd: checkout,
|
|
245
|
+
encoding: "utf8",
|
|
246
|
+
timeout: 20_000,
|
|
247
|
+
});
|
|
248
|
+
if (fetched.status !== 0 || !hasCommit(checkout, sha)) {
|
|
249
|
+
throw new Error(`Could not fetch pull request ${prNumber} head ${sha.slice(0, 12)}.`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Schedule and Jira triggers for one tick.
|
|
254
|
+
* Pull-request activity is recorded from the snapshot the feedback loop already read.
|
|
255
|
+
*/
|
|
256
|
+
export function evaluateTriggers(input) {
|
|
257
|
+
const now = input.now ?? new Date();
|
|
258
|
+
const startup = input.startup ?? false;
|
|
259
|
+
const triggers = effectiveTriggers(input.config.triggers);
|
|
260
|
+
const routes = effectiveRoutes(input.config.routes);
|
|
261
|
+
for (const trigger of triggers) {
|
|
262
|
+
if (trigger.enabled === false)
|
|
263
|
+
continue;
|
|
264
|
+
if (trigger.kind === "pr")
|
|
265
|
+
continue;
|
|
266
|
+
try {
|
|
267
|
+
if (trigger.kind === "jira")
|
|
268
|
+
runJira(input, trigger, routes, now, startup);
|
|
269
|
+
else
|
|
270
|
+
runSchedule(input, trigger, routes, now, startup);
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
noteConfigError(input.config, trigger.id, errorMessage(error), now);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/** Fold one already-read pull request snapshot into the trigger log. */
|
|
278
|
+
export function recordOwnedPullRequest(config, activity, now = new Date()) {
|
|
279
|
+
const trigger = effectiveTriggers(config.triggers).find((candidate) => candidate.id === PR_ACTIVITY_TRIGGER);
|
|
280
|
+
if (!trigger || trigger.enabled === false)
|
|
281
|
+
return;
|
|
282
|
+
const pending = prTriggerEvents(activity);
|
|
283
|
+
if (pending.length === 0)
|
|
284
|
+
return;
|
|
285
|
+
const reason = blockReason(config, now);
|
|
286
|
+
if (reason) {
|
|
287
|
+
noteSkip(config, PR_ACTIVITY_TRIGGER, reason, hourStamp(now), now);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const routes = effectiveRoutes(config.routes);
|
|
291
|
+
for (const event of pending)
|
|
292
|
+
ingest(config, event, routes, now);
|
|
293
|
+
}
|
|
294
|
+
export function triggerRows(config, events, now = new Date()) {
|
|
295
|
+
const runtime = readRuntime(config.kilnDir);
|
|
296
|
+
return effectiveTriggers(config.triggers).map((trigger) => {
|
|
297
|
+
const state = runtime[trigger.id];
|
|
298
|
+
const recent = events
|
|
299
|
+
.filter((event) => isTriggerEvent(event) && event.properties.trigger_id === trigger.id)
|
|
300
|
+
.slice(-8)
|
|
301
|
+
.reverse()
|
|
302
|
+
.map((event) => ({
|
|
303
|
+
ts: event.ts,
|
|
304
|
+
event: event.event,
|
|
305
|
+
summary: triggerSummary(event),
|
|
306
|
+
}));
|
|
307
|
+
return {
|
|
308
|
+
id: trigger.id,
|
|
309
|
+
kind: trigger.kind,
|
|
310
|
+
enabled: trigger.enabled !== false,
|
|
311
|
+
schedule: scheduleLabel(trigger),
|
|
312
|
+
...(nextFire(trigger, state, now) ? { nextFire: nextFire(trigger, state, now) } : {}),
|
|
313
|
+
...(state?.lastFire && Number.isFinite(Date.parse(state.lastFire)) ? { lastFire: state.lastFire } : {}),
|
|
314
|
+
...(state?.lastError ? { lastError: state.lastError } : {}),
|
|
315
|
+
...(state?.lastErrorAt ? { lastErrorAt: state.lastErrorAt } : {}),
|
|
316
|
+
recent,
|
|
317
|
+
};
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
function runSchedule(input, trigger, routes, now, startup) {
|
|
321
|
+
if (!trigger.cron)
|
|
322
|
+
throw new Error(`Trigger ${trigger.id} needs a cron expression.`);
|
|
323
|
+
const state = readRuntime(input.config.kilnDir)[trigger.id];
|
|
324
|
+
const slot = dueCronSlot(trigger.cron, state?.lastFire, now, startup);
|
|
325
|
+
if (!slot)
|
|
326
|
+
return;
|
|
327
|
+
const stamp = slot.toISOString();
|
|
328
|
+
const reason = blockReason(input.config, now);
|
|
329
|
+
if (reason) {
|
|
330
|
+
noteSkip(input.config, trigger.id, reason, stamp, now);
|
|
331
|
+
remember(input.config.kilnDir, trigger.id, { lastFire: stamp });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const route = routes.find((candidate) => candidate.trigger === trigger.id);
|
|
335
|
+
const reviews = route?.workflow === REVIEW_WORKFLOW || trigger.id === REVIEW_REQUESTS_TRIGGER;
|
|
336
|
+
if (reviews) {
|
|
337
|
+
if (!input.reviews)
|
|
338
|
+
return;
|
|
339
|
+
let requests;
|
|
340
|
+
try {
|
|
341
|
+
requests = input.reviews.list(Object.entries(input.config.repos).map(([name, repo]) => ({ name, path: repo.path })));
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
failSlot(input.config, trigger.id, stamp, errorMessage(error), now);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
succeedSlot(input.config.kilnDir, trigger.id, stamp);
|
|
348
|
+
appendFire(input.config, {
|
|
349
|
+
key: `schedule:${trigger.id}:${stamp}`,
|
|
350
|
+
source: "schedule",
|
|
351
|
+
triggerId: trigger.id,
|
|
352
|
+
id: stamp,
|
|
353
|
+
revision: stamp,
|
|
354
|
+
});
|
|
355
|
+
for (const request of requests) {
|
|
356
|
+
try {
|
|
357
|
+
ingestReview(input.config, request, routes, now);
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
remember(input.config.kilnDir, trigger.id, {
|
|
361
|
+
lastFire: stamp,
|
|
362
|
+
lastError: publicError(errorMessage(error)),
|
|
363
|
+
lastErrorAt: now.toISOString(),
|
|
364
|
+
});
|
|
365
|
+
appendFailed(input.config, trigger.id, publicError(errorMessage(error)), `review:${request.repo}:${request.number}`, now);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
succeedSlot(input.config.kilnDir, trigger.id, stamp);
|
|
371
|
+
ingest(input.config, {
|
|
372
|
+
key: `schedule:${trigger.id}:${stamp}`,
|
|
373
|
+
source: "schedule",
|
|
374
|
+
triggerId: trigger.id,
|
|
375
|
+
id: stamp,
|
|
376
|
+
revision: stamp,
|
|
377
|
+
...(route?.repo ? { repo: route.repo } : {}),
|
|
378
|
+
title: trigger.id,
|
|
379
|
+
...(route?.workflow ? { workflow: route.workflow } : {}),
|
|
380
|
+
}, routes, now);
|
|
381
|
+
}
|
|
382
|
+
function ingestReview(config, request, routes, now) {
|
|
383
|
+
const sha = request.headSha.trim().toLowerCase();
|
|
384
|
+
if (!/^[0-9a-f]{7,64}$/.test(sha))
|
|
385
|
+
throw new Error(`Pull request ${request.number} has no head SHA.`);
|
|
386
|
+
const repo = config.repos[request.repo];
|
|
387
|
+
if (!repo)
|
|
388
|
+
throw new Error(`Pull request ${request.number} is not in a registered repo.`);
|
|
389
|
+
ensureReviewHead(repo.path, request.number, sha);
|
|
390
|
+
const base = repo.base ?? "the default branch";
|
|
391
|
+
const url = httpUrl(request.url);
|
|
392
|
+
const title = oneLine(request.title) || `PR ${request.number}`;
|
|
393
|
+
ingest(config, {
|
|
394
|
+
key: `review:${request.repo}:${request.number}:${sha}`,
|
|
395
|
+
source: "review",
|
|
396
|
+
triggerId: REVIEW_REQUESTS_TRIGGER,
|
|
397
|
+
id: String(request.number),
|
|
398
|
+
revision: sha,
|
|
399
|
+
repo: request.repo,
|
|
400
|
+
title: `Review: ${title}`,
|
|
401
|
+
workflow: REVIEW_WORKFLOW,
|
|
402
|
+
body: reviewSpec({ title, url, number: request.number, sha, base }),
|
|
403
|
+
}, routes, now);
|
|
404
|
+
}
|
|
405
|
+
function runJira(input, trigger, routes, now, _startup) {
|
|
406
|
+
if (!trigger.repo)
|
|
407
|
+
throw new Error(`Jira trigger ${trigger.id} needs a repo.`);
|
|
408
|
+
const every = parseEvery(trigger.every);
|
|
409
|
+
const state = readRuntime(input.config.kilnDir)[trigger.id];
|
|
410
|
+
const last = state?.lastFire ? Date.parse(state.lastFire) : Number.NaN;
|
|
411
|
+
if (Number.isFinite(last) && now.getTime() - last < every)
|
|
412
|
+
return;
|
|
413
|
+
const stamp = now.toISOString();
|
|
414
|
+
const reason = blockReason(input.config, now);
|
|
415
|
+
if (reason) {
|
|
416
|
+
noteSkip(input.config, trigger.id, reason, stamp, now);
|
|
417
|
+
remember(input.config.kilnDir, trigger.id, { lastFire: stamp });
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const jql = compileJql(trigger);
|
|
421
|
+
const client = input.jira ?? { search: (query) => searchJiraIssues(input.config, query) };
|
|
422
|
+
let issues;
|
|
423
|
+
try {
|
|
424
|
+
issues = client.search(jql);
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
failSlot(input.config, trigger.id, stamp, errorMessage(error), now);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
succeedSlot(input.config.kilnDir, trigger.id, stamp);
|
|
431
|
+
const route = routes.find((candidate) => candidate.trigger === trigger.id);
|
|
432
|
+
for (const issue of issues) {
|
|
433
|
+
try {
|
|
434
|
+
ingest(input.config, {
|
|
435
|
+
...jiraIssueEvent(trigger.id, trigger.repo, issue),
|
|
436
|
+
...(route?.workflow ? { workflow: route.workflow } : {}),
|
|
437
|
+
}, routes, now);
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
remember(input.config.kilnDir, trigger.id, {
|
|
441
|
+
lastFire: stamp,
|
|
442
|
+
lastError: publicError(errorMessage(error)),
|
|
443
|
+
lastErrorAt: now.toISOString(),
|
|
444
|
+
});
|
|
445
|
+
appendFailed(input.config, trigger.id, publicError(errorMessage(error)), issue.key, now);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Apply one route. A key already on a `trigger.routed` event, or already
|
|
451
|
+
* stored as a job `source.ref`, does not create a second job.
|
|
452
|
+
*/
|
|
453
|
+
function ingest(config, event, routes, now) {
|
|
454
|
+
const events = readEvents(config.kilnDir, { warn: () => { } });
|
|
455
|
+
const routed = routedKeys(events);
|
|
456
|
+
const decision = routeTrigger(event, routes, routed);
|
|
457
|
+
if (decision.type === "ignore" || decision.type === "deduped")
|
|
458
|
+
return;
|
|
459
|
+
if (decision.type === "unrouted") {
|
|
460
|
+
noteConfigError(config, event.triggerId, `No route for trigger ${event.triggerId}.`, now);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
appendFire(config, event, now);
|
|
464
|
+
if (decision.type === "attach") {
|
|
465
|
+
appendRouted(config, event, decision.jobId, "attach", undefined, now);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const existing = listValidJobs(config).find((job) => job.source?.kind === "trigger" && job.source.ref === event.key);
|
|
469
|
+
if (existing) {
|
|
470
|
+
appendRouted(config, event, existing.id, "create", decision.workflow, now);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
const title = oneLine(event.title ?? "") || event.id;
|
|
474
|
+
const specText = redact(event.body?.trim() || `# ${title}\n`);
|
|
475
|
+
const issue = ISSUE_KEY.test(event.id) ? event.id : undefined;
|
|
476
|
+
const written = writeJob(config, {
|
|
477
|
+
workflow: decision.workflow,
|
|
478
|
+
repo: decision.repo,
|
|
479
|
+
specText: specText.endsWith("\n") ? specText : `${specText}\n`,
|
|
480
|
+
title,
|
|
481
|
+
dependsOn: [],
|
|
482
|
+
...(issue ? { issue } : {}),
|
|
483
|
+
source: { kind: "trigger", ref: event.key },
|
|
484
|
+
now,
|
|
485
|
+
});
|
|
486
|
+
appendRouted(config, event, written.job.id, "create", decision.workflow, now);
|
|
487
|
+
}
|
|
488
|
+
function reviewSpec(input) {
|
|
489
|
+
const lines = [`# Draft review: ${input.title}`, ""];
|
|
490
|
+
if (input.url)
|
|
491
|
+
lines.push(`Pull request: ${input.url}`);
|
|
492
|
+
lines.push(`Number: ${input.number}`, `Head: ${input.sha}`, `Base: ${input.base}`, "");
|
|
493
|
+
lines.push("Read the change and return a draft review.", "Do not post, approve, merge, or push.", "Approving the gate keeps the draft. Kiln does not post it to GitHub.", "");
|
|
494
|
+
return lines.join("\n");
|
|
495
|
+
}
|
|
496
|
+
function appendFire(config, event, now) {
|
|
497
|
+
appendEvent(config.kilnDir, {
|
|
498
|
+
job_id: null,
|
|
499
|
+
event: "trigger.fired",
|
|
500
|
+
...(now ? { ts: now.toISOString() } : {}),
|
|
501
|
+
properties: {
|
|
502
|
+
trigger_id: event.triggerId,
|
|
503
|
+
source: event.source,
|
|
504
|
+
dedupe: event.key,
|
|
505
|
+
external_id: event.id,
|
|
506
|
+
revision: event.revision,
|
|
507
|
+
...(event.repo ? { repo: event.repo } : {}),
|
|
508
|
+
...(event.title ? { title: oneLine(event.title) } : {}),
|
|
509
|
+
once: `fired:${event.key}`,
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
function appendRouted(config, event, jobId, action, workflow, now) {
|
|
514
|
+
appendEvent(config.kilnDir, {
|
|
515
|
+
job_id: jobId,
|
|
516
|
+
event: "trigger.routed",
|
|
517
|
+
ts: now.toISOString(),
|
|
518
|
+
properties: {
|
|
519
|
+
trigger_id: event.triggerId,
|
|
520
|
+
dedupe: event.key,
|
|
521
|
+
action,
|
|
522
|
+
...(workflow ? { workflow } : {}),
|
|
523
|
+
...(event.repo ? { repo: event.repo } : {}),
|
|
524
|
+
once: `routed:${event.key}`,
|
|
525
|
+
},
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
function appendFailed(config, triggerId, error, slot, now) {
|
|
529
|
+
appendEvent(config.kilnDir, {
|
|
530
|
+
job_id: null,
|
|
531
|
+
event: "trigger.failed",
|
|
532
|
+
ts: now.toISOString(),
|
|
533
|
+
properties: {
|
|
534
|
+
trigger_id: triggerId,
|
|
535
|
+
error,
|
|
536
|
+
once: `failed:${triggerId}:${slot}`,
|
|
537
|
+
},
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
function failSlot(config, triggerId, stamp, message, now) {
|
|
541
|
+
const error = publicError(message);
|
|
542
|
+
remember(config.kilnDir, triggerId, { lastFire: stamp, lastError: error, lastErrorAt: now.toISOString() });
|
|
543
|
+
appendFailed(config, triggerId, error, stamp, now);
|
|
544
|
+
}
|
|
545
|
+
function succeedSlot(kilnDir, triggerId, stamp) {
|
|
546
|
+
remember(kilnDir, triggerId, { lastFire: stamp, lastError: undefined, lastErrorAt: undefined });
|
|
547
|
+
}
|
|
548
|
+
function noteSkip(config, triggerId, reason, stamp, now) {
|
|
549
|
+
appendEvent(config.kilnDir, {
|
|
550
|
+
job_id: null,
|
|
551
|
+
event: "trigger.skipped",
|
|
552
|
+
ts: now.toISOString(),
|
|
553
|
+
properties: {
|
|
554
|
+
trigger_id: triggerId,
|
|
555
|
+
reason,
|
|
556
|
+
once: `skipped:${triggerId}:${stamp}:${reason}`,
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
function noteConfigError(config, triggerId, message, now) {
|
|
561
|
+
const error = publicError(message);
|
|
562
|
+
const current = readRuntime(config.kilnDir)[triggerId];
|
|
563
|
+
if (current?.lastError === error)
|
|
564
|
+
return;
|
|
565
|
+
remember(config.kilnDir, triggerId, { lastError: error, lastErrorAt: now.toISOString() });
|
|
566
|
+
appendFailed(config, triggerId, error, `config:${error}`, now);
|
|
567
|
+
}
|
|
568
|
+
function blockReason(config, now) {
|
|
569
|
+
const events = readEvents(config.kilnDir, { warn: () => { } });
|
|
570
|
+
if (pausedFrom(events))
|
|
571
|
+
return "paused";
|
|
572
|
+
const spend = spendSnapshot(events, config.dailyDollarCap, now);
|
|
573
|
+
if (spend.blocked)
|
|
574
|
+
return "capped";
|
|
575
|
+
return undefined;
|
|
576
|
+
}
|
|
577
|
+
function routedKeys(events) {
|
|
578
|
+
const keys = new Set();
|
|
579
|
+
for (const event of events) {
|
|
580
|
+
if (event.event === "trigger.routed")
|
|
581
|
+
keys.add(event.properties.dedupe);
|
|
582
|
+
}
|
|
583
|
+
return keys;
|
|
584
|
+
}
|
|
585
|
+
function isTriggerEvent(event) {
|
|
586
|
+
return (event.event === "trigger.fired" ||
|
|
587
|
+
event.event === "trigger.routed" ||
|
|
588
|
+
event.event === "trigger.failed" ||
|
|
589
|
+
event.event === "trigger.skipped");
|
|
590
|
+
}
|
|
591
|
+
function triggerSummary(event) {
|
|
592
|
+
if (event.event === "trigger.failed")
|
|
593
|
+
return event.properties.error;
|
|
594
|
+
if (event.event === "trigger.skipped")
|
|
595
|
+
return event.properties.reason;
|
|
596
|
+
if (event.event === "trigger.routed")
|
|
597
|
+
return `${event.properties.action} ${event.properties.dedupe}`;
|
|
598
|
+
if (event.event === "trigger.fired")
|
|
599
|
+
return event.properties.dedupe;
|
|
600
|
+
return event.event;
|
|
601
|
+
}
|
|
602
|
+
function scheduleLabel(trigger) {
|
|
603
|
+
if (trigger.kind === "schedule")
|
|
604
|
+
return trigger.cron ?? DEFAULT_REVIEW_CRON;
|
|
605
|
+
if (trigger.kind === "jira")
|
|
606
|
+
return trigger.every ?? DEFAULT_JIRA_EVERY;
|
|
607
|
+
return "each tick";
|
|
608
|
+
}
|
|
609
|
+
function nextFire(trigger, state, now) {
|
|
610
|
+
if (trigger.enabled === false || trigger.kind === "pr")
|
|
611
|
+
return undefined;
|
|
612
|
+
if (trigger.kind === "jira") {
|
|
613
|
+
try {
|
|
614
|
+
const every = parseEvery(trigger.every);
|
|
615
|
+
const last = state?.lastFire ? Date.parse(state.lastFire) : Number.NaN;
|
|
616
|
+
const at = Number.isFinite(last) ? last + every : now.getTime();
|
|
617
|
+
return new Date(at).toISOString();
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
return undefined;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
if (!trigger.cron)
|
|
624
|
+
return undefined;
|
|
625
|
+
try {
|
|
626
|
+
const slot = previousCronSlot(trigger.cron, now);
|
|
627
|
+
const last = state?.lastFire ? Date.parse(state.lastFire) : Number.NaN;
|
|
628
|
+
if (slot && (!Number.isFinite(last) || slot.getTime() > last))
|
|
629
|
+
return slot.toISOString();
|
|
630
|
+
return nextCronSlot(trigger.cron, now)?.toISOString();
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
return undefined;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
function remember(kilnDir, id, patch) {
|
|
637
|
+
const all = readRuntime(kilnDir);
|
|
638
|
+
const prior = all[id] ?? {};
|
|
639
|
+
const next = { ...prior };
|
|
640
|
+
if (patch.lastFire !== undefined)
|
|
641
|
+
next.lastFire = patch.lastFire;
|
|
642
|
+
if (patch.lastError === undefined && "lastError" in patch)
|
|
643
|
+
delete next.lastError;
|
|
644
|
+
else if (patch.lastError !== undefined)
|
|
645
|
+
next.lastError = patch.lastError;
|
|
646
|
+
if (patch.lastErrorAt === undefined && "lastErrorAt" in patch)
|
|
647
|
+
delete next.lastErrorAt;
|
|
648
|
+
else if (patch.lastErrorAt !== undefined)
|
|
649
|
+
next.lastErrorAt = patch.lastErrorAt;
|
|
650
|
+
all[id] = next;
|
|
651
|
+
const path = join(kilnDir, "triggers", "state.json");
|
|
652
|
+
mkdirSync(join(kilnDir, "triggers"), { recursive: true });
|
|
653
|
+
writeFileSync(path, `${JSON.stringify(all, null, 2)}\n`);
|
|
654
|
+
}
|
|
655
|
+
function readRuntime(kilnDir) {
|
|
656
|
+
const path = join(kilnDir, "triggers", "state.json");
|
|
657
|
+
if (!existsSync(path))
|
|
658
|
+
return {};
|
|
659
|
+
try {
|
|
660
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
661
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
662
|
+
return {};
|
|
663
|
+
return parsed;
|
|
664
|
+
}
|
|
665
|
+
catch {
|
|
666
|
+
return {};
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
function parseCron(expr) {
|
|
670
|
+
const parts = expr.trim().split(/\s+/);
|
|
671
|
+
if (parts.length !== 5) {
|
|
672
|
+
throw new Error(`Cron ${JSON.stringify(expr)} needs 5 fields (minute hour day month weekday).`);
|
|
673
|
+
}
|
|
674
|
+
const minute = parseField(parts[0] ?? "", 0, 59);
|
|
675
|
+
const hour = parseField(parts[1] ?? "", 0, 23);
|
|
676
|
+
const dom = parseField(parts[2] ?? "", 1, 31);
|
|
677
|
+
const month = parseField(parts[3] ?? "", 1, 12);
|
|
678
|
+
const dow = parseField(parts[4] ?? "", 0, 7);
|
|
679
|
+
if (dow.values.has(7))
|
|
680
|
+
dow.values.add(0);
|
|
681
|
+
return { minute, hour, dom, month, dow };
|
|
682
|
+
}
|
|
683
|
+
function parseField(raw, min, max) {
|
|
684
|
+
if (!raw || /\s/.test(raw))
|
|
685
|
+
throw new Error(`Cron field ${JSON.stringify(raw)} is not valid.`);
|
|
686
|
+
const values = new Set();
|
|
687
|
+
for (const part of raw.split(",")) {
|
|
688
|
+
if (!part)
|
|
689
|
+
throw new Error(`Cron field ${JSON.stringify(raw)} is not valid.`);
|
|
690
|
+
const pieces = part.split("/");
|
|
691
|
+
if (pieces.length > 2)
|
|
692
|
+
throw new Error(`Cron field ${JSON.stringify(raw)} is not valid.`);
|
|
693
|
+
const step = pieces.length === 2 ? Number(pieces[1]) : 1;
|
|
694
|
+
if (!Number.isInteger(step) || step < 1)
|
|
695
|
+
throw new Error(`Cron field ${JSON.stringify(raw)} is not valid.`);
|
|
696
|
+
const base = pieces[0] ?? "";
|
|
697
|
+
let start = min;
|
|
698
|
+
let end = max;
|
|
699
|
+
if (base === "*") {
|
|
700
|
+
start = min;
|
|
701
|
+
end = max;
|
|
702
|
+
}
|
|
703
|
+
else if (base.includes("-")) {
|
|
704
|
+
const bounds = base.split("-");
|
|
705
|
+
if (bounds.length !== 2)
|
|
706
|
+
throw new Error(`Cron field ${JSON.stringify(raw)} is not valid.`);
|
|
707
|
+
start = Number(bounds[0]);
|
|
708
|
+
end = Number(bounds[1]);
|
|
709
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start < min || end > max || start > end) {
|
|
710
|
+
throw new Error(`Cron field ${JSON.stringify(raw)} is outside ${min}-${max}.`);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
else {
|
|
714
|
+
start = Number(base);
|
|
715
|
+
end = pieces.length === 2 ? max : start;
|
|
716
|
+
if (!Number.isInteger(start) || start < min || start > max) {
|
|
717
|
+
throw new Error(`Cron field ${JSON.stringify(raw)} is outside ${min}-${max}.`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
for (let value = start; value <= end; value += step)
|
|
721
|
+
values.add(value);
|
|
722
|
+
}
|
|
723
|
+
if (values.size === 0)
|
|
724
|
+
throw new Error(`Cron field ${JSON.stringify(raw)} matches nothing.`);
|
|
725
|
+
return { values, star: raw === "*" };
|
|
726
|
+
}
|
|
727
|
+
function cronMatches(cron, date) {
|
|
728
|
+
if (!cron.minute.values.has(date.getMinutes()))
|
|
729
|
+
return false;
|
|
730
|
+
if (!cron.hour.values.has(date.getHours()))
|
|
731
|
+
return false;
|
|
732
|
+
if (!cron.month.values.has(date.getMonth() + 1))
|
|
733
|
+
return false;
|
|
734
|
+
const dom = cron.dom.values.has(date.getDate());
|
|
735
|
+
const dow = cron.dow.values.has(date.getDay());
|
|
736
|
+
if (cron.dom.star && cron.dow.star)
|
|
737
|
+
return true;
|
|
738
|
+
if (cron.dom.star)
|
|
739
|
+
return dow;
|
|
740
|
+
if (cron.dow.star)
|
|
741
|
+
return dom;
|
|
742
|
+
return dom || dow;
|
|
743
|
+
}
|
|
744
|
+
function sameMinute(left, right) {
|
|
745
|
+
return (left.getFullYear() === right.getFullYear() &&
|
|
746
|
+
left.getMonth() === right.getMonth() &&
|
|
747
|
+
left.getDate() === right.getDate() &&
|
|
748
|
+
left.getHours() === right.getHours() &&
|
|
749
|
+
left.getMinutes() === right.getMinutes());
|
|
750
|
+
}
|
|
751
|
+
function hourStamp(now) {
|
|
752
|
+
return new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours()).toISOString();
|
|
753
|
+
}
|
|
754
|
+
function hasCommit(checkout, sha) {
|
|
755
|
+
const result = spawnSync("git", ["rev-parse", "--verify", "--quiet", `${sha}^{commit}`], {
|
|
756
|
+
cwd: checkout,
|
|
757
|
+
encoding: "utf8",
|
|
758
|
+
});
|
|
759
|
+
return result.status === 0;
|
|
760
|
+
}
|
|
761
|
+
function jqlQuote(value) {
|
|
762
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
763
|
+
}
|
|
764
|
+
function oneLine(value) {
|
|
765
|
+
return value.replace(/\s+/g, " ").trim().slice(0, 200);
|
|
766
|
+
}
|
|
767
|
+
function httpUrl(value) {
|
|
768
|
+
if (!value)
|
|
769
|
+
return undefined;
|
|
770
|
+
try {
|
|
771
|
+
const parsed = new URL(value);
|
|
772
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
773
|
+
return undefined;
|
|
774
|
+
return value;
|
|
775
|
+
}
|
|
776
|
+
catch {
|
|
777
|
+
return undefined;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
function publicError(message) {
|
|
781
|
+
return redact(message)
|
|
782
|
+
.replace(/(?:\/|[A-Za-z]:\\)[^\s"'`]+/g, "[path]")
|
|
783
|
+
.slice(0, 400);
|
|
784
|
+
}
|
|
785
|
+
function errorMessage(error) {
|
|
786
|
+
return error instanceof Error ? error.message : String(error);
|
|
787
|
+
}
|