@lchase/kiln 0.10.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.
- package/README.md +27 -0
- package/assets/serve.css +210 -480
- package/assets/serve.js +125 -3
- package/dist/board-view.js +688 -0
- package/dist/board.js +0 -97
- package/dist/cli.js +7 -1
- package/dist/config.js +5 -0
- package/dist/driver.js +48 -13
- package/dist/events.js +59 -2
- package/dist/host.js +53 -0
- package/dist/intake/jira.js +87 -0
- package/dist/jobs.js +4 -3
- package/dist/reconcile.js +7 -3
- package/dist/schemas.js +51 -3
- package/dist/serve-page.js +415 -324
- package/dist/serve.js +196 -15
- package/dist/settings.js +40 -1
- package/dist/triggers.js +787 -0
- package/dist/workflow.js +18 -15
- package/package.json +3 -2
- package/prompts/kiln/review.md +13 -0
- package/workflows/review.yaml +64 -0
package/dist/serve.js
CHANGED
|
@@ -7,15 +7,17 @@ import { AUTH_PREFLIGHT_PREFIX } from "./auth.js";
|
|
|
7
7
|
import { loadKilnConfig } from "./config.js";
|
|
8
8
|
import { answerJob, readStatus, retryJob, reviseJob, runTick, startupRecovery } from "./driver.js";
|
|
9
9
|
import { browse } from "./fs-browse.js";
|
|
10
|
-
import {
|
|
10
|
+
import { branchOf, buildBoard, dayReport, jobDetail, parseSeenQuery, parseStatsWindow, readBranchCommits, readDiffStat, statsFor, unseenChanges, } from "./board-view.js";
|
|
11
|
+
import { appendEvent, eventsForJob, readEvents } from "./events.js";
|
|
11
12
|
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
12
|
-
import {
|
|
13
|
+
import { productionHost } from "./host.js";
|
|
13
14
|
import { intakeJobs } from "./intake-http.js";
|
|
14
|
-
import { packageRoot } from "./paths.js";
|
|
15
|
+
import { jobBasename, packageRoot } from "./paths.js";
|
|
15
16
|
import { SANDBOX_PREFLIGHT_PREFIX } from "./sandbox.js";
|
|
16
|
-
import {
|
|
17
|
+
import { renderApp, renderMessagePage, renderTextPage } from "./serve-page.js";
|
|
17
18
|
import { isPaused, pauseFactory, resumeFactory, spendFor } from "./rails.js";
|
|
18
19
|
import { readSettings, saveSettings } from "./settings.js";
|
|
20
|
+
import { triggerRows } from "./triggers.js";
|
|
19
21
|
import { listWorkflowNames } from "./workflow.js";
|
|
20
22
|
const BODY_LIMIT = 65_536;
|
|
21
23
|
const TEXT_LIMIT = 100_000;
|
|
@@ -42,6 +44,7 @@ export function startServe(options) {
|
|
|
42
44
|
let stopped = false;
|
|
43
45
|
let timer;
|
|
44
46
|
let firstTick;
|
|
47
|
+
let triggerStartup = true;
|
|
45
48
|
const shutdown = () => {
|
|
46
49
|
if (stopped)
|
|
47
50
|
return;
|
|
@@ -67,8 +70,10 @@ export function startServe(options) {
|
|
|
67
70
|
const tick = () => {
|
|
68
71
|
if (stopped)
|
|
69
72
|
return;
|
|
73
|
+
const startup = triggerStartup;
|
|
74
|
+
triggerStartup = false;
|
|
70
75
|
refreshLiveConfig(state);
|
|
71
|
-
safeTick(state.config, state.secrets,
|
|
76
|
+
safeTick(state.config, state.secrets, startup);
|
|
72
77
|
};
|
|
73
78
|
firstTick = setTimeout(tick, 0);
|
|
74
79
|
timer = setInterval(tick, options.tickSeconds * 1000);
|
|
@@ -82,9 +87,9 @@ export function loadServeCss() {
|
|
|
82
87
|
}
|
|
83
88
|
return readFileSync(path, "utf8");
|
|
84
89
|
}
|
|
85
|
-
function safeTick(config, secrets,
|
|
90
|
+
function safeTick(config, secrets, triggerStartup) {
|
|
86
91
|
try {
|
|
87
|
-
const report = runTick(config,
|
|
92
|
+
const report = runTick(config, productionHost(), { startup: false, triggerStartup });
|
|
88
93
|
if (report.startsWith(SANDBOX_PREFLIGHT_PREFIX) || report.startsWith(AUTH_PREFLIGHT_PREFIX)) {
|
|
89
94
|
const tickAt = report.indexOf("\ntick ");
|
|
90
95
|
const head = tickAt === -1 ? report : report.slice(0, tickAt);
|
|
@@ -201,14 +206,31 @@ async function handle(req, res, state, boundPort, css, csrf) {
|
|
|
201
206
|
sendJson(res, 405, { error: "Method not allowed." }, secrets);
|
|
202
207
|
return;
|
|
203
208
|
}
|
|
204
|
-
if (path === "/" || path === "" || path === "/board") {
|
|
205
|
-
sendHtml(res, 200, renderConsole(css, readStatus(config).attention, pageOptions(state, csrf)), secrets);
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
209
|
if (path === "/api/jobs" || path === "/api/status") {
|
|
209
210
|
sendJson(res, 200, statusDocument(state), secrets);
|
|
210
211
|
return;
|
|
211
212
|
}
|
|
213
|
+
if (path === "/api/board") {
|
|
214
|
+
sendJson(res, 200, boardPayload(state), secrets);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (path === "/api/stats") {
|
|
218
|
+
const window = parseStatsWindow(url.searchParams.get("window"));
|
|
219
|
+
if (!window) {
|
|
220
|
+
sendJson(res, 400, { error: "window must be today, 7d, or 30d." }, secrets);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
sendJson(res, 200, statsDocument(state, window), secrets);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (path === "/api/report") {
|
|
227
|
+
sendJson(res, 200, reportDocument(state), secrets);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (path === "/api/unseen") {
|
|
231
|
+
sendJson(res, 200, unseenDocument(state, url.searchParams.getAll("seen")), secrets);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
212
234
|
if (path === "/api/events") {
|
|
213
235
|
const jobId = url.searchParams.get("job_id");
|
|
214
236
|
if (!jobId || !JOB_ID.test(jobId)) {
|
|
@@ -218,6 +240,17 @@ async function handle(req, res, state, boundPort, css, csrf) {
|
|
|
218
240
|
sendJson(res, 200, { job_id: jobId, events: eventsForJob(config.kilnDir, jobId) }, secrets);
|
|
219
241
|
return;
|
|
220
242
|
}
|
|
243
|
+
const apiDetail = /^\/api\/jobs\/([^/]+)\/detail$/.exec(path);
|
|
244
|
+
if (apiDetail?.[1]) {
|
|
245
|
+
const jobId = parseJobId(apiDetail[1]);
|
|
246
|
+
const job = jobId ? findJob(config, jobId) : undefined;
|
|
247
|
+
if (!job) {
|
|
248
|
+
sendJson(res, 404, { error: jobId ? `No job ${jobId}.` : "Not found." }, secrets);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
sendJson(res, 200, detailDocument(state, job), secrets);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
221
254
|
const apiJob = /^\/api\/jobs\/([^/]+)$/.exec(path);
|
|
222
255
|
if (apiJob?.[1]) {
|
|
223
256
|
const jobId = parseJobId(apiJob[1]);
|
|
@@ -229,6 +262,54 @@ async function handle(req, res, state, boundPort, css, csrf) {
|
|
|
229
262
|
sendJson(res, 200, job, secrets);
|
|
230
263
|
return;
|
|
231
264
|
}
|
|
265
|
+
const listed = listSection(path);
|
|
266
|
+
if (listed) {
|
|
267
|
+
if (listed === "stats") {
|
|
268
|
+
const window = parseStatsWindow(url.searchParams.get("window"));
|
|
269
|
+
if (!window) {
|
|
270
|
+
sendHtml(res, 400, renderMessagePage(css, "Stats", "window must be today, 7d, or 30d."), secrets);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
sendHtml(res, 200, renderShell(state, css, csrf, "stats", { window }), secrets);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
sendHtml(res, 200, renderShell(state, css, csrf, listed), secrets);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const repoPage = /^\/repos\/([^/]+)$/.exec(path);
|
|
280
|
+
if (repoPage?.[1]) {
|
|
281
|
+
let name;
|
|
282
|
+
try {
|
|
283
|
+
name = decodeURIComponent(repoPage[1]);
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
sendHtml(res, 404, renderMessagePage(css, "Not found", "Not found."), secrets);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (!state.config.repos[name]) {
|
|
290
|
+
sendHtml(res, 404, renderMessagePage(css, "Not found", `No repo ${name}.`), secrets);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
sendHtml(res, 200, renderShell(state, css, csrf, "repos", { repo: name }), secrets);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const jobPage = /^\/jobs\/([^/]+)$/.exec(path);
|
|
297
|
+
if (jobPage?.[1]) {
|
|
298
|
+
const jobId = parseJobId(jobPage[1]);
|
|
299
|
+
const job = jobId ? findJob(config, jobId) : undefined;
|
|
300
|
+
if (!job) {
|
|
301
|
+
sendHtml(res, 404, renderMessagePage(css, "Not found", jobId ? `No job ${jobId}.` : "Not found."), secrets);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const tabRaw = url.searchParams.get("tab");
|
|
305
|
+
if (tabRaw && tabRaw !== "overview" && tabRaw !== "changes" && tabRaw !== "timeline") {
|
|
306
|
+
sendHtml(res, 400, renderMessagePage(css, "Job", "tab must be overview, changes, or timeline."), secrets);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const tab = tabRaw === "changes" || tabRaw === "timeline" ? tabRaw : "overview";
|
|
310
|
+
sendHtml(res, 200, renderShell(state, css, csrf, "job", { detail: detailDocument(state, job), tab }), secrets);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
232
313
|
const page = /^\/jobs\/([^/]+)\/(spec|log|findings)$/.exec(path);
|
|
233
314
|
if (page?.[1] && (page[2] === "spec" || page[2] === "log" || page[2] === "findings")) {
|
|
234
315
|
const jobId = parseJobId(page[1]);
|
|
@@ -402,13 +483,13 @@ function redirectTarget(req) {
|
|
|
402
483
|
return "/";
|
|
403
484
|
try {
|
|
404
485
|
const url = new URL(referer);
|
|
405
|
-
if (url.pathname
|
|
406
|
-
return "/
|
|
486
|
+
if (!url.pathname.startsWith("/") || url.pathname.startsWith("//"))
|
|
487
|
+
return "/";
|
|
488
|
+
return `${url.pathname}${url.search}`;
|
|
407
489
|
}
|
|
408
490
|
catch {
|
|
409
491
|
return "/";
|
|
410
492
|
}
|
|
411
|
-
return "/";
|
|
412
493
|
}
|
|
413
494
|
function wantsJson(req) {
|
|
414
495
|
const accept = headerValue(req.headers.accept);
|
|
@@ -639,9 +720,109 @@ function statusDocument(state) {
|
|
|
639
720
|
repos: repoList(state.config),
|
|
640
721
|
configError: state.configError ?? null,
|
|
641
722
|
paused: isPaused(state.config.kilnDir),
|
|
642
|
-
spend: { day: spend.day, usd: spend.usd, cap: spend.cap },
|
|
723
|
+
spend: { day: spend.day, usd: spend.usd, cap: spend.cap, blocked: spend.blocked },
|
|
643
724
|
};
|
|
644
725
|
}
|
|
726
|
+
function listSection(path) {
|
|
727
|
+
if (path === "/" || path === "" || path === "/board" || path === "/inbox")
|
|
728
|
+
return "inbox";
|
|
729
|
+
if (path === "/hand")
|
|
730
|
+
return "hand";
|
|
731
|
+
if (path === "/repos")
|
|
732
|
+
return "repos";
|
|
733
|
+
if (path === "/history")
|
|
734
|
+
return "history";
|
|
735
|
+
if (path === "/stats")
|
|
736
|
+
return "stats";
|
|
737
|
+
if (path === "/report")
|
|
738
|
+
return "report";
|
|
739
|
+
if (path === "/settings")
|
|
740
|
+
return "settings";
|
|
741
|
+
if (path === "/triggers")
|
|
742
|
+
return "triggers";
|
|
743
|
+
return undefined;
|
|
744
|
+
}
|
|
745
|
+
function renderShell(state, css, csrf, section, extra = {}) {
|
|
746
|
+
const status = readStatus(state.config);
|
|
747
|
+
const events = readEvents(state.config.kilnDir, { warn: () => { } });
|
|
748
|
+
const board = buildBoard(status.jobs, events);
|
|
749
|
+
const page = pageOptions(state, csrf);
|
|
750
|
+
return renderApp({
|
|
751
|
+
css,
|
|
752
|
+
section,
|
|
753
|
+
board,
|
|
754
|
+
attention: status.attention,
|
|
755
|
+
page: extra.repo ? { ...page, repo: extra.repo } : page,
|
|
756
|
+
...(extra.repo ? { repo: extra.repo } : {}),
|
|
757
|
+
...(section === "stats" && extra.window ? { stats: statsDocument(state, extra.window) } : {}),
|
|
758
|
+
...(section === "report" ? { report: reportDocument(state) } : {}),
|
|
759
|
+
...(extra.detail ? { detail: extra.detail, tab: extra.tab ?? "overview" } : {}),
|
|
760
|
+
...(section === "triggers"
|
|
761
|
+
? { triggers: triggerRows(state.config, readEvents(state.config.kilnDir, { warn: () => { } })) }
|
|
762
|
+
: {}),
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
function boardPayload(state) {
|
|
766
|
+
const status = readStatus(state.config);
|
|
767
|
+
const events = readEvents(state.config.kilnDir, { warn: () => { } });
|
|
768
|
+
const spend = spendFor(state.config.kilnDir, state.config.dailyDollarCap);
|
|
769
|
+
return {
|
|
770
|
+
...buildBoard(status.jobs, events),
|
|
771
|
+
paused: isPaused(state.config.kilnDir),
|
|
772
|
+
spend,
|
|
773
|
+
configError: state.configError ?? null,
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
function statsDocument(state, window) {
|
|
777
|
+
const events = readEvents(state.config.kilnDir, { warn: () => { } });
|
|
778
|
+
return statsFor(events, {
|
|
779
|
+
window,
|
|
780
|
+
...(state.config.dailyDollarCap !== undefined ? { cap: state.config.dailyDollarCap } : {}),
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
function reportDocument(state) {
|
|
784
|
+
const events = readEvents(state.config.kilnDir, { warn: () => { } });
|
|
785
|
+
return dayReport(events, state.config.dailyDollarCap !== undefined ? { cap: state.config.dailyDollarCap } : {});
|
|
786
|
+
}
|
|
787
|
+
function unseenDocument(state, seenValues) {
|
|
788
|
+
const events = readEvents(state.config.kilnDir, { warn: () => { } });
|
|
789
|
+
const board = buildBoard(readStatus(state.config).jobs, events);
|
|
790
|
+
return unseenChanges(events, parseSeenQuery(seenValues), new Set(board.inbox.map((job) => job.jobId)));
|
|
791
|
+
}
|
|
792
|
+
function detailDocument(state, job) {
|
|
793
|
+
const events = readEvents(state.config.kilnDir, { warn: () => { } });
|
|
794
|
+
const repo = state.config.repos[job.repo];
|
|
795
|
+
const branch = branchOf(job, events);
|
|
796
|
+
const base = prBase(events, job.jobId);
|
|
797
|
+
const commits = repo && branch ? readBranchCommits(repo.path, branch) : [];
|
|
798
|
+
const diff = repo && branch && base ? readDiffStat(repo.path, base, branch) : undefined;
|
|
799
|
+
const issue = jobIssue(state.config, job.jobId);
|
|
800
|
+
return jobDetail({
|
|
801
|
+
job,
|
|
802
|
+
events,
|
|
803
|
+
maxFixRounds: repo?.maxFixRounds ?? state.config.maxFixRounds,
|
|
804
|
+
commits,
|
|
805
|
+
...(issue ? { issue } : {}),
|
|
806
|
+
...(diff ? { diffStat: diff } : {}),
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
function prBase(events, jobId) {
|
|
810
|
+
let base;
|
|
811
|
+
for (const event of events) {
|
|
812
|
+
if (event.job_id === jobId && event.event === "pr.opened")
|
|
813
|
+
base = event.properties.base;
|
|
814
|
+
}
|
|
815
|
+
return base;
|
|
816
|
+
}
|
|
817
|
+
function jobIssue(config, jobId) {
|
|
818
|
+
try {
|
|
819
|
+
const raw = JSON.parse(readFileSync(join(config.jobsDir, `${jobBasename(jobId)}.json`), "utf8"));
|
|
820
|
+
return typeof raw.issue === "string" ? raw.issue : undefined;
|
|
821
|
+
}
|
|
822
|
+
catch {
|
|
823
|
+
return undefined;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
645
826
|
function statusCode(error) {
|
|
646
827
|
if (typeof error === "object" && error !== null && "statusCode" in error) {
|
|
647
828
|
const code = error.statusCode;
|
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)) {
|