@lchase/kiln 0.6.1 → 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.
@@ -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
- assertYakBranch(target, baseBranch);
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"}`);
@@ -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 } : {}),
@@ -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() || `${key}: ${issue.summary}`;
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 = [`dry-run ${input.key}`, `repo ${input.repo}`, `workflow ${input.workflow}`, `title ${input.title}`];
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", "");
@@ -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 } : {}),
@@ -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)
@@ -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
- /** Why a transient attempt failed. Written under `<kilnDir>/attempts`, not on the job. */
3
- export const FAILURE_REASONS = ["stalled", "orphaned", "crashed", "failed"];
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
  }