@lchase/kiln 0.12.0 → 0.14.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/dist/triggers.js CHANGED
@@ -1,12 +1,16 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ import { loadKilnConfig } from "./config.js";
4
5
  import { appendEvent, readEvents } from "./events.js";
5
6
  import { isIgnoredAuthor } from "./feedback.js";
7
+ import { DEFAULT_REVIEW_LABEL } from "./host.js";
6
8
  import { searchJiraIssues } from "./intake/jira.js";
7
9
  import { listValidJobs, writeJob } from "./jobs.js";
10
+ import { jobBasename } from "./paths.js";
8
11
  import { pausedFrom, spendSnapshot } from "./rails.js";
9
12
  import { redact } from "./redact.js";
13
+ import { isPidAlive } from "./reliability.js";
10
14
  import { ISSUE_KEY } from "./schemas.js";
11
15
  import { REVIEW_WORKFLOW } from "./workflow.js";
12
16
  /**
@@ -14,7 +18,8 @@ import { REVIEW_WORKFLOW } from "./workflow.js";
14
18
  * A trigger writes a typed event. The router creates a job or attaches to
15
19
  * one. The dedupe key lives on that event, so a restart does not create
16
20
  * the job twice. A failed fire records `last_error` and waits for the next
17
- * slot. Pause and the daily cap record a skip. Nothing is queued.
21
+ * slot. Pause, the daily cap, and the review burst caps record a skip.
22
+ * Nothing over the cap is queued.
18
23
  *
19
24
  * Jira is polled. `jiraIssueEvent` is the seam a webhook can call later.
20
25
  * Pull-request activity reuses the feedback loop's snapshot. It does not
@@ -25,8 +30,20 @@ export const PR_ACTIVITY_TRIGGER = "pr-activity";
25
30
  /** Minute 0 of every hour, local time. */
26
31
  export const DEFAULT_REVIEW_CRON = "0 * * * *";
27
32
  export const DEFAULT_JIRA_EVERY = "5m";
33
+ /** New review jobs one fire may create. Further pull requests are skipped. */
34
+ export const DEFAULT_REVIEW_MAX_NEW = 3;
35
+ /** Queued or running review jobs at once. A review waiting in the Inbox does not count. */
36
+ export const DEFAULT_REVIEW_MAX_QUEUED = 5;
28
37
  const BUILTIN_TRIGGERS = [
29
- { id: REVIEW_REQUESTS_TRIGGER, kind: "schedule", cron: DEFAULT_REVIEW_CRON, enabled: true },
38
+ {
39
+ id: REVIEW_REQUESTS_TRIGGER,
40
+ kind: "schedule",
41
+ cron: DEFAULT_REVIEW_CRON,
42
+ enabled: true,
43
+ label: DEFAULT_REVIEW_LABEL,
44
+ maxNew: DEFAULT_REVIEW_MAX_NEW,
45
+ maxQueued: DEFAULT_REVIEW_MAX_QUEUED,
46
+ },
30
47
  { id: PR_ACTIVITY_TRIGGER, kind: "pr", enabled: true },
31
48
  ];
