@lchase/kiln 0.4.0 → 0.5.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 +7 -5
- package/dist/attention.js +10 -0
- package/dist/cli.js +4 -0
- package/dist/config.js +2 -1
- package/dist/driver.js +424 -48
- package/dist/feedback.js +610 -0
- package/dist/host.js +26 -1
- package/dist/reconcile.js +31 -3
- package/dist/report.js +48 -9
- package/dist/schemas.js +7 -0
- package/dist/serve-page.js +7 -1
- package/dist/workflow.js +2 -0
- package/package.json +1 -1
- package/prompts/kiln/fix.md +17 -0
- package/workflows/fix.yaml +44 -0
package/dist/feedback.js
ADDED
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { HostError } from "./host.js";
|
|
7
|
+
import { jobBasename } from "./paths.js";
|
|
8
|
+
/**
|
|
9
|
+
* Feedback ledger under `<kilnDir>/feedback/<jobId>.json`.
|
|
10
|
+
* Job files stay free of stored state. Tick is the only writer.
|
|
11
|
+
* A second tick that sees the same head sha and the same items does not
|
|
12
|
+
* append a round. `at` is when the round was opened, so a later review can
|
|
13
|
+
* be told from one this round already included.
|
|
14
|
+
*
|
|
15
|
+
* Kiln reads pull request state. It does not merge, approve, or comment.
|
|
16
|
+
*/
|
|
17
|
+
const EXCERPT_LINES = 40;
|
|
18
|
+
const EXCERPT_CHARS = 2_000;
|
|
19
|
+
const ItemSchema = z.object({
|
|
20
|
+
kind: z.string().min(1),
|
|
21
|
+
source: z.string().min(1),
|
|
22
|
+
summary: z.string().min(1),
|
|
23
|
+
url: z.string().min(1).optional(),
|
|
24
|
+
});
|
|
25
|
+
const RoundSchema = z.object({
|
|
26
|
+
round: z.number().int().min(1),
|
|
27
|
+
headSha: z.string().min(1),
|
|
28
|
+
trigger: z.enum(["ci", "review"]),
|
|
29
|
+
items: z.array(ItemSchema).min(1),
|
|
30
|
+
fixRunId: z.string().min(1).optional(),
|
|
31
|
+
outcome: z.enum(["fixed", "pushed"]).optional(),
|
|
32
|
+
/** When this round was opened. Review feedback newer than this can start another round. */
|
|
33
|
+
at: z.string().min(1),
|
|
34
|
+
});
|
|
35
|
+
const LedgerSchema = z.object({
|
|
36
|
+
jobId: z.string().min(1),
|
|
37
|
+
rounds: z.array(RoundSchema),
|
|
38
|
+
/** A further round would pass `maxFixRounds`. The job is needs-human. */
|
|
39
|
+
exhausted: z.boolean().optional(),
|
|
40
|
+
/** Feedback Kiln could not classify. The job is needs-human. No fix is launched. */
|
|
41
|
+
ambiguous: z.boolean().optional(),
|
|
42
|
+
});
|
|
43
|
+
const FAILING = new Set(["failure", "timed_out", "startup_failure", "error"]);
|
|
44
|
+
const PASSING = new Set(["success", "neutral", "skipped", "cancelled"]);
|
|
45
|
+
const QUIET_REVIEW = new Set(["APPROVED", "COMMENTED", "DISMISSED", "PENDING"]);
|
|
46
|
+
/** A top-level operator note. The rest of the body is the fix instruction. */
|
|
47
|
+
const KILN_REQUEST = /^\/kiln\b\s*/;
|
|
48
|
+
export function feedbackLedgerPath(kilnDir, jobId) {
|
|
49
|
+
return join(kilnDir, "feedback", `${jobBasename(jobId)}.json`);
|
|
50
|
+
}
|
|
51
|
+
export function readFeedbackLedger(kilnDir, jobId) {
|
|
52
|
+
const path = feedbackLedgerPath(kilnDir, jobId);
|
|
53
|
+
if (!existsSync(path))
|
|
54
|
+
return undefined;
|
|
55
|
+
try {
|
|
56
|
+
const parsed = LedgerSchema.parse(JSON.parse(readFileSync(path, "utf8")));
|
|
57
|
+
if (parsed.jobId !== jobId)
|
|
58
|
+
return "corrupt";
|
|
59
|
+
return parsed;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return "corrupt";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export function writeFeedbackLedger(kilnDir, ledger) {
|
|
66
|
+
const path = feedbackLedgerPath(kilnDir, ledger.jobId);
|
|
67
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
68
|
+
const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
69
|
+
writeFileSync(tmp, `${JSON.stringify(LedgerSchema.parse(ledger), null, 2)}\n`);
|
|
70
|
+
renameSync(tmp, path);
|
|
71
|
+
}
|
|
72
|
+
/** A round that has not been marked fixed or pushed. Undefined once the ledger is closed. */
|
|
73
|
+
export function openFeedbackRound(ledger) {
|
|
74
|
+
if (!ledger || ledger.exhausted || ledger.ambiguous)
|
|
75
|
+
return undefined;
|
|
76
|
+
return [...ledger.rounds].reverse().find((round) => round.outcome !== "pushed" && round.outcome !== "fixed");
|
|
77
|
+
}
|
|
78
|
+
/** Last 40 lines, and at most 2000 characters, keeping the tail. */
|
|
79
|
+
export function truncateExcerpt(text) {
|
|
80
|
+
const trimmed = text.trim();
|
|
81
|
+
if (!trimmed)
|
|
82
|
+
return "";
|
|
83
|
+
const lines = trimmed.split(/\r?\n/).slice(-EXCERPT_LINES);
|
|
84
|
+
let excerpt = lines.join("\n").trim();
|
|
85
|
+
if (excerpt.length > EXCERPT_CHARS)
|
|
86
|
+
excerpt = excerpt.slice(excerpt.length - EXCERPT_CHARS).trim();
|
|
87
|
+
return excerpt;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Kiln itself, and any GitHub App (`name[bot]`).
|
|
91
|
+
* CI checks are not authors, so a failing check still counts.
|
|
92
|
+
* The operator is not ignored: they open the pull request, and GitHub will
|
|
93
|
+
* not let them request changes on it.
|
|
94
|
+
*/
|
|
95
|
+
export function isIgnoredAuthor(author) {
|
|
96
|
+
const login = author.trim().toLowerCase();
|
|
97
|
+
if (!login)
|
|
98
|
+
return false;
|
|
99
|
+
return login === "kiln" || login.endsWith("[bot]");
|
|
100
|
+
}
|
|
101
|
+
function isOperator(author, operatorLogin) {
|
|
102
|
+
if (!operatorLogin)
|
|
103
|
+
return false;
|
|
104
|
+
return author.trim().toLowerCase() === operatorLogin.trim().toLowerCase();
|
|
105
|
+
}
|
|
106
|
+
/** Text after a leading `/kiln`, or undefined when the body is not a kiln request. */
|
|
107
|
+
function kilnRequest(body) {
|
|
108
|
+
const trimmed = body.trim();
|
|
109
|
+
const match = KILN_REQUEST.exec(trimmed);
|
|
110
|
+
if (!match)
|
|
111
|
+
return undefined;
|
|
112
|
+
const rest = trimmed.slice(match[0].length).trim();
|
|
113
|
+
return rest.length > 0 ? rest : undefined;
|
|
114
|
+
}
|
|
115
|
+
/** An inline comment or any other comment that belongs to a review thread. */
|
|
116
|
+
function isThreadComment(comment) {
|
|
117
|
+
return Boolean(comment.path) || typeof comment.line === "number";
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* What this tick should do with one pull request snapshot.
|
|
121
|
+
* Pending checks are ignored. A failing check counts only on a head sha that
|
|
122
|
+
* no round has already recorded. Review feedback counts when it is newer than
|
|
123
|
+
* the last round and was not already an item. The same sha and the same item
|
|
124
|
+
* source never return `fix` twice.
|
|
125
|
+
*/
|
|
126
|
+
export function planFeedback(input) {
|
|
127
|
+
const ledger = input.ledger;
|
|
128
|
+
if (ledger?.exhausted || ledger?.ambiguous)
|
|
129
|
+
return { action: "none" };
|
|
130
|
+
if (openFeedbackRound(ledger))
|
|
131
|
+
return { action: "none" };
|
|
132
|
+
const seen = new Set();
|
|
133
|
+
for (const round of ledger?.rounds ?? []) {
|
|
134
|
+
for (const item of round.items)
|
|
135
|
+
seen.add(item.source);
|
|
136
|
+
}
|
|
137
|
+
const last = ledger?.rounds.at(-1);
|
|
138
|
+
const lastAt = last ? Date.parse(last.at) : Number.NaN;
|
|
139
|
+
const shaIsNew = !last || last.headSha !== input.snapshot.headSha;
|
|
140
|
+
const isNew = (source, submittedAt) => {
|
|
141
|
+
if (seen.has(source))
|
|
142
|
+
return false;
|
|
143
|
+
if (!last)
|
|
144
|
+
return true;
|
|
145
|
+
if (!submittedAt)
|
|
146
|
+
return true;
|
|
147
|
+
const at = Date.parse(submittedAt);
|
|
148
|
+
if (!Number.isFinite(at) || !Number.isFinite(lastAt))
|
|
149
|
+
return true;
|
|
150
|
+
return at > lastAt;
|
|
151
|
+
};
|
|
152
|
+
let ambiguous = false;
|
|
153
|
+
const items = [];
|
|
154
|
+
if (shaIsNew) {
|
|
155
|
+
for (const check of input.snapshot.checks) {
|
|
156
|
+
const status = normalize(check.status);
|
|
157
|
+
const conclusion = check.conclusion ? normalize(check.conclusion) : null;
|
|
158
|
+
if (isPending(status, conclusion))
|
|
159
|
+
continue;
|
|
160
|
+
if (conclusion && PASSING.has(conclusion))
|
|
161
|
+
continue;
|
|
162
|
+
if (conclusion && FAILING.has(conclusion)) {
|
|
163
|
+
const source = `check:${input.snapshot.headSha}:${check.name}`;
|
|
164
|
+
if (seen.has(source))
|
|
165
|
+
continue;
|
|
166
|
+
const excerpt = check.logExcerpt ? truncateExcerpt(check.logExcerpt) : "";
|
|
167
|
+
const summary = excerpt ? `${check.name} failed\n\n${excerpt}` : `${check.name} failed`;
|
|
168
|
+
items.push({
|
|
169
|
+
kind: "check",
|
|
170
|
+
source,
|
|
171
|
+
summary,
|
|
172
|
+
...(check.url ? { url: check.url } : {}),
|
|
173
|
+
});
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
ambiguous = true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
for (const review of input.snapshot.reviews) {
|
|
180
|
+
if (isIgnoredAuthor(review.author))
|
|
181
|
+
continue;
|
|
182
|
+
const source = `review:${review.id}`;
|
|
183
|
+
if (isOperator(review.author, input.snapshot.operatorLogin)) {
|
|
184
|
+
const request = kilnRequest(review.body);
|
|
185
|
+
if (!request || !isNew(source, review.submittedAt))
|
|
186
|
+
continue;
|
|
187
|
+
items.push({
|
|
188
|
+
kind: "review",
|
|
189
|
+
source,
|
|
190
|
+
summary: request,
|
|
191
|
+
...(review.url ? { url: review.url } : {}),
|
|
192
|
+
});
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const state = review.state.trim().toUpperCase();
|
|
196
|
+
if (QUIET_REVIEW.has(state))
|
|
197
|
+
continue;
|
|
198
|
+
if (state !== "CHANGES_REQUESTED") {
|
|
199
|
+
ambiguous = true;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (!isNew(source, review.submittedAt))
|
|
203
|
+
continue;
|
|
204
|
+
const body = review.body.trim();
|
|
205
|
+
items.push({
|
|
206
|
+
kind: "review",
|
|
207
|
+
source,
|
|
208
|
+
summary: body ? `Changes requested\n\n${body}` : "Changes requested",
|
|
209
|
+
...(review.url ? { url: review.url } : {}),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
for (const comment of input.snapshot.comments) {
|
|
213
|
+
if (comment.resolved)
|
|
214
|
+
continue;
|
|
215
|
+
if (isIgnoredAuthor(comment.author))
|
|
216
|
+
continue;
|
|
217
|
+
const source = `comment:${comment.id}`;
|
|
218
|
+
if (!isNew(source, comment.submittedAt))
|
|
219
|
+
continue;
|
|
220
|
+
const operator = isOperator(comment.author, input.snapshot.operatorLogin);
|
|
221
|
+
let body = comment.body.trim();
|
|
222
|
+
if (!isThreadComment(comment)) {
|
|
223
|
+
// Top-level conversation notes are not review threads. Other people
|
|
224
|
+
// still act through a review or an inline comment. The operator acts
|
|
225
|
+
// here only with a `/kiln` request, and the prefix stays out of the prompt.
|
|
226
|
+
if (!operator)
|
|
227
|
+
continue;
|
|
228
|
+
const request = kilnRequest(comment.body);
|
|
229
|
+
if (!request)
|
|
230
|
+
continue;
|
|
231
|
+
body = request;
|
|
232
|
+
}
|
|
233
|
+
const where = comment.path ? (typeof comment.line === "number" ? `${comment.path}:${comment.line}` : comment.path) : "";
|
|
234
|
+
const summary = [where, body].filter((part) => part.length > 0).join("\n\n") || "Review comment";
|
|
235
|
+
items.push({
|
|
236
|
+
kind: "comment",
|
|
237
|
+
source,
|
|
238
|
+
summary,
|
|
239
|
+
...(comment.url ? { url: comment.url } : {}),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
if (items.length === 0)
|
|
243
|
+
return ambiguous ? { action: "ambiguous" } : { action: "none" };
|
|
244
|
+
if ((ledger?.rounds.length ?? 0) >= input.maxFixRounds)
|
|
245
|
+
return { action: "exhaust" };
|
|
246
|
+
const trigger = items.some((item) => item.kind === "check") ? "ci" : "review";
|
|
247
|
+
return { action: "fix", headSha: input.snapshot.headSha, trigger, items };
|
|
248
|
+
}
|
|
249
|
+
/** Original spec plus the feedback items the fix agent has to address. */
|
|
250
|
+
export function feedbackSpec(spec, items) {
|
|
251
|
+
const blocks = items.map((item) => {
|
|
252
|
+
const body = item.summary.trim();
|
|
253
|
+
return item.url ? `${body}\n\n${item.url}` : body;
|
|
254
|
+
});
|
|
255
|
+
return [
|
|
256
|
+
spec.trim(),
|
|
257
|
+
"",
|
|
258
|
+
"## Pull request feedback",
|
|
259
|
+
"",
|
|
260
|
+
"Address every item below. Commit on the current branch.",
|
|
261
|
+
"Do not push. Do not force-push. Do not open a pull request. Do not approve one. Do not comment on one.",
|
|
262
|
+
"",
|
|
263
|
+
blocks.join("\n\n"),
|
|
264
|
+
"",
|
|
265
|
+
].join("\n");
|
|
266
|
+
}
|
|
267
|
+
/** Chip text for one feedback round. Outcome, when set, follows the trigger. */
|
|
268
|
+
export function feedbackRoundLabel(round) {
|
|
269
|
+
const trigger = round.trigger === "ci" ? "CI failed" : "changes requested";
|
|
270
|
+
if (round.outcome === "pushed")
|
|
271
|
+
return `${trigger} · pushed`;
|
|
272
|
+
if (round.outcome === "fixed")
|
|
273
|
+
return `${trigger} · fixed`;
|
|
274
|
+
return trigger;
|
|
275
|
+
}
|
|
276
|
+
/** Drawer markdown for one round. Item urls become links. */
|
|
277
|
+
export function feedbackRoundText(round) {
|
|
278
|
+
const lines = [`# R${round.round}`, "", feedbackRoundLabel(round), ""];
|
|
279
|
+
for (const item of round.items) {
|
|
280
|
+
const [title, ...rest] = item.summary.split("\n");
|
|
281
|
+
const head = (title ?? item.kind).trim();
|
|
282
|
+
lines.push(item.url ? `- [${head}](${item.url})` : `- ${head}`);
|
|
283
|
+
const body = rest.join("\n").trim();
|
|
284
|
+
if (body)
|
|
285
|
+
lines.push("", body);
|
|
286
|
+
lines.push("");
|
|
287
|
+
}
|
|
288
|
+
return `${lines.join("\n").trim()}\n`;
|
|
289
|
+
}
|
|
290
|
+
function normalize(value) {
|
|
291
|
+
return value.trim().toLowerCase().replace(/[\s-]+/g, "_");
|
|
292
|
+
}
|
|
293
|
+
function isPending(status, conclusion) {
|
|
294
|
+
if (status === "completed")
|
|
295
|
+
return false;
|
|
296
|
+
if (conclusion && (FAILING.has(conclusion) || PASSING.has(conclusion)))
|
|
297
|
+
return false;
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
export const realPullRequestFeedback = {
|
|
301
|
+
read(checkout, prNumber) {
|
|
302
|
+
const operatorLogin = ghLogin(checkout);
|
|
303
|
+
const repo = ghRepo(checkout);
|
|
304
|
+
const view = ghJson(checkout, ["pr", "view", String(prNumber), "--json", "headRefOid,statusCheckRollup,reviews,url"]);
|
|
305
|
+
const headSha = stringField(view, "headSha") || stringField(view, "headRefOid");
|
|
306
|
+
if (!/^[0-9a-f]{40}$/.test(headSha)) {
|
|
307
|
+
throw new HostError(`gh pr view did not return a head sha for pull request #${prNumber}.`);
|
|
308
|
+
}
|
|
309
|
+
const prUrl = stringField(view, "url");
|
|
310
|
+
const checks = checksFrom(view);
|
|
311
|
+
attachCheckExcerpts(checkout, repo, headSha, checks);
|
|
312
|
+
const reviews = reviewsFrom(view, prUrl);
|
|
313
|
+
const comments = threadsFrom(checkout, repo, prNumber);
|
|
314
|
+
return { operatorLogin, headSha, checks, reviews, comments };
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
function ghLogin(checkout) {
|
|
318
|
+
const result = runGh(checkout, ["api", "user", "--jq", ".login"]);
|
|
319
|
+
const login = result.stdout.trim();
|
|
320
|
+
if (result.status !== 0 || !login || /\s/.test(login)) {
|
|
321
|
+
throw new HostError(`Could not read the gh login. Kiln will not act on pull request feedback until gh api user works. ${result.stderr.trim()}`.trim());
|
|
322
|
+
}
|
|
323
|
+
return login;
|
|
324
|
+
}
|
|
325
|
+
function ghRepo(checkout) {
|
|
326
|
+
const result = runGh(checkout, ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]);
|
|
327
|
+
const name = result.stdout.trim();
|
|
328
|
+
const [owner, repo] = name.split("/");
|
|
329
|
+
if (result.status !== 0 || !owner || !repo || name.split("/").length !== 2) {
|
|
330
|
+
throw new HostError(`Could not read owner/name for ${checkout}: ${result.stderr.trim() || name || "gh repo view failed"}`);
|
|
331
|
+
}
|
|
332
|
+
return { owner, name: repo };
|
|
333
|
+
}
|
|
334
|
+
function ghJson(checkout, args) {
|
|
335
|
+
const result = runGh(checkout, args);
|
|
336
|
+
if (result.status !== 0) {
|
|
337
|
+
throw new HostError(`Could not read the pull request: ${(result.stderr || result.stdout).trim() || "gh failed"}`);
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
const parsed = JSON.parse(result.stdout);
|
|
341
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
342
|
+
throw new Error("not an object");
|
|
343
|
+
}
|
|
344
|
+
return parsed;
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
throw new HostError("gh pr view returned data that is not a JSON object.");
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
function checksFrom(view) {
|
|
351
|
+
const rollup = view.statusCheckRollup;
|
|
352
|
+
if (!Array.isArray(rollup))
|
|
353
|
+
return [];
|
|
354
|
+
const checks = [];
|
|
355
|
+
for (const entry of rollup) {
|
|
356
|
+
if (typeof entry !== "object" || entry === null)
|
|
357
|
+
continue;
|
|
358
|
+
const record = entry;
|
|
359
|
+
const name = stringField(record, "name") || stringField(record, "context");
|
|
360
|
+
if (!name)
|
|
361
|
+
continue;
|
|
362
|
+
const status = stringField(record, "status") || (stringField(record, "state") ? "completed" : "");
|
|
363
|
+
const conclusion = stringField(record, "conclusion") || stringField(record, "state") || null;
|
|
364
|
+
const url = stringField(record, "detailsUrl") || stringField(record, "targetUrl") || undefined;
|
|
365
|
+
if (stringField(record, "state").toUpperCase() === "PENDING") {
|
|
366
|
+
checks.push({ name, status: "pending", conclusion: null, ...(url ? { url } : {}) });
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
checks.push({
|
|
370
|
+
name,
|
|
371
|
+
status: status || "completed",
|
|
372
|
+
conclusion: conclusion && conclusion.toUpperCase() !== "PENDING" ? conclusion : null,
|
|
373
|
+
...(url ? { url } : {}),
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
return checks;
|
|
377
|
+
}
|
|
378
|
+
function attachCheckExcerpts(checkout, repo, headSha, checks) {
|
|
379
|
+
const failing = checks.filter((check) => {
|
|
380
|
+
const conclusion = check.conclusion ? normalize(check.conclusion) : null;
|
|
381
|
+
return conclusion !== null && FAILING.has(conclusion);
|
|
382
|
+
});
|
|
383
|
+
if (failing.length === 0)
|
|
384
|
+
return;
|
|
385
|
+
const result = runGh(checkout, [
|
|
386
|
+
"api",
|
|
387
|
+
`repos/${repo.owner}/${repo.name}/commits/${headSha}/check-runs`,
|
|
388
|
+
"--paginate",
|
|
389
|
+
]);
|
|
390
|
+
if (result.status !== 0)
|
|
391
|
+
return;
|
|
392
|
+
let parsed;
|
|
393
|
+
try {
|
|
394
|
+
parsed = JSON.parse(result.stdout);
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const runs = checkRunList(parsed);
|
|
400
|
+
for (const check of failing) {
|
|
401
|
+
const match = runs.find((run) => run.name === check.name);
|
|
402
|
+
if (!match)
|
|
403
|
+
continue;
|
|
404
|
+
const excerpt = truncateExcerpt([match.summary, match.text].filter(Boolean).join("\n\n"));
|
|
405
|
+
if (excerpt)
|
|
406
|
+
check.logExcerpt = excerpt;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
function checkRunList(parsed) {
|
|
410
|
+
const pages = Array.isArray(parsed) ? parsed : [parsed];
|
|
411
|
+
const runs = [];
|
|
412
|
+
for (const page of pages) {
|
|
413
|
+
if (typeof page !== "object" || page === null)
|
|
414
|
+
continue;
|
|
415
|
+
const list = page.check_runs;
|
|
416
|
+
if (!Array.isArray(list))
|
|
417
|
+
continue;
|
|
418
|
+
for (const entry of list) {
|
|
419
|
+
if (typeof entry !== "object" || entry === null)
|
|
420
|
+
continue;
|
|
421
|
+
const record = entry;
|
|
422
|
+
const name = stringField(record, "name");
|
|
423
|
+
if (!name)
|
|
424
|
+
continue;
|
|
425
|
+
const output = record.output;
|
|
426
|
+
const summary = typeof output === "object" && output !== null ? stringField(output, "summary") : "";
|
|
427
|
+
const text = typeof output === "object" && output !== null ? stringField(output, "text") : "";
|
|
428
|
+
runs.push({ name, summary, text });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return runs;
|
|
432
|
+
}
|
|
433
|
+
function reviewsFrom(view, prUrl) {
|
|
434
|
+
if (!Array.isArray(view.reviews))
|
|
435
|
+
return [];
|
|
436
|
+
const reviews = [];
|
|
437
|
+
for (const entry of view.reviews) {
|
|
438
|
+
if (typeof entry !== "object" || entry === null)
|
|
439
|
+
continue;
|
|
440
|
+
const record = entry;
|
|
441
|
+
const id = reviewId(record);
|
|
442
|
+
if (!id)
|
|
443
|
+
continue;
|
|
444
|
+
const author = authorLogin(record.author);
|
|
445
|
+
const state = stringField(record, "state") || "COMMENTED";
|
|
446
|
+
const body = stringField(record, "body");
|
|
447
|
+
const submittedAt = stringField(record, "submittedAt");
|
|
448
|
+
const url = stringField(record, "url") || reviewUrl(prUrl, id);
|
|
449
|
+
reviews.push({
|
|
450
|
+
id,
|
|
451
|
+
author,
|
|
452
|
+
state,
|
|
453
|
+
body,
|
|
454
|
+
submittedAt,
|
|
455
|
+
...(url ? { url } : {}),
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
return reviews;
|
|
459
|
+
}
|
|
460
|
+
const THREADS_QUERY = `
|
|
461
|
+
query($owner: String!, $name: String!, $number: Int!) {
|
|
462
|
+
repository(owner: $owner, name: $name) {
|
|
463
|
+
pullRequest(number: $number) {
|
|
464
|
+
comments(first: 100) {
|
|
465
|
+
nodes {
|
|
466
|
+
id
|
|
467
|
+
body
|
|
468
|
+
createdAt
|
|
469
|
+
url
|
|
470
|
+
author { login }
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
reviewThreads(first: 100) {
|
|
474
|
+
nodes {
|
|
475
|
+
isResolved
|
|
476
|
+
comments(first: 20) {
|
|
477
|
+
nodes {
|
|
478
|
+
id
|
|
479
|
+
body
|
|
480
|
+
createdAt
|
|
481
|
+
url
|
|
482
|
+
path
|
|
483
|
+
line
|
|
484
|
+
author { login }
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}`.trim();
|
|
492
|
+
function threadsFrom(checkout, repo, prNumber) {
|
|
493
|
+
const result = runGh(checkout, [
|
|
494
|
+
"api",
|
|
495
|
+
"graphql",
|
|
496
|
+
"-f",
|
|
497
|
+
`query=${THREADS_QUERY}`,
|
|
498
|
+
"-f",
|
|
499
|
+
`owner=${repo.owner}`,
|
|
500
|
+
"-f",
|
|
501
|
+
`name=${repo.name}`,
|
|
502
|
+
"-F",
|
|
503
|
+
`number=${prNumber}`,
|
|
504
|
+
]);
|
|
505
|
+
if (result.status !== 0) {
|
|
506
|
+
throw new HostError(`Could not read review threads for pull request #${prNumber}: ${(result.stderr || result.stdout).trim() || "gh api graphql failed"}`);
|
|
507
|
+
}
|
|
508
|
+
let parsed;
|
|
509
|
+
try {
|
|
510
|
+
parsed = JSON.parse(result.stdout);
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
throw new HostError("gh api graphql returned data that is not JSON.");
|
|
514
|
+
}
|
|
515
|
+
const comments = [];
|
|
516
|
+
const conversation = dig(parsed, ["data", "repository", "pullRequest", "comments", "nodes"]);
|
|
517
|
+
if (Array.isArray(conversation)) {
|
|
518
|
+
for (const node of conversation) {
|
|
519
|
+
const comment = commentFrom(node, false);
|
|
520
|
+
if (comment)
|
|
521
|
+
comments.push(comment);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
const threads = dig(parsed, ["data", "repository", "pullRequest", "reviewThreads", "nodes"]);
|
|
525
|
+
if (!Array.isArray(threads))
|
|
526
|
+
return comments;
|
|
527
|
+
for (const thread of threads) {
|
|
528
|
+
if (typeof thread !== "object" || thread === null)
|
|
529
|
+
continue;
|
|
530
|
+
const resolved = thread.isResolved === true;
|
|
531
|
+
const nodes = dig(thread, ["comments", "nodes"]);
|
|
532
|
+
if (!Array.isArray(nodes))
|
|
533
|
+
continue;
|
|
534
|
+
for (const node of nodes) {
|
|
535
|
+
const comment = commentFrom(node, resolved);
|
|
536
|
+
if (comment)
|
|
537
|
+
comments.push(comment);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return comments;
|
|
541
|
+
}
|
|
542
|
+
function commentFrom(node, resolved) {
|
|
543
|
+
if (typeof node !== "object" || node === null)
|
|
544
|
+
return undefined;
|
|
545
|
+
const record = node;
|
|
546
|
+
const id = stringField(record, "id");
|
|
547
|
+
if (!id)
|
|
548
|
+
return undefined;
|
|
549
|
+
const line = typeof record.line === "number" ? record.line : undefined;
|
|
550
|
+
const path = stringField(record, "path");
|
|
551
|
+
const url = stringField(record, "url");
|
|
552
|
+
return {
|
|
553
|
+
id,
|
|
554
|
+
author: authorLogin(record.author),
|
|
555
|
+
body: stringField(record, "body"),
|
|
556
|
+
...(path ? { path } : {}),
|
|
557
|
+
...(line !== undefined ? { line } : {}),
|
|
558
|
+
submittedAt: stringField(record, "createdAt"),
|
|
559
|
+
...(url ? { url } : {}),
|
|
560
|
+
resolved,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
function reviewId(record) {
|
|
564
|
+
if (typeof record.id === "string" && record.id.length > 0)
|
|
565
|
+
return record.id;
|
|
566
|
+
if (typeof record.id === "number" && Number.isFinite(record.id))
|
|
567
|
+
return String(record.id);
|
|
568
|
+
return "";
|
|
569
|
+
}
|
|
570
|
+
function reviewUrl(prUrl, id) {
|
|
571
|
+
const numeric = /\/(\d+)$/.exec(id)?.[1] ?? (/^\d+$/.test(id) ? id : "");
|
|
572
|
+
if (!prUrl || !numeric)
|
|
573
|
+
return undefined;
|
|
574
|
+
return `${prUrl}#pullrequestreview-${numeric}`;
|
|
575
|
+
}
|
|
576
|
+
function authorLogin(value) {
|
|
577
|
+
if (typeof value === "string")
|
|
578
|
+
return value;
|
|
579
|
+
if (typeof value === "object" && value !== null && "login" in value) {
|
|
580
|
+
const login = value.login;
|
|
581
|
+
return typeof login === "string" ? login : "";
|
|
582
|
+
}
|
|
583
|
+
return "";
|
|
584
|
+
}
|
|
585
|
+
function stringField(record, key) {
|
|
586
|
+
const value = record[key];
|
|
587
|
+
return typeof value === "string" ? value : "";
|
|
588
|
+
}
|
|
589
|
+
function dig(value, path) {
|
|
590
|
+
let current = value;
|
|
591
|
+
for (const key of path) {
|
|
592
|
+
if (typeof current !== "object" || current === null || !(key in current))
|
|
593
|
+
return undefined;
|
|
594
|
+
current = current[key];
|
|
595
|
+
}
|
|
596
|
+
return current;
|
|
597
|
+
}
|
|
598
|
+
function runGh(checkout, args) {
|
|
599
|
+
const bin = process.env.KILN_GH && process.env.KILN_GH.length > 0 ? process.env.KILN_GH : "gh";
|
|
600
|
+
const result = spawnSync(bin, args, {
|
|
601
|
+
cwd: checkout,
|
|
602
|
+
encoding: "utf8",
|
|
603
|
+
env: { ...process.env, GH_PROMPT_DISABLED: "1", GIT_TERMINAL_PROMPT: "0" },
|
|
604
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
605
|
+
});
|
|
606
|
+
if (result.error && result.error.code === "ENOENT") {
|
|
607
|
+
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.`);
|
|
608
|
+
}
|
|
609
|
+
return { status: result.status ?? 1, stdout: result.stdout ?? "", stderr: result.stderr || result.error?.message || "" };
|
|
610
|
+
}
|
package/dist/host.js
CHANGED
|
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { realPullRequestFeedback } from "./feedback.js";
|
|
5
6
|
/**
|
|
6
7
|
* Git remote and GitHub CLI, behind one seam so tests can fake `gh` and point
|
|
7
8
|
* `origin` at a local bare repo. Nothing here merges a pull request or pushes
|
|
@@ -14,7 +15,16 @@ export class HostError extends Error {
|
|
|
14
15
|
}
|
|
15
16
|
}
|
|
16
17
|
export function defaultHost() {
|
|
17
|
-
return { github: realGitHub, git: realGitRemote };
|
|
18
|
+
return { github: realGitHub, git: realGitRemote, feedback: realPullRequestFeedback };
|
|
19
|
+
}
|
|
20
|
+
const YAK_BRANCH = /^yak\/[A-Za-z0-9._-]+$/;
|
|
21
|
+
function assertYakBranch(branch, baseBranch) {
|
|
22
|
+
if (branch === baseBranch || branch === `refs/heads/${baseBranch}`) {
|
|
23
|
+
throw new HostError(`Refusing to push ${branch}: that is the base branch.`);
|
|
24
|
+
}
|
|
25
|
+
if (!YAK_BRANCH.test(branch)) {
|
|
26
|
+
throw new HostError(`Refusing to push ${branch}: kiln only pushes yak/<run-id>.`);
|
|
27
|
+
}
|
|
18
28
|
}
|
|
19
29
|
const gitEnv = () => ({ ...process.env, GIT_TERMINAL_PROMPT: "0" });
|
|
20
30
|
function gitResult(checkout, args) {
|
|
@@ -70,6 +80,21 @@ export const realGitRemote = {
|
|
|
70
80
|
throw new HostError(`Could not push ${branch}: ${result.stderr.trim() || "git push failed"}`);
|
|
71
81
|
}
|
|
72
82
|
},
|
|
83
|
+
publishBranch(checkout, source, target, baseBranch) {
|
|
84
|
+
assertYakBranch(source, baseBranch);
|
|
85
|
+
assertYakBranch(target, baseBranch);
|
|
86
|
+
if (source.includes("+") || target.includes("+")) {
|
|
87
|
+
throw new HostError(`Refusing to push ${target}: kiln does not force-push.`);
|
|
88
|
+
}
|
|
89
|
+
const result = gitResult(checkout, ["push", "origin", `refs/heads/${source}:refs/heads/${target}`]);
|
|
90
|
+
if (result.status !== 0) {
|
|
91
|
+
throw new HostError(`Could not push ${source} onto ${target}: ${result.stderr.trim() || "git push failed"}`);
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
isAncestor(checkout, ancestor, descendant) {
|
|
95
|
+
const result = gitResult(checkout, ["merge-base", "--is-ancestor", ancestor, descendant]);
|
|
96
|
+
return result.status === 0;
|
|
97
|
+
},
|
|
73
98
|
defaultBranch(checkout) {
|
|
74
99
|
const symbolic = gitResult(checkout, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
|
|
75
100
|
if (symbolic.status === 0) {
|