@lchase/kiln 0.11.0 → 0.12.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.
@@ -110,6 +110,7 @@ ${repos}
110
110
  ${item("/history", "history", "History", "history", board.history.length)}
111
111
  ${item("/stats", "stats", "Stats", "stats")}
112
112
  ${item("/report", "report", "Today", "report")}
113
+ ${item("/triggers", "triggers", "Triggers", "triggers")}
113
114
  ${item("/settings", "open-settings", "Settings", "settings")}
114
115
  </nav>`;
115
116
  }
@@ -127,8 +128,38 @@ function mainHtml(model, page) {
127
128
  if (model.section === "job" && model.detail) {
128
129
  return `${error}<main class="main detail">${detailHtml(model.detail, model.tab ?? "overview", page.csrf)}</main>`;
129
130
  }
131
+ if (model.section === "triggers") {
132
+ return `${error}<main class="main">${triggersHtml(model.triggers ?? [])}</main>`;
133
+ }
130
134
  return `${error}<main class="main">${listHtml(model, page.csrf)}</main>`;
131
135
  }
136
+ function triggersHtml(rows) {
137
+ const body = rows.length === 0
138
+ ? `<p class="empty">No triggers.</p>`
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}`;
141
+ }
142
+ function triggerCard(row) {
143
+ const on = row.enabled;
144
+ const bits = [row.kind, row.schedule, on ? "enabled" : "disabled"];
145
+ const next = row.nextFire ? `<p class="meta">next <time datetime="${escapeHtml(row.nextFire)}" data-time>${escapeHtml(row.nextFire)}</time></p>` : "";
146
+ const last = row.lastFire ? `<p class="meta">last fire <time datetime="${escapeHtml(row.lastFire)}" data-time>${escapeHtml(row.lastFire)}</time></p>` : `<p class="meta">last fire none</p>`;
147
+ const error = row.lastError
148
+ ? `<p class="err">last error${row.lastErrorAt ? ` <time datetime="${escapeHtml(row.lastErrorAt)}" data-time>${escapeHtml(row.lastErrorAt)}</time>` : ""}: ${escapeHtml(row.lastError)}</p>`
149
+ : "";
150
+ const recent = row.recent.length === 0
151
+ ? `<p class="empty">No routed events yet.</p>`
152
+ : `<ul class="recent">${row.recent
153
+ .map((item) => `<li><time datetime="${escapeHtml(item.ts)}" data-time>${escapeHtml(item.ts)}</time> <span class="ev">${escapeHtml(item.event)}</span> ${escapeHtml(item.summary)}</li>`)
154
+ .join("")}</ul>`;
155
+ return `<article class="trigger" id="trigger-${escapeHtml(row.id)}">
156
+ <h3>${escapeHtml(row.id)}</h3>
157
+ <p class="meta">${escapeHtml(bits.join(" · "))}</p>
158
+ ${next}${last}${error}
159
+ <button type="button" data-trigger-toggle="${escapeHtml(row.id)}" data-enabled="${on ? "true" : "false"}">${on ? "Disable" : "Enable"}</button>
160
+ ${recent}
161
+ </article>`;
162
+ }
132
163
  function listHtml(model, csrf) {
133
164
  const board = model.board;
134
165
  if (model.section === "inbox") {
package/dist/serve.js CHANGED
@@ -10,13 +10,14 @@ import { browse } from "./fs-browse.js";
10
10
  import { branchOf, buildBoard, dayReport, jobDetail, parseSeenQuery, parseStatsWindow, readBranchCommits, readDiffStat, statsFor, unseenChanges, } from "./board-view.js";
11
11
  import { appendEvent, eventsForJob, readEvents } from "./events.js";
12
12
  import { INVESTIGATION_WORKFLOW } from "./findings.js";
13
- import { defaultHost } from "./host.js";
13
+ import { productionHost } from "./host.js";
14
14
  import { intakeJobs } from "./intake-http.js";
15
15
  import { jobBasename, packageRoot } from "./paths.js";
16
16
  import { SANDBOX_PREFLIGHT_PREFIX } from "./sandbox.js";
17
17
  import { renderApp, renderMessagePage, renderTextPage } from "./serve-page.js";
18
18
  import { isPaused, pauseFactory, resumeFactory, spendFor } from "./rails.js";
19
19
  import { readSettings, saveSettings } from "./settings.js";
20
+ import { triggerRows } from "./triggers.js";
20
21
  import { listWorkflowNames } from "./workflow.js";
21
22
  const BODY_LIMIT = 65_536;
22
23
  const TEXT_LIMIT = 100_000;
@@ -43,6 +44,7 @@ export function startServe(options) {
43
44
  let stopped = false;
44
45
  let timer;
45
46
  let firstTick;
47
+ let triggerStartup = true;
46
48
  const shutdown = () => {
47
49
  if (stopped)
48
50
  return;
@@ -68,8 +70,10 @@ export function startServe(options) {
68
70
  const tick = () => {
69
71
  if (stopped)
70
72
  return;
73
+ const startup = triggerStartup;
74
+ triggerStartup = false;
71
75
  refreshLiveConfig(state);
72
- safeTick(state.config, state.secrets, false);
76
+ safeTick(state.config, state.secrets, startup);
73
77
  };
74
78
  firstTick = setTimeout(tick, 0);
75
79
  timer = setInterval(tick, options.tickSeconds * 1000);
@@ -83,9 +87,9 @@ export function loadServeCss() {
83
87
  }
84
88
  return readFileSync(path, "utf8");
85
89
  }
86
- function safeTick(config, secrets, startup) {
90
+ function safeTick(config, secrets, triggerStartup) {
87
91
  try {
88
- const report = runTick(config, defaultHost(), { startup });
92
+ const report = runTick(config, productionHost(), { startup: false, triggerStartup });
89
93
  if (report.startsWith(SANDBOX_PREFLIGHT_PREFIX) || report.startsWith(AUTH_PREFLIGHT_PREFIX)) {
90
94
  const tickAt = report.indexOf("\ntick ");
91
95
  const head = tickAt === -1 ? report : report.slice(0, tickAt);
@@ -734,6 +738,8 @@ function listSection(path) {
734
738
  return "report";
735
739
  if (path === "/settings")
736
740
  return "settings";
741
+ if (path === "/triggers")
742
+ return "triggers";
737
743
  return undefined;
738
744
  }
739
745
  function renderShell(state, css, csrf, section, extra = {}) {
@@ -751,6 +757,9 @@ function renderShell(state, css, csrf, section, extra = {}) {
751
757
  ...(section === "stats" && extra.window ? { stats: statsDocument(state, extra.window) } : {}),
752
758
  ...(section === "report" ? { report: reportDocument(state) } : {}),
753
759
  ...(extra.detail ? { detail: extra.detail, tab: extra.tab ?? "overview" } : {}),
760
+ ...(section === "triggers"
761
+ ? { triggers: triggerRows(state.config, readEvents(state.config.kilnDir, { warn: () => { } })) }
762
+ : {}),
754
763
  });
755
764
  }
756
765
  function boardPayload(state) {
package/dist/settings.js CHANGED
@@ -4,7 +4,8 @@ import { homedir } from "node:os";
4
4
  import { basename, dirname, join } from "node:path";
5
5
  import { assertBranchName, assertCheckoutDirectory } from "./config.js";
6
6
  import { DEFAULT_BRANCH_TEMPLATE, DEFAULT_PR_TITLE } from "./pr-names.js";
7
- import { DEFAULT_MAX_ATTEMPTS, DEFAULT_MAX_FIX_ROUNDS, DEFAULT_MAX_SAME_FINGERPRINT, DEFAULT_STALL_TIMEOUT_MS, KilnConfigFileSchema, SettingsWriteSchema, } from "./schemas.js";
7
+ import { DEFAULT_MAX_ATTEMPTS, DEFAULT_MAX_FIX_ROUNDS, DEFAULT_MAX_SAME_FINGERPRINT, DEFAULT_STALL_TIMEOUT_MS, KilnConfigFileSchema, SettingsWriteSchema, TriggerConfigSchema, } from "./schemas.js";
8
+ import { applyTriggerToggles, effectiveTriggers, knownTrigger } from "./triggers.js";
8
9
  const DEFAULTS = {
9
10
  deliver: "local",
10
11
  prTitle: DEFAULT_PR_TITLE,
@@ -48,6 +49,8 @@ export function saveSettings(configPath, body, home = homedir()) {
48
49
  return fail(400, "Settings are invalid.", fieldErrors(parsed.error));
49
50
  const { revision: _revision, ...editable } = parsed.data;
50
51
  const fields = checkoutFields(dirname(configPath), editable, home);
52
+ const triggerFields = triggerToggleFields(read.raw, editable);
53
+ Object.assign(fields, triggerFields);
51
54
  if (Object.keys(fields).length > 0)
52
55
  return fail(400, "Settings are invalid.", fields);
53
56
  const next = mergeSettings(read.raw, editable);
@@ -74,6 +77,7 @@ function project(path, revision, file) {
74
77
  ...(file.maxSameFingerprint !== undefined ? { maxSameFingerprint: file.maxSameFingerprint } : {}),
75
78
  ...(file.dailyDollarCap !== undefined ? { dailyDollarCap: file.dailyDollarCap } : {}),
76
79
  ...(sandbox ? { sandbox } : {}),
80
+ triggers: effectiveTriggers(file.triggers).map(triggerView),
77
81
  defaults: DEFAULTS,
78
82
  };
79
83
  }
@@ -111,6 +115,8 @@ function mergeSettings(raw, settings) {
111
115
  setOptional(next, "maxFixRounds", settings.maxFixRounds);
112
116
  setOptional(next, "maxSameFingerprint", settings.maxSameFingerprint);
113
117
  setOptional(next, "dailyDollarCap", settings.dailyDollarCap);
118
+ if (settings.triggers)
119
+ next.triggers = applyTriggerToggles(storedTriggers(raw.triggers), settings.triggers);
114
120
  if (settings.sandbox) {
115
121
  const prior = isRecord(raw.sandbox) ? { ...raw.sandbox } : {};
116
122
  setOptional(prior, "mode", settings.sandbox.mode);
@@ -122,6 +128,39 @@ function mergeSettings(raw, settings) {
122
128
  }
123
129
  return next;
124
130
  }
131
+ function triggerView(trigger) {
132
+ return {
133
+ id: trigger.id,
134
+ kind: trigger.kind,
135
+ enabled: trigger.enabled !== false,
136
+ ...(trigger.cron ? { cron: trigger.cron } : {}),
137
+ ...(trigger.every ? { every: trigger.every } : {}),
138
+ ...(trigger.repo ? { repo: trigger.repo } : {}),
139
+ ...(trigger.jql ? { jql: trigger.jql } : {}),
140
+ ...(trigger.label ? { label: trigger.label } : {}),
141
+ ...(trigger.status ? { status: trigger.status } : {}),
142
+ };
143
+ }
144
+ function storedTriggers(value) {
145
+ if (!Array.isArray(value))
146
+ return [];
147
+ const out = [];
148
+ for (const item of value) {
149
+ const parsed = TriggerConfigSchema.safeParse(item);
150
+ if (parsed.success)
151
+ out.push(parsed.data);
152
+ }
153
+ return out;
154
+ }
155
+ function triggerToggleFields(raw, settings) {
156
+ const fields = {};
157
+ const file = storedTriggers(raw.triggers);
158
+ for (const toggle of settings.triggers ?? []) {
159
+ if (!knownTrigger(toggle.id, file))
160
+ fields[`triggers.${toggle.id}`] = `Unknown trigger ${toggle.id}.`;
161
+ }
162
+ return fields;
163
+ }
125
164
  function checkoutFields(configDir, settings, home) {
126
165
  const fields = {};
127
166
  for (const [name, entry] of Object.entries(settings.repos)) {