@lchase/kiln 0.0.0-stage → 0.1.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 +227 -2
- package/dist/cli.js +334 -0
- package/dist/config.js +237 -0
- package/dist/delivery.js +245 -0
- package/dist/driver.js +634 -0
- package/dist/errors.js +6 -0
- package/dist/host.js +223 -0
- package/dist/intake/file.js +29 -0
- package/dist/intake/jira.js +582 -0
- package/dist/intake/prompt.js +47 -0
- package/dist/intake/tickets.js +443 -0
- package/dist/jobs.js +149 -0
- package/dist/paths.js +34 -0
- package/dist/reconcile.js +123 -0
- package/dist/report.js +157 -0
- package/dist/schemas.js +90 -0
- package/dist/truncate-output.js +122 -0
- package/dist/workflow.js +113 -0
- package/package.json +40 -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/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/dist/config.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
import { KilnConfigError } from "./errors.js";
|
|
5
|
+
import { KilnConfigFileSchema } from "./schemas.js";
|
|
6
|
+
const CONFIG_NAME = "kiln.config.json";
|
|
7
|
+
const REPO_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
8
|
+
export function loadKilnConfig(configPath, options = {}) {
|
|
9
|
+
const cwd = options.cwd ?? process.cwd();
|
|
10
|
+
const home = options.home ?? homedir();
|
|
11
|
+
const resolvedPath = resolve(configPath);
|
|
12
|
+
let raw;
|
|
13
|
+
try {
|
|
14
|
+
raw = JSON.parse(readFileSync(resolvedPath, "utf8"));
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
18
|
+
throw new KilnConfigError(`Could not read kiln config ${resolvedPath}: ${message}`);
|
|
19
|
+
}
|
|
20
|
+
const parsed = KilnConfigFileSchema.safeParse(raw);
|
|
21
|
+
if (!parsed.success) {
|
|
22
|
+
throw new KilnConfigError(`Kiln config ${resolvedPath} is invalid: ${parsed.error.message}`);
|
|
23
|
+
}
|
|
24
|
+
const configDir = dirname(resolvedPath);
|
|
25
|
+
const repos = {};
|
|
26
|
+
for (const [name, entry] of Object.entries(parsed.data.repos)) {
|
|
27
|
+
assertRepoName(name);
|
|
28
|
+
const checkout = resolveConfiguredPath(configDir, entry.path, home);
|
|
29
|
+
if (!existsSync(checkout) || !statSync(checkout).isDirectory()) {
|
|
30
|
+
throw new KilnConfigError(`Checkout for ${name} does not exist or is not a directory: ${checkout}. Set repos.${name}.path in the kiln config.`);
|
|
31
|
+
}
|
|
32
|
+
repos[name] = {
|
|
33
|
+
path: checkout,
|
|
34
|
+
...(entry.test ? { test: entry.test } : {}),
|
|
35
|
+
deliver: entry.deliver ?? "local",
|
|
36
|
+
...(entry.base ? { base: entry.base } : {}),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const kilnDir = basename(configDir) === ".kiln" ? configDir : join(cwd, ".kiln");
|
|
40
|
+
const jira = jiraFromFile(parsed.data.jira);
|
|
41
|
+
return {
|
|
42
|
+
configPath: resolvedPath,
|
|
43
|
+
configDir,
|
|
44
|
+
note: parsed.data.note,
|
|
45
|
+
repos,
|
|
46
|
+
kilnDir,
|
|
47
|
+
jobsDir: join(kilnDir, "jobs"),
|
|
48
|
+
maxConcurrent: parsed.data.maxConcurrent ?? 2,
|
|
49
|
+
adapter: parsed.data.adapter ?? "claude-code",
|
|
50
|
+
...(jira ? { jira } : {}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Local config wins: `./.kiln/kiln.config.json`, then `~/.kiln/kiln.config.json`.
|
|
55
|
+
*/
|
|
56
|
+
export function resolveKilnConfig(options = {}) {
|
|
57
|
+
const cwd = options.cwd ?? process.cwd();
|
|
58
|
+
const home = options.home ?? homedir();
|
|
59
|
+
if (options.configPath) {
|
|
60
|
+
return loadKilnConfig(resolve(cwd, options.configPath), { cwd, home });
|
|
61
|
+
}
|
|
62
|
+
const cwdConfig = join(cwd, ".kiln", CONFIG_NAME);
|
|
63
|
+
if (existsSync(cwdConfig))
|
|
64
|
+
return loadKilnConfig(cwdConfig, { cwd, home });
|
|
65
|
+
const homeConfig = join(home, ".kiln", CONFIG_NAME);
|
|
66
|
+
if (existsSync(homeConfig))
|
|
67
|
+
return loadKilnConfig(homeConfig, { cwd, home });
|
|
68
|
+
throw new KilnConfigError([
|
|
69
|
+
"No kiln config found.",
|
|
70
|
+
`Run kiln repo add <name> <path> to create ${join(cwd, ".kiln", CONFIG_NAME)},`,
|
|
71
|
+
`or create ${join(home, ".kiln", CONFIG_NAME)}.`,
|
|
72
|
+
"Those directories are gitignored. Do not commit checkout paths.",
|
|
73
|
+
].join("\n"));
|
|
74
|
+
}
|
|
75
|
+
const BRANCH_NAME = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
76
|
+
/** Create or update one repo entry. Returns the text to print. */
|
|
77
|
+
export function addRepo(options) {
|
|
78
|
+
const cwd = options.cwd ?? process.cwd();
|
|
79
|
+
const home = options.home ?? homedir();
|
|
80
|
+
assertRepoName(options.name);
|
|
81
|
+
if (options.test !== undefined && !options.test.trim()) {
|
|
82
|
+
throw new KilnConfigError("The test command is empty. Omit --test, or pass a command.");
|
|
83
|
+
}
|
|
84
|
+
if (options.base !== undefined)
|
|
85
|
+
assertBranchName(options.base);
|
|
86
|
+
const configPath = configPathForWrite(options);
|
|
87
|
+
const configDir = dirname(configPath);
|
|
88
|
+
const storedPath = pathToStore(options.repoPath, configDir, cwd, home);
|
|
89
|
+
const checkout = resolveConfiguredPath(configDir, storedPath, home);
|
|
90
|
+
if (!existsSync(checkout) || !statSync(checkout).isDirectory()) {
|
|
91
|
+
throw new KilnConfigError(`Checkout for ${options.name} does not exist or is not a directory: ${checkout}.`);
|
|
92
|
+
}
|
|
93
|
+
const raw = readConfigObject(configPath);
|
|
94
|
+
const repos = repoMap(raw);
|
|
95
|
+
const previous = repos[options.name];
|
|
96
|
+
const entry = { path: storedPath };
|
|
97
|
+
const previousTest = previousString(previous, "test");
|
|
98
|
+
const test = options.test ?? previousTest;
|
|
99
|
+
if (test)
|
|
100
|
+
entry.test = test;
|
|
101
|
+
const deliver = options.deliver ?? previousDeliver(previous);
|
|
102
|
+
if (deliver)
|
|
103
|
+
entry.deliver = deliver;
|
|
104
|
+
const base = options.base ?? previousString(previous, "base");
|
|
105
|
+
if (base)
|
|
106
|
+
entry.base = base;
|
|
107
|
+
repos[options.name] = entry;
|
|
108
|
+
raw.repos = repos;
|
|
109
|
+
const parsed = KilnConfigFileSchema.safeParse(raw);
|
|
110
|
+
if (!parsed.success) {
|
|
111
|
+
throw new KilnConfigError(`Kiln config ${configPath} is invalid: ${parsed.error.message}`);
|
|
112
|
+
}
|
|
113
|
+
mkdirSync(configDir, { recursive: true });
|
|
114
|
+
writeFileSync(configPath, `${JSON.stringify(raw, null, 2)}\n`);
|
|
115
|
+
const lines = [`added ${options.name}`, `path ${checkout}`];
|
|
116
|
+
if (entry.test)
|
|
117
|
+
lines.push(`test ${entry.test}`);
|
|
118
|
+
if (entry.deliver)
|
|
119
|
+
lines.push(`deliver ${entry.deliver}`);
|
|
120
|
+
if (entry.base)
|
|
121
|
+
lines.push(`base ${entry.base}`);
|
|
122
|
+
lines.push(`wrote ${configPath}`);
|
|
123
|
+
return `${lines.join("\n")}\n`;
|
|
124
|
+
}
|
|
125
|
+
export function renderRepoList(config) {
|
|
126
|
+
const names = Object.keys(config.repos).sort();
|
|
127
|
+
if (names.length === 0)
|
|
128
|
+
return "no repos\n";
|
|
129
|
+
const lines = names.map((name) => {
|
|
130
|
+
const repo = config.repos[name];
|
|
131
|
+
if (!repo)
|
|
132
|
+
return name;
|
|
133
|
+
const bits = [name, repo.path];
|
|
134
|
+
if (repo.test)
|
|
135
|
+
bits.push(`test ${repo.test}`);
|
|
136
|
+
if (repo.deliver === "pr")
|
|
137
|
+
bits.push("deliver pr");
|
|
138
|
+
if (repo.base)
|
|
139
|
+
bits.push(`base ${repo.base}`);
|
|
140
|
+
return bits.join(" ");
|
|
141
|
+
});
|
|
142
|
+
return `${lines.join("\n")}\n`;
|
|
143
|
+
}
|
|
144
|
+
export function assertBranchName(name) {
|
|
145
|
+
if (!BRANCH_NAME.test(name) || name.includes("..") || name.endsWith("/") || name.includes("//")) {
|
|
146
|
+
throw new KilnConfigError(`--base ${JSON.stringify(name)} must be a branch name (letters, digits, dots, underscores, hyphens, and slashes).`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export function assertRepoName(name) {
|
|
150
|
+
if (!REPO_NAME.test(name)) {
|
|
151
|
+
throw new KilnConfigError(`Repo name ${JSON.stringify(name)} must start with a letter or digit and contain only letters, digits, dots, underscores, and hyphens.`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function configPathForWrite(options) {
|
|
155
|
+
const cwd = options.cwd ?? process.cwd();
|
|
156
|
+
const home = options.home ?? homedir();
|
|
157
|
+
if (options.configPath)
|
|
158
|
+
return resolve(cwd, options.configPath);
|
|
159
|
+
const cwdConfig = join(cwd, ".kiln", CONFIG_NAME);
|
|
160
|
+
if (existsSync(cwdConfig))
|
|
161
|
+
return cwdConfig;
|
|
162
|
+
const homeConfig = join(home, ".kiln", CONFIG_NAME);
|
|
163
|
+
if (existsSync(homeConfig))
|
|
164
|
+
return homeConfig;
|
|
165
|
+
return cwdConfig;
|
|
166
|
+
}
|
|
167
|
+
function readConfigObject(configPath) {
|
|
168
|
+
if (!existsSync(configPath))
|
|
169
|
+
return { repos: {} };
|
|
170
|
+
let raw;
|
|
171
|
+
try {
|
|
172
|
+
raw = JSON.parse(readFileSync(configPath, "utf8"));
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
176
|
+
throw new KilnConfigError(`Could not read kiln config ${configPath}: ${message}`);
|
|
177
|
+
}
|
|
178
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
179
|
+
throw new KilnConfigError(`Kiln config ${configPath} must be a JSON object.`);
|
|
180
|
+
}
|
|
181
|
+
return raw;
|
|
182
|
+
}
|
|
183
|
+
function previousString(previous, key) {
|
|
184
|
+
if (typeof previous !== "object" || previous === null || !(key in previous))
|
|
185
|
+
return undefined;
|
|
186
|
+
const value = previous[key];
|
|
187
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
188
|
+
}
|
|
189
|
+
function previousDeliver(previous) {
|
|
190
|
+
const value = previousString(previous, "deliver");
|
|
191
|
+
if (value === "local" || value === "pr")
|
|
192
|
+
return value;
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
function repoMap(raw) {
|
|
196
|
+
const repos = raw.repos;
|
|
197
|
+
if (repos === undefined)
|
|
198
|
+
return {};
|
|
199
|
+
if (typeof repos !== "object" || repos === null || Array.isArray(repos)) {
|
|
200
|
+
throw new KilnConfigError("Kiln config repos must be an object.");
|
|
201
|
+
}
|
|
202
|
+
return repos;
|
|
203
|
+
}
|
|
204
|
+
export function resolveConfiguredPath(configDir, value, home) {
|
|
205
|
+
const expanded = expandHome(value, home);
|
|
206
|
+
return isAbsolute(expanded) ? expanded : resolve(configDir, expanded);
|
|
207
|
+
}
|
|
208
|
+
function pathToStore(userPath, configDir, cwd, home) {
|
|
209
|
+
if (userPath === "~" || userPath.startsWith("~/"))
|
|
210
|
+
return userPath;
|
|
211
|
+
if (isAbsolute(userPath))
|
|
212
|
+
return userPath;
|
|
213
|
+
const absolute = resolve(cwd, userPath);
|
|
214
|
+
const stored = relative(configDir, absolute);
|
|
215
|
+
return stored === "" ? "." : stored;
|
|
216
|
+
}
|
|
217
|
+
function jiraFromFile(jira) {
|
|
218
|
+
if (!jira)
|
|
219
|
+
return undefined;
|
|
220
|
+
const baseUrl = jira.baseUrl?.trim();
|
|
221
|
+
const email = jira.email?.trim();
|
|
222
|
+
const apiToken = jira.apiToken?.trim();
|
|
223
|
+
if (!baseUrl && !email && !apiToken)
|
|
224
|
+
return undefined;
|
|
225
|
+
return {
|
|
226
|
+
...(baseUrl ? { baseUrl } : {}),
|
|
227
|
+
...(email ? { email } : {}),
|
|
228
|
+
...(apiToken ? { apiToken } : {}),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
function expandHome(value, home) {
|
|
232
|
+
if (value === "~")
|
|
233
|
+
return home;
|
|
234
|
+
if (value.startsWith("~/"))
|
|
235
|
+
return join(home, value.slice(2));
|
|
236
|
+
return value;
|
|
237
|
+
}
|
package/dist/delivery.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { HostError } from "./host.js";
|
|
5
|
+
import { readJobSpec } from "./jobs.js";
|
|
6
|
+
import { jobBasename } from "./paths.js";
|
|
7
|
+
/**
|
|
8
|
+
* Derived per run, under `<kilnDir>/deliveries/<jobId>.json`. Not the job file.
|
|
9
|
+
* `pushed` is true once origin has this branch at `sha`. Re-tick uses it, and
|
|
10
|
+
* `gh pr list --head`, so a crash between push and create does not open a
|
|
11
|
+
* second pull request or push the same commit again.
|
|
12
|
+
*/
|
|
13
|
+
export const DeliveryRecordSchema = z.object({
|
|
14
|
+
jobId: z.string().min(1),
|
|
15
|
+
runId: z.string().min(1),
|
|
16
|
+
branch: z.string().min(1),
|
|
17
|
+
base: z.string().min(1),
|
|
18
|
+
sha: z.string().min(1).optional(),
|
|
19
|
+
pushed: z.boolean(),
|
|
20
|
+
prNumber: z.number().int().positive().optional(),
|
|
21
|
+
prUrl: z.string().min(1).optional(),
|
|
22
|
+
error: z.string().min(1).optional(),
|
|
23
|
+
});
|
|
24
|
+
export function deliveryPath(kilnDir, jobId) {
|
|
25
|
+
return join(kilnDir, "deliveries", `${jobBasename(jobId)}.json`);
|
|
26
|
+
}
|
|
27
|
+
export function readDelivery(kilnDir, jobId) {
|
|
28
|
+
const path = deliveryPath(kilnDir, jobId);
|
|
29
|
+
if (!existsSync(path))
|
|
30
|
+
return undefined;
|
|
31
|
+
try {
|
|
32
|
+
return DeliveryRecordSchema.parse(JSON.parse(readFileSync(path, "utf8")));
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function writeDelivery(kilnDir, record) {
|
|
39
|
+
const path = deliveryPath(kilnDir, record.jobId);
|
|
40
|
+
mkdirSync(join(kilnDir, "deliveries"), { recursive: true });
|
|
41
|
+
writeFileSync(path, `${JSON.stringify(DeliveryRecordSchema.parse(record), null, 2)}\n`);
|
|
42
|
+
}
|
|
43
|
+
const LAUNCH_ERROR = z.object({ error: z.string().min(1) });
|
|
44
|
+
export function launchErrorPath(kilnDir, jobId) {
|
|
45
|
+
return join(kilnDir, "launch-errors", `${jobBasename(jobId)}.json`);
|
|
46
|
+
}
|
|
47
|
+
export function readLaunchError(kilnDir, jobId) {
|
|
48
|
+
const path = launchErrorPath(kilnDir, jobId);
|
|
49
|
+
if (!existsSync(path))
|
|
50
|
+
return undefined;
|
|
51
|
+
try {
|
|
52
|
+
return LAUNCH_ERROR.parse(JSON.parse(readFileSync(path, "utf8"))).error;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function writeLaunchError(kilnDir, jobId, error) {
|
|
59
|
+
const path = launchErrorPath(kilnDir, jobId);
|
|
60
|
+
mkdirSync(join(kilnDir, "launch-errors"), { recursive: true });
|
|
61
|
+
writeFileSync(path, `${JSON.stringify({ error }, null, 2)}\n`);
|
|
62
|
+
}
|
|
63
|
+
export function clearLaunchError(kilnDir, jobId) {
|
|
64
|
+
rmSync(launchErrorPath(kilnDir, jobId), { force: true });
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Full SHA the worktree is cut from. Passed to yak as `--from` and to the
|
|
68
|
+
* workflow as `baseRef`.
|
|
69
|
+
*
|
|
70
|
+
* `deliver=local` without `--stack` is checkout HEAD.
|
|
71
|
+
* `deliver=pr` fetches and uses `origin/<base>` (configured, or origin's default).
|
|
72
|
+
* A stacked job uses the upstream `yak/<run-id>` tip in this checkout.
|
|
73
|
+
*/
|
|
74
|
+
export function resolveLaunchBase(input) {
|
|
75
|
+
if (input.stack) {
|
|
76
|
+
if (!input.upstreamBranch) {
|
|
77
|
+
throw new HostError("This stacked job has no upstream branch to start from yet.");
|
|
78
|
+
}
|
|
79
|
+
return input.git.revParse(input.checkout, input.upstreamBranch);
|
|
80
|
+
}
|
|
81
|
+
if (input.deliver === "pr") {
|
|
82
|
+
input.git.fetch(input.checkout);
|
|
83
|
+
const base = input.configuredBase ?? input.git.defaultBranch(input.checkout);
|
|
84
|
+
return input.git.revParse(input.checkout, `origin/${base}`);
|
|
85
|
+
}
|
|
86
|
+
return input.git.revParse(input.checkout, "HEAD");
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Push `yak/<run-id>` and open one draft pull request. A later tick looks up
|
|
90
|
+
* the branch's pull request and skips both when the remote SHA already matches.
|
|
91
|
+
* Failures stay on this job (`HostError` text) and do not escape.
|
|
92
|
+
*/
|
|
93
|
+
export function deliverJob(request) {
|
|
94
|
+
const branch = `yak/${request.runId}`;
|
|
95
|
+
const prior = readDelivery(request.config.kilnDir, request.job.id);
|
|
96
|
+
const sameRun = prior?.runId === request.runId ? prior : undefined;
|
|
97
|
+
try {
|
|
98
|
+
const localSha = request.host.git.revParse(request.checkout, branch);
|
|
99
|
+
if (sameRun?.prNumber && sameRun.prUrl && sameRun.sha === localSha) {
|
|
100
|
+
return { ok: true, detail: sameRun.prUrl, prUrl: sameRun.prUrl, prNumber: sameRun.prNumber };
|
|
101
|
+
}
|
|
102
|
+
// Ask gh before pushing so a missing or logged-out CLI does not publish the branch.
|
|
103
|
+
let existing = request.host.github.findByHead(request.checkout, branch);
|
|
104
|
+
const remoteSha = request.host.git.remoteBranchSha(request.checkout, branch);
|
|
105
|
+
if (remoteSha !== localSha) {
|
|
106
|
+
request.host.git.pushBranch(request.checkout, branch, request.baseBranch);
|
|
107
|
+
}
|
|
108
|
+
writeDelivery(request.config.kilnDir, {
|
|
109
|
+
jobId: request.job.id,
|
|
110
|
+
runId: request.runId,
|
|
111
|
+
branch,
|
|
112
|
+
base: request.baseBranch,
|
|
113
|
+
sha: localSha,
|
|
114
|
+
pushed: true,
|
|
115
|
+
...(sameRun?.prNumber ? { prNumber: sameRun.prNumber, prUrl: sameRun.prUrl } : {}),
|
|
116
|
+
});
|
|
117
|
+
if (!existing)
|
|
118
|
+
existing = request.host.github.findByHead(request.checkout, branch);
|
|
119
|
+
const pr = existing ??
|
|
120
|
+
(sameRun?.prNumber && sameRun.prUrl
|
|
121
|
+
? {
|
|
122
|
+
number: sameRun.prNumber,
|
|
123
|
+
url: sameRun.prUrl,
|
|
124
|
+
state: "OPEN",
|
|
125
|
+
mergedAt: null,
|
|
126
|
+
head: branch,
|
|
127
|
+
base: request.baseBranch,
|
|
128
|
+
}
|
|
129
|
+
: request.host.github.createDraft({
|
|
130
|
+
checkout: request.checkout,
|
|
131
|
+
head: branch,
|
|
132
|
+
base: request.baseBranch,
|
|
133
|
+
title: request.job.title,
|
|
134
|
+
body: pullRequestBody(request),
|
|
135
|
+
}));
|
|
136
|
+
writeDelivery(request.config.kilnDir, {
|
|
137
|
+
jobId: request.job.id,
|
|
138
|
+
runId: request.runId,
|
|
139
|
+
branch,
|
|
140
|
+
base: request.baseBranch,
|
|
141
|
+
sha: localSha,
|
|
142
|
+
pushed: true,
|
|
143
|
+
prNumber: pr.number,
|
|
144
|
+
prUrl: pr.url,
|
|
145
|
+
});
|
|
146
|
+
return { ok: true, detail: pr.url, prUrl: pr.url, prNumber: pr.number };
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
150
|
+
const kept = readDelivery(request.config.kilnDir, request.job.id);
|
|
151
|
+
const base = kept?.runId === request.runId ? kept : sameRun;
|
|
152
|
+
writeDelivery(request.config.kilnDir, {
|
|
153
|
+
jobId: request.job.id,
|
|
154
|
+
runId: request.runId,
|
|
155
|
+
branch,
|
|
156
|
+
base: request.baseBranch,
|
|
157
|
+
...(base?.sha ? { sha: base.sha } : {}),
|
|
158
|
+
pushed: base?.pushed ?? false,
|
|
159
|
+
...(base?.prNumber ? { prNumber: base.prNumber, prUrl: base.prUrl } : {}),
|
|
160
|
+
error: message,
|
|
161
|
+
});
|
|
162
|
+
return { ok: false, detail: message };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** First paragraph of the job spec, capped, plus the job id and reviewer verdicts. */
|
|
166
|
+
export function pullRequestBody(request) {
|
|
167
|
+
const spec = readJobSpec(request.config.kilnDir, request.job);
|
|
168
|
+
const summary = specSummary(spec);
|
|
169
|
+
const reviews = reviewerLines(join(request.checkout, ".runs", request.runId, "artifacts"));
|
|
170
|
+
return [
|
|
171
|
+
summary,
|
|
172
|
+
"",
|
|
173
|
+
`Kiln job: ${request.job.id}`,
|
|
174
|
+
"",
|
|
175
|
+
"## Reviewers",
|
|
176
|
+
...(reviews.length > 0 ? reviews : ["- (no reviewer artifacts)"]),
|
|
177
|
+
"",
|
|
178
|
+
].join("\n");
|
|
179
|
+
}
|
|
180
|
+
export function specSummary(specText) {
|
|
181
|
+
const lines = specText.split(/\r?\n/);
|
|
182
|
+
const body = [];
|
|
183
|
+
let skippedTitle = false;
|
|
184
|
+
for (const line of lines) {
|
|
185
|
+
if (!skippedTitle && /^#\s+/.test(line)) {
|
|
186
|
+
skippedTitle = true;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
body.push(line);
|
|
190
|
+
}
|
|
191
|
+
const paragraph = (body.join("\n").trim().split(/\n\s*\n/)[0] ?? "").trim().replace(/\s+/g, " ");
|
|
192
|
+
if (!paragraph)
|
|
193
|
+
return "(empty spec)";
|
|
194
|
+
if (paragraph.length <= 500)
|
|
195
|
+
return paragraph;
|
|
196
|
+
return `${paragraph.slice(0, 497)}...`;
|
|
197
|
+
}
|
|
198
|
+
function reviewerLines(artifactsDir) {
|
|
199
|
+
const lines = [];
|
|
200
|
+
for (const name of ["security", "ux", "ui", "code"]) {
|
|
201
|
+
const verdict = verdictOf(latestArtifact(artifactsDir, name));
|
|
202
|
+
if (verdict)
|
|
203
|
+
lines.push(`- ${name}: ${verdict}`);
|
|
204
|
+
}
|
|
205
|
+
const decision = verdictOf(latestArtifact(artifactsDir, "decision"), "decision");
|
|
206
|
+
if (decision)
|
|
207
|
+
lines.push(`- approver: ${decision}`);
|
|
208
|
+
return lines;
|
|
209
|
+
}
|
|
210
|
+
function verdictOf(value, field = "verdict") {
|
|
211
|
+
if (typeof value !== "object" || value === null || !(field in value))
|
|
212
|
+
return undefined;
|
|
213
|
+
const verdict = value[field];
|
|
214
|
+
return typeof verdict === "string" && verdict.length > 0 ? verdict : undefined;
|
|
215
|
+
}
|
|
216
|
+
function latestArtifact(dir, name) {
|
|
217
|
+
if (!existsSync(dir))
|
|
218
|
+
return undefined;
|
|
219
|
+
let best;
|
|
220
|
+
for (const file of readdirSync(dir)) {
|
|
221
|
+
if (file === `${name}.json`) {
|
|
222
|
+
if (!best || best.n <= 0)
|
|
223
|
+
best = { n: 0, file };
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const match = new RegExp(`^${name}\\.(\\d+)\\.json$`).exec(file);
|
|
227
|
+
if (!match?.[1])
|
|
228
|
+
continue;
|
|
229
|
+
const n = Number(match[1]);
|
|
230
|
+
if (!best || n >= best.n)
|
|
231
|
+
best = { n, file };
|
|
232
|
+
}
|
|
233
|
+
if (!best)
|
|
234
|
+
return undefined;
|
|
235
|
+
try {
|
|
236
|
+
return JSON.parse(readFileSync(join(dir, best.file), "utf8"));
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/** True when `gh pr view` says the pull request is merged, including a squash. */
|
|
243
|
+
export function isMerged(pr) {
|
|
244
|
+
return pr.state === "MERGED" || (typeof pr.mergedAt === "string" && pr.mergedAt.length > 0);
|
|
245
|
+
}
|