@lchase/kiln 0.11.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 +20 -0
- package/assets/serve.js +53 -0
- 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 +31 -0
- package/dist/serve.js +13 -4
- package/dist/settings.js +40 -1
- package/dist/triggers.js +787 -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/README.md
CHANGED
|
@@ -254,6 +254,33 @@ By default, serve runs `kiln tick` once when it starts and then every 30 seconds
|
|
|
254
254
|
|
|
255
255
|
The Jira API token is not rendered. Do not commit checkout paths or job files.
|
|
256
256
|
|
|
257
|
+
## Triggers
|
|
258
|
+
|
|
259
|
+
A trigger starts work without `kiln add`. `kiln serve` evaluates triggers on each tick. `kiln tick` does the same pass once. Config is the `triggers` and `routes` arrays in `kiln.config.json`, re-read when the file changes.
|
|
260
|
+
|
|
261
|
+
Two triggers are built in. `review-requests` is a schedule, cron `0 * * * *` (minute 0 of every hour, local time). `pr-activity` reads Kiln-owned pull requests from the same snapshot the fix loop already fetched. It does not poll a second time. Add a Jira trigger per repo when you want one:
|
|
262
|
+
|
|
263
|
+
```json
|
|
264
|
+
{
|
|
265
|
+
"triggers": [
|
|
266
|
+
{ "id": "sample-ready", "kind": "jira", "repo": "web", "every": "5m", "label": "sample", "status": "Ready" }
|
|
267
|
+
],
|
|
268
|
+
"routes": [
|
|
269
|
+
{ "id": "sample-ready", "trigger": "sample-ready", "action": "create", "workflow": "feature", "repo": "web" }
|
|
270
|
+
]
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
`every` defaults to `5m` (`30s`, `5m`, `1h`). `label` and `status` compile to JQL. A `jql` string replaces them. `enabled` defaults to true. The Triggers page and `PUT /api/settings` send `{ "triggers": [{ "id", "enabled" }] }` and leave cron, JQL, and repo on the file.
|
|
275
|
+
|
|
276
|
+
A cron fires once per slot. While serve was down, startup fires the latest missed slot once and does not replay the ones behind it. A Jira poll that was missed does the same: one search, not one per skipped interval. The router writes `trigger.routed` with a `dedupe` property (`review:<repo>:<number>:<head sha>`, `jira:<trigger>:<issue>`, `pr:<job>:<comment>`). A later tick that sees that value does not create another job. Comments and commits from `kiln`, a `[bot]` login, a `kiln:` subject, or a `Kiln-Job` trailer are ignored.
|
|
277
|
+
|
|
278
|
+
A failed fire stores `last_error` and `lastErrorAt` under `<kiln-home>/triggers/state.json`, appends `trigger.failed`, and waits for the next slot. It does not retry on the next tick. Pause and the daily dollar cap append `trigger.skipped` with reason `paused` or `capped` and do not queue the fire.
|
|
279
|
+
|
|
280
|
+
`review-requests` lists open pull requests where your `gh` user is the requested reviewer (`gh pr list --search review-requested:@me` in each registered GitHub repo). A pull request already drafted at that head SHA is skipped. The `review` workflow returns findings (severity, file, line, suggested comment) and stops on a gate in the Inbox. Approving the gate does not post, approve, or merge. There is no gated posting path for a review comment yet, so the draft stops there. `reviewPrompt` is a path to your own prompt file. Omitted, Kiln uses `prompts/kiln/review.md`.
|
|
281
|
+
|
|
282
|
+
Jira search sends `JIRA_API_TOKEN` only as a Basic header in a child process. The token is not written into the job, the spec, or the event log. Polling is the only Jira ingress. A webhook can later call the same issue-to-event function.
|
|
283
|
+
|
|
257
284
|
## Tick, status, approve
|
|
258
285
|
|
|
259
286
|
```bash
|
package/assets/serve.css
CHANGED
|
@@ -257,6 +257,26 @@ body.open .drawer { transform: none; }
|
|
|
257
257
|
.repo-row .num { font-family: var(--mono); font-size: 13px; }
|
|
258
258
|
.repo-row .d-actions { margin-top: 0; flex: none; }
|
|
259
259
|
#settings-error { white-space: pre-wrap; }
|
|
260
|
+
.trigger {
|
|
261
|
+
border: 1px solid var(--line);
|
|
262
|
+
background: var(--card);
|
|
263
|
+
border-radius: 10px;
|
|
264
|
+
padding: 14px 16px;
|
|
265
|
+
margin: 0 0 12px;
|
|
266
|
+
}
|
|
267
|
+
.trigger h3 { margin: 0 0 4px; color: var(--ink); font-size: 15px; letter-spacing: 0; text-transform: none; }
|
|
268
|
+
.trigger .err { margin: 6px 0 0; color: var(--human); font-family: var(--mono); font-size: 12px; line-height: 16px; }
|
|
269
|
+
.trigger button {
|
|
270
|
+
margin-top: 10px;
|
|
271
|
+
background: var(--card-hover);
|
|
272
|
+
border: 1px solid var(--line-strong);
|
|
273
|
+
border-radius: 6px;
|
|
274
|
+
padding: 4px 10px;
|
|
275
|
+
cursor: pointer;
|
|
276
|
+
}
|
|
277
|
+
.trigger .recent { list-style: none; margin: 10px 0 0; padding: 0; }
|
|
278
|
+
.trigger .recent li { margin: 4px 0; font-size: 12px; line-height: 16px; color: var(--muted); }
|
|
279
|
+
.trigger .ev { font-family: var(--mono); color: var(--ink); }
|
|
260
280
|
|
|
261
281
|
@media (max-width: 960px) {
|
|
262
282
|
.shell { grid-template-columns: 1fr; }
|
package/assets/serve.js
CHANGED
|
@@ -911,6 +911,54 @@
|
|
|
911
911
|
.catch(function () { showSettingsError("Could not load settings."); });
|
|
912
912
|
}
|
|
913
913
|
|
|
914
|
+
function toggleTrigger(button) {
|
|
915
|
+
var id = button.getAttribute("data-trigger-toggle");
|
|
916
|
+
var enabled = button.getAttribute("data-enabled") !== "true";
|
|
917
|
+
var error = document.getElementById("trigger-error");
|
|
918
|
+
if (error) error.hidden = true;
|
|
919
|
+
button.disabled = true;
|
|
920
|
+
fetch("/api/settings", { cache: "no-store", headers: { Accept: "application/json" } })
|
|
921
|
+
.then(function (response) { return response.json().then(function (data) { return { ok: response.ok, data: data }; }); })
|
|
922
|
+
.then(function (result) {
|
|
923
|
+
if (!result.ok) throw new Error((result.data && result.data.error) || "Could not load settings.");
|
|
924
|
+
var data = result.data;
|
|
925
|
+
var toggles = [];
|
|
926
|
+
var buttons = document.querySelectorAll("[data-trigger-toggle]");
|
|
927
|
+
for (var i = 0; i < buttons.length; i++) {
|
|
928
|
+
var buttonId = buttons[i].getAttribute("data-trigger-toggle");
|
|
929
|
+
var on = buttons[i].getAttribute("data-enabled") === "true";
|
|
930
|
+
if (buttonId === id) on = enabled;
|
|
931
|
+
toggles.push({ id: buttonId, enabled: on });
|
|
932
|
+
}
|
|
933
|
+
var body = { revision: data.revision, repos: data.repos || {}, triggers: toggles };
|
|
934
|
+
["stallTimeoutMs", "maxAttempts", "maxFixRounds", "maxSameFingerprint", "dailyDollarCap"].forEach(function (key) {
|
|
935
|
+
if (data[key] !== undefined && data[key] !== null) body[key] = data[key];
|
|
936
|
+
});
|
|
937
|
+
if (data.sandbox) body.sandbox = data.sandbox;
|
|
938
|
+
return fetch("/api/settings", {
|
|
939
|
+
method: "PUT",
|
|
940
|
+
headers: {
|
|
941
|
+
Accept: "application/json",
|
|
942
|
+
"Content-Type": "application/json",
|
|
943
|
+
"X-Kiln-CSRF": document.body.getAttribute("data-csrf") || "",
|
|
944
|
+
},
|
|
945
|
+
body: JSON.stringify(body),
|
|
946
|
+
});
|
|
947
|
+
})
|
|
948
|
+
.then(function (response) { return response.json().then(function (data) { return { ok: response.ok, data: data }; }); })
|
|
949
|
+
.then(function (result) {
|
|
950
|
+
if (!result.ok) throw new Error((result.data && result.data.error) || "Could not save settings.");
|
|
951
|
+
location.reload();
|
|
952
|
+
})
|
|
953
|
+
.catch(function (err) {
|
|
954
|
+
button.disabled = false;
|
|
955
|
+
if (error) {
|
|
956
|
+
error.hidden = false;
|
|
957
|
+
error.textContent = err && err.message ? err.message : "Could not update the trigger.";
|
|
958
|
+
}
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
|
|
914
962
|
function saveSettings() {
|
|
915
963
|
if (settingsSaving) return;
|
|
916
964
|
if (!flushEditor()) return;
|
|
@@ -1011,6 +1059,11 @@
|
|
|
1011
1059
|
document.addEventListener("click", function (event) {
|
|
1012
1060
|
var target = event.target;
|
|
1013
1061
|
if (!target || !target.closest) return;
|
|
1062
|
+
var triggerToggle = target.closest("[data-trigger-toggle]");
|
|
1063
|
+
if (triggerToggle) {
|
|
1064
|
+
toggleTrigger(triggerToggle);
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1014
1067
|
var settingsControl = target.closest("#open-settings");
|
|
1015
1068
|
if (settingsControl) {
|
|
1016
1069
|
if (settingsControl.tagName !== "A") openSettings();
|
package/dist/cli.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { addRepo, renderRepoList, resolveKilnConfig } from "./config.js";
|
|
3
3
|
import { renderSandboxStatus, runSandboxBuild } from "./sandbox.js";
|
|
4
4
|
import { answerJob, retryJob, reviseJob, runStatus, runTick } from "./driver.js";
|
|
5
|
+
import { productionHost } from "./host.js";
|
|
5
6
|
import { pauseFactory, resumeFactory } from "./rails.js";
|
|
6
7
|
import { parseSince, renderLedgerFile } from "./ledger.js";
|
|
7
8
|
import { startServe } from "./serve.js";
|
|
@@ -88,6 +89,11 @@ function usage() {
|
|
|
88
89
|
"that requests changes starts a fix on that same branch, up to maxFixRounds",
|
|
89
90
|
"(default 2). Kiln does not merge, approve, or comment. Past that bound the",
|
|
90
91
|
"job is needs-human with reason feedback-exhausted.",
|
|
92
|
+
"Triggers in the kiln config start work without kiln add. A cron fires",
|
|
93
|
+
"on the serve tick in local time, once per slot, and once on startup",
|
|
94
|
+
"when a slot was missed. Jira polls are per repo. Kiln-owned pull",
|
|
95
|
+
"request activity uses the same read as the fix loop. A failed fire",
|
|
96
|
+
"waits for the next slot. Pause and the daily cap skip the fire.",
|
|
91
97
|
"",
|
|
92
98
|
"serve listens on 127.0.0.1. The page is a board of workflow stages:",
|
|
93
99
|
"queued, spec, build, review, approve, land, PR review, Done (landed locally),",
|
|
@@ -401,7 +407,7 @@ function tickCommand(argv) {
|
|
|
401
407
|
usage();
|
|
402
408
|
}
|
|
403
409
|
const config = resolveKilnConfig({ configPath: flags.configPath });
|
|
404
|
-
process.stdout.write(runTick(config));
|
|
410
|
+
process.stdout.write(runTick(config, productionHost()));
|
|
405
411
|
}
|
|
406
412
|
function statusCommand(argv) {
|
|
407
413
|
const flags = parseFlags(argv);
|
package/dist/config.js
CHANGED
|
@@ -54,6 +54,11 @@ export function loadKilnConfig(configPath, options = {}) {
|
|
|
54
54
|
sandbox: parsed.data.sandbox ?? {},
|
|
55
55
|
workflowSandbox: workflowSandboxFrom(parsed.data.workflows),
|
|
56
56
|
...(jira ? { jira } : {}),
|
|
57
|
+
triggers: parsed.data.triggers ?? [],
|
|
58
|
+
routes: parsed.data.routes ?? [],
|
|
59
|
+
...(parsed.data.reviewPrompt
|
|
60
|
+
? { reviewPrompt: resolveConfiguredPath(configDir, parsed.data.reviewPrompt, home) }
|
|
61
|
+
: {}),
|
|
57
62
|
};
|
|
58
63
|
}
|
|
59
64
|
/**
|
package/dist/driver.js
CHANGED
|
@@ -21,7 +21,8 @@ import { projectRunJournal, promptsHash } from "./project.js";
|
|
|
21
21
|
import { gateAnswerCommand, renderStatus, renderTick, statusView, yakCliPath, sandboxFailureDetail, } from "./report.js";
|
|
22
22
|
import { AGENT_SANDBOX_IMAGE, injectAgentSandbox, jobIsSandboxed, probeSandboxEnvironment, PROXY_SANDBOX_IMAGE, requiredSandboxImages, resolveSandbox, sandboxIsEnabled, sandboxPreflight, sandboxPreflightFor, } from "./sandbox.js";
|
|
23
23
|
import { JobSchema } from "./schemas.js";
|
|
24
|
-
import {
|
|
24
|
+
import { ensureReviewHead, evaluateTriggers, recordOwnedPullRequest, reviewHeadOf, } from "./triggers.js";
|
|
25
|
+
import { leafStepIds, materializeWorkflow, REVIEW_WORKFLOW, workflowInputs, workflowNeedsTestCommand, workflowStepPlan, } from "./workflow.js";
|
|
25
26
|
const TAG_PREFIX = "kiln:";
|
|
26
27
|
export function jobTag(jobId) {
|
|
27
28
|
return `${TAG_PREFIX}${jobId}`;
|
|
@@ -53,8 +54,14 @@ export function runTick(config, host = defaultHost(), options = {}) {
|
|
|
53
54
|
const midway = observe(config, host, now);
|
|
54
55
|
bindOpenFixRuns(config, midway);
|
|
55
56
|
const published = publishReadyFixes(config, host, midway);
|
|
57
|
+
evaluateTriggers({
|
|
58
|
+
config,
|
|
59
|
+
now,
|
|
60
|
+
startup: options.triggerStartup ?? startup,
|
|
61
|
+
...(host.reviews ? { reviews: host.reviews } : {}),
|
|
62
|
+
});
|
|
56
63
|
const world = observe(config, host, now);
|
|
57
|
-
const feedbackNotes = watchPullRequests(config, world, host);
|
|
64
|
+
const feedbackNotes = watchPullRequests(config, world, host, now);
|
|
58
65
|
const actions = decide(world.observation, now);
|
|
59
66
|
if (world.observation.spendBlocked) {
|
|
60
67
|
recordDailyCap(config.kilnDir, spendSnapshot(readEvents(config.kilnDir, { warn: () => { } }), config.dailyDollarCap, now), now);
|
|
@@ -749,7 +756,7 @@ function gateEdit(checkout, runId, stepId) {
|
|
|
749
756
|
* Read each open pull request once per tick and attach a plan.
|
|
750
757
|
* `readStatus` does not call this. A host with no feedback reader does not watch.
|
|
751
758
|
*/
|
|
752
|
-
function watchPullRequests(config, world, host) {
|
|
759
|
+
function watchPullRequests(config, world, host, now) {
|
|
753
760
|
const notes = [];
|
|
754
761
|
if (!host.feedback)
|
|
755
762
|
return notes;
|
|
@@ -775,11 +782,28 @@ function watchPullRequests(config, world, host) {
|
|
|
775
782
|
ledger,
|
|
776
783
|
snapshot,
|
|
777
784
|
});
|
|
785
|
+
recordOwnedPullRequest(config, {
|
|
786
|
+
jobId: job.jobId,
|
|
787
|
+
repo: job.repo,
|
|
788
|
+
prNumber: job.delivery?.prNumber ?? 0,
|
|
789
|
+
headSha: snapshot.headSha,
|
|
790
|
+
comments: snapshot.comments.map((comment) => ({
|
|
791
|
+
id: comment.id,
|
|
792
|
+
author: comment.author,
|
|
793
|
+
body: comment.body,
|
|
794
|
+
resolved: comment.resolved,
|
|
795
|
+
})),
|
|
796
|
+
reviews: snapshot.reviews.map((review) => ({
|
|
797
|
+
id: review.id,
|
|
798
|
+
author: review.author,
|
|
799
|
+
body: review.body,
|
|
800
|
+
})),
|
|
801
|
+
}, now);
|
|
778
802
|
}
|
|
779
803
|
return notes;
|
|
780
804
|
}
|
|
781
805
|
function shouldWatch(job) {
|
|
782
|
-
if (job.deliver !== "pr" || job.workflow === INVESTIGATION_WORKFLOW)
|
|
806
|
+
if (job.deliver !== "pr" || job.workflow === INVESTIGATION_WORKFLOW || job.workflow === REVIEW_WORKFLOW)
|
|
783
807
|
return false;
|
|
784
808
|
if (job.error || job.feedbackLedgerError || job.feedbackExhausted || job.feedbackAmbiguous)
|
|
785
809
|
return false;
|
|
@@ -1041,7 +1065,9 @@ function launchJob(config, world, job, host, sandboxFacts) {
|
|
|
1041
1065
|
if (workflowNeedsTestCommand(sourceWorkflow) && !testCommand?.trim()) {
|
|
1042
1066
|
throw new Error(`Repo ${job.repo} has no test command. Set repos.${job.repo}.test. The feature workflow refuses to run an empty KILN_TEST.`);
|
|
1043
1067
|
}
|
|
1044
|
-
const workflow = materializeWorkflow(config.kilnDir, job.id, job.workflow
|
|
1068
|
+
const workflow = materializeWorkflow(config.kilnDir, job.id, job.workflow, {
|
|
1069
|
+
...(config.reviewPrompt ? { reviewPrompt: config.reviewPrompt } : {}),
|
|
1070
|
+
});
|
|
1045
1071
|
const logPath = join(config.kilnDir, "logs", `${jobBasename(job.id)}.log`);
|
|
1046
1072
|
mkdirSync(dirname(logPath), { recursive: true });
|
|
1047
1073
|
ensureCheckoutExcludes(repo.path);
|
|
@@ -1053,14 +1079,23 @@ function launchJob(config, world, job, host, sandboxFacts) {
|
|
|
1053
1079
|
if (blocked)
|
|
1054
1080
|
return { jobId: job.id, repo: job.repo, output: blocked };
|
|
1055
1081
|
assertStackTarget(world, job);
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1082
|
+
const pinned = reviewHeadOf(job);
|
|
1083
|
+
if (pinned) {
|
|
1084
|
+
const number = Number(job.source?.ref.split(":")[2]);
|
|
1085
|
+
if (Number.isInteger(number) && number > 0)
|
|
1086
|
+
ensureReviewHead(repo.path, number, pinned);
|
|
1087
|
+
baseSha = resolveCommit(host, repo.path, pinned);
|
|
1088
|
+
}
|
|
1089
|
+
else {
|
|
1090
|
+
baseSha = resolveLaunchBase({
|
|
1091
|
+
checkout: repo.path,
|
|
1092
|
+
deliver: repo.deliver,
|
|
1093
|
+
stack: job.stack === true,
|
|
1094
|
+
...(repo.base ? { configuredBase: repo.base } : {}),
|
|
1095
|
+
...(job.stack ? { upstreamBranch: upstreamLocalBranch(world, job) } : {}),
|
|
1096
|
+
git: host.git,
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1064
1099
|
specText = specHandedToYak(config, world, job);
|
|
1065
1100
|
}
|
|
1066
1101
|
catch (error) {
|
package/dist/events.js
CHANGED
|
@@ -9,8 +9,8 @@ import { redactDeep } from "./redact.js";
|
|
|
9
9
|
* parses it. A write failure is reported and does not escape to the caller.
|
|
10
10
|
*
|
|
11
11
|
* `run_id` and `step` are null when the moment has none. `job_id` is null
|
|
12
|
-
* for `config.reloaded`, `kiln.paused`, `kiln.resumed`,
|
|
13
|
-
*
|
|
12
|
+
* for `config.reloaded`, `kiln.paused`, `kiln.resumed`, `budget.blocked`,
|
|
13
|
+
* and trigger fires that do not belong to a job yet.
|
|
14
14
|
*/
|
|
15
15
|
export const EVENT_NAMES = [
|
|
16
16
|
"job.added",
|
|
@@ -31,6 +31,10 @@ export const EVENT_NAMES = [
|
|
|
31
31
|
"kiln.paused",
|
|
32
32
|
"kiln.resumed",
|
|
33
33
|
"budget.blocked",
|
|
34
|
+
"trigger.fired",
|
|
35
|
+
"trigger.routed",
|
|
36
|
+
"trigger.failed",
|
|
37
|
+
"trigger.skipped",
|
|
34
38
|
];
|
|
35
39
|
const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
|
|
36
40
|
const once = z.string().min(1).optional();
|
|
@@ -131,6 +135,35 @@ const BudgetBlocked = z.object({
|
|
|
131
135
|
cap: z.number().nonnegative(),
|
|
132
136
|
once,
|
|
133
137
|
});
|
|
138
|
+
const TriggerFired = z.object({
|
|
139
|
+
trigger_id: z.string().min(1),
|
|
140
|
+
source: z.enum(["schedule", "jira", "pr", "review"]),
|
|
141
|
+
/** Stable dedupe id. Not named `key`: the redactor masks any property with that name. */
|
|
142
|
+
dedupe: z.string().min(1),
|
|
143
|
+
external_id: z.string().min(1),
|
|
144
|
+
revision: z.string().min(1),
|
|
145
|
+
repo: z.string().min(1).optional(),
|
|
146
|
+
title: z.string().min(1).optional(),
|
|
147
|
+
once,
|
|
148
|
+
});
|
|
149
|
+
const TriggerRouted = z.object({
|
|
150
|
+
trigger_id: z.string().min(1),
|
|
151
|
+
dedupe: z.string().min(1),
|
|
152
|
+
action: z.enum(["create", "attach"]),
|
|
153
|
+
workflow: z.string().min(1).optional(),
|
|
154
|
+
repo: z.string().min(1).optional(),
|
|
155
|
+
once,
|
|
156
|
+
});
|
|
157
|
+
const TriggerFailed = z.object({
|
|
158
|
+
trigger_id: z.string().min(1),
|
|
159
|
+
error: z.string().min(1),
|
|
160
|
+
once,
|
|
161
|
+
});
|
|
162
|
+
const TriggerSkipped = z.object({
|
|
163
|
+
trigger_id: z.string().min(1),
|
|
164
|
+
reason: z.enum(["paused", "capped"]),
|
|
165
|
+
once,
|
|
166
|
+
});
|
|
134
167
|
const envelope = {
|
|
135
168
|
id: z.string().min(1),
|
|
136
169
|
ts: z.string().regex(ISO_UTC, "ts must be ISO-8601 UTC"),
|
|
@@ -176,6 +209,30 @@ export const EventSchema = z.discriminatedUnion("event", [
|
|
|
176
209
|
job_id: z.string().min(1).nullable(),
|
|
177
210
|
properties: BudgetBlocked,
|
|
178
211
|
}),
|
|
212
|
+
z.object({
|
|
213
|
+
...envelope,
|
|
214
|
+
event: z.literal("trigger.fired"),
|
|
215
|
+
job_id: z.string().min(1).nullable(),
|
|
216
|
+
properties: TriggerFired,
|
|
217
|
+
}),
|
|
218
|
+
z.object({
|
|
219
|
+
...envelope,
|
|
220
|
+
event: z.literal("trigger.routed"),
|
|
221
|
+
job_id: z.string().min(1).nullable(),
|
|
222
|
+
properties: TriggerRouted,
|
|
223
|
+
}),
|
|
224
|
+
z.object({
|
|
225
|
+
...envelope,
|
|
226
|
+
event: z.literal("trigger.failed"),
|
|
227
|
+
job_id: z.string().min(1).nullable(),
|
|
228
|
+
properties: TriggerFailed,
|
|
229
|
+
}),
|
|
230
|
+
z.object({
|
|
231
|
+
...envelope,
|
|
232
|
+
event: z.literal("trigger.skipped"),
|
|
233
|
+
job_id: z.string().min(1).nullable(),
|
|
234
|
+
properties: TriggerSkipped,
|
|
235
|
+
}),
|
|
179
236
|
]);
|
|
180
237
|
const LOCK_WAIT_MS = 5_000;
|
|
181
238
|
const pauseBuf = new Int32Array(new SharedArrayBuffer(4));
|
package/dist/host.js
CHANGED
|
@@ -18,6 +18,59 @@ export class HostError extends Error {
|
|
|
18
18
|
export function defaultHost() {
|
|
19
19
|
return { github: realGitHub, git: realGitRemote, feedback: realPullRequestFeedback };
|
|
20
20
|
}
|
|
21
|
+
/** `kiln tick` and `kiln serve`. Review requests use the operator's gh auth. */
|
|
22
|
+
export function productionHost() {
|
|
23
|
+
return { ...defaultHost(), reviews: { list: listReviewRequests } };
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Open pull requests awaiting the authenticated user's review, per registered repo.
|
|
27
|
+
* A remote that is not GitHub is skipped, so a local test origin does not call gh.
|
|
28
|
+
*/
|
|
29
|
+
export function listReviewRequests(repos) {
|
|
30
|
+
const found = [];
|
|
31
|
+
for (const repo of repos) {
|
|
32
|
+
if (!githubRemote(repo.path))
|
|
33
|
+
continue;
|
|
34
|
+
const result = spawnSync(ghBin(), ["pr", "list", "--search", "review-requested:@me", "--state", "open", "--limit", "50", "--json", "number,title,url,headRefOid"], {
|
|
35
|
+
cwd: repo.path,
|
|
36
|
+
encoding: "utf8",
|
|
37
|
+
timeout: 20_000,
|
|
38
|
+
env: { ...process.env, GH_PROMPT_DISABLED: "1", GIT_TERMINAL_PROMPT: "0" },
|
|
39
|
+
});
|
|
40
|
+
if (result.error || result.status !== 0) {
|
|
41
|
+
throw new HostError(`Could not list review requests for ${repo.name}.`);
|
|
42
|
+
}
|
|
43
|
+
let parsed;
|
|
44
|
+
try {
|
|
45
|
+
parsed = JSON.parse(result.stdout || "[]");
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
throw new HostError("gh pr list returned data that is not JSON.");
|
|
49
|
+
}
|
|
50
|
+
if (!Array.isArray(parsed))
|
|
51
|
+
throw new HostError("gh pr list returned data that is not a list.");
|
|
52
|
+
for (const item of parsed) {
|
|
53
|
+
if (typeof item !== "object" || item === null)
|
|
54
|
+
continue;
|
|
55
|
+
const record = item;
|
|
56
|
+
const number = typeof record.number === "number" ? record.number : Number.NaN;
|
|
57
|
+
const sha = typeof record.headRefOid === "string" ? record.headRefOid.trim().toLowerCase() : "";
|
|
58
|
+
if (!Number.isInteger(number) || number <= 0 || !/^[0-9a-f]{7,64}$/.test(sha))
|
|
59
|
+
continue;
|
|
60
|
+
const title = typeof record.title === "string" && record.title.trim() ? record.title.trim() : `PR ${number}`;
|
|
61
|
+
const url = typeof record.url === "string" ? record.url : "";
|
|
62
|
+
found.push({ repo: repo.name, number, title, url, headSha: sha });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return found;
|
|
66
|
+
}
|
|
67
|
+
function githubRemote(checkout) {
|
|
68
|
+
const result = gitResult(checkout, ["remote", "get-url", "origin"]);
|
|
69
|
+
if (result.status !== 0)
|
|
70
|
+
return false;
|
|
71
|
+
const url = result.stdout.trim();
|
|
72
|
+
return /github\.com[:/]/i.test(url);
|
|
73
|
+
}
|
|
21
74
|
const YAK_BRANCH = /^yak\/[A-Za-z0-9._-]+$/;
|
|
22
75
|
function assertYakBranch(branch, baseBranch) {
|
|
23
76
|
if (branch === baseBranch || branch === `refs/heads/${baseBranch}`) {
|
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/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
|
/**
|