@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
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
|
+
}
|