@lchase/kiln 0.6.0 → 0.7.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/LICENSE +21 -0
- package/README.md +58 -21
- package/assets/serve.css +93 -0
- package/assets/serve.js +844 -0
- package/dist/attention.js +5 -0
- package/dist/auth.js +48 -0
- package/dist/board.js +10 -5
- package/dist/cli.js +31 -7
- package/dist/config.js +33 -10
- package/dist/delivery.js +53 -15
- package/dist/driver.js +29 -5
- package/dist/errors.js +9 -0
- package/dist/fs-browse.js +113 -0
- package/dist/host.js +7 -1
- package/dist/intake/file.js +1 -0
- package/dist/intake/jira.js +19 -4
- package/dist/intake/prompt.js +1 -0
- package/dist/intake/tickets.js +41 -1
- package/dist/intake-http.js +201 -0
- package/dist/jobs.js +5 -1
- package/dist/pr-names.js +116 -0
- package/dist/reconcile.js +5 -2
- package/dist/reliability.js +83 -22
- package/dist/report.js +7 -2
- package/dist/sandbox.js +18 -10
- package/dist/schemas.js +76 -16
- package/dist/serve-page.js +157 -13
- package/dist/serve.js +254 -16
- package/dist/settings.js +204 -0
- package/dist/workflow.js +17 -1
- package/package.json +3 -2
- package/workflows/feature.yaml +7 -4
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { closeSync, openSync, readSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { HttpError } from "./errors.js";
|
|
5
|
+
/** A chosen markdown file returns this many lines, and no more of the body. */
|
|
6
|
+
export const PREVIEW_LINES = 40;
|
|
7
|
+
const PREVIEW_BYTES = 16 * 1024;
|
|
8
|
+
export function homeReal(home = homedir()) {
|
|
9
|
+
try {
|
|
10
|
+
return realpathSync(resolve(home));
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
14
|
+
throw new HttpError(500, `Home directory is not available: ${message}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Absolute path of `target` after symlink resolution.
|
|
19
|
+
* Refuses anything outside `home`, and any hidden path segment.
|
|
20
|
+
*/
|
|
21
|
+
export function assertInsideHome(home, target) {
|
|
22
|
+
const root = homeReal(home);
|
|
23
|
+
const resolved = resolve(target);
|
|
24
|
+
let real;
|
|
25
|
+
try {
|
|
26
|
+
real = realpathSync(resolved);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw new HttpError(404, `No such path ${resolved}.`);
|
|
30
|
+
}
|
|
31
|
+
if (real !== root && !real.startsWith(root + sep)) {
|
|
32
|
+
throw new HttpError(403, "Path is outside the home directory.");
|
|
33
|
+
}
|
|
34
|
+
const rel = relative(root, real);
|
|
35
|
+
if (rel !== "" && rel.split(sep).some((part) => part.startsWith("."))) {
|
|
36
|
+
throw new HttpError(403, "Hidden files are not listed.");
|
|
37
|
+
}
|
|
38
|
+
return real;
|
|
39
|
+
}
|
|
40
|
+
/** Directories and `.md` files under the home directory. A file returns a short preview. */
|
|
41
|
+
export function browse(home, rawPath) {
|
|
42
|
+
const root = homeReal(home);
|
|
43
|
+
const requested = rawPath?.trim() ? rawPath : root;
|
|
44
|
+
const real = assertInsideHome(home, requested);
|
|
45
|
+
const stat = statSync(real);
|
|
46
|
+
if (stat.isDirectory())
|
|
47
|
+
return listDir(root, real);
|
|
48
|
+
if (stat.isFile())
|
|
49
|
+
return previewFile(real);
|
|
50
|
+
throw new HttpError(400, "Only directories and .md files can be opened.");
|
|
51
|
+
}
|
|
52
|
+
function listDir(root, dir) {
|
|
53
|
+
const names = readdirSync(dir);
|
|
54
|
+
const entries = [];
|
|
55
|
+
for (const name of names) {
|
|
56
|
+
if (name.startsWith("."))
|
|
57
|
+
continue;
|
|
58
|
+
const path = join(dir, name);
|
|
59
|
+
let real;
|
|
60
|
+
let info;
|
|
61
|
+
try {
|
|
62
|
+
info = statSync(path);
|
|
63
|
+
real = realpathSync(path);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (real !== root && !real.startsWith(root + sep))
|
|
69
|
+
continue;
|
|
70
|
+
const rel = relative(root, real);
|
|
71
|
+
if (rel !== "" && rel.split(sep).some((part) => part.startsWith(".")))
|
|
72
|
+
continue;
|
|
73
|
+
if (info.isDirectory())
|
|
74
|
+
entries.push({ name, path: real, kind: "dir" });
|
|
75
|
+
else if (info.isFile() && name.toLowerCase().endsWith(".md"))
|
|
76
|
+
entries.push({ name, path: real, kind: "file" });
|
|
77
|
+
}
|
|
78
|
+
entries.sort((left, right) => {
|
|
79
|
+
if (left.kind !== right.kind)
|
|
80
|
+
return left.kind === "dir" ? -1 : 1;
|
|
81
|
+
return left.name.localeCompare(right.name);
|
|
82
|
+
});
|
|
83
|
+
return { path: dir, parent: parentInside(root, dir), kind: "dir", entries };
|
|
84
|
+
}
|
|
85
|
+
function parentInside(root, dir) {
|
|
86
|
+
if (dir === root)
|
|
87
|
+
return null;
|
|
88
|
+
const parent = dirname(dir);
|
|
89
|
+
if (parent !== root && !parent.startsWith(root + sep))
|
|
90
|
+
return null;
|
|
91
|
+
return parent;
|
|
92
|
+
}
|
|
93
|
+
function previewFile(path) {
|
|
94
|
+
if (!basename(path).toLowerCase().endsWith(".md")) {
|
|
95
|
+
throw new HttpError(400, "Only .md files can be previewed.");
|
|
96
|
+
}
|
|
97
|
+
const size = statSync(path).size;
|
|
98
|
+
const fd = openSync(path, "r");
|
|
99
|
+
try {
|
|
100
|
+
const buf = Buffer.alloc(PREVIEW_BYTES);
|
|
101
|
+
const read = readSync(fd, buf, 0, PREVIEW_BYTES, 0);
|
|
102
|
+
const lines = buf.subarray(0, read).toString("utf8").split(/\r?\n/);
|
|
103
|
+
return {
|
|
104
|
+
path,
|
|
105
|
+
kind: "file",
|
|
106
|
+
preview: lines.slice(0, PREVIEW_LINES).join("\n"),
|
|
107
|
+
truncated: lines.length > PREVIEW_LINES || size > read,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
closeSync(fd);
|
|
112
|
+
}
|
|
113
|
+
}
|
package/dist/host.js
CHANGED
|
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { realPullRequestFeedback } from "./feedback.js";
|
|
6
|
+
import { isPublishableBranch } from "./pr-names.js";
|
|
6
7
|
/**
|
|
7
8
|
* Git remote and GitHub CLI, behind one seam so tests can fake `gh` and point
|
|
8
9
|
* `origin` at a local bare repo. Nothing here merges a pull request or pushes
|
|
@@ -82,10 +83,15 @@ export const realGitRemote = {
|
|
|
82
83
|
},
|
|
83
84
|
publishBranch(checkout, source, target, baseBranch) {
|
|
84
85
|
assertYakBranch(source, baseBranch);
|
|
85
|
-
|
|
86
|
+
if (target === baseBranch || target === `refs/heads/${baseBranch}`) {
|
|
87
|
+
throw new HostError(`Refusing to push ${target}: that is the base branch.`);
|
|
88
|
+
}
|
|
86
89
|
if (source.includes("+") || target.includes("+")) {
|
|
87
90
|
throw new HostError(`Refusing to push ${target}: kiln does not force-push.`);
|
|
88
91
|
}
|
|
92
|
+
if (!isPublishableBranch(target)) {
|
|
93
|
+
throw new HostError(`Refusing to push ${target}: that branch name is not safe.`);
|
|
94
|
+
}
|
|
89
95
|
const result = gitResult(checkout, ["push", "origin", `refs/heads/${source}:refs/heads/${target}`]);
|
|
90
96
|
if (result.status !== 0) {
|
|
91
97
|
throw new HostError(`Could not push ${source} onto ${target}: ${result.stderr.trim() || "git push failed"}`);
|
package/dist/intake/file.js
CHANGED
|
@@ -22,6 +22,7 @@ export function addFromFile(config, input) {
|
|
|
22
22
|
specText: text,
|
|
23
23
|
title,
|
|
24
24
|
dependsOn: input.dependsOn,
|
|
25
|
+
...(input.issue !== undefined ? { issue: input.issue } : {}),
|
|
25
26
|
...(input.stack ? { stack: true } : {}),
|
|
26
27
|
source: { kind: "file", ref: source },
|
|
27
28
|
...(input.now ? { now: input.now } : {}),
|
package/dist/intake/jira.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { assertCanStack, listValidJobs, renderAddedJob, writeJob } from "../jobs.js";
|
|
2
|
+
import { ISSUE_KEY } from "../schemas.js";
|
|
2
3
|
import { assertWorkflowName } from "../workflow.js";
|
|
3
4
|
import { INVESTIGATION_WORKFLOW } from "../findings.js";
|
|
4
5
|
import { typeLooksLikeSpike, workflowForSpike } from "./tickets.js";
|
|
5
|
-
/** Jira issue keys, stored and matched in uppercase (`PROJ-123`). */
|
|
6
|
-
const ISSUE_KEY = /^[A-Z][A-Z0-9]+-\d+$/;
|
|
7
6
|
const ENV_BASE_URL = "JIRA_BASE_URL";
|
|
8
7
|
const ENV_EMAIL = "JIRA_EMAIL";
|
|
9
8
|
const ENV_API_TOKEN = "JIRA_API_TOKEN";
|
|
@@ -13,6 +12,15 @@ const ENV_API_TOKEN = "JIRA_API_TOKEN";
|
|
|
13
12
|
*/
|
|
14
13
|
export async function addFromJira(config, input) {
|
|
15
14
|
const key = normalizeIssueKey(input.key);
|
|
15
|
+
if (input.issue !== undefined) {
|
|
16
|
+
const explicit = input.issue.trim().toUpperCase();
|
|
17
|
+
if (!ISSUE_KEY.test(explicit)) {
|
|
18
|
+
throw new Error(`Issue key ${JSON.stringify(input.issue)} must match KEY-123.`);
|
|
19
|
+
}
|
|
20
|
+
if (explicit !== key) {
|
|
21
|
+
throw new Error(`--issue ${explicit} does not match --jira ${key}.`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
16
24
|
assertKnownRepo(config, input.repo);
|
|
17
25
|
assertWorkflowName(input.workflow);
|
|
18
26
|
const existing = existingJiraJob(config, key);
|
|
@@ -43,7 +51,7 @@ export async function addFromJira(config, input) {
|
|
|
43
51
|
acceptanceMarkdown: acceptanceMarkdown(issue.acceptance),
|
|
44
52
|
});
|
|
45
53
|
assertCanStack(input.repo, dependsOn, input.stack, listValidJobs(config));
|
|
46
|
-
const title = input.title?.trim() ||
|
|
54
|
+
const title = input.title?.trim() || issue.summary.trim();
|
|
47
55
|
if (!title) {
|
|
48
56
|
throw new Error("Job title is empty. Pass --title.");
|
|
49
57
|
}
|
|
@@ -67,6 +75,7 @@ export async function addFromJira(config, input) {
|
|
|
67
75
|
repo: input.repo,
|
|
68
76
|
specText: safeSpec,
|
|
69
77
|
title: redactSecrets(title, credentials),
|
|
78
|
+
issue: key,
|
|
70
79
|
dependsOn,
|
|
71
80
|
...(input.stack ? { stack: true } : {}),
|
|
72
81
|
source: { kind: "jira", ref: key },
|
|
@@ -382,7 +391,13 @@ function renderExisting(job) {
|
|
|
382
391
|
return [`exists ${job.id}`, `repo ${job.repo}`, `workflow ${job.workflow}`, `source jira ${ref}`, ""].join("\n");
|
|
383
392
|
}
|
|
384
393
|
function renderDryRun(input) {
|
|
385
|
-
const lines = [
|
|
394
|
+
const lines = [
|
|
395
|
+
`dry-run ${input.key}`,
|
|
396
|
+
`repo ${input.repo}`,
|
|
397
|
+
`workflow ${input.workflow}`,
|
|
398
|
+
`title ${input.title}`,
|
|
399
|
+
`issue ${input.key}`,
|
|
400
|
+
];
|
|
386
401
|
if (input.dependsOn.length > 0)
|
|
387
402
|
lines.push(`dependsOn ${input.dependsOn.join(", ")}`);
|
|
388
403
|
lines.push(`source jira ${input.key}`, "spec:", input.specText.trimEnd(), "created nothing", "");
|
package/dist/intake/prompt.js
CHANGED
|
@@ -40,6 +40,7 @@ export function addFromPrompt(config, input) {
|
|
|
40
40
|
specText: text,
|
|
41
41
|
title,
|
|
42
42
|
dependsOn: input.dependsOn,
|
|
43
|
+
...(input.issue !== undefined ? { issue: input.issue } : {}),
|
|
43
44
|
...(input.stack ? { stack: true } : {}),
|
|
44
45
|
source: { kind: "prompt", ref: fromStdin ? "stdin" : "inline" },
|
|
45
46
|
...(input.now ? { now: input.now } : {}),
|
package/dist/intake/tickets.js
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "n
|
|
|
2
2
|
import { basename, dirname, join, resolve } from "node:path";
|
|
3
3
|
import { INVESTIGATION_WORKFLOW } from "../findings.js";
|
|
4
4
|
import { listValidJobs, newJobId, writeJob } from "../jobs.js";
|
|
5
|
+
import { ISSUE_KEY } from "../schemas.js";
|
|
5
6
|
import { assertWorkflowName } from "../workflow.js";
|
|
6
7
|
/** Context files larger than this are named in the spec instead of copied in. */
|
|
7
8
|
export const CONTEXT_INLINE_LIMIT = 40 * 1024;
|
|
@@ -125,6 +126,7 @@ export function importTickets(config, input) {
|
|
|
125
126
|
repo: input.repo,
|
|
126
127
|
specText: composeSpec(write.ticket.text, context),
|
|
127
128
|
title: write.ticket.title,
|
|
129
|
+
...(write.ticket.issue ? { issue: write.ticket.issue } : {}),
|
|
128
130
|
dependsOn: write.dependsOn,
|
|
129
131
|
...(input.stack ? { stack: true } : {}),
|
|
130
132
|
source: { kind: "tickets", ref: write.ticket.path },
|
|
@@ -133,9 +135,19 @@ export function importTickets(config, input) {
|
|
|
133
135
|
});
|
|
134
136
|
}
|
|
135
137
|
}
|
|
138
|
+
const jobs = rows.map((row) => ({
|
|
139
|
+
status: row.status,
|
|
140
|
+
label: row.label,
|
|
141
|
+
title: row.title,
|
|
142
|
+
workflow: row.workflow,
|
|
143
|
+
dependsOn: row.depLabels,
|
|
144
|
+
...(row.jobId ? { jobId: row.jobId } : {}),
|
|
145
|
+
}));
|
|
136
146
|
return {
|
|
137
147
|
report: renderImport(dirAbs, input.repo, rows, input.dryRun),
|
|
138
148
|
warnings,
|
|
149
|
+
ids: input.dryRun ? [] : writes.map((write) => write.id),
|
|
150
|
+
jobs,
|
|
139
151
|
};
|
|
140
152
|
}
|
|
141
153
|
function resolveTicketDir(cwd, dir) {
|
|
@@ -265,12 +277,18 @@ function parseTicket(dirAbs, file) {
|
|
|
265
277
|
if (!title) {
|
|
266
278
|
throw new Error(`Ticket ${file} has no title. Expected a "# NN: Title" heading or front matter title.`);
|
|
267
279
|
}
|
|
280
|
+
const headingIssue = issueKeyIn(title);
|
|
281
|
+
if (meta.issue && headingIssue && meta.issue !== headingIssue) {
|
|
282
|
+
throw new Error(`Ticket ${file} issue ${meta.issue} does not match the heading issue ${headingIssue}.`);
|
|
283
|
+
}
|
|
284
|
+
const issue = meta.issue ?? headingIssue;
|
|
268
285
|
return {
|
|
269
286
|
file,
|
|
270
287
|
path,
|
|
271
288
|
label,
|
|
272
289
|
number,
|
|
273
290
|
title,
|
|
291
|
+
...(issue ? { issue } : {}),
|
|
274
292
|
text,
|
|
275
293
|
blockedBy: dedupeRefs([...meta.blockedBy, ...bodyDeps]),
|
|
276
294
|
spike: looksLikeSpike(title, text, meta.type),
|
|
@@ -286,6 +304,7 @@ function parseFrontMatter(yaml) {
|
|
|
286
304
|
const lines = yaml.split(/\r?\n/);
|
|
287
305
|
let title;
|
|
288
306
|
let type;
|
|
307
|
+
let issue;
|
|
289
308
|
const blockedBy = [];
|
|
290
309
|
let listKey;
|
|
291
310
|
for (const line of lines) {
|
|
@@ -306,6 +325,16 @@ function parseFrontMatter(yaml) {
|
|
|
306
325
|
title = unquote(value);
|
|
307
326
|
else if (key === "type")
|
|
308
327
|
type = unquote(value);
|
|
328
|
+
else if (key === "issue") {
|
|
329
|
+
const raw = unquote(value).trim();
|
|
330
|
+
if (!raw)
|
|
331
|
+
throw new Error("front matter issue is empty");
|
|
332
|
+
const keyNorm = raw.toUpperCase();
|
|
333
|
+
if (!ISSUE_KEY.test(keyNorm)) {
|
|
334
|
+
throw new Error(`front matter issue ${JSON.stringify(raw)} is not a KEY-123 identifier.`);
|
|
335
|
+
}
|
|
336
|
+
issue = keyNorm;
|
|
337
|
+
}
|
|
309
338
|
else if (key === "blocked-by") {
|
|
310
339
|
if (!value.trim())
|
|
311
340
|
listKey = "blocked-by";
|
|
@@ -313,7 +342,7 @@ function parseFrontMatter(yaml) {
|
|
|
313
342
|
blockedBy.push(...refsFromScalar(value));
|
|
314
343
|
}
|
|
315
344
|
}
|
|
316
|
-
return { title: title?.trim() || undefined, type: type?.trim() || undefined, blockedBy };
|
|
345
|
+
return { title: title?.trim() || undefined, type: type?.trim() || undefined, ...(issue ? { issue } : {}), blockedBy };
|
|
317
346
|
}
|
|
318
347
|
function refsFromScalar(raw) {
|
|
319
348
|
const value = unquote(raw.trim());
|
|
@@ -371,6 +400,17 @@ function blockedByFromBody(body, filename) {
|
|
|
371
400
|
}
|
|
372
401
|
return refs;
|
|
373
402
|
}
|
|
403
|
+
/** A leading `ABC-123:`, `ABC-123 `, or `[ABC-123]` on a title. `01: Title` is not an issue. */
|
|
404
|
+
function issueKeyIn(value) {
|
|
405
|
+
if (!value)
|
|
406
|
+
return undefined;
|
|
407
|
+
const match = /^\s*(?:\[([A-Za-z][A-Za-z0-9]+-\d+)\]|([A-Za-z][A-Za-z0-9]+-\d+):|([A-Za-z][A-Za-z0-9]+-\d+)\s+)/.exec(value);
|
|
408
|
+
const raw = match?.[1] ?? match?.[2] ?? match?.[3];
|
|
409
|
+
if (!raw)
|
|
410
|
+
return undefined;
|
|
411
|
+
const key = raw.toUpperCase();
|
|
412
|
+
return ISSUE_KEY.test(key) ? key : undefined;
|
|
413
|
+
}
|
|
374
414
|
function headingTitle(body) {
|
|
375
415
|
for (const line of body.split(/\r?\n/)) {
|
|
376
416
|
const heading = /^#\s+(.+?)\s*$/.exec(line);
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { isAbsolute, resolve } from "node:path";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { HttpError } from "./errors.js";
|
|
4
|
+
import { assertInsideHome } from "./fs-browse.js";
|
|
5
|
+
import { addFromFile } from "./intake/file.js";
|
|
6
|
+
import { addFromJira } from "./intake/jira.js";
|
|
7
|
+
import { addFromPrompt } from "./intake/prompt.js";
|
|
8
|
+
import { importTickets } from "./intake/tickets.js";
|
|
9
|
+
import { parseIssueKey } from "./schemas.js";
|
|
10
|
+
/**
|
|
11
|
+
* `POST /api/jobs`. The body is the CLI intake, and the call goes through
|
|
12
|
+
* `addFromPrompt`, `addFromFile`, `importTickets`, or `addFromJira`.
|
|
13
|
+
*/
|
|
14
|
+
export async function intakeJobs(config, body, home = homedir()) {
|
|
15
|
+
const request = parseIntake(body);
|
|
16
|
+
try {
|
|
17
|
+
return await runIntake(config, request, home);
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (error instanceof HttpError)
|
|
21
|
+
throw error;
|
|
22
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
23
|
+
throw new HttpError(400, message);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async function runIntake(config, request, home) {
|
|
27
|
+
const shared = {
|
|
28
|
+
workflow: request.workflow,
|
|
29
|
+
repo: request.repo,
|
|
30
|
+
...(request.title !== undefined ? { title: request.title } : {}),
|
|
31
|
+
...(request.issue !== undefined ? { issue: request.issue } : {}),
|
|
32
|
+
dependsOn: request.after,
|
|
33
|
+
...(request.stack ? { stack: true } : {}),
|
|
34
|
+
};
|
|
35
|
+
if (request.source.kind === "prompt") {
|
|
36
|
+
if (request.dryRun)
|
|
37
|
+
throw new HttpError(400, "Dry-run is only supported for a tickets import.");
|
|
38
|
+
if (request.source.text === "-") {
|
|
39
|
+
throw new HttpError(400, "Pass the prompt text in source.text. Serve does not read stdin.");
|
|
40
|
+
}
|
|
41
|
+
const written = addFromPrompt(config, { ...shared, prompt: request.source.text });
|
|
42
|
+
return { ids: [written.job.id] };
|
|
43
|
+
}
|
|
44
|
+
if (request.source.kind === "spec") {
|
|
45
|
+
if (request.dryRun)
|
|
46
|
+
throw new HttpError(400, "Dry-run is only supported for a tickets import.");
|
|
47
|
+
const specPath = absoluteInside(home, request.source.path, "Spec path");
|
|
48
|
+
const written = addFromFile(config, { ...shared, specPath, cwd: process.cwd() });
|
|
49
|
+
return { ids: [written.job.id] };
|
|
50
|
+
}
|
|
51
|
+
if (request.source.kind === "jira") {
|
|
52
|
+
if (request.dryRun)
|
|
53
|
+
throw new HttpError(400, "Dry-run is only supported for a tickets import.");
|
|
54
|
+
const result = await addFromJira(config, { ...shared, key: request.source.key, dryRun: false });
|
|
55
|
+
return {
|
|
56
|
+
ids: result.job ? [result.job.id] : [],
|
|
57
|
+
created: result.created,
|
|
58
|
+
...(result.warnings.length > 0 ? { warnings: result.warnings } : {}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const dir = absoluteInside(home, request.source.dir, "Tickets directory");
|
|
62
|
+
const contextPath = request.source.context
|
|
63
|
+
? absoluteInside(home, request.source.context, "Context path")
|
|
64
|
+
: undefined;
|
|
65
|
+
const result = importTickets(config, {
|
|
66
|
+
dir,
|
|
67
|
+
repo: request.repo,
|
|
68
|
+
workflow: request.workflow,
|
|
69
|
+
...(contextPath ? { contextPath } : {}),
|
|
70
|
+
dryRun: request.dryRun,
|
|
71
|
+
...(request.stack ? { stack: true } : {}),
|
|
72
|
+
cwd: process.cwd(),
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
ids: result.ids,
|
|
76
|
+
jobs: result.jobs,
|
|
77
|
+
report: result.report,
|
|
78
|
+
...(request.dryRun ? { dryRun: true } : {}),
|
|
79
|
+
...(result.warnings.length > 0 ? { warnings: result.warnings } : {}),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function parseIntake(body) {
|
|
83
|
+
const record = object(body, "Request body must be a JSON object.");
|
|
84
|
+
const repo = requiredString(record, "repo", "Name a repo.");
|
|
85
|
+
const workflow = requiredString(record, "workflow", "Name a workflow.");
|
|
86
|
+
const title = optionalString(record, "title", "title must be a string.");
|
|
87
|
+
const after = stringList(record.after, "after must be a list of job ids.");
|
|
88
|
+
const stack = optionalBoolean(record.stack, "stack must be a boolean.");
|
|
89
|
+
const dryRun = optionalBoolean(record.dryRun, "dryRun must be a boolean.") ?? false;
|
|
90
|
+
const source = parseSource(record.source);
|
|
91
|
+
const issue = parseIssueField(record.issue);
|
|
92
|
+
if (source.kind === "tickets" && title !== undefined) {
|
|
93
|
+
throw new HttpError(400, "A tickets import does not take a title. Each file has its own.");
|
|
94
|
+
}
|
|
95
|
+
if (source.kind === "tickets" && issue !== undefined) {
|
|
96
|
+
throw new HttpError(400, "A tickets import does not take an issue. Each file has its own.");
|
|
97
|
+
}
|
|
98
|
+
if (source.kind === "tickets" && after.length > 0) {
|
|
99
|
+
throw new HttpError(400, "A tickets import does not take after. Blocked-by in the files sets dependencies.");
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
repo,
|
|
103
|
+
workflow,
|
|
104
|
+
...(title !== undefined ? { title } : {}),
|
|
105
|
+
...(issue !== undefined ? { issue } : {}),
|
|
106
|
+
after,
|
|
107
|
+
...(stack ? { stack: true } : {}),
|
|
108
|
+
dryRun,
|
|
109
|
+
source,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/** Same `KEY-123` rule as `kiln add --issue`. A blank field is omitted. */
|
|
113
|
+
function parseIssueField(value) {
|
|
114
|
+
if (value === undefined || value === null)
|
|
115
|
+
return undefined;
|
|
116
|
+
if (typeof value !== "string")
|
|
117
|
+
throw new HttpError(400, "issue must be a string.");
|
|
118
|
+
if (!value.trim())
|
|
119
|
+
return undefined;
|
|
120
|
+
try {
|
|
121
|
+
return parseIssueKey(value);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
125
|
+
throw new HttpError(400, message);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function parseSource(value) {
|
|
129
|
+
const source = object(value, "Name a source: prompt, spec, tickets, or jira.");
|
|
130
|
+
const kind = source.kind;
|
|
131
|
+
if (kind === "prompt") {
|
|
132
|
+
if (typeof source.text !== "string")
|
|
133
|
+
throw new HttpError(400, "Prompt text must be a string.");
|
|
134
|
+
return { kind, text: source.text };
|
|
135
|
+
}
|
|
136
|
+
if (kind === "spec") {
|
|
137
|
+
if (typeof source.path !== "string")
|
|
138
|
+
throw new HttpError(400, "Spec path must be a string.");
|
|
139
|
+
return { kind, path: source.path };
|
|
140
|
+
}
|
|
141
|
+
if (kind === "tickets") {
|
|
142
|
+
if (typeof source.dir !== "string")
|
|
143
|
+
throw new HttpError(400, "Tickets directory must be a string.");
|
|
144
|
+
if (source.context !== undefined && typeof source.context !== "string") {
|
|
145
|
+
throw new HttpError(400, "Context path must be a string.");
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
kind,
|
|
149
|
+
dir: source.dir,
|
|
150
|
+
...(typeof source.context === "string" ? { context: source.context } : {}),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (kind === "jira") {
|
|
154
|
+
if (typeof source.key !== "string")
|
|
155
|
+
throw new HttpError(400, "Jira key must be a string.");
|
|
156
|
+
return { kind, key: source.key };
|
|
157
|
+
}
|
|
158
|
+
throw new HttpError(400, "Source kind must be prompt, spec, tickets, or jira.");
|
|
159
|
+
}
|
|
160
|
+
function absoluteInside(home, path, label) {
|
|
161
|
+
if (!path.trim() || !isAbsolute(path)) {
|
|
162
|
+
throw new HttpError(400, `${label} must be an absolute path.`);
|
|
163
|
+
}
|
|
164
|
+
const absolute = resolve(path);
|
|
165
|
+
assertInsideHome(home, absolute);
|
|
166
|
+
return absolute;
|
|
167
|
+
}
|
|
168
|
+
function object(value, message) {
|
|
169
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
170
|
+
throw new HttpError(400, message);
|
|
171
|
+
}
|
|
172
|
+
return value;
|
|
173
|
+
}
|
|
174
|
+
function requiredString(record, key, message) {
|
|
175
|
+
const value = record[key];
|
|
176
|
+
if (typeof value !== "string" || !value.trim())
|
|
177
|
+
throw new HttpError(400, message);
|
|
178
|
+
return value.trim();
|
|
179
|
+
}
|
|
180
|
+
function optionalString(record, key, message) {
|
|
181
|
+
if (record[key] === undefined)
|
|
182
|
+
return undefined;
|
|
183
|
+
if (typeof record[key] !== "string")
|
|
184
|
+
throw new HttpError(400, message);
|
|
185
|
+
return record[key];
|
|
186
|
+
}
|
|
187
|
+
function optionalBoolean(value, message) {
|
|
188
|
+
if (value === undefined)
|
|
189
|
+
return undefined;
|
|
190
|
+
if (typeof value !== "boolean")
|
|
191
|
+
throw new HttpError(400, message);
|
|
192
|
+
return value;
|
|
193
|
+
}
|
|
194
|
+
function stringList(value, message) {
|
|
195
|
+
if (value === undefined)
|
|
196
|
+
return [];
|
|
197
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !item.trim())) {
|
|
198
|
+
throw new HttpError(400, message);
|
|
199
|
+
}
|
|
200
|
+
return value.map((item) => item.trim());
|
|
201
|
+
}
|
package/dist/jobs.js
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from
|
|
|
3
3
|
import { basename, isAbsolute, join } from "node:path";
|
|
4
4
|
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
5
5
|
import { jobBasename } from "./paths.js";
|
|
6
|
-
import { JobSchema } from "./schemas.js";
|
|
6
|
+
import { JobSchema, parseIssueKey } from "./schemas.js";
|
|
7
7
|
import { assertWorkflowName } from "./workflow.js";
|
|
8
8
|
export function newJobId(now = new Date()) {
|
|
9
9
|
const stamp = now.toISOString().replace(/[-:TZ.]/g, "").slice(0, 14);
|
|
@@ -52,6 +52,7 @@ export function writeJob(config, input) {
|
|
|
52
52
|
if (!title) {
|
|
53
53
|
throw new Error("Job title is empty. Pass --title.");
|
|
54
54
|
}
|
|
55
|
+
const issue = input.issue !== undefined ? parseIssueKey(input.issue) : undefined;
|
|
55
56
|
const now = input.now ?? new Date();
|
|
56
57
|
const id = input.id ?? newJobId(now);
|
|
57
58
|
const dependsOn = normalizedDepends(input.dependsOn);
|
|
@@ -80,6 +81,7 @@ export function writeJob(config, input) {
|
|
|
80
81
|
title,
|
|
81
82
|
...(dependsOn.length > 0 ? { dependsOn } : {}),
|
|
82
83
|
...(input.stack ? { stack: true } : {}),
|
|
84
|
+
...(issue ? { issue } : {}),
|
|
83
85
|
source: input.source,
|
|
84
86
|
createdAt: now.toISOString(),
|
|
85
87
|
});
|
|
@@ -90,6 +92,8 @@ export function writeJob(config, input) {
|
|
|
90
92
|
}
|
|
91
93
|
export function renderAddedJob(job, jsonPath) {
|
|
92
94
|
const lines = [`added ${job.id}`, `repo ${job.repo}`, `workflow ${job.workflow}`, `spec ${job.spec}`];
|
|
95
|
+
if (job.issue)
|
|
96
|
+
lines.push(`issue ${job.issue}`);
|
|
93
97
|
if (job.stack)
|
|
94
98
|
lines.push("stack");
|
|
95
99
|
if (job.source)
|
package/dist/pr-names.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pull request title and remote branch for `deliver=pr`.
|
|
3
|
+
* Yak keeps the local branch `yak/<run-id>`. Kiln pushes that ref to a
|
|
4
|
+
* readable name and opens the pull request with `--head` set to it.
|
|
5
|
+
*/
|
|
6
|
+
export const DEFAULT_PR_TITLE = "[{issue}] {title}";
|
|
7
|
+
/** Default remote branch when the job has an issue. Renders as `ABC-123-slug`. */
|
|
8
|
+
export const DEFAULT_BRANCH_TEMPLATE = "{issue}-{slug}";
|
|
9
|
+
export function stripIssuePrefix(title, issue) {
|
|
10
|
+
const trimmed = title.trim();
|
|
11
|
+
const escaped = issue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
12
|
+
const patterns = [
|
|
13
|
+
new RegExp(`^\\[${escaped}\\]\\s*`),
|
|
14
|
+
new RegExp(`^${escaped}:\\s*`),
|
|
15
|
+
new RegExp(`^${escaped}\\s+`),
|
|
16
|
+
];
|
|
17
|
+
for (const pattern of patterns) {
|
|
18
|
+
if (!pattern.test(trimmed))
|
|
19
|
+
continue;
|
|
20
|
+
const next = trimmed.replace(pattern, "").trim();
|
|
21
|
+
return next || trimmed;
|
|
22
|
+
}
|
|
23
|
+
return trimmed;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* No issue: the title is unchanged, including when a repo template is set.
|
|
27
|
+
* With an issue: strip a leading `ABC-123:`, `ABC-123 `, or `[ABC-123]`
|
|
28
|
+
* so a Jira `KEY: summary` title is not applied twice, then fill the template.
|
|
29
|
+
* The default template is `[{issue}] {title}`.
|
|
30
|
+
*/
|
|
31
|
+
export function pullRequestTitle(input) {
|
|
32
|
+
const raw = input.title.trim();
|
|
33
|
+
if (!input.issue)
|
|
34
|
+
return raw;
|
|
35
|
+
const title = stripIssuePrefix(raw, input.issue);
|
|
36
|
+
const template = input.template?.trim() || DEFAULT_PR_TITLE;
|
|
37
|
+
return template.replaceAll("{issue}", input.issue).replaceAll("{title}", title).replace(/[ \t]{2,}/g, " ").trim();
|
|
38
|
+
}
|
|
39
|
+
/** Kebab-case title, at most 50 characters. An issue prefix is not part of the slug. */
|
|
40
|
+
export function titleSlug(title, issue) {
|
|
41
|
+
const base = issue ? stripIssuePrefix(title, issue) : title.trim();
|
|
42
|
+
const slug = base
|
|
43
|
+
.toLowerCase()
|
|
44
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
45
|
+
.replace(/^-+|-+$/g, "");
|
|
46
|
+
const cut = slug.slice(0, 50).replace(/-+$/g, "");
|
|
47
|
+
return cut || "task";
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The random suffix of a kiln job id (`j` + 14 digits + 4 hex), otherwise
|
|
51
|
+
* the last six alphanumeric characters.
|
|
52
|
+
*/
|
|
53
|
+
export function shortJobId(jobId) {
|
|
54
|
+
const hex = /^j\d{14}([0-9a-f]{4})$/.exec(jobId);
|
|
55
|
+
if (hex?.[1])
|
|
56
|
+
return hex[1];
|
|
57
|
+
const compact = jobId.replace(/[^A-Za-z0-9]/g, "").toLowerCase();
|
|
58
|
+
return compact.slice(-6) || "job";
|
|
59
|
+
}
|
|
60
|
+
export function defaultRemoteBranch(input) {
|
|
61
|
+
if (input.issue)
|
|
62
|
+
return formatBranchTemplate(DEFAULT_BRANCH_TEMPLATE, input);
|
|
63
|
+
const slug = titleSlug(input.title, input.issue);
|
|
64
|
+
return `kiln/${slug}-${shortJobId(input.jobId)}`;
|
|
65
|
+
}
|
|
66
|
+
export function formatBranchTemplate(template, input) {
|
|
67
|
+
const slug = titleSlug(input.title, input.issue);
|
|
68
|
+
return template
|
|
69
|
+
.replaceAll("{issue}", input.issue ?? "")
|
|
70
|
+
.replaceAll("{slug}", slug)
|
|
71
|
+
.replaceAll("{jobId}", input.jobId)
|
|
72
|
+
.replace(/\/{2,}/g, "/")
|
|
73
|
+
.replace(/-{2,}/g, "-")
|
|
74
|
+
.replace(/^[-\/]+|[-\/]+$/g, "");
|
|
75
|
+
}
|
|
76
|
+
export function isPublishableBranch(name) {
|
|
77
|
+
return (/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(name) &&
|
|
78
|
+
!name.includes("..") &&
|
|
79
|
+
!name.includes("//") &&
|
|
80
|
+
!name.endsWith("/") &&
|
|
81
|
+
!name.endsWith(".lock") &&
|
|
82
|
+
!name.includes("@"));
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Remote branch for this job. A name that already points at `localSha` is
|
|
86
|
+
* ours (a crash between push and the delivery record). Any other existing
|
|
87
|
+
* remote branch gets `-<short job id>` appended, or the full job id when
|
|
88
|
+
* the short id is already on the name.
|
|
89
|
+
*/
|
|
90
|
+
export function chooseRemoteBranch(input) {
|
|
91
|
+
if (input.recorded)
|
|
92
|
+
return input.recorded;
|
|
93
|
+
const desired = input.template ? formatBranchTemplate(input.template, input) : defaultRemoteBranch(input);
|
|
94
|
+
assertPublishable(desired);
|
|
95
|
+
const current = input.remoteSha(desired);
|
|
96
|
+
if (current === null || current === input.localSha)
|
|
97
|
+
return desired;
|
|
98
|
+
const short = shortJobId(input.jobId);
|
|
99
|
+
const already = desired.endsWith(`-${short}`) || desired.endsWith(`/${short}`);
|
|
100
|
+
const suffixed = (already ? `${desired}-${input.jobId}` : `${desired}-${short}`)
|
|
101
|
+
.replace(/[^A-Za-z0-9._/-]/g, "-")
|
|
102
|
+
.replace(/\/{2,}/g, "/")
|
|
103
|
+
.replace(/-{2,}/g, "-");
|
|
104
|
+
assertPublishable(suffixed);
|
|
105
|
+
const altSha = input.remoteSha(suffixed);
|
|
106
|
+
if (altSha === null || altSha === input.localSha)
|
|
107
|
+
return suffixed;
|
|
108
|
+
const full = `${desired}-${input.jobId}`.replace(/[^A-Za-z0-9._/-]/g, "-").replace(/-{2,}/g, "-");
|
|
109
|
+
assertPublishable(full);
|
|
110
|
+
return full;
|
|
111
|
+
}
|
|
112
|
+
function assertPublishable(name) {
|
|
113
|
+
if (isPublishableBranch(name))
|
|
114
|
+
return;
|
|
115
|
+
throw new Error(`Branch name ${JSON.stringify(name)} is not a safe git branch. Check the repo branch template.`);
|
|
116
|
+
}
|
package/dist/reconcile.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { INVESTIGATION_WORKFLOW } from "./findings.js";
|
|
2
|
-
/**
|
|
3
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Why an attempt failed. Written under `<kilnDir>/attempts`, not on the job.
|
|
4
|
+
* `auth-failed` is not an automatic retry: the job stays in Needs human.
|
|
5
|
+
*/
|
|
6
|
+
export const FAILURE_REASONS = ["stalled", "orphaned", "crashed", "failed", "auth-failed"];
|
|
4
7
|
export function inFlightCount(observation) {
|
|
5
8
|
return observation.jobs.filter((job) => inFlight(job, observation)).length;
|
|
6
9
|
}
|