@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/host.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
/**
|
|
6
|
+
* Git remote and GitHub CLI, behind one seam so tests can fake `gh` and point
|
|
7
|
+
* `origin` at a local bare repo. Nothing here merges a pull request or pushes
|
|
8
|
+
* the base branch.
|
|
9
|
+
*/
|
|
10
|
+
export class HostError extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "HostError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function defaultHost() {
|
|
17
|
+
return { github: realGitHub, git: realGitRemote };
|
|
18
|
+
}
|
|
19
|
+
const gitEnv = () => ({ ...process.env, GIT_TERMINAL_PROMPT: "0" });
|
|
20
|
+
function gitResult(checkout, args) {
|
|
21
|
+
const result = spawnSync("git", args, { cwd: checkout, encoding: "utf8", env: gitEnv() });
|
|
22
|
+
return {
|
|
23
|
+
status: result.status ?? 1,
|
|
24
|
+
stdout: result.stdout ?? "",
|
|
25
|
+
stderr: result.stderr || result.error?.message || "",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export const realGitRemote = {
|
|
29
|
+
fetch(checkout) {
|
|
30
|
+
const result = gitResult(checkout, ["fetch", "origin"]);
|
|
31
|
+
if (result.status !== 0) {
|
|
32
|
+
throw new HostError(`Could not fetch origin in ${checkout}: ${result.stderr.trim() || "git fetch failed"}`);
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
revParse(checkout, ref) {
|
|
36
|
+
const result = gitResult(checkout, ["rev-parse", "--verify", ref]);
|
|
37
|
+
const sha = result.stdout.trim();
|
|
38
|
+
if (result.status !== 0 || !/^[0-9a-f]{40}$/.test(sha)) {
|
|
39
|
+
throw new HostError(`Could not resolve ${ref} in ${checkout}: ${(result.stderr || result.stdout || "no such ref").trim()}`);
|
|
40
|
+
}
|
|
41
|
+
return sha;
|
|
42
|
+
},
|
|
43
|
+
remoteBranchSha(checkout, branch) {
|
|
44
|
+
const result = gitResult(checkout, ["ls-remote", "--heads", "origin", `refs/heads/${branch}`]);
|
|
45
|
+
if (result.status !== 0) {
|
|
46
|
+
throw new HostError(`Could not look up origin/${branch} in ${checkout}: ${result.stderr.trim() || "git ls-remote failed"}`);
|
|
47
|
+
}
|
|
48
|
+
const line = result.stdout
|
|
49
|
+
.split("\n")
|
|
50
|
+
.map((item) => item.trim())
|
|
51
|
+
.find((item) => item.endsWith(`refs/heads/${branch}`));
|
|
52
|
+
if (!line)
|
|
53
|
+
return null;
|
|
54
|
+
const sha = line.split(/\s+/)[0] ?? "";
|
|
55
|
+
return /^[0-9a-f]{40}$/.test(sha) ? sha : null;
|
|
56
|
+
},
|
|
57
|
+
pushBranch(checkout, branch, baseBranch) {
|
|
58
|
+
if (branch === baseBranch || branch === `refs/heads/${baseBranch}`) {
|
|
59
|
+
throw new HostError(`Refusing to push ${branch}: that is the base branch.`);
|
|
60
|
+
}
|
|
61
|
+
if (!/^yak\/[A-Za-z0-9._-]+$/.test(branch)) {
|
|
62
|
+
throw new HostError(`Refusing to push ${branch}: kiln only pushes yak/<run-id>.`);
|
|
63
|
+
}
|
|
64
|
+
const result = gitResult(checkout, [
|
|
65
|
+
"push",
|
|
66
|
+
"origin",
|
|
67
|
+
`refs/heads/${branch}:refs/heads/${branch}`,
|
|
68
|
+
]);
|
|
69
|
+
if (result.status !== 0) {
|
|
70
|
+
throw new HostError(`Could not push ${branch}: ${result.stderr.trim() || "git push failed"}`);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
defaultBranch(checkout) {
|
|
74
|
+
const symbolic = gitResult(checkout, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
|
|
75
|
+
if (symbolic.status === 0) {
|
|
76
|
+
const name = symbolic.stdout.trim().replace(/^origin\//, "");
|
|
77
|
+
if (name && !name.includes(" "))
|
|
78
|
+
return name;
|
|
79
|
+
}
|
|
80
|
+
const listed = gitResult(checkout, ["ls-remote", "--symref", "origin", "HEAD"]);
|
|
81
|
+
if (listed.status === 0) {
|
|
82
|
+
const match = listed.stdout.match(/^ref:\s+refs\/heads\/(\S+)\s+HEAD/m);
|
|
83
|
+
const name = match?.[1];
|
|
84
|
+
if (name)
|
|
85
|
+
return name;
|
|
86
|
+
}
|
|
87
|
+
throw new HostError(`Could not discover origin's default branch in ${checkout}. Pass --base when you register the repo.`);
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
function ghBin() {
|
|
91
|
+
return process.env.KILN_GH && process.env.KILN_GH.length > 0 ? process.env.KILN_GH : "gh";
|
|
92
|
+
}
|
|
93
|
+
function runGh(checkout, args) {
|
|
94
|
+
const bin = ghBin();
|
|
95
|
+
const result = spawnSync(bin, args, {
|
|
96
|
+
cwd: checkout,
|
|
97
|
+
encoding: "utf8",
|
|
98
|
+
env: { ...process.env, GH_PROMPT_DISABLED: "1", GIT_TERMINAL_PROMPT: "0" },
|
|
99
|
+
});
|
|
100
|
+
if (result.error) {
|
|
101
|
+
const code = result.error.code;
|
|
102
|
+
if (code === "ENOENT") {
|
|
103
|
+
throw new HostError(`gh is not installed (looked for ${bin}). Install the GitHub CLI from https://cli.github.com/ and run gh auth login, then run kiln tick again.`);
|
|
104
|
+
}
|
|
105
|
+
throw new HostError(`Could not run gh: ${result.error.message}`);
|
|
106
|
+
}
|
|
107
|
+
return { status: result.status ?? 1, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
|
108
|
+
}
|
|
109
|
+
function ghFailure(stderr, stdout) {
|
|
110
|
+
const detail = (stderr || stdout).trim() || "gh failed";
|
|
111
|
+
const auth = /auth|login|token|credential/i.test(detail);
|
|
112
|
+
const hint = auth
|
|
113
|
+
? "Run gh auth login, then run kiln tick again."
|
|
114
|
+
: "Fix gh, then run kiln tick again.";
|
|
115
|
+
return new HostError(`Could not open a pull request: ${detail} ${hint}`);
|
|
116
|
+
}
|
|
117
|
+
function asState(value) {
|
|
118
|
+
if (value === "MERGED" || value === "CLOSED" || value === "OPEN")
|
|
119
|
+
return value;
|
|
120
|
+
return "OPEN";
|
|
121
|
+
}
|
|
122
|
+
function asMergedAt(value) {
|
|
123
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
124
|
+
}
|
|
125
|
+
function toRef(raw, fallback) {
|
|
126
|
+
const number = typeof raw.number === "number" ? raw.number : Number.NaN;
|
|
127
|
+
const url = typeof raw.url === "string" ? raw.url : "";
|
|
128
|
+
if (!Number.isInteger(number) || number <= 0 || !url)
|
|
129
|
+
return null;
|
|
130
|
+
return {
|
|
131
|
+
number,
|
|
132
|
+
url,
|
|
133
|
+
state: asState(raw.state),
|
|
134
|
+
mergedAt: asMergedAt(raw.mergedAt),
|
|
135
|
+
head: typeof raw.headRefName === "string" && raw.headRefName.length > 0 ? raw.headRefName : fallback.head,
|
|
136
|
+
base: typeof raw.baseRefName === "string" && raw.baseRefName.length > 0 ? raw.baseRefName : fallback.base,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
export const realGitHub = {
|
|
140
|
+
findByHead(checkout, branch) {
|
|
141
|
+
const result = runGh(checkout, [
|
|
142
|
+
"pr",
|
|
143
|
+
"list",
|
|
144
|
+
"--head",
|
|
145
|
+
branch,
|
|
146
|
+
"--state",
|
|
147
|
+
"all",
|
|
148
|
+
"--limit",
|
|
149
|
+
"30",
|
|
150
|
+
"--json",
|
|
151
|
+
"number,url,state,mergedAt,headRefName,baseRefName",
|
|
152
|
+
]);
|
|
153
|
+
if (result.status !== 0)
|
|
154
|
+
throw ghFailure(result.stderr, result.stdout);
|
|
155
|
+
let parsed;
|
|
156
|
+
try {
|
|
157
|
+
parsed = JSON.parse(result.stdout || "[]");
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
throw new HostError(`gh pr list returned data that is not JSON.`);
|
|
161
|
+
}
|
|
162
|
+
if (!Array.isArray(parsed))
|
|
163
|
+
throw new HostError(`gh pr list returned data that is not a list.`);
|
|
164
|
+
const matches = parsed
|
|
165
|
+
.map((item) => toRef(item, { head: branch, base: "" }))
|
|
166
|
+
.filter((item) => item !== null && item.head === branch);
|
|
167
|
+
const open = matches.find((item) => item.state === "OPEN");
|
|
168
|
+
if (open)
|
|
169
|
+
return open;
|
|
170
|
+
const merged = matches.find((item) => item.state === "MERGED" || item.mergedAt);
|
|
171
|
+
if (merged)
|
|
172
|
+
return merged;
|
|
173
|
+
return matches.sort((left, right) => right.number - left.number)[0] ?? null;
|
|
174
|
+
},
|
|
175
|
+
createDraft(input) {
|
|
176
|
+
const dir = mkdtempSync(join(tmpdir(), "kiln-pr-"));
|
|
177
|
+
const bodyFile = join(dir, "body.md");
|
|
178
|
+
writeFileSync(bodyFile, input.body);
|
|
179
|
+
try {
|
|
180
|
+
const result = runGh(input.checkout, [
|
|
181
|
+
"pr",
|
|
182
|
+
"create",
|
|
183
|
+
"--draft",
|
|
184
|
+
"--head",
|
|
185
|
+
input.head,
|
|
186
|
+
"--base",
|
|
187
|
+
input.base,
|
|
188
|
+
"--title",
|
|
189
|
+
input.title.replace(/[\r\n]+/g, " ").trim(),
|
|
190
|
+
"--body-file",
|
|
191
|
+
bodyFile,
|
|
192
|
+
]);
|
|
193
|
+
if (result.status !== 0)
|
|
194
|
+
throw ghFailure(result.stderr, result.stdout);
|
|
195
|
+
const url = result.stdout
|
|
196
|
+
.split("\n")
|
|
197
|
+
.map((line) => line.trim())
|
|
198
|
+
.filter((line) => line.startsWith("http"))
|
|
199
|
+
.at(-1);
|
|
200
|
+
const number = Number(url?.match(/\/pull\/(\d+)\s*$/)?.[1]);
|
|
201
|
+
if (!url || !Number.isInteger(number)) {
|
|
202
|
+
throw new HostError(`gh pr create did not return a pull request URL: ${result.stdout.trim()}`);
|
|
203
|
+
}
|
|
204
|
+
return { number, url, state: "OPEN", mergedAt: null, head: input.head, base: input.base };
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
rmSync(dir, { recursive: true, force: true });
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
view(checkout, number) {
|
|
211
|
+
const result = runGh(checkout, ["pr", "view", String(number), "--json", "state,mergedAt"]);
|
|
212
|
+
if (result.status !== 0)
|
|
213
|
+
throw ghFailure(result.stderr, result.stdout);
|
|
214
|
+
let parsed;
|
|
215
|
+
try {
|
|
216
|
+
parsed = JSON.parse(result.stdout);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
throw new HostError(`gh pr view returned data that is not JSON.`);
|
|
220
|
+
}
|
|
221
|
+
return { state: asState(parsed.state), mergedAt: asMergedAt(parsed.mergedAt) };
|
|
222
|
+
},
|
|
223
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { titleFromSpec, writeJob } from "../jobs.js";
|
|
4
|
+
/** `kiln add <workflow> --repo R --spec file.md` */
|
|
5
|
+
export function addFromFile(config, input) {
|
|
6
|
+
const source = resolve(input.cwd, input.specPath);
|
|
7
|
+
let text;
|
|
8
|
+
try {
|
|
9
|
+
text = readFileSync(source, "utf8");
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
13
|
+
throw new Error(`Could not read spec ${source}: ${message}`);
|
|
14
|
+
}
|
|
15
|
+
if (!text.trim()) {
|
|
16
|
+
throw new Error(`Spec ${source} is empty.`);
|
|
17
|
+
}
|
|
18
|
+
const title = input.title?.trim() || titleFromSpec(text, source);
|
|
19
|
+
return writeJob(config, {
|
|
20
|
+
workflow: input.workflow,
|
|
21
|
+
repo: input.repo,
|
|
22
|
+
specText: text,
|
|
23
|
+
title,
|
|
24
|
+
dependsOn: input.dependsOn,
|
|
25
|
+
...(input.stack ? { stack: true } : {}),
|
|
26
|
+
source: { kind: "file", ref: source },
|
|
27
|
+
...(input.now ? { now: input.now } : {}),
|
|
28
|
+
});
|
|
29
|
+
}
|