@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 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")
@@ -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. retry launches a failed or dead",
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 { leafStepIds, materializeWorkflow, workflowInputs, workflowNeedsTestCommand, workflowStepPlan } from "./workflow.js";
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
- baseSha = resolveLaunchBase({
1057
- checkout: repo.path,
1058
- deliver: repo.deliver,
1059
- stack: job.stack === true,
1060
- ...(repo.base ? { configuredBase: repo.base } : {}),
1061
- ...(job.stack ? { upstreamBranch: upstreamLocalBranch(world, job) } : {}),
1062
- git: host.git,
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`, and `budget.blocked`,
13
- * which are not a job.
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
+ }