@lchase/kiln 0.13.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/README.md +14 -3
- package/assets/serve.css +136 -11
- package/assets/serve.js +44 -5
- package/dist/attention.js +3 -1
- package/dist/board-view.js +4 -169
- package/dist/cli.js +48 -2
- package/dist/config.js +1 -0
- package/dist/driver.js +44 -9
- package/dist/events.js +10 -1
- package/dist/host.js +45 -2
- package/dist/job-view.js +755 -0
- package/dist/jobs.js +9 -0
- package/dist/markdown.js +91 -0
- package/dist/project.js +26 -1
- package/dist/reconcile.js +5 -2
- package/dist/schemas.js +21 -1
- package/dist/serve-page.js +275 -66
- package/dist/serve.js +46 -2
- package/dist/settings.js +4 -2
- package/dist/triggers.js +220 -21
- package/package.json +1 -1
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
|
|
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
|
-
{
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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")
|