@lchase/kiln 0.12.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
@@ -277,7 +277,7 @@ A cron fires once per slot. While serve was down, startup fires the latest misse
277
277
 
278
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
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`.
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
281
 
282
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
283
 
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
@@ -63,7 +63,9 @@ function usage() {
63
63
  "yak/<run-id> and opens a draft pull request. An investigation is done when",
64
64
  "its gate is approved. It does not open a pull request. status prints the",
65
65
  "derived state, and the findings path once an investigation is approved.",
66
- "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",
67
69
  "job again, the same way tick launches, and clears the attempt ledger.",
68
70
  "A landed job that only failed to open its pull request is delivered again.",
69
71
  "A running job with no yak event or log output for stallTimeoutMs (default",
package/dist/driver.js CHANGED
@@ -18,6 +18,7 @@ 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";
@@ -97,6 +98,19 @@ export function answerJob(config, jobId, choice, host = defaultHost()) {
97
98
  if (!gate) {
98
99
  throw new Error(`Job ${jobId} has no open gate that accepts ${choice}.`);
99
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
+ }
100
114
  const command = gateAnswerCommand(job.run.checkout, job.run.runId, gate, choice, job.run.title ?? job.title);
101
115
  const result = spawnSync("sh", ["-c", command], { encoding: "utf8" });
102
116
  const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
@@ -321,6 +335,9 @@ function observe(config, host, now = new Date()) {
321
335
  const findingsPath = entry.job?.workflow === INVESTIGATION_WORKFLOW
322
336
  ? existingFindingsPath(config.kilnDir, entry.job.id)
323
337
  : undefined;
338
+ const reviewPost = entry.job
339
+ ? reviewPostForJob(events, { jobId: entry.job.id, repo: entry.job.repo, source: entry.job.source })
340
+ : {};
324
341
  const logTail = readLogTail(logPath);
325
342
  const stepPlan = entry.job ? workflowStepPlan(entry.job.workflow) : undefined;
326
343
  const diffSummary = run &&
@@ -340,6 +357,8 @@ function observe(config, host, now = new Date()) {
340
357
  ...(entry.job?.stack ? { stack: true } : {}),
341
358
  ...(delivery ? { delivery } : {}),
342
359
  ...(findingsPath ? { findingsPath } : {}),
360
+ ...(reviewPost.error ? { reviewPostError: reviewPost.error } : {}),
361
+ ...(reviewPost.url ? { reviewUrl: reviewPost.url } : {}),
343
362
  launching,
344
363
  launchFailed,
345
364
  ...(launchError ? { launchError } : {}),
package/dist/events.js CHANGED
@@ -35,6 +35,8 @@ export const EVENT_NAMES = [
35
35
  "trigger.routed",
36
36
  "trigger.failed",
37
37
  "trigger.skipped",
38
+ "review.posted",
39
+ "review.failed",
38
40
  ];
39
41
  const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
40
42
  const once = z.string().min(1).optional();
@@ -164,6 +166,28 @@ const TriggerSkipped = z.object({
164
166
  reason: z.enum(["paused", "capped"]),
165
167
  once,
166
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
+ });
167
191
  const envelope = {
168
192
  id: z.string().min(1),
169
193
  ts: z.string().regex(ISO_UTC, "ts must be ISO-8601 UTC"),
@@ -233,6 +257,8 @@ export const EventSchema = z.discriminatedUnion("event", [
233
257
  job_id: z.string().min(1).nullable(),
234
258
  properties: TriggerSkipped,
235
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 }),
236
262
  ]);
237
263
  const LOCK_WAIT_MS = 5_000;
238
264
  const pauseBuf = new Int32Array(new SharedArrayBuffer(4));
package/dist/host.js CHANGED
@@ -174,11 +174,12 @@ export const realGitRemote = {
174
174
  function ghBin() {
175
175
  return process.env.KILN_GH && process.env.KILN_GH.length > 0 ? process.env.KILN_GH : "gh";
176
176
  }
177
- function runGh(checkout, args) {
177
+ function runGh(checkout, args, input) {
178
178
  const bin = ghBin();
179
179
  const result = spawnSync(bin, args, {
180
180
  cwd: checkout,
181
181
  encoding: "utf8",
182
+ input,
182
183
  env: { ...process.env, GH_PROMPT_DISABLED: "1", GIT_TERMINAL_PROMPT: "0" },
183
184
  });
184
185
  if (result.error) {
@@ -304,4 +305,55 @@ export const realGitHub = {
304
305
  }
305
306
  return { state: asState(parsed.state), mergedAt: asMergedAt(parsed.mergedAt) };
306
307
  },
308
+ postCommentReview(input) {
309
+ return submitCommentReview(input);
310
+ },
307
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
+ }
package/dist/report.js CHANGED
@@ -132,6 +132,10 @@ export function statusDetails(job) {
132
132
  lines.push(`findings ${job.findingsPath}`);
133
133
  else if (job.workflow === INVESTIGATION_WORKFLOW && job.state === "done")
134
134
  lines.push("findings missing");
135
+ if (job.reviewUrl)
136
+ lines.push(`review ${job.reviewUrl}`);
137
+ if (job.reviewPostError)
138
+ lines.push(job.reviewPostError);
135
139
  if (job.prPending)
136
140
  lines.push("pull request not opened yet");
137
141
  if (job.state === "failed" && job.deliveryError)
@@ -222,6 +226,8 @@ function toStatusJob(job, observation) {
222
226
  ...(job.specPath ? { specPath: job.specPath } : {}),
223
227
  ...(job.logPath ? { logPath: job.logPath } : {}),
224
228
  ...(job.findingsPath ? { findingsPath: job.findingsPath } : {}),
229
+ ...(job.reviewPostError ? { reviewPostError: job.reviewPostError } : {}),
230
+ ...(job.reviewUrl ? { reviewUrl: job.reviewUrl } : {}),
225
231
  launchFailed: job.launchFailed,
226
232
  attemptCount: job.attemptCount ?? 0,
227
233
  maxAttempts: job.attemptCap ?? observation.maxAttempts,
@@ -270,6 +276,8 @@ function toStatusJob(job, observation) {
270
276
  ...(job.logPath ? { logPath: job.logPath } : {}),
271
277
  ...(job.specPath ? { specPath: job.specPath } : {}),
272
278
  ...(job.findingsPath ? { findingsPath: job.findingsPath } : {}),
279
+ ...(job.reviewPostError ? { reviewPostError: job.reviewPostError } : {}),
280
+ ...(job.reviewUrl ? { reviewUrl: job.reviewUrl } : {}),
273
281
  launchFailed: job.launchFailed,
274
282
  attemptCount: job.attemptCount ?? 0,
275
283
  maxAttempts: job.attemptCap ?? observation.maxAttempts,
@@ -0,0 +1,269 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { appendEvent, readEvents } from "./events.js";
4
+ import { classifyFailureText } from "./failure.js";
5
+ import { realGitHub } from "./host.js";
6
+ import { redact } from "./redact.js";
7
+ import { parseReviewSource } from "./triggers.js";
8
+ /**
9
+ * A review draft the human has not posted yet, and the COMMENT review Kiln
10
+ * writes after they approve the Inbox gate.
11
+ *
12
+ * The review event is always COMMENT. Kiln does not approve, request
13
+ * changes, or merge. A second approve at the same `review:<repo>:<number>:<sha>`
14
+ * does not post again.
15
+ */
16
+ export const REVIEW_SEVERITIES = ["note", "nit", "warning", "blocking"];
17
+ const NEEDS_HUMAN_REASON = "review-post";
18
+ /** Target of a review-request job, or undefined when the draft is not tied to a pull request. */
19
+ export function reviewTargetFrom(repo, sourceRef) {
20
+ if (!sourceRef)
21
+ return undefined;
22
+ const parsed = parseReviewSource(sourceRef);
23
+ if (!parsed || parsed.repo !== repo)
24
+ return undefined;
25
+ return {
26
+ repo: parsed.repo,
27
+ number: parsed.number,
28
+ sha: parsed.sha,
29
+ dedupe: `review:${parsed.repo}:${parsed.number}:${parsed.sha}`,
30
+ };
31
+ }
32
+ export function reviewAlreadyPosted(events, dedupe) {
33
+ return events.some((event) => event.event === "review.posted" && event.properties.dedupe === dedupe);
34
+ }
35
+ /**
36
+ * Latest post for this job. A `review.posted` for the same dedupe, including
37
+ * one recorded on another job, counts as posted.
38
+ */
39
+ export function reviewPostForJob(events, job) {
40
+ const dedupe = job.source?.kind === "trigger" ? reviewTargetFrom(job.repo, job.source.ref)?.dedupe : undefined;
41
+ let url;
42
+ let error;
43
+ for (const event of events) {
44
+ const posted = event.event === "review.posted" &&
45
+ (event.job_id === job.jobId || (dedupe !== undefined && event.properties.dedupe === dedupe));
46
+ if (posted) {
47
+ url = event.properties.url;
48
+ error = undefined;
49
+ continue;
50
+ }
51
+ if (event.event === "review.failed" && event.job_id === job.jobId) {
52
+ url = undefined;
53
+ error = `Could not post the review (${event.properties.class}). ${event.properties.detail}`;
54
+ }
55
+ }
56
+ return { ...(url ? { url } : {}), ...(error ? { error } : {}) };
57
+ }
58
+ /** The draft step's artifact. Missing or unreadable is undefined. Extra fields are ignored. */
59
+ export function readReviewDraft(checkout, runId) {
60
+ const path = join(checkout, ".runs", runId, "artifacts", "draft.json");
61
+ if (!existsSync(path))
62
+ return undefined;
63
+ try {
64
+ return parseReviewDraft(JSON.parse(readFileSync(path, "utf8")));
65
+ }
66
+ catch {
67
+ return undefined;
68
+ }
69
+ }
70
+ export function parseReviewDraft(value) {
71
+ if (typeof value !== "object" || value === null)
72
+ return undefined;
73
+ const markdown = value.markdown;
74
+ if (typeof markdown !== "string" || !markdown.trim())
75
+ return undefined;
76
+ const raw = value.findings;
77
+ const findings = [];
78
+ if (Array.isArray(raw)) {
79
+ for (const item of raw) {
80
+ const finding = parseFinding(item);
81
+ if (finding)
82
+ findings.push(finding);
83
+ }
84
+ }
85
+ return { markdown: markdown.trim(), findings };
86
+ }
87
+ /**
88
+ * Body is the draft markdown. Findings with a real file and line become
89
+ * inline comments. Anything else is folded into the body when the markdown
90
+ * does not already contain it. Line numbers are never invented.
91
+ */
92
+ export function buildCommentReview(draft) {
93
+ const comments = [];
94
+ const skipped = [];
95
+ for (const finding of draft.findings) {
96
+ const path = commentPath(finding.file);
97
+ const line = commentLine(finding.line);
98
+ if (!path || line === undefined) {
99
+ skipped.push(finding);
100
+ continue;
101
+ }
102
+ comments.push({ path, line, body: commentText(finding) });
103
+ }
104
+ return {
105
+ body: redact(foldSkipped(draft.markdown, skipped)),
106
+ comments: comments.map((comment) => ({ ...comment, body: redact(comment.body) })),
107
+ findings: draft.findings.length,
108
+ inline: comments.length,
109
+ skipped: skipped.length,
110
+ };
111
+ }
112
+ /**
113
+ * Post the approved draft, or do nothing when that head SHA was already
114
+ * posted. Throws without calling gh when the factory is paused, the daily
115
+ * cap is blocking, or the post fails. The caller leaves the gate open on throw.
116
+ */
117
+ export function postApprovedReview(input) {
118
+ const target = reviewTargetFrom(input.repo, input.sourceRef);
119
+ if (!target) {
120
+ recordNeedsHuman(input);
121
+ throw new Error("This review draft is not tied to a pull request, so Kiln will not post it.");
122
+ }
123
+ const events = readEvents(input.kilnDir, { warn: () => { } });
124
+ if (reviewAlreadyPosted(events, target.dedupe))
125
+ return;
126
+ if (input.paused) {
127
+ throw new Error("Paused. Kiln will not post this review until kiln resume.");
128
+ }
129
+ if (input.spendBlocked) {
130
+ throw new Error("Daily cap reached. Kiln will not post this review until the local day rolls over.");
131
+ }
132
+ const draft = readReviewDraft(input.checkout, input.runId);
133
+ if (!draft) {
134
+ failPost(input, target, "deterministic", "The draft artifact is missing or unreadable.");
135
+ }
136
+ const review = buildCommentReview(draft);
137
+ const poster = input.host.github.postCommentReview ?? realGitHub.postCommentReview;
138
+ if (!poster) {
139
+ failPost(input, target, "deterministic", "gh cannot post a review from this host.");
140
+ }
141
+ let posted;
142
+ try {
143
+ posted = poster.call(input.host.github, {
144
+ checkout: input.checkout,
145
+ number: target.number,
146
+ commitId: target.sha,
147
+ body: review.body,
148
+ comments: review.comments,
149
+ });
150
+ }
151
+ catch (error) {
152
+ const detail = publicDetail(error);
153
+ failPost(input, target, classifyFailureText(detail), detail);
154
+ }
155
+ appendEvent(input.kilnDir, {
156
+ job_id: input.jobId,
157
+ run_id: input.runId,
158
+ event: "review.posted",
159
+ step: "approve",
160
+ properties: {
161
+ repo: target.repo,
162
+ number: target.number,
163
+ sha: target.sha,
164
+ review_id: posted.id,
165
+ url: posted.url,
166
+ findings: review.findings,
167
+ inline: review.inline,
168
+ skipped: review.skipped,
169
+ dedupe: target.dedupe,
170
+ once: `posted:${target.dedupe}`,
171
+ },
172
+ });
173
+ }
174
+ function failPost(input, target, failureClass, detail) {
175
+ const clipped = detail.trim() || "The review was not posted.";
176
+ appendEvent(input.kilnDir, {
177
+ job_id: input.jobId,
178
+ run_id: input.runId,
179
+ event: "review.failed",
180
+ step: "approve",
181
+ properties: {
182
+ repo: target.repo,
183
+ number: target.number,
184
+ sha: target.sha,
185
+ class: failureClass,
186
+ detail: clipped,
187
+ dedupe: target.dedupe,
188
+ },
189
+ });
190
+ recordNeedsHuman(input);
191
+ throw new Error(`Could not post the review (${failureClass}). ${clipped}`);
192
+ }
193
+ function recordNeedsHuman(input) {
194
+ appendEvent(input.kilnDir, {
195
+ job_id: input.jobId,
196
+ run_id: input.runId,
197
+ event: "needs-human",
198
+ step: "approve",
199
+ properties: { reason: NEEDS_HUMAN_REASON, once: `needs-human:${NEEDS_HUMAN_REASON}` },
200
+ });
201
+ }
202
+ function publicDetail(error) {
203
+ const message = error instanceof Error ? error.message : String(error);
204
+ const cleaned = redact(message).replace(/\s+/g, " ").trim();
205
+ if (cleaned.length > 500)
206
+ return `${cleaned.slice(0, 499)}…`;
207
+ return cleaned || "The review was not posted.";
208
+ }
209
+ function parseFinding(value) {
210
+ if (typeof value !== "object" || value === null)
211
+ return undefined;
212
+ const record = value;
213
+ if (typeof record.comment !== "string" || !record.comment.trim())
214
+ return undefined;
215
+ const severity = REVIEW_SEVERITIES.find((item) => item === record.severity) ?? "note";
216
+ const file = typeof record.file === "string" ? record.file : undefined;
217
+ const line = commentLine(record.line);
218
+ return {
219
+ severity,
220
+ comment: record.comment.trim(),
221
+ ...(file !== undefined ? { file } : {}),
222
+ ...(line !== undefined ? { line } : {}),
223
+ };
224
+ }
225
+ /** A relative path inside the repo. Absolute paths and `..` are not locations. A leading `./` is a normal relative path. */
226
+ export function commentPath(file) {
227
+ if (!file)
228
+ return undefined;
229
+ const raw = file.trim().replace(/\\/g, "/");
230
+ if (!raw || raw.startsWith("/") || raw.startsWith("~") || /^[A-Za-z]:/.test(raw))
231
+ return undefined;
232
+ if (/[\0\r\n]/.test(raw))
233
+ return undefined;
234
+ const parts = raw.split("/");
235
+ if (parts.some((part) => part === "" || part === ".."))
236
+ return undefined;
237
+ const cleaned = parts.filter((part) => part !== ".");
238
+ if (cleaned.length === 0)
239
+ return undefined;
240
+ return cleaned.join("/");
241
+ }
242
+ function commentLine(line) {
243
+ if (typeof line !== "number" || !Number.isInteger(line) || line < 1)
244
+ return undefined;
245
+ return line;
246
+ }
247
+ function commentText(finding) {
248
+ const text = finding.comment.trim();
249
+ if (finding.severity === "nit")
250
+ return `**nit** (not blocking)\n\n${text}`;
251
+ return `**${finding.severity}**\n\n${text}`;
252
+ }
253
+ function foldSkipped(markdown, skipped) {
254
+ const base = markdown.trim();
255
+ const extra = skipped.filter((finding) => !base.includes(finding.comment.trim()));
256
+ if (extra.length === 0)
257
+ return `${base}\n`;
258
+ const lines = [
259
+ base,
260
+ "",
261
+ "## Not posted on a line",
262
+ "",
263
+ "These findings had no file and line Kiln could check, so they are only in this review.",
264
+ "",
265
+ ];
266
+ for (const finding of extra)
267
+ lines.push(`- **${finding.severity}** ${finding.comment.trim()}`);
268
+ return `${lines.join("\n")}\n`;
269
+ }
@@ -137,7 +137,7 @@ function triggersHtml(rows) {
137
137
  const body = rows.length === 0
138
138
  ? `<p class="empty">No triggers.</p>`
139
139
  : rows.map((row) => triggerCard(row)).join("\n");
140
- return `<header class="list-head"><h2>Triggers</h2><p class="meta">The clock, Jira, and pull request activity can start work. Kiln does not post, approve, or merge.</p><p class="form-error" id="trigger-error" role="alert" hidden></p></header>${body}`;
140
+ return `<header class="list-head"><h2>Triggers</h2><p class="meta">The clock, Jira, and pull request activity can start work. A review is posted only after you approve it. Kiln does not approve or merge.</p><p class="form-error" id="trigger-error" role="alert" hidden></p></header>${body}`;
141
141
  }
142
142
  function triggerCard(row) {
143
143
  const on = row.enabled;
@@ -166,7 +166,7 @@ function listHtml(model, csrf) {
166
166
  return `<header class="list-head"><h2>Inbox</h2><p class="meta">Approve, reject, or revise. Kiln does not do this on its own.</p></header>${jobList(board.inbox, "inbox", "Nothing is waiting on a gate.", csrf)}`;
167
167
  }
168
168
  if (model.section === "hand") {
169
- return `<header class="list-head"><h2>Needs a hand</h2><p class="meta">A breaker tripped, a cap was hit, or a run failed with no retry left.</p></header>${jobList(board.hand, "hand", "Nothing needs a person.", csrf)}`;
169
+ return `<header class="list-head"><h2>Needs a hand</h2><p class="meta">A breaker tripped, a cap was hit, a review could not be posted, or a run failed with no retry left.</p></header>${jobList(board.hand, "hand", "Nothing needs a person.", csrf)}`;
170
170
  }
171
171
  if (model.section === "history") {
172
172
  return `<header class="list-head"><h2>History</h2></header>${jobList(board.history, "history", "No finished jobs.", csrf)}`;
@@ -266,7 +266,7 @@ function dependsHtml(job) {
266
266
  return `<p class="deps">depends on ${links}</p>${detail ? `<p class="meta">${escapeHtml(detail)}</p>` : ""}`;
267
267
  }
268
268
  function actionsHtml(job, section, csrf) {
269
- const showGate = section === "inbox" || section === "job";
269
+ const showGate = section === "inbox" || section === "job" || (section === "hand" && Boolean(job.reviewPostError));
270
270
  const showRetry = section === "hand" || section === "repos" || section === "job";
271
271
  if (!showGate && !showRetry)
272
272
  return "";
package/dist/triggers.js CHANGED
@@ -34,6 +34,16 @@ const BUILTIN_ROUTES = [
34
34
  { id: PR_ACTIVITY_TRIGGER, trigger: PR_ACTIVITY_TRIGGER, action: "attach" },
35
35
  ];
36
36
  const REVIEW_REF = /^review:([^:]+):(\d+):([0-9a-f]{7,64})$/i;
37
+ /** `review:<repo>:<number>:<head sha>` from a review-request job. SHA is lowercase. */
38
+ export function parseReviewSource(ref) {
39
+ const match = REVIEW_REF.exec(ref);
40
+ const repo = match?.[1];
41
+ const number = Number(match?.[2]);
42
+ const sha = match?.[3]?.toLowerCase();
43
+ if (!repo || !sha || !Number.isInteger(number) || number <= 0)
44
+ return undefined;
45
+ return { repo, number, sha };
46
+ }
37
47
  export function effectiveTriggers(file) {
38
48
  const byId = new Map();
39
49
  for (const trigger of BUILTIN_TRIGGERS)
@@ -234,7 +244,7 @@ export function jiraIssueEvent(triggerId, repo, issue) {
234
244
  export function reviewHeadOf(job) {
235
245
  if (job.workflow !== REVIEW_WORKFLOW || job.source?.kind !== "trigger")
236
246
  return undefined;
237
- return REVIEW_REF.exec(job.source.ref)?.[3];
247
+ return parseReviewSource(job.source.ref)?.sha;
238
248
  }
239
249
  /** Download the pull request head when the checkout does not have that commit yet. */
240
250
  export function ensureReviewHead(checkout, prNumber, sha) {
@@ -490,7 +500,7 @@ function reviewSpec(input) {
490
500
  if (input.url)
491
501
  lines.push(`Pull request: ${input.url}`);
492
502
  lines.push(`Number: ${input.number}`, `Head: ${input.sha}`, `Base: ${input.base}`, "");
493
- lines.push("Read the change and return a draft review.", "Do not post, approve, merge, or push.", "Approving the gate keeps the draft. Kiln does not post it to GitHub.", "");
503
+ lines.push("Read the change and return a draft review.", "Do not post, approve, merge, or push.", "Approving the gate posts this draft as a pull request comment. Kiln does not approve the pull request or merge it.", "");
494
504
  return lines.join("\n");
495
505
  }
496
506
  function appendFire(config, event, now) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lchase/kiln",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Run a workflow on any registered repo. A markdown spec is the input.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  Kiln runs this review inside yak. The working directory is an isolated worktree at `{{input.baseRef}}`. That commit is the pull request head. Do not guess a branch name.
2
2
 
3
- Read the change. Return a draft review and nothing else. Do not post the review, approve the pull request, merge it, push, or comment. Do not change product code. Do not create, modify, or delete files. Read-only git and file inspection are allowed.
3
+ Read the change. Return a draft review and nothing else. Do not post the review, approve the pull request, merge it, push, or comment. Kiln posts the draft only after a person approves the gate, and only as a comment. Do not change product code. Do not create, modify, or delete files. Read-only git and file inspection are allowed.
4
4
 
5
5
  Return JSON with `markdown` and `findings`. `findings` is a list. Each item has `severity` (`note`, `nit`, `warning`, or `blocking`), `file`, `line`, and `comment`. `comment` is the text a person might post later. `markdown` is the whole draft a person will read, and it must include every finding with its severity, file, line, and suggested comment. If you find nothing, return an empty `findings` list and say so in `markdown`.
6
6
 
@@ -1,9 +1,9 @@
1
1
  # Draft review of one pull request, run by yak (@lchase/yak).
2
2
  #
3
3
  # The agent returns findings (severity, file, line, suggested comment).
4
- # A person approves or rejects the draft in the Inbox. Approving does not
5
- # post, approve, or merge on GitHub: Kiln has no gated posting path for a
6
- # review comment, so the draft stops here.
4
+ # A person approves or rejects the draft in the Inbox. Approving posts the
5
+ # draft as a pull request comment. Kiln does not approve the pull request
6
+ # or merge it. Rejecting or revising does not post.
7
7
  #
8
8
  # `reviewPrompt` in the kiln config replaces prompts/kiln/review.md.
9
9
  # The packaged file is the generic default.
@@ -60,5 +60,5 @@ steps:
60
60
  decision: { type: string, enum: [approve, reject] }
61
61
  required: [decision]
62
62
  render:
63
- inline: "Draft review for \"{{input.title}}\"?\n\nApproving keeps this draft. Kiln does not post it to GitHub.\n\n{{draft.markdown}}"
63
+ inline: "Draft review for \"{{input.title}}\"?\n\nApproving posts this draft as a pull request comment. Kiln does not approve the pull request or merge it.\n\n{{draft.markdown}}"
64
64
  produces: approval