32
49
  const BUILTIN_ROUTES = [
@@ -34,6 +51,16 @@ const BUILTIN_ROUTES = [
34
51
  { id: PR_ACTIVITY_TRIGGER, trigger: PR_ACTIVITY_TRIGGER, action: "attach" },
35
52
  ];
36
53
  const REVIEW_REF = /^review:([^:]+):(\d+):([0-9a-f]{7,64})$/i;
54
+ /** `review:<repo>:<number>:<head sha>` from a review-request job. SHA is lowercase. */
55
+ export function parseReviewSource(ref) {
56
+ const match = REVIEW_REF.exec(ref);
57
+ const repo = match?.[1];
58
+ const number = Number(match?.[2]);
59
+ const sha = match?.[3]?.toLowerCase();
60
+ if (!repo || !sha || !Number.isInteger(number) || number <= 0)
61
+ return undefined;
62
+ return { repo, number, sha };
63
+ }
37
64
  export function effectiveTriggers(file) {
38
65
  const byId = new Map();
39
66
  for (const trigger of BUILTIN_TRIGGERS)
@@ -234,7 +261,7 @@ export function jiraIssueEvent(triggerId, repo, issue) {
234
261
  export function reviewHeadOf(job) {
235
262
  if (job.workflow !== REVIEW_WORKFLOW || job.source?.kind !== "trigger")
236
263
  return undefined;
237
- return REVIEW_REF.exec(job.source.ref)?.[3];
264
+ return parseReviewSource(job.source.ref)?.sha;
238
265
  }
239
266
  /** Download the pull request head when the checkout does not have that commit yet. */
240
267
  export function ensureReviewHead(checkout, prNumber, sha) {
@@ -304,11 +331,21 @@ export function triggerRows(config, events, now = new Date()) {
304
331
  event: event.event,
305
332
  summary: triggerSummary(event),
306
333
  }));
334
+ const review = trigger.id === REVIEW_REQUESTS_TRIGGER;
335
+ const repoLabels = review ? repoReviewLabels(config) : [];
307
336
  return {
308
337
  id: trigger.id,
309
338
  kind: trigger.kind,
310
339
  enabled: trigger.enabled !== false,
311
340
  schedule: scheduleLabel(trigger),
341
+ ...(review
342
+ ? {
343
+ label: reviewRequestLabel(trigger),
344
+ maxNew: reviewMaxNew(trigger),
345
+ maxQueued: reviewMaxQueued(trigger),
346
+ ...(repoLabels.length > 0 ? { repoLabels } : {}),
347
+ }
348
+ : {}),
312
349
  ...(nextFire(trigger, state, now) ? { nextFire: nextFire(trigger, state, now) } : {}),
313
350
  ...(state?.lastFire && Number.isFinite(Date.parse(state.lastFire)) ? { lastFire: state.lastFire } : {}),
314
351
  ...(state?.lastError ? { lastError: state.lastError } : {}),
@@ -336,14 +373,21 @@ function runSchedule(input, trigger, routes, now, startup) {
336
373
  if (reviews) {
337
374
  if (!input.reviews)
338
375
  return;
376
+ // A disable that landed after this process started, including during the
377
+ // startup list, must not create jobs. The Triggers page reads the file,
378
+ // so it can already say disabled while this fire is still in the list.
379
+ if (!triggerEnabledNow(input.config, trigger.id))
380
+ return;
339
381
  let requests;
340
382
  try {
341
- requests = input.reviews.list(Object.entries(input.config.repos).map(([name, repo]) => ({ name, path: repo.path })));
383
+ requests = input.reviews.list(reviewTargets(input.config, trigger));
342
384
  }
343
385
  catch (error) {
344
386
  failSlot(input.config, trigger.id, stamp, errorMessage(error), now);
345
387
  return;
346
388
  }
389
+ if (!triggerEnabledNow(input.config, trigger.id))
390
+ return;
347
391
  succeedSlot(input.config.kilnDir, trigger.id, stamp);
348
392
  appendFire(input.config, {
349
393
  key: `schedule:${trigger.id}:${stamp}`,
@@ -352,17 +396,40 @@ function runSchedule(input, trigger, routes, now, startup) {
352
396
  id: stamp,
353
397
  revision: stamp,
354
398
  });
399
+ const maxNew = reviewMaxNew(trigger);
400
+ const maxQueued = reviewMaxQueued(trigger);
401
+ let created = 0;
402
+ let active = reviewSlotsInUse(input.config);
355
403
  for (const request of requests) {
404
+ if (!triggerEnabledNow(input.config, trigger.id))
405
+ break;
406
+ const key = reviewDedupeKey(request);
407
+ if (reviewDrafted(input.config, key)) {
408
+ try {
409
+ ingestReview(input.config, request, routes, now);
410
+ }
411
+ catch (error) {
412
+ noteReviewFailure(input.config, trigger.id, stamp, request, error, now);
413
+ }
414
+ continue;
415
+ }
416
+ if (active >= maxQueued) {
417
+ noteSkip(input.config, trigger.id, "queued-cap", stamp, now, key);
418
+ continue;
419
+ }
420
+ if (created >= maxNew) {
421
+ noteSkip(input.config, trigger.id, "burst-cap", stamp, now, key);
422
+ continue;
423
+ }
356
424
  try {
357
- ingestReview(input.config, request, routes, now);
425
+ const outcome = ingestReview(input.config, request, routes, now);
426
+ if (outcome === "created") {
427
+ created += 1;
428
+ active += 1;
429
+ }
358
430
  }
359
431
  catch (error) {
360
- remember(input.config.kilnDir, trigger.id, {
361
- lastFire: stamp,
362
- lastError: publicError(errorMessage(error)),
363
- lastErrorAt: now.toISOString(),
364
- });
365
- appendFailed(input.config, trigger.id, publicError(errorMessage(error)), `review:${request.repo}:${request.number}`, now);
432
+ noteReviewFailure(input.config, trigger.id, stamp, request, error, now);
366
433
  }
367
434
  }
368
435
  return;
@@ -390,8 +457,8 @@ function ingestReview(config, request, routes, now) {
390
457
  const base = repo.base ?? "the default branch";
391
458
  const url = httpUrl(request.url);
392
459
  const title = oneLine(request.title) || `PR ${request.number}`;
393
- ingest(config, {
394
- key: `review:${request.repo}:${request.number}:${sha}`,
460
+ return ingest(config, {
461
+ key: reviewDedupeKey(request),
395
462
  source: "review",
396
463
  triggerId: REVIEW_REQUESTS_TRIGGER,
397
464
  id: String(request.number),
@@ -455,20 +522,20 @@ function ingest(config, event, routes, now) {
455
522
  const routed = routedKeys(events);
456
523
  const decision = routeTrigger(event, routes, routed);
457
524
  if (decision.type === "ignore" || decision.type === "deduped")
458
- return;
525
+ return "ignored";
459
526
  if (decision.type === "unrouted") {
460
527
  noteConfigError(config, event.triggerId, `No route for trigger ${event.triggerId}.`, now);
461
- return;
528
+ return "ignored";
462
529
  }
463
530
  appendFire(config, event, now);
464
531
  if (decision.type === "attach") {
465
532
  appendRouted(config, event, decision.jobId, "attach", undefined, now);
466
- return;
533
+ return "existing";
467
534
  }
468
535
  const existing = listValidJobs(config).find((job) => job.source?.kind === "trigger" && job.source.ref === event.key);
469
536
  if (existing) {
470
537
  appendRouted(config, event, existing.id, "create", decision.workflow, now);
471
- return;
538
+ return "existing";
472
539
  }
473
540
  const title = oneLine(event.title ?? "") || event.id;
474
541
  const specText = redact(event.body?.trim() || `# ${title}\n`);
@@ -484,13 +551,14 @@ function ingest(config, event, routes, now) {
484
551
  now,
485
552
  });
486
553
  appendRouted(config, event, written.job.id, "create", decision.workflow, now);
554
+ return "created";
487
555
  }
488
556
  function reviewSpec(input) {
489
557
  const lines = [`# Draft review: ${input.title}`, ""];
490
558
  if (input.url)
491
559
  lines.push(`Pull request: ${input.url}`);
492
560
  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.", "");
561
+ 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
562
  return lines.join("\n");
495
563
  }
496
564
  function appendFire(config, event, now) {
@@ -545,7 +613,7 @@ function failSlot(config, triggerId, stamp, message, now) {
545
613
  function succeedSlot(kilnDir, triggerId, stamp) {
546
614
  remember(kilnDir, triggerId, { lastFire: stamp, lastError: undefined, lastErrorAt: undefined });
547
615
  }
548
- function noteSkip(config, triggerId, reason, stamp, now) {
616
+ function noteSkip(config, triggerId, reason, stamp, now, externalId) {
549
617
  appendEvent(config.kilnDir, {
550
618
  job_id: null,
551
619
  event: "trigger.skipped",
@@ -553,10 +621,21 @@ function noteSkip(config, triggerId, reason, stamp, now) {
553
621
  properties: {
554
622
  trigger_id: triggerId,
555
623
  reason,
556
- once: `skipped:${triggerId}:${stamp}:${reason}`,
624
+ ...(externalId ? { external_id: externalId } : {}),
625
+ once: externalId
626
+ ? `skipped:${triggerId}:${stamp}:${reason}:${externalId}`
627
+ : `skipped:${triggerId}:${stamp}:${reason}`,
557
628
  },
558
629
  });
559
630
  }
631
+ function noteReviewFailure(config, triggerId, stamp, request, error, now) {
632
+ remember(config.kilnDir, triggerId, {
633
+ lastFire: stamp,
634
+ lastError: publicError(errorMessage(error)),
635
+ lastErrorAt: now.toISOString(),
636
+ });
637
+ appendFailed(config, triggerId, publicError(errorMessage(error)), `review:${request.repo}:${request.number}`, now);
638
+ }
560
639
  function noteConfigError(config, triggerId, message, now) {
561
640
  const error = publicError(message);
562
641
  const current = readRuntime(config.kilnDir)[triggerId];
@@ -565,6 +644,133 @@ function noteConfigError(config, triggerId, message, now) {
565
644
  remember(config.kilnDir, triggerId, { lastError: error, lastErrorAt: now.toISOString() });
566
645
  appendFailed(config, triggerId, error, `config:${error}`, now);
567
646
  }
647
+ /** GitHub label for a review-requests fire. Omitted config uses {@link DEFAULT_REVIEW_LABEL}. */
648
+ export function reviewRequestLabel(trigger) {
649
+ return trigger.label?.trim() || DEFAULT_REVIEW_LABEL;
650
+ }
651
+ export function reviewMaxNew(trigger) {
652
+ return trigger.maxNew ?? DEFAULT_REVIEW_MAX_NEW;
653
+ }
654
+ export function reviewMaxQueued(trigger) {
655
+ return trigger.maxQueued ?? DEFAULT_REVIEW_MAX_QUEUED;
656
+ }
657
+ /**
658
+ * Review jobs that are queued or running.
659
+ * A review sitting on the Inbox gate has a finished journal and does not count.
660
+ * A cancelled job does not count.
661
+ */
662
+ export function reviewSlotsInUse(config) {
663
+ let count = 0;
664
+ for (const job of listValidJobs(config)) {
665
+ if (job.workflow !== REVIEW_WORKFLOW || job.cancelledAt)
666
+ continue;
667
+ if (reviewOccupiesSlot(config, job))
668
+ count += 1;
669
+ }
670
+ return count;
671
+ }
672
+ function reviewTargets(config, trigger) {
673
+ const fallback = reviewRequestLabel(trigger);
674
+ return Object.entries(config.repos).map(([name, repo]) => ({
675
+ name,
676
+ path: repo.path,
677
+ label: repo.reviewLabel?.trim() || fallback,
678
+ }));
679
+ }
680
+ function repoReviewLabels(config) {
681
+ const labels = [];
682
+ for (const [repo, entry] of Object.entries(config.repos)) {
683
+ const label = entry.reviewLabel?.trim();
684
+ if (label)
685
+ labels.push({ repo, label });
686
+ }
687
+ return labels;
688
+ }
689
+ function reviewDedupeKey(request) {
690
+ return `review:${request.repo}:${request.number}:${request.headSha.trim().toLowerCase()}`;
691
+ }
692
+ function reviewDrafted(config, key) {
693
+ if (routedKeys(readEvents(config.kilnDir, { warn: () => { } })).has(key))
694
+ return true;
695
+ return listValidJobs(config).some((job) => job.source?.kind === "trigger" && job.source.ref === key);
696
+ }
697
+ /**
698
+ * True when the trigger is enabled in memory and on disk.
699
+ * The disk read catches a disable that arrived while `gh pr list` or a fetch was running.
700
+ */
701
+ function triggerEnabledNow(config, id) {
702
+ if (!triggerIsEnabled(config.triggers, id))
703
+ return false;
704
+ try {
705
+ return triggerIsEnabled(loadKilnConfig(config.configPath).triggers, id);
706
+ }
707
+ catch {
708
+ return true;
709
+ }
710
+ }
711
+ function triggerIsEnabled(file, id) {
712
+ const trigger = effectiveTriggers(file).find((item) => item.id === id);
713
+ return trigger?.enabled !== false;
714
+ }
715
+ function reviewOccupiesSlot(config, job) {
716
+ if (launchingAlive(config.kilnDir, job.id))
717
+ return true;
718
+ const repo = config.repos[job.repo];
719
+ if (!repo)
720
+ return true;
721
+ const runs = journalsForJob(repo.path, job.id);
722
+ if (runs.length === 0)
723
+ return !deadLaunch(config.kilnDir, job.id);
724
+ return runs.some((run) => !run.finished);
725
+ }
726
+ function launchingAlive(kilnDir, jobId) {
727
+ const path = join(kilnDir, "launching", `${jobBasename(jobId)}.json`);
728
+ if (!existsSync(path))
729
+ return false;
730
+ try {
731
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
732
+ return typeof parsed.pid === "number" && isPidAlive(parsed.pid);
733
+ }
734
+ catch {
735
+ return false;
736
+ }
737
+ }
738
+ function deadLaunch(kilnDir, jobId) {
739
+ const path = join(kilnDir, "launching", `${jobBasename(jobId)}.json`);
740
+ if (!existsSync(path))
741
+ return false;
742
+ return !launchingAlive(kilnDir, jobId);
743
+ }
744
+ function journalsForJob(checkout, jobId) {
745
+ const runsDir = join(checkout, ".runs");
746
+ if (!existsSync(runsDir))
747
+ return [];
748
+ const tag = `"kiln:${jobId}"`;
749
+ let names;
750
+ try {
751
+ names = readdirSync(runsDir);
752
+ }
753
+ catch {
754
+ return [];
755
+ }
756
+ const found = [];
757
+ for (const name of names) {
758
+ const file = join(runsDir, name, "journal.jsonl");
759
+ if (!existsSync(file))
760
+ continue;
761
+ let text;
762
+ try {
763
+ text = readFileSync(file, "utf8");
764
+ }
765
+ catch {
766
+ continue;
767
+ }
768
+ if (!text.includes(tag))
769
+ continue;
770
+ found.push({ finished: /"t"\s*:\s*"run\.finished"/.test(text) });
771
+ }
772
+ return found;
773
+ }
568
774
  function blockReason(config, now) {
569
775
  const events = readEvents(config.kilnDir, { warn: () => { } });
570
776
  if (pausedFrom(events))
@@ -591,8 +797,11 @@ function isTriggerEvent(event) {
591
797
  function triggerSummary(event) {
592
798
  if (event.event === "trigger.failed")
593
799
  return event.properties.error;
594
- if (event.event === "trigger.skipped")
595
- return event.properties.reason;
800
+ if (event.event === "trigger.skipped") {
801
+ return event.properties.external_id
802
+ ? `${event.properties.reason} ${event.properties.external_id}`
803
+ : event.properties.reason;
804
+ }
596
805
  if (event.event === "trigger.routed")
597
806
  return `${event.properties.action} ${event.properties.dedupe}`;
598
807
  if (event.event === "trigger.fired")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lchase/kiln",
3
- "version": "0.12.0",
3
+ "version": "0.14.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