@lchase/kiln 0.0.0-stage → 0.3.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 +263 -2
- package/assets/serve.css +233 -0
- package/dist/cli.js +409 -0
- package/dist/config.js +255 -0
- package/dist/delivery.js +245 -0
- package/dist/driver.js +825 -0
- package/dist/errors.js +6 -0
- package/dist/findings.js +79 -0
- package/dist/host.js +223 -0
- package/dist/intake/file.js +29 -0
- package/dist/intake/jira.js +585 -0
- package/dist/intake/prompt.js +47 -0
- package/dist/intake/tickets.js +448 -0
- package/dist/jobs.js +154 -0
- package/dist/paths.js +34 -0
- package/dist/reconcile.js +141 -0
- package/dist/report.js +254 -0
- package/dist/schemas.js +95 -0
- package/dist/serve-page.js +216 -0
- package/dist/serve.js +351 -0
- package/dist/truncate-output.js +122 -0
- package/dist/workflow.js +131 -0
- package/package.json +41 -4
- package/prompts/factory/approver.md +49 -0
- package/prompts/factory/builder.md +76 -0
- package/prompts/factory/review-code.md +55 -0
- package/prompts/factory/review-security.md +50 -0
- package/prompts/factory/review-ui.md +50 -0
- package/prompts/factory/review-ux.md +48 -0
- package/prompts/factory/spec-writer.md +67 -0
- package/prompts/kiln/approver.md +33 -0
- package/prompts/kiln/build.md +38 -0
- package/prompts/kiln/code.md +26 -0
- package/prompts/kiln/investigation.md +31 -0
- package/prompts/kiln/security.md +26 -0
- package/prompts/kiln/spec.md +18 -0
- package/prompts/kiln/ui.md +26 -0
- package/prompts/kiln/ux.md +26 -0
- package/workflows/feature.yaml +239 -0
- package/workflows/investigation.yaml +67 -0
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
import { assertCanStack, listValidJobs, renderAddedJob, writeJob } from "../jobs.js";
|
|
2
|
+
import { assertWorkflowName } from "../workflow.js";
|
|
3
|
+
import { INVESTIGATION_WORKFLOW } from "../findings.js";
|
|
4
|
+
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
|
+
const ENV_BASE_URL = "JIRA_BASE_URL";
|
|
8
|
+
const ENV_EMAIL = "JIRA_EMAIL";
|
|
9
|
+
const ENV_API_TOKEN = "JIRA_API_TOKEN";
|
|
10
|
+
/**
|
|
11
|
+
* `kiln add <workflow> --repo R --jira PROJ-123`.
|
|
12
|
+
* One issue becomes one job. Re-running the same key prints the existing id.
|
|
13
|
+
*/
|
|
14
|
+
export async function addFromJira(config, input) {
|
|
15
|
+
const key = normalizeIssueKey(input.key);
|
|
16
|
+
assertKnownRepo(config, input.repo);
|
|
17
|
+
assertWorkflowName(input.workflow);
|
|
18
|
+
const existing = existingJiraJob(config, key);
|
|
19
|
+
if (existing) {
|
|
20
|
+
return { created: false, job: existing, report: renderExisting(existing), warnings: [] };
|
|
21
|
+
}
|
|
22
|
+
const credentials = resolveJiraCredentials(config, input.env ?? process.env);
|
|
23
|
+
const fetchImpl = input.fetch ?? globalThis.fetch;
|
|
24
|
+
const client = { fetchImpl, credentials };
|
|
25
|
+
const acceptanceField = await findAcceptanceCriteriaField(client);
|
|
26
|
+
const issueUrl = jiraIssueUrl(credentials.baseUrl, key, acceptanceField);
|
|
27
|
+
const issueResponse = await jiraGet(issueUrl, client);
|
|
28
|
+
if (issueResponse.status !== 200) {
|
|
29
|
+
failStatus(key, issueResponse.status, issueResponse.body, credentials);
|
|
30
|
+
}
|
|
31
|
+
const issue = parseIssue(key, parseJson(issueResponse.body, key), acceptanceField);
|
|
32
|
+
const warnings = [];
|
|
33
|
+
const workflow = workflowForSpike(typeLooksLikeSpike(issue.issueType), input.workflow);
|
|
34
|
+
if (workflow === INVESTIGATION_WORKFLOW)
|
|
35
|
+
assertWorkflowName(workflow);
|
|
36
|
+
const dependsOn = mergeDepends(dependencyIds(config, issue.blockedBy, warnings), input.dependsOn);
|
|
37
|
+
assertDependsExist(config, dependsOn);
|
|
38
|
+
const specText = jiraIssueSpec({
|
|
39
|
+
key,
|
|
40
|
+
summary: issue.summary,
|
|
41
|
+
browseUrl: `${credentials.baseUrl}/browse/${encodeURIComponent(key)}`,
|
|
42
|
+
descriptionMarkdown: adfToMarkdown(issue.description),
|
|
43
|
+
acceptanceMarkdown: acceptanceMarkdown(issue.acceptance),
|
|
44
|
+
});
|
|
45
|
+
assertCanStack(input.repo, dependsOn, input.stack, listValidJobs(config));
|
|
46
|
+
const title = input.title?.trim() || `${key}: ${issue.summary}`;
|
|
47
|
+
if (!title) {
|
|
48
|
+
throw new Error("Job title is empty. Pass --title.");
|
|
49
|
+
}
|
|
50
|
+
const safeSpec = redactSecrets(specText, credentials);
|
|
51
|
+
if (input.dryRun) {
|
|
52
|
+
return {
|
|
53
|
+
created: false,
|
|
54
|
+
report: renderDryRun({
|
|
55
|
+
key,
|
|
56
|
+
repo: input.repo,
|
|
57
|
+
workflow,
|
|
58
|
+
title: redactSecrets(title, credentials),
|
|
59
|
+
dependsOn,
|
|
60
|
+
specText: safeSpec,
|
|
61
|
+
}),
|
|
62
|
+
warnings,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const written = writeJob(config, {
|
|
66
|
+
workflow,
|
|
67
|
+
repo: input.repo,
|
|
68
|
+
specText: safeSpec,
|
|
69
|
+
title: redactSecrets(title, credentials),
|
|
70
|
+
dependsOn,
|
|
71
|
+
...(input.stack ? { stack: true } : {}),
|
|
72
|
+
source: { kind: "jira", ref: key },
|
|
73
|
+
...(input.now ? { now: input.now } : {}),
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
created: true,
|
|
77
|
+
job: written.job,
|
|
78
|
+
jsonPath: written.jsonPath,
|
|
79
|
+
report: renderAddedJob(written.job, written.jsonPath),
|
|
80
|
+
warnings,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export function normalizeIssueKey(raw) {
|
|
84
|
+
const key = raw.trim().toUpperCase();
|
|
85
|
+
if (!ISSUE_KEY.test(key)) {
|
|
86
|
+
throw new Error(`Jira issue key ${JSON.stringify(raw)} is not a KEY-123 identifier.`);
|
|
87
|
+
}
|
|
88
|
+
return key;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Atlassian document format to markdown. Unknown nodes contribute their text.
|
|
92
|
+
* Covered: paragraph, heading, bullet and ordered lists, list items, code
|
|
93
|
+
* blocks, blockquotes, text marks (strong, em, code, link), hard breaks, rules.
|
|
94
|
+
*/
|
|
95
|
+
export function adfToMarkdown(value) {
|
|
96
|
+
if (typeof value === "string")
|
|
97
|
+
return value.trim();
|
|
98
|
+
if (value === null || value === undefined)
|
|
99
|
+
return "";
|
|
100
|
+
const node = asNode(value);
|
|
101
|
+
if (!node)
|
|
102
|
+
return "";
|
|
103
|
+
const blocks = node.type === "doc" ? childNodes(node) : [node];
|
|
104
|
+
return renderBlocks(blocks).trim();
|
|
105
|
+
}
|
|
106
|
+
function jiraIssueSpec(input) {
|
|
107
|
+
const lines = [`# ${input.key}: ${input.summary}`, `Source: ${input.browseUrl}`];
|
|
108
|
+
const description = input.descriptionMarkdown.trim();
|
|
109
|
+
if (description)
|
|
110
|
+
lines.push("", description);
|
|
111
|
+
const acceptance = input.acceptanceMarkdown.trim();
|
|
112
|
+
if (acceptance)
|
|
113
|
+
lines.push("", "## Acceptance criteria", "", acceptance);
|
|
114
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
115
|
+
}
|
|
116
|
+
function resolveJiraCredentials(config, env) {
|
|
117
|
+
const file = config.jira;
|
|
118
|
+
const baseUrl = firstSet(env[ENV_BASE_URL], file?.baseUrl);
|
|
119
|
+
const email = firstSet(env[ENV_EMAIL], file?.email);
|
|
120
|
+
const apiToken = firstSet(env[ENV_API_TOKEN], file?.apiToken);
|
|
121
|
+
const missing = [];
|
|
122
|
+
if (!baseUrl)
|
|
123
|
+
missing.push(ENV_BASE_URL);
|
|
124
|
+
if (!email)
|
|
125
|
+
missing.push(ENV_EMAIL);
|
|
126
|
+
if (!apiToken)
|
|
127
|
+
missing.push(ENV_API_TOKEN);
|
|
128
|
+
if (!baseUrl || !email || !apiToken) {
|
|
129
|
+
throw new Error(`Jira credentials are missing: ${missing.join(", ")}. Set them in the environment, or add baseUrl, email, and apiToken to the jira block in the kiln config.`);
|
|
130
|
+
}
|
|
131
|
+
return { baseUrl: normalizeBaseUrl(baseUrl), email, apiToken };
|
|
132
|
+
}
|
|
133
|
+
function firstSet(envValue, fileValue) {
|
|
134
|
+
const fromEnv = envValue?.trim();
|
|
135
|
+
if (fromEnv)
|
|
136
|
+
return fromEnv;
|
|
137
|
+
const fromFile = fileValue?.trim();
|
|
138
|
+
return fromFile || undefined;
|
|
139
|
+
}
|
|
140
|
+
function normalizeBaseUrl(raw) {
|
|
141
|
+
let url;
|
|
142
|
+
try {
|
|
143
|
+
url = new URL(raw);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
throw new Error(`${ENV_BASE_URL} is not a valid http or https URL.`);
|
|
147
|
+
}
|
|
148
|
+
if (url.username || url.password) {
|
|
149
|
+
throw new Error(`${ENV_BASE_URL} must not include a username or password.`);
|
|
150
|
+
}
|
|
151
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
152
|
+
throw new Error(`${ENV_BASE_URL} is not a valid http or https URL.`);
|
|
153
|
+
}
|
|
154
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
155
|
+
return `${url.origin}${path === "/" ? "" : path}`;
|
|
156
|
+
}
|
|
157
|
+
async function findAcceptanceCriteriaField(client) {
|
|
158
|
+
const result = await jiraGet(`${client.credentials.baseUrl}/rest/api/3/field`, client);
|
|
159
|
+
if (result.status !== 200)
|
|
160
|
+
return undefined;
|
|
161
|
+
let parsed;
|
|
162
|
+
try {
|
|
163
|
+
parsed = JSON.parse(result.body);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
if (!Array.isArray(parsed))
|
|
169
|
+
return undefined;
|
|
170
|
+
for (const field of parsed) {
|
|
171
|
+
if (typeof field !== "object" || field === null)
|
|
172
|
+
continue;
|
|
173
|
+
const record = field;
|
|
174
|
+
if (typeof record.name !== "string" || !/^acceptance criteria$/i.test(record.name.trim()))
|
|
175
|
+
continue;
|
|
176
|
+
if (typeof record.id !== "string" || !/^customfield_\d+$/.test(record.id))
|
|
177
|
+
continue;
|
|
178
|
+
return record.id;
|
|
179
|
+
}
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
function jiraIssueUrl(baseUrl, key, acceptanceField) {
|
|
183
|
+
const fields = ["summary", "description", "issuetype", "issuelinks", "status"];
|
|
184
|
+
if (acceptanceField)
|
|
185
|
+
fields.push(acceptanceField);
|
|
186
|
+
const query = new URLSearchParams({ fields: fields.join(",") });
|
|
187
|
+
return `${baseUrl}/rest/api/3/issue/${encodeURIComponent(key)}?${query.toString()}`;
|
|
188
|
+
}
|
|
189
|
+
async function jiraGet(url, client) {
|
|
190
|
+
const { credentials, fetchImpl } = client;
|
|
191
|
+
let response;
|
|
192
|
+
try {
|
|
193
|
+
response = await fetchImpl(url, {
|
|
194
|
+
method: "GET",
|
|
195
|
+
headers: {
|
|
196
|
+
Authorization: authorizationHeader(credentials),
|
|
197
|
+
Accept: "application/json",
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
203
|
+
throw new Error(redactSecrets(`Could not reach Jira at ${credentials.baseUrl}: ${message}`, credentials));
|
|
204
|
+
}
|
|
205
|
+
let body = "";
|
|
206
|
+
try {
|
|
207
|
+
body = await response.text();
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
body = "";
|
|
211
|
+
}
|
|
212
|
+
return { status: response.status, body: redactSecrets(body, credentials) };
|
|
213
|
+
}
|
|
214
|
+
function authorizationHeader(credentials) {
|
|
215
|
+
const encoded = Buffer.from(`${credentials.email}:${credentials.apiToken}`, "utf8").toString("base64");
|
|
216
|
+
return `Basic ${encoded}`;
|
|
217
|
+
}
|
|
218
|
+
function failStatus(key, status, body, credentials) {
|
|
219
|
+
const detail = jiraErrorDetail(body);
|
|
220
|
+
const suffix = detail ? `: ${detail}` : "";
|
|
221
|
+
const message = status === 401 || status === 403
|
|
222
|
+
? `Jira authentication failed for ${key} (HTTP ${status}). Check ${ENV_EMAIL} and ${ENV_API_TOKEN}${suffix}.`
|
|
223
|
+
: status === 404
|
|
224
|
+
? `Jira issue ${key} was not found (HTTP 404)${suffix}.`
|
|
225
|
+
: `Jira request for ${key} failed (HTTP ${status})${suffix}.`;
|
|
226
|
+
throw new Error(redactSecrets(message, credentials));
|
|
227
|
+
}
|
|
228
|
+
function jiraErrorDetail(body) {
|
|
229
|
+
const trimmed = body.trim();
|
|
230
|
+
if (!trimmed)
|
|
231
|
+
return "";
|
|
232
|
+
try {
|
|
233
|
+
const parsed = JSON.parse(trimmed);
|
|
234
|
+
const messages = [];
|
|
235
|
+
if (Array.isArray(parsed.errorMessages)) {
|
|
236
|
+
for (const item of parsed.errorMessages) {
|
|
237
|
+
if (typeof item === "string" && item.trim())
|
|
238
|
+
messages.push(item.trim());
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (typeof parsed.errors === "object" && parsed.errors !== null) {
|
|
242
|
+
for (const value of Object.values(parsed.errors)) {
|
|
243
|
+
if (typeof value === "string" && value.trim())
|
|
244
|
+
messages.push(value.trim());
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (messages.length > 0)
|
|
248
|
+
return messages.join("; ").slice(0, 300);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
// The body is not JSON. A short slice is still more useful than the status alone.
|
|
252
|
+
}
|
|
253
|
+
return trimmed.slice(0, 300).replace(/\s+/g, " ");
|
|
254
|
+
}
|
|
255
|
+
function parseJson(body, key) {
|
|
256
|
+
if (!body.trim()) {
|
|
257
|
+
throw new Error(`Jira returned an unreadable response for ${key} (HTTP 200).`);
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
return JSON.parse(body);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
throw new Error(`Jira returned an unreadable response for ${key} (HTTP 200).`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function parseIssue(key, payload, acceptanceField) {
|
|
267
|
+
if (typeof payload !== "object" || payload === null) {
|
|
268
|
+
throw new Error(`Jira returned an unreadable response for ${key} (HTTP 200).`);
|
|
269
|
+
}
|
|
270
|
+
const fields = payload.fields;
|
|
271
|
+
if (typeof fields !== "object" || fields === null) {
|
|
272
|
+
throw new Error(`Jira issue ${key} has no fields.`);
|
|
273
|
+
}
|
|
274
|
+
const record = fields;
|
|
275
|
+
if (typeof record.summary !== "string" || !record.summary.trim()) {
|
|
276
|
+
throw new Error(`Jira issue ${key} has no summary.`);
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
summary: record.summary.trim().replace(/\s+/g, " "),
|
|
280
|
+
description: record.description,
|
|
281
|
+
issueType: readIssueType(record.issuetype),
|
|
282
|
+
acceptance: acceptanceField ? record[acceptanceField] : undefined,
|
|
283
|
+
blockedBy: readBlockedBy(record.issuelinks, key),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
function readIssueType(value) {
|
|
287
|
+
if (typeof value !== "object" || value === null)
|
|
288
|
+
return undefined;
|
|
289
|
+
const name = value.name;
|
|
290
|
+
if (typeof name !== "string")
|
|
291
|
+
return undefined;
|
|
292
|
+
const trimmed = name.trim();
|
|
293
|
+
return trimmed || undefined;
|
|
294
|
+
}
|
|
295
|
+
function readBlockedBy(links, selfKey) {
|
|
296
|
+
if (!Array.isArray(links))
|
|
297
|
+
return [];
|
|
298
|
+
const keys = [];
|
|
299
|
+
const seen = new Set();
|
|
300
|
+
for (const link of links) {
|
|
301
|
+
if (typeof link !== "object" || link === null)
|
|
302
|
+
continue;
|
|
303
|
+
const record = link;
|
|
304
|
+
const name = record.type?.name;
|
|
305
|
+
if (typeof name !== "string" || !/^blocks$/i.test(name.trim()))
|
|
306
|
+
continue;
|
|
307
|
+
const raw = record.inwardIssue?.key;
|
|
308
|
+
if (typeof raw !== "string")
|
|
309
|
+
continue;
|
|
310
|
+
const blocker = raw.trim().toUpperCase();
|
|
311
|
+
if (!ISSUE_KEY.test(blocker) || blocker === selfKey || seen.has(blocker))
|
|
312
|
+
continue;
|
|
313
|
+
seen.add(blocker);
|
|
314
|
+
keys.push(blocker);
|
|
315
|
+
}
|
|
316
|
+
return keys;
|
|
317
|
+
}
|
|
318
|
+
function acceptanceMarkdown(value) {
|
|
319
|
+
if (value === null || value === undefined)
|
|
320
|
+
return "";
|
|
321
|
+
if (typeof value === "string")
|
|
322
|
+
return value.trim();
|
|
323
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
324
|
+
return String(value);
|
|
325
|
+
return adfToMarkdown(value);
|
|
326
|
+
}
|
|
327
|
+
function existingJiraJob(config, key) {
|
|
328
|
+
return jiraJobs(config, key)[0];
|
|
329
|
+
}
|
|
330
|
+
function dependencyIds(config, blockers, warnings) {
|
|
331
|
+
const ids = [];
|
|
332
|
+
for (const blocker of blockers) {
|
|
333
|
+
const match = jiraJobs(config, blocker)[0];
|
|
334
|
+
if (!match) {
|
|
335
|
+
warnings.push(`warning: blocked by ${blocker}, which has no kiln job yet.`);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
ids.push(match.id);
|
|
339
|
+
}
|
|
340
|
+
return ids;
|
|
341
|
+
}
|
|
342
|
+
function jiraJobs(config, key) {
|
|
343
|
+
return listValidJobs(config)
|
|
344
|
+
.filter((job) => job.source?.kind === "jira" && job.source.ref === key)
|
|
345
|
+
.sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id));
|
|
346
|
+
}
|
|
347
|
+
function mergeDepends(fromLinks, after) {
|
|
348
|
+
const repeated = new Set();
|
|
349
|
+
for (const id of after) {
|
|
350
|
+
if (repeated.has(id)) {
|
|
351
|
+
throw new Error(`--after lists ${id} more than once.`);
|
|
352
|
+
}
|
|
353
|
+
repeated.add(id);
|
|
354
|
+
}
|
|
355
|
+
const seen = new Set();
|
|
356
|
+
const out = [];
|
|
357
|
+
for (const id of [...fromLinks, ...after]) {
|
|
358
|
+
if (seen.has(id))
|
|
359
|
+
continue;
|
|
360
|
+
seen.add(id);
|
|
361
|
+
out.push(id);
|
|
362
|
+
}
|
|
363
|
+
return out;
|
|
364
|
+
}
|
|
365
|
+
function assertDependsExist(config, dependsOn) {
|
|
366
|
+
const known = new Set(listValidJobs(config).map((job) => job.id));
|
|
367
|
+
for (const id of dependsOn) {
|
|
368
|
+
if (!known.has(id)) {
|
|
369
|
+
throw new Error(`Job ${id} does not exist. --after must name a job already under ${config.jobsDir}.`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
function assertKnownRepo(config, repo) {
|
|
374
|
+
if (config.repos[repo])
|
|
375
|
+
return;
|
|
376
|
+
const known = Object.keys(config.repos).sort();
|
|
377
|
+
const suffix = known.length > 0 ? ` Known repos: ${known.join(", ")}.` : " No repos are registered.";
|
|
378
|
+
throw new Error(`Unknown repo ${repo}.${suffix} Run kiln repo add ${repo} <path>.`);
|
|
379
|
+
}
|
|
380
|
+
function renderExisting(job) {
|
|
381
|
+
const ref = job.source?.ref ?? "";
|
|
382
|
+
return [`exists ${job.id}`, `repo ${job.repo}`, `workflow ${job.workflow}`, `source jira ${ref}`, ""].join("\n");
|
|
383
|
+
}
|
|
384
|
+
function renderDryRun(input) {
|
|
385
|
+
const lines = [`dry-run ${input.key}`, `repo ${input.repo}`, `workflow ${input.workflow}`, `title ${input.title}`];
|
|
386
|
+
if (input.dependsOn.length > 0)
|
|
387
|
+
lines.push(`dependsOn ${input.dependsOn.join(", ")}`);
|
|
388
|
+
lines.push(`source jira ${input.key}`, "spec:", input.specText.trimEnd(), "created nothing", "");
|
|
389
|
+
return lines.join("\n");
|
|
390
|
+
}
|
|
391
|
+
/** The API token is sent as a Basic header and must not appear in errors or job files. */
|
|
392
|
+
function redactSecrets(text, credentials) {
|
|
393
|
+
const basic = Buffer.from(`${credentials.email}:${credentials.apiToken}`, "utf8").toString("base64");
|
|
394
|
+
return text.split(credentials.apiToken).join("[redacted]").split(basic).join("[redacted]");
|
|
395
|
+
}
|
|
396
|
+
function asNode(value) {
|
|
397
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
398
|
+
return undefined;
|
|
399
|
+
return value;
|
|
400
|
+
}
|
|
401
|
+
function childNodes(node) {
|
|
402
|
+
if (!Array.isArray(node.content))
|
|
403
|
+
return [];
|
|
404
|
+
const nodes = [];
|
|
405
|
+
for (const child of node.content) {
|
|
406
|
+
const nodeChild = asNode(child);
|
|
407
|
+
if (nodeChild)
|
|
408
|
+
nodes.push(nodeChild);
|
|
409
|
+
}
|
|
410
|
+
return nodes;
|
|
411
|
+
}
|
|
412
|
+
function renderBlocks(nodes) {
|
|
413
|
+
return nodes.map((node) => renderBlock(node, 0)).join("");
|
|
414
|
+
}
|
|
415
|
+
function renderBlock(node, depth) {
|
|
416
|
+
switch (node.type) {
|
|
417
|
+
case "doc":
|
|
418
|
+
return renderBlocks(childNodes(node));
|
|
419
|
+
case "paragraph":
|
|
420
|
+
return `${renderInline(childNodes(node)).trim()}\n\n`;
|
|
421
|
+
case "heading":
|
|
422
|
+
return `${"#".repeat(headingLevel(node.attrs?.level))} ${renderInline(childNodes(node)).trim()}\n\n`;
|
|
423
|
+
case "bulletList":
|
|
424
|
+
return `${renderList(node, "bullet", depth)}\n`;
|
|
425
|
+
case "orderedList":
|
|
426
|
+
return `${renderList(node, "ordered", depth)}\n`;
|
|
427
|
+
case "codeBlock":
|
|
428
|
+
return renderCodeBlock(node);
|
|
429
|
+
case "blockquote":
|
|
430
|
+
return renderBlockquote(node);
|
|
431
|
+
case "rule":
|
|
432
|
+
return "---\n\n";
|
|
433
|
+
case "listItem":
|
|
434
|
+
return renderListItem(node, "-", depth);
|
|
435
|
+
default:
|
|
436
|
+
return renderUnknownBlock(node);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
function renderList(node, kind, depth) {
|
|
440
|
+
let index = kind === "ordered" ? listStart(node.attrs?.order) : 1;
|
|
441
|
+
const lines = [];
|
|
442
|
+
for (const child of childNodes(node)) {
|
|
443
|
+
const marker = kind === "bullet" ? "-" : `${index}.`;
|
|
444
|
+
if (kind === "ordered")
|
|
445
|
+
index += 1;
|
|
446
|
+
lines.push(renderListItem(child, marker, depth));
|
|
447
|
+
}
|
|
448
|
+
return lines.join("");
|
|
449
|
+
}
|
|
450
|
+
function renderListItem(node, marker, depth) {
|
|
451
|
+
const indent = " ".repeat(depth);
|
|
452
|
+
const parts = [];
|
|
453
|
+
const blocks = childNodes(node);
|
|
454
|
+
if (blocks.length === 0) {
|
|
455
|
+
const text = fallbackText(node).trim();
|
|
456
|
+
return `${indent}${marker} ${text}\n`;
|
|
457
|
+
}
|
|
458
|
+
for (const block of blocks) {
|
|
459
|
+
if (block.type === "bulletList" || block.type === "orderedList") {
|
|
460
|
+
const nested = renderList(block, block.type === "bulletList" ? "bullet" : "ordered", depth + 1).replace(/\n$/, "");
|
|
461
|
+
if (nested)
|
|
462
|
+
parts.push(nested);
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
if (block.type === "paragraph" || block.type === undefined) {
|
|
466
|
+
const text = renderInline(childNodes(block)).trim() || fallbackText(block).trim();
|
|
467
|
+
if (text)
|
|
468
|
+
parts.push(text);
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
const rendered = renderBlock(block, depth + 1).trim();
|
|
472
|
+
if (rendered)
|
|
473
|
+
parts.push(rendered);
|
|
474
|
+
}
|
|
475
|
+
const body = parts.join("\n\n");
|
|
476
|
+
const [first, ...rest] = body.split("\n");
|
|
477
|
+
const continuation = `${indent} `;
|
|
478
|
+
const head = `${indent}${marker} ${first ?? ""}`;
|
|
479
|
+
if (rest.length === 0)
|
|
480
|
+
return `${head}\n`;
|
|
481
|
+
const tail = rest.map((line) => (line.length > 0 ? `${continuation}${line}` : "")).join("\n");
|
|
482
|
+
return `${head}\n${tail}\n`;
|
|
483
|
+
}
|
|
484
|
+
function renderCodeBlock(node) {
|
|
485
|
+
const language = typeof node.attrs?.language === "string" ? node.attrs.language : "";
|
|
486
|
+
const code = rawText(node).replace(/\n$/, "");
|
|
487
|
+
return `\`\`\`${language}\n${code}\n\`\`\`\n\n`;
|
|
488
|
+
}
|
|
489
|
+
function renderBlockquote(node) {
|
|
490
|
+
const inner = renderBlocks(childNodes(node)).trim();
|
|
491
|
+
if (!inner)
|
|
492
|
+
return "";
|
|
493
|
+
const quoted = inner
|
|
494
|
+
.split("\n")
|
|
495
|
+
.map((line) => (line.length > 0 ? `> ${line}` : ">"))
|
|
496
|
+
.join("\n");
|
|
497
|
+
return `${quoted}\n\n`;
|
|
498
|
+
}
|
|
499
|
+
function renderUnknownBlock(node) {
|
|
500
|
+
const nested = childNodes(node);
|
|
501
|
+
if (nested.length > 0) {
|
|
502
|
+
const blockTypes = new Set([
|
|
503
|
+
"paragraph",
|
|
504
|
+
"heading",
|
|
505
|
+
"bulletList",
|
|
506
|
+
"orderedList",
|
|
507
|
+
"listItem",
|
|
508
|
+
"codeBlock",
|
|
509
|
+
"blockquote",
|
|
510
|
+
"rule",
|
|
511
|
+
"doc",
|
|
512
|
+
]);
|
|
513
|
+
if (nested.some((child) => child.type !== undefined && blockTypes.has(child.type))) {
|
|
514
|
+
return renderBlocks(nested);
|
|
515
|
+
}
|
|
516
|
+
const inline = renderInline(nested).trim();
|
|
517
|
+
const own = fallbackText(node).trim();
|
|
518
|
+
const text = [own, inline].filter((part) => part.length > 0).join("");
|
|
519
|
+
return text ? `${text}\n\n` : "";
|
|
520
|
+
}
|
|
521
|
+
const text = fallbackText(node).trim();
|
|
522
|
+
return text ? `${text}\n\n` : "";
|
|
523
|
+
}
|
|
524
|
+
function renderInline(nodes) {
|
|
525
|
+
let out = "";
|
|
526
|
+
for (const node of nodes) {
|
|
527
|
+
if (node.type === "hardBreak") {
|
|
528
|
+
out += "\\\n";
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
if (node.type === "text") {
|
|
532
|
+
out += applyMarks(node.text ?? "", node.marks ?? []);
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
const nested = childNodes(node);
|
|
536
|
+
const inner = nested.length > 0 ? renderInline(nested) : fallbackText(node);
|
|
537
|
+
out += applyMarks(inner, node.marks ?? []);
|
|
538
|
+
}
|
|
539
|
+
return out;
|
|
540
|
+
}
|
|
541
|
+
function applyMarks(text, marks) {
|
|
542
|
+
let out = text;
|
|
543
|
+
for (const mark of marks) {
|
|
544
|
+
if (mark.type === "strong")
|
|
545
|
+
out = `**${out}**`;
|
|
546
|
+
else if (mark.type === "em")
|
|
547
|
+
out = `*${out}*`;
|
|
548
|
+
else if (mark.type === "code")
|
|
549
|
+
out = `\`${out}\``;
|
|
550
|
+
else if (mark.type === "link") {
|
|
551
|
+
const href = typeof mark.attrs?.href === "string" ? mark.attrs.href : "";
|
|
552
|
+
if (href)
|
|
553
|
+
out = `[${out}](${href})`;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return out;
|
|
557
|
+
}
|
|
558
|
+
function fallbackText(node) {
|
|
559
|
+
if (typeof node.text === "string")
|
|
560
|
+
return node.text;
|
|
561
|
+
const attrs = node.attrs;
|
|
562
|
+
if (!attrs)
|
|
563
|
+
return "";
|
|
564
|
+
if (typeof attrs.text === "string")
|
|
565
|
+
return attrs.text;
|
|
566
|
+
return "";
|
|
567
|
+
}
|
|
568
|
+
function rawText(node) {
|
|
569
|
+
const nested = childNodes(node);
|
|
570
|
+
if (nested.length === 0)
|
|
571
|
+
return typeof node.text === "string" ? node.text : "";
|
|
572
|
+
return nested.map((child) => rawText(child)).join("");
|
|
573
|
+
}
|
|
574
|
+
function headingLevel(value) {
|
|
575
|
+
const level = typeof value === "number" ? value : 1;
|
|
576
|
+
if (!Number.isInteger(level))
|
|
577
|
+
return 1;
|
|
578
|
+
return Math.min(6, Math.max(1, level));
|
|
579
|
+
}
|
|
580
|
+
function listStart(value) {
|
|
581
|
+
const start = typeof value === "number" ? value : 1;
|
|
582
|
+
if (!Number.isInteger(start) || start < 1)
|
|
583
|
+
return 1;
|
|
584
|
+
return start;
|
|
585
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { writeJob } from "../jobs.js";
|
|
3
|
+
/** First-line titles longer than this are cut and end with `...`. */
|
|
4
|
+
export const PROMPT_TITLE_MAX = 80;
|
|
5
|
+
export function titleFromPrompt(text) {
|
|
6
|
+
const line = text
|
|
7
|
+
.split(/\r?\n/)
|
|
8
|
+
.map((item) => item.trim())
|
|
9
|
+
.find((item) => item.length > 0);
|
|
10
|
+
if (!line) {
|
|
11
|
+
throw new Error("Prompt is empty.");
|
|
12
|
+
}
|
|
13
|
+
if (line.length <= PROMPT_TITLE_MAX)
|
|
14
|
+
return line;
|
|
15
|
+
return `${line.slice(0, PROMPT_TITLE_MAX - 3)}...`;
|
|
16
|
+
}
|
|
17
|
+
export function readPrompt(value) {
|
|
18
|
+
if (value !== "-")
|
|
19
|
+
return { text: value, fromStdin: false };
|
|
20
|
+
let text;
|
|
21
|
+
try {
|
|
22
|
+
text = readFileSync(0, "utf8");
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
26
|
+
throw new Error(`Could not read prompt from stdin: ${message}`);
|
|
27
|
+
}
|
|
28
|
+
return { text, fromStdin: true };
|
|
29
|
+
}
|
|
30
|
+
/** `kiln add <workflow> --repo R --prompt text` or `--prompt -` for stdin. */
|
|
31
|
+
export function addFromPrompt(config, input) {
|
|
32
|
+
const { text, fromStdin } = readPrompt(input.prompt);
|
|
33
|
+
if (!text.trim()) {
|
|
34
|
+
throw new Error(fromStdin ? "Prompt from stdin is empty." : "Prompt is empty.");
|
|
35
|
+
}
|
|
36
|
+
const title = input.title?.trim() || titleFromPrompt(text);
|
|
37
|
+
return writeJob(config, {
|
|
38
|
+
workflow: input.workflow,
|
|
39
|
+
repo: input.repo,
|
|
40
|
+
specText: text,
|
|
41
|
+
title,
|
|
42
|
+
dependsOn: input.dependsOn,
|
|
43
|
+
...(input.stack ? { stack: true } : {}),
|
|
44
|
+
source: { kind: "prompt", ref: fromStdin ? "stdin" : "inline" },
|
|
45
|
+
...(input.now ? { now: input.now } : {}),
|
|
46
|
+
});
|
|
47
|
+
}
|