@lchase/kiln 0.13.0 → 0.15.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 = [
@@ -314,11 +331,21 @@ export function triggerRows(config, events, now = new Date()) {
314
331
  event: event.event,
315
332
  summary: triggerSummary(event),
316
333
  }));
334
+ const review = trigger.id === REVIEW_REQUESTS_TRIGGER;
335
+ const repoLabels = review ? repoReviewLabels(config) : [];
317
336
  return {
318
337
  id: trigger.id,
319
338
  kind: trigger.kind,
320
339
  enabled: trigger.enabled !== false,
321
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
+ : {}),
322
349
  ...(nextFire(trigger, state, now) ? { nextFire: nextFire(trigger, state, now) } : {}),
323
350
  ...(state?.lastFire && Number.isFinite(Date.parse(state.lastFire)) ? { lastFire: state.lastFire } : {}),
324
351
  ...(state?.lastError ? { lastError: state.lastError } : {}),
@@ -346,14 +373,21 @@ function runSchedule(input, trigger, routes, now, startup) {
346
373
  if (reviews) {
347
374
  if (!input.reviews)
348
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;
349
381
  let requests;
350
382
  try {
351
- 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));
352
384
  }
353
385
  catch (error) {
354
386
  failSlot(input.config, trigger.id, stamp, errorMessage(error), now);
355
387
  return;
356
388
  }
389
+ if (!triggerEnabledNow(input.config, trigger.id))
390
+ return;
357
391
  succeedSlot(input.config.kilnDir, trigger.id, stamp);
358
392
  appendFire(input.config, {
359
393
  key: `schedule:${trigger.id}:${stamp}`,
@@ -362,17 +396,40 @@ function runSchedule(input, trigger, routes, now, startup) {
362
396
  id: stamp,
363
397
  revision: stamp,
364
398
  });
399
+ const maxNew = reviewMaxNew(trigger);
400
+ const maxQueued = reviewMaxQueued(trigger);
401
+ let created = 0;
402
+ let active = reviewSlotsInUse(input.config);
365
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
+ }
366
424
  try {
367
- 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
+ }
368
430
  }
369
431
  catch (error) {
370
- remember(input.config.kilnDir, trigger.id, {
371
- lastFire: stamp,
372
- lastError: publicError(errorMessage(error)),
373
- lastErrorAt: now.toISOString(),
374
- });
375
- appendFailed(input.config, trigger.id, publicError(errorMessage(error)), `review:${request.repo}:${request.number}`, now);
432
+ noteReviewFailure(input.config, trigger.id, stamp, request, error, now);
376
433
  }
377
434
  }
378
435
  return;
@@ -400,8 +457,8 @@ function ingestReview(config, request, routes, now) {
400
457
  const base = repo.base ?? "the default branch";
401
458
  const url = httpUrl(request.url);
402
459
  const title = oneLine(request.title) || `PR ${request.number}`;
403
- ingest(config, {
404
- key: `review:${request.repo}:${request.number}:${sha}`,
460
+ return ingest(config, {
461
+ key: reviewDedupeKey(request),
405
462
  source: "review",
406
463
  triggerId: REVIEW_REQUESTS_TRIGGER,
407
464
  id: String(request.number),
@@ -465,20 +522,20 @@ function ingest(config, event, routes, now) {
465
522
  const routed = routedKeys(events);
466
523
  const decision = routeTrigger(event, routes, routed);
467
524
  if (decision.type === "ignore" || decision.type === "deduped")
468
- return;
525
+ return "ignored";
469
526
  if (decision.type === "unrouted") {
470
527
  noteConfigError(config, event.triggerId, `No route for trigger ${event.triggerId}.`, now);
471
- return;
528
+ return "ignored";
472
529
  }
473
530
  appendFire(config, event, now);
474
531
  if (decision.type === "attach") {
475
532
  appendRouted(config, event, decision.jobId, "attach", undefined, now);
476
- return;
533
+ return "existing";
477
534
  }
478
535
  const existing = listValidJobs(config).find((job) => job.source?.kind === "trigger" && job.source.ref === event.key);
479
536
  if (existing) {
480
537
  appendRouted(config, event, existing.id, "create", decision.workflow, now);
481
- return;
538
+ return "existing";
482
539
  }
483
540
  const title = oneLine(event.title ?? "") || event.id;
484
541
  const specText = redact(event.body?.trim() || `# ${title}\n`);
@@ -494,6 +551,7 @@ function ingest(config, event, routes, now) {
494
551
  now,
495
552
  });
496
553
  appendRouted(config, event, written.job.id, "create", decision.workflow, now);
554
+ return "created";
497
555
  }
498
556
  function reviewSpec(input) {
499
557
  const lines = [`# Draft review: ${input.title}`, ""];
@@ -555,7 +613,7 @@ function failSlot(config, triggerId, stamp, message, now) {
555
613
  function succeedSlot(kilnDir, triggerId, stamp) {
556
614
  remember(kilnDir, triggerId, { lastFire: stamp, lastError: undefined, lastErrorAt: undefined });
557
615
  }
558
- function noteSkip(config, triggerId, reason, stamp, now) {
616
+ function noteSkip(config, triggerId, reason, stamp, now, externalId) {
559
617
  appendEvent(config.kilnDir, {
560
618
  job_id: null,
561
619
  event: "trigger.skipped",
@@ -563,10 +621,21 @@ function noteSkip(config, triggerId, reason, stamp, now) {
563
621
  properties: {
564
622
  trigger_id: triggerId,
565
623
  reason,
566
- once: `skipped:${triggerId}:${stamp}:${reason}`,
624
+ ...(externalId ? { external_id: externalId } : {}),
625
+ once: externalId
626
+ ? `skipped:${triggerId}:${stamp}:${reason}:${externalId}`
627
+ : `skipped:${triggerId}:${stamp}:${reason}`,
567
628
  },
568
629
  });
569
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
+ }
570
639
  function noteConfigError(config, triggerId, message, now) {
571
640
  const error = publicError(message);
572
641
  const current = readRuntime(config.kilnDir)[triggerId];
@@ -575,6 +644,133 @@ function noteConfigError(config, triggerId, message, now) {
575
644
  remember(config.kilnDir, triggerId, { lastError: error, lastErrorAt: now.toISOString() });
576
645
  appendFailed(config, triggerId, error, `config:${error}`, now);
577
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
+ }
578
774
  function blockReason(config, now) {
579
775
  const events = readEvents(config.kilnDir, { warn: () => { } });
580
776
  if (pausedFrom(events))
@@ -601,8 +797,11 @@ function isTriggerEvent(event) {
601
797
  function triggerSummary(event) {
602
798
  if (event.event === "trigger.failed")
603
799
  return event.properties.error;
604
- if (event.event === "trigger.skipped")
605
- 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
+ }
606
805
  if (event.event === "trigger.routed")
607
806
  return `${event.properties.action} ${event.properties.dedupe}`;
608
807
  if (event.event === "trigger.fired")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lchase/kiln",
3
- "version": "0.13.0",
3
+ "version": "0.15.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",