@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/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 that gate posts the draft as a pull request comment (`COMMENT` only) under your `gh` user: the markdown is the body, and findings with a file and line are inline comments. Kiln does not approve the pull request or merge it. Rejecting or revising the gate does not post. Pause and the daily cap do not post. Posting the same repo, pull request, and head SHA again does not create a second review. A failed post stays in Needs a hand with `review.failed` and does not count as posted. `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/attention.js
CHANGED
|
@@ -102,6 +102,8 @@ function toItem(job) {
|
|
|
102
102
|
export function attentionReason(job, tail = errorTail(job)) {
|
|
103
103
|
switch (job.state) {
|
|
104
104
|
case "needs-human": {
|
|
105
|
+
if (job.reviewPostError)
|
|
106
|
+
return job.reviewPostError;
|
|
105
107
|
if (job.stopReason === "attempt-cap")
|
|
106
108
|
return "Attempt cap reached";
|
|
107
109
|
if (job.stopReason === "fingerprint")
|
package/dist/board-view.js
CHANGED
|
@@ -61,6 +61,8 @@ export function parseSeenQuery(values) {
|
|
|
61
61
|
return seen;
|
|
62
62
|
}
|
|
63
63
|
export function isInbox(job) {
|
|
64
|
+
if (job.reviewPostError)
|
|
65
|
+
return false;
|
|
64
66
|
if (job.canApprove || job.canReject || job.canRevise)
|
|
65
67
|
return true;
|
|
66
68
|
return job.state === "needs-human" && job.gates.length > 0 && !job.stopReason && !job.feedbackReason;
|
|
@@ -504,11 +506,14 @@ function toBoardJob(job, events) {
|
|
|
504
506
|
spec: Boolean(job.specPath),
|
|
505
507
|
log: Boolean(job.logPath),
|
|
506
508
|
...(job.logTail?.trim() ? { logTail: job.logTail.trim() } : {}),
|
|
509
|
+
...(job.reviewPostError ? { reviewPostError: job.reviewPostError } : {}),
|
|
507
510
|
};
|
|
508
511
|
}
|
|
509
512
|
function finishedLine(job) {
|
|
510
513
|
if (job.state === "rejected")
|
|
511
514
|
return "Rejected";
|
|
515
|
+
if (job.workflow === "review")
|
|
516
|
+
return job.reviewUrl ? "comment posted" : "approved";
|
|
512
517
|
if (job.prNumber)
|
|
513
518
|
return `merged · PR #${job.prNumber}`;
|
|
514
519
|
if (job.prUrl && httpUrl(job.prUrl))
|
|
@@ -525,6 +530,8 @@ function httpUrl(url) {
|
|
|
525
530
|
}
|
|
526
531
|
}
|
|
527
532
|
function handLine(job, failure) {
|
|
533
|
+
if (job.reviewPostError)
|
|
534
|
+
return job.reviewPostError;
|
|
528
535
|
let label = "Needs a person";
|
|
529
536
|
if (job.stopReason === "attempt-cap")
|
|
530
537
|
label = "Attempt cap";
|
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";
|
|
@@ -62,7 +63,9 @@ function usage() {
|
|
|
62
63
|
"yak/<run-id> and opens a draft pull request. An investigation is done when",
|
|
63
64
|
"its gate is approved. It does not open a pull request. status prints the",
|
|
64
65
|
"derived state, and the findings path once an investigation is approved.",
|
|
65
|
-
"approve and reject answer the human gate.
|
|
66
|
+
"approve and reject answer the human gate. Approving a review draft posts",
|
|
67
|
+
"it as a pull request comment and does not approve or merge that pull request.",
|
|
68
|
+
"retry launches a failed or dead",
|
|
66
69
|
"job again, the same way tick launches, and clears the attempt ledger.",
|
|
67
70
|
"A landed job that only failed to open its pull request is delivered again.",
|
|
68
71
|
"A running job with no yak event or log output for stallTimeoutMs (default",
|
|
@@ -88,6 +91,11 @@ function usage() {
|
|
|
88
91
|
"that requests changes starts a fix on that same branch, up to maxFixRounds",
|
|
89
92
|
"(default 2). Kiln does not merge, approve, or comment. Past that bound the",
|
|
90
93
|
"job is needs-human with reason feedback-exhausted.",
|
|
94
|
+
"Triggers in the kiln config start work without kiln add. A cron fires",
|
|
95
|
+
"on the serve tick in local time, once per slot, and once on startup",
|
|
96
|
+
"when a slot was missed. Jira polls are per repo. Kiln-owned pull",
|
|
97
|
+
"request activity uses the same read as the fix loop. A failed fire",
|
|
98
|
+
"waits for the next slot. Pause and the daily cap skip the fire.",
|
|
91
99
|
"",
|
|
92
100
|
"serve listens on 127.0.0.1. The page is a board of workflow stages:",
|
|
93
101
|
"queued, spec, build, review, approve, land, PR review, Done (landed locally),",
|
|
@@ -401,7 +409,7 @@ function tickCommand(argv) {
|
|
|
401
409
|
usage();
|
|
402
410
|
}
|
|
403
411
|
const config = resolveKilnConfig({ configPath: flags.configPath });
|
|
404
|
-
process.stdout.write(runTick(config));
|
|
412
|
+
process.stdout.write(runTick(config, productionHost()));
|
|
405
413
|
}
|
|
406
414
|
function statusCommand(argv) {
|
|
407
415
|
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
|
@@ -18,10 +18,12 @@ import { jobBasename, packageRoot } from "./paths.js";
|
|
|
18
18
|
import { decide, isLanded, jobState, } from "./reconcile.js";
|
|
19
19
|
import { fileMtimeMs, isPidAlive, readAttemptLedger, recoverRuns, resetAttemptLedger, } from "./reliability.js";
|
|
20
20
|
import { projectRunJournal, promptsHash } from "./project.js";
|
|
21
|
+
import { postApprovedReview, reviewPostForJob } from "./review-post.js";
|
|
21
22
|
import { gateAnswerCommand, renderStatus, renderTick, statusView, yakCliPath, sandboxFailureDetail, } from "./report.js";
|
|
22
23
|
import { AGENT_SANDBOX_IMAGE, injectAgentSandbox, jobIsSandboxed, probeSandboxEnvironment, PROXY_SANDBOX_IMAGE, requiredSandboxImages, resolveSandbox, sandboxIsEnabled, sandboxPreflight, sandboxPreflightFor, } from "./sandbox.js";
|
|
23
24
|
import { JobSchema } from "./schemas.js";
|
|
24
|
-
import {
|
|
25
|
+
import { ensureReviewHead, evaluateTriggers, recordOwnedPullRequest, reviewHeadOf, } from "./triggers.js";
|
|
26
|
+
import { leafStepIds, materializeWorkflow, REVIEW_WORKFLOW, workflowInputs, workflowNeedsTestCommand, workflowStepPlan, } from "./workflow.js";
|
|
25
27
|
const TAG_PREFIX = "kiln:";
|
|
26
28
|
export function jobTag(jobId) {
|
|
27
29
|
return `${TAG_PREFIX}${jobId}`;
|
|
@@ -53,8 +55,14 @@ export function runTick(config, host = defaultHost(), options = {}) {
|
|
|
53
55
|
const midway = observe(config, host, now);
|
|
54
56
|
bindOpenFixRuns(config, midway);
|
|
55
57
|
const published = publishReadyFixes(config, host, midway);
|
|
58
|
+
evaluateTriggers({
|
|
59
|
+
config,
|
|
60
|
+
now,
|
|
61
|
+
startup: options.triggerStartup ?? startup,
|
|
62
|
+
...(host.reviews ? { reviews: host.reviews } : {}),
|
|
63
|
+
});
|
|
56
64
|
const world = observe(config, host, now);
|
|
57
|
-
const feedbackNotes = watchPullRequests(config, world, host);
|
|
65
|
+
const feedbackNotes = watchPullRequests(config, world, host, now);
|
|
58
66
|
const actions = decide(world.observation, now);
|
|
59
67
|
if (world.observation.spendBlocked) {
|
|
60
68
|
recordDailyCap(config.kilnDir, spendSnapshot(readEvents(config.kilnDir, { warn: () => { } }), config.dailyDollarCap, now), now);
|
|
@@ -90,6 +98,19 @@ export function answerJob(config, jobId, choice, host = defaultHost()) {
|
|
|
90
98
|
if (!gate) {
|
|
91
99
|
throw new Error(`Job ${jobId} has no open gate that accepts ${choice}.`);
|
|
92
100
|
}
|
|
101
|
+
if (choice === "approve" && job.workflow === REVIEW_WORKFLOW) {
|
|
102
|
+
postApprovedReview({
|
|
103
|
+
kilnDir: config.kilnDir,
|
|
104
|
+
jobId,
|
|
105
|
+
runId: job.run.runId,
|
|
106
|
+
repo: job.repo,
|
|
107
|
+
sourceRef: job.source?.kind === "trigger" ? job.source.ref : undefined,
|
|
108
|
+
checkout: job.run.checkout,
|
|
109
|
+
host,
|
|
110
|
+
paused: world.observation.paused === true,
|
|
111
|
+
spendBlocked: world.observation.spendBlocked === true,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
93
114
|
const command = gateAnswerCommand(job.run.checkout, job.run.runId, gate, choice, job.run.title ?? job.title);
|
|
94
115
|
const result = spawnSync("sh", ["-c", command], { encoding: "utf8" });
|
|
95
116
|
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
|
@@ -314,6 +335,9 @@ function observe(config, host, now = new Date()) {
|
|
|
314
335
|
const findingsPath = entry.job?.workflow === INVESTIGATION_WORKFLOW
|
|
315
336
|
? existingFindingsPath(config.kilnDir, entry.job.id)
|
|
316
337
|
: undefined;
|
|
338
|
+
const reviewPost = entry.job
|
|
339
|
+
? reviewPostForJob(events, { jobId: entry.job.id, repo: entry.job.repo, source: entry.job.source })
|
|
340
|
+
: {};
|
|
317
341
|
const logTail = readLogTail(logPath);
|
|
318
342
|
const stepPlan = entry.job ? workflowStepPlan(entry.job.workflow) : undefined;
|
|
319
343
|
const diffSummary = run &&
|
|
@@ -333,6 +357,8 @@ function observe(config, host, now = new Date()) {
|
|
|
333
357
|
...(entry.job?.stack ? { stack: true } : {}),
|
|
334
358
|
...(delivery ? { delivery } : {}),
|
|
335
359
|
...(findingsPath ? { findingsPath } : {}),
|
|
360
|
+
...(reviewPost.error ? { reviewPostError: reviewPost.error } : {}),
|
|
361
|
+
...(reviewPost.url ? { reviewUrl: reviewPost.url } : {}),
|
|
336
362
|
launching,
|
|
337
363
|
launchFailed,
|
|
338
364
|
...(launchError ? { launchError } : {}),
|
|
@@ -749,7 +775,7 @@ function gateEdit(checkout, runId, stepId) {
|
|
|
749
775
|
* Read each open pull request once per tick and attach a plan.
|
|
750
776
|
* `readStatus` does not call this. A host with no feedback reader does not watch.
|
|
751
777
|
*/
|
|
752
|
-
function watchPullRequests(config, world, host) {
|
|
778
|
+
function watchPullRequests(config, world, host, now) {
|
|
753
779
|
const notes = [];
|
|
754
780
|
if (!host.feedback)
|
|
755
781
|
return notes;
|
|
@@ -775,11 +801,28 @@ function watchPullRequests(config, world, host) {
|
|
|
775
801
|
ledger,
|
|
776
802
|
snapshot,
|
|
777
803
|
});
|
|
804
|
+
recordOwnedPullRequest(config, {
|
|
805
|
+
jobId: job.jobId,
|
|
806
|
+
repo: job.repo,
|
|
807
|
+
prNumber: job.delivery?.prNumber ?? 0,
|
|
808
|
+
headSha: snapshot.headSha,
|
|
809
|
+
comments: snapshot.comments.map((comment) => ({
|
|
810
|
+
id: comment.id,
|
|
811
|
+
author: comment.author,
|
|
812
|
+
body: comment.body,
|
|
813
|
+
resolved: comment.resolved,
|
|
814
|
+
})),
|
|
815
|
+
reviews: snapshot.reviews.map((review) => ({
|
|
816
|
+
id: review.id,
|
|
817
|
+
author: review.author,
|
|
818
|
+
body: review.body,
|
|
819
|
+
})),
|
|
820
|
+
}, now);
|
|
778
821
|
}
|
|
779
822
|
return notes;
|
|
780
823
|
}
|
|
781
824
|
function shouldWatch(job) {
|
|
782
|
-
if (job.deliver !== "pr" || job.workflow === INVESTIGATION_WORKFLOW)
|
|
825
|
+
if (job.deliver !== "pr" || job.workflow === INVESTIGATION_WORKFLOW || job.workflow === REVIEW_WORKFLOW)
|
|
783
826
|
return false;
|
|
784
827
|
if (job.error || job.feedbackLedgerError || job.feedbackExhausted || job.feedbackAmbiguous)
|
|
785
828
|
return false;
|
|
@@ -1041,7 +1084,9 @@ function launchJob(config, world, job, host, sandboxFacts) {
|
|
|
1041
1084
|
if (workflowNeedsTestCommand(sourceWorkflow) && !testCommand?.trim()) {
|
|
1042
1085
|
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
1086
|
}
|
|
1044
|
-
const workflow = materializeWorkflow(config.kilnDir, job.id, job.workflow
|
|
1087
|
+
const workflow = materializeWorkflow(config.kilnDir, job.id, job.workflow, {
|
|
1088
|
+
...(config.reviewPrompt ? { reviewPrompt: config.reviewPrompt } : {}),
|
|
1089
|
+
});
|
|
1045
1090
|
const logPath = join(config.kilnDir, "logs", `${jobBasename(job.id)}.log`);
|
|
1046
1091
|
mkdirSync(dirname(logPath), { recursive: true });
|
|
1047
1092
|
ensureCheckoutExcludes(repo.path);
|
|
@@ -1053,14 +1098,23 @@ function launchJob(config, world, job, host, sandboxFacts) {
|
|
|
1053
1098
|
if (blocked)
|
|
1054
1099
|
return { jobId: job.id, repo: job.repo, output: blocked };
|
|
1055
1100
|
assertStackTarget(world, job);
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1101
|
+
const pinned = reviewHeadOf(job);
|
|
1102
|
+
if (pinned) {
|
|
1103
|
+
const number = Number(job.source?.ref.split(":")[2]);
|
|
1104
|
+
if (Number.isInteger(number) && number > 0)
|
|
1105
|
+
ensureReviewHead(repo.path, number, pinned);
|
|
1106
|
+
baseSha = resolveCommit(host, repo.path, pinned);
|
|
1107
|
+
}
|
|
1108
|
+
else {
|
|
1109
|
+
baseSha = resolveLaunchBase({
|
|
1110
|
+
checkout: repo.path,
|
|
1111
|
+
deliver: repo.deliver,
|
|
1112
|
+
stack: job.stack === true,
|
|
1113
|
+
...(repo.base ? { configuredBase: repo.base } : {}),
|
|
1114
|
+
...(job.stack ? { upstreamBranch: upstreamLocalBranch(world, job) } : {}),
|
|
1115
|
+
git: host.git,
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1064
1118
|
specText = specHandedToYak(config, world, job);
|
|
1065
1119
|
}
|
|
1066
1120
|
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,12 @@ 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",
|
|
38
|
+
"review.posted",
|
|
39
|
+
"review.failed",
|
|
34
40
|
];
|
|
35
41
|
const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
|
|
36
42
|
const once = z.string().min(1).optional();
|
|
@@ -131,6 +137,57 @@ const BudgetBlocked = z.object({
|
|
|
131
137
|
cap: z.number().nonnegative(),
|
|
132
138
|
once,
|
|
133
139
|
});
|
|
140
|
+
const TriggerFired = z.object({
|
|
141
|
+
trigger_id: z.string().min(1),
|
|
142
|
+
source: z.enum(["schedule", "jira", "pr", "review"]),
|
|
143
|
+
/** Stable dedupe id. Not named `key`: the redactor masks any property with that name. */
|
|
144
|
+
dedupe: z.string().min(1),
|
|
145
|
+
external_id: z.string().min(1),
|
|
146
|
+
revision: z.string().min(1),
|
|
147
|
+
repo: z.string().min(1).optional(),
|
|
148
|
+
title: z.string().min(1).optional(),
|
|
149
|
+
once,
|
|
150
|
+
});
|
|
151
|
+
const TriggerRouted = z.object({
|
|
152
|
+
trigger_id: z.string().min(1),
|
|
153
|
+
dedupe: z.string().min(1),
|
|
154
|
+
action: z.enum(["create", "attach"]),
|
|
155
|
+
workflow: z.string().min(1).optional(),
|
|
156
|
+
repo: z.string().min(1).optional(),
|
|
157
|
+
once,
|
|
158
|
+
});
|
|
159
|
+
const TriggerFailed = z.object({
|
|
160
|
+
trigger_id: z.string().min(1),
|
|
161
|
+
error: z.string().min(1),
|
|
162
|
+
once,
|
|
163
|
+
});
|
|
164
|
+
const TriggerSkipped = z.object({
|
|
165
|
+
trigger_id: z.string().min(1),
|
|
166
|
+
reason: z.enum(["paused", "capped"]),
|
|
167
|
+
once,
|
|
168
|
+
});
|
|
169
|
+
const ReviewPosted = z.object({
|
|
170
|
+
repo: z.string().min(1),
|
|
171
|
+
number: z.number().int().positive(),
|
|
172
|
+
sha: z.string().regex(/^[0-9a-f]{7,64}$/i),
|
|
173
|
+
review_id: z.string().min(1),
|
|
174
|
+
url: z.string().min(1),
|
|
175
|
+
findings: z.number().int().nonnegative(),
|
|
176
|
+
inline: z.number().int().nonnegative(),
|
|
177
|
+
skipped: z.number().int().nonnegative(),
|
|
178
|
+
/** `review:<repo>:<number>:<head sha>`. Same shape as the review-request dedupe. */
|
|
179
|
+
dedupe: z.string().min(1),
|
|
180
|
+
once,
|
|
181
|
+
});
|
|
182
|
+
const ReviewFailed = z.object({
|
|
183
|
+
repo: z.string().min(1),
|
|
184
|
+
number: z.number().int().positive(),
|
|
185
|
+
sha: z.string().regex(/^[0-9a-f]{7,64}$/i),
|
|
186
|
+
class: z.enum(["transient", "deterministic", "budget"]),
|
|
187
|
+
detail: z.string().min(1),
|
|
188
|
+
dedupe: z.string().min(1),
|
|
189
|
+
once,
|
|
190
|
+
});
|
|
134
191
|
const envelope = {
|
|
135
192
|
id: z.string().min(1),
|
|
136
193
|
ts: z.string().regex(ISO_UTC, "ts must be ISO-8601 UTC"),
|
|
@@ -176,6 +233,32 @@ export const EventSchema = z.discriminatedUnion("event", [
|
|
|
176
233
|
job_id: z.string().min(1).nullable(),
|
|
177
234
|
properties: BudgetBlocked,
|
|
178
235
|
}),
|
|
236
|
+
z.object({
|
|
237
|
+
...envelope,
|
|
238
|
+
event: z.literal("trigger.fired"),
|
|
239
|
+
job_id: z.string().min(1).nullable(),
|
|
240
|
+
properties: TriggerFired,
|
|
241
|
+
}),
|
|
242
|
+
z.object({
|
|
243
|
+
...envelope,
|
|
244
|
+
event: z.literal("trigger.routed"),
|
|
245
|
+
job_id: z.string().min(1).nullable(),
|
|
246
|
+
properties: TriggerRouted,
|
|
247
|
+
}),
|
|
248
|
+
z.object({
|
|
249
|
+
...envelope,
|
|
250
|
+
event: z.literal("trigger.failed"),
|
|
251
|
+
job_id: z.string().min(1).nullable(),
|
|
252
|
+
properties: TriggerFailed,
|
|
253
|
+
}),
|
|
254
|
+
z.object({
|
|
255
|
+
...envelope,
|
|
256
|
+
event: z.literal("trigger.skipped"),
|
|
257
|
+
job_id: z.string().min(1).nullable(),
|
|
258
|
+
properties: TriggerSkipped,
|
|
259
|
+
}),
|
|
260
|
+
z.object({ ...envelope, event: z.literal("review.posted"), job_id: z.string().min(1), properties: ReviewPosted }),
|
|
261
|
+
z.object({ ...envelope, event: z.literal("review.failed"), job_id: z.string().min(1), properties: ReviewFailed }),
|
|
179
262
|
]);
|
|
180
263
|
const LOCK_WAIT_MS = 5_000;
|
|
181
264
|
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}`) {
|
|
@@ -121,11 +174,12 @@ export const realGitRemote = {
|
|
|
121
174
|
function ghBin() {
|
|
122
175
|
return process.env.KILN_GH && process.env.KILN_GH.length > 0 ? process.env.KILN_GH : "gh";
|
|
123
176
|
}
|
|
124
|
-
function runGh(checkout, args) {
|
|
177
|
+
function runGh(checkout, args, input) {
|
|
125
178
|
const bin = ghBin();
|
|
126
179
|
const result = spawnSync(bin, args, {
|
|
127
180
|
cwd: checkout,
|
|
128
181
|
encoding: "utf8",
|
|
182
|
+
input,
|
|
129
183
|
env: { ...process.env, GH_PROMPT_DISABLED: "1", GIT_TERMINAL_PROMPT: "0" },
|
|
130
184
|
});
|
|
131
185
|
if (result.error) {
|
|
@@ -251,4 +305,55 @@ export const realGitHub = {
|
|
|
251
305
|
}
|
|
252
306
|
return { state: asState(parsed.state), mergedAt: asMergedAt(parsed.mergedAt) };
|
|
253
307
|
},
|
|
308
|
+
postCommentReview(input) {
|
|
309
|
+
return submitCommentReview(input);
|
|
310
|
+
},
|
|
254
311
|
};
|
|
312
|
+
/**
|
|
313
|
+
* `POST /repos/{owner}/{repo}/pulls/{number}/reviews` with `event: COMMENT`.
|
|
314
|
+
* One call submits the review. There is no pending-review step, no approve,
|
|
315
|
+
* and no merge.
|
|
316
|
+
*/
|
|
317
|
+
export function submitCommentReview(input) {
|
|
318
|
+
const repo = githubOwnerRepo(input.checkout);
|
|
319
|
+
const event = "COMMENT";
|
|
320
|
+
const payload = {
|
|
321
|
+
commit_id: input.commitId,
|
|
322
|
+
body: input.body,
|
|
323
|
+
event,
|
|
324
|
+
};
|
|
325
|
+
if (input.comments.length > 0) {
|
|
326
|
+
payload.comments = input.comments.map((comment) => ({
|
|
327
|
+
path: comment.path,
|
|
328
|
+
body: comment.body,
|
|
329
|
+
line: comment.line,
|
|
330
|
+
side: "RIGHT",
|
|
331
|
+
}));
|
|
332
|
+
}
|
|
333
|
+
const result = runGh(input.checkout, ["api", "--method", "POST", `repos/${repo.owner}/${repo.name}/pulls/${input.number}/reviews`, "--input", "-"], JSON.stringify(payload));
|
|
334
|
+
if (result.status !== 0) {
|
|
335
|
+
const detail = (result.stderr || result.stdout).trim() || "gh api failed";
|
|
336
|
+
throw new HostError(`Could not post the pull request review: ${detail}`);
|
|
337
|
+
}
|
|
338
|
+
let parsed;
|
|
339
|
+
try {
|
|
340
|
+
parsed = JSON.parse(result.stdout);
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
throw new HostError("gh api returned data that is not a pull request review.");
|
|
344
|
+
}
|
|
345
|
+
const id = typeof parsed.id === "number" || typeof parsed.id === "string" ? String(parsed.id) : "";
|
|
346
|
+
const url = typeof parsed.html_url === "string" ? parsed.html_url.trim() : "";
|
|
347
|
+
if (!id || !url)
|
|
348
|
+
throw new HostError("gh api did not return a review id and URL.");
|
|
349
|
+
return { id, url };
|
|
350
|
+
}
|
|
351
|
+
function githubOwnerRepo(checkout) {
|
|
352
|
+
const result = runGh(checkout, ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]);
|
|
353
|
+
const name = result.stdout.trim();
|
|
354
|
+
const [owner, repo] = name.split("/");
|
|
355
|
+
if (result.status !== 0 || !owner || !repo || name.split("/").length !== 2) {
|
|
356
|
+
throw new HostError(`Could not read owner/name for the review: ${(result.stderr || result.stdout).trim() || name || "gh repo view failed"}`);
|
|
357
|
+
}
|
|
358
|
+
return { owner, name: repo };
|
|
359
|
+
}
|