@mergelight/cli 0.0.0-stage → 0.1.14

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.
@@ -0,0 +1,367 @@
1
+ import { GitHub } from "./api.js";
2
+ import { commentableLines, place } from "./diff-lines.js";
3
+ import { closedBody, readClosed, reopenedBody, resolvedByPeople, threadAction, threadFinding } from "./closed.js";
4
+ import { failedSummaryBody, FINDING_MARKER_RE, headline, inlineBody, isFailedSummary, isPendingSummary, LIGHT, pendingSummaryBody, SUMMARY_MARKER, summaryBody } from "./markdown.js";
5
+ import { readMarks, trustedComment, writeAccess } from "./handled.js";
6
+ import { decodeState, nextState } from "./state.js";
7
+ import { newerVersion } from "../update.js";
8
+ export { trustedComment } from "./handled.js";
9
+ export const CHECK_NAME = "Mergelight";
10
+ /**
11
+ * How each merge light shows up as a GitHub check. Yellow is neutral, which
12
+ * GitHub counts as passing: a solo developer can't approve their own PR, so
13
+ * yellow must not wait for an approval unless a team asks for that. Then it's
14
+ * action_required, which doesn't pass until someone approves (see approveCheck).
15
+ */
16
+ export function checkConclusion(light, yellowBlocks = false) {
17
+ if (light === "green")
18
+ return "success";
19
+ if (light === "red")
20
+ return "failure";
21
+ return yellowBlocks ? "action_required" : "neutral";
22
+ }
23
+ /**
24
+ * The summary comment, which Mergelight edits and reads its state from. Only a bot's comment or the
25
+ * current login's own counts. A person's association isn't enough: a teammate who wrote the marker
26
+ * could edit the comment later, and change a login code people are told to enter.
27
+ */
28
+ export async function ownSummary(comments, viewer) {
29
+ const marked = comments.filter((c) => c.body?.includes(SUMMARY_MARKER));
30
+ const bots = marked.find((c) => c.user?.type === "Bot");
31
+ if (bots || !marked.length)
32
+ return bots;
33
+ const me = await viewer();
34
+ return me ? marked.find((c) => c.user?.login === me) : undefined;
35
+ }
36
+ /** What Mergelight has on this PR so far. Reviews read it before they publish, and the MCP server hands it to agents. */
37
+ export async function readPrReviewState(api, number) {
38
+ const issueComments = await api.list(`issues/${number}/comments`);
39
+ const reviewComments = await api.list(`pulls/${number}/comments`);
40
+ const summary = await ownSummary(issueComments, () => api.viewer());
41
+ const marks = await readMarks([...issueComments, ...reviewComments], writeAccess(api));
42
+ return { summary, state: decodeState(summary?.body), marks, reviewComments };
43
+ }
44
+ const MAX_PREVIOUS = 40;
45
+ /** What earlier rounds found on this PR, latest first, for the verifier to check again. */
46
+ export async function previousFindings(api, number) {
47
+ const { state, marks } = await readPrReviewState(api, number);
48
+ const resolved = await resolvedByPeople(await reviewThreads(api, number).catch(() => []), writeAccess(api));
49
+ // A decision not to fix must reach the verifier however long the list gets, or the problem comes back as new.
50
+ const order = (f) => (marks.get(f.id)?.as === "wont_fix" || resolved.has(f.id) ? 0 : f.status === "gone" ? 2 : 1);
51
+ return (state?.findings ?? [])
52
+ .sort((a, b) => order(a) - order(b))
53
+ .slice(0, MAX_PREVIOUS)
54
+ .map(({ id, title, file, line, end_line, how }) => {
55
+ const mark = marks.get(id);
56
+ const by = resolved.get(id);
57
+ const handled = mark
58
+ ? { as: mark.as, note: mark.note, by: mark.by, sha: mark.sha }
59
+ : by
60
+ ? { as: "wont_fix", note: "Resolved the comment on the pull request.", by, sha: null }
61
+ : undefined;
62
+ return { id, title, file, line, end_line, how, ...(handled ? { handled } : {}) };
63
+ });
64
+ }
65
+ export const CHECK_FAILED_TITLE = "Couldn't review this pull request";
66
+ export const checkLoginTitle = (plan) => `The ${plan} login didn't work`;
67
+ export const checkLimitTitle = (plan) => `${CHECK_FAILED_TITLE}: your ${plan} plan hit its limit`;
68
+ export const isFailedCheckTitle = (title) => Boolean(title && (title.startsWith(CHECK_FAILED_TITLE) || /^The \w+ login didn't work$/.test(title)));
69
+ /**
70
+ * Creates the check, or updates it if it has an id. GitHub only lets Apps write
71
+ * checks, so a personal `gh` login gets a 403 on any repository. That doesn't sink
72
+ * a review, since the comments carry it. Every other error still throws.
73
+ */
74
+ export async function writeCheck(api, id, headSha, body, log) {
75
+ try {
76
+ if (id)
77
+ return await api.write("PATCH", `check-runs/${id}`, body);
78
+ return await api.write("POST", "check-runs", { name: CHECK_NAME, head_sha: headSha, ...body });
79
+ }
80
+ catch (err) {
81
+ if (!/must authenticate via a GitHub App/i.test(err.message))
82
+ throw err;
83
+ log?.("Skipped the Mergelight check: GitHub only lets a GitHub App write checks, not a personal gh login.");
84
+ return null;
85
+ }
86
+ }
87
+ /**
88
+ * Shows a live "Reviewing" check right away, so people and agents can see work is
89
+ * happening. A login that can't write checks hears about it once, from the last write.
90
+ */
91
+ export async function startCheck(api, headSha) {
92
+ const run = await writeCheck(api, null, headSha, {
93
+ status: "in_progress",
94
+ started_at: new Date().toISOString(),
95
+ output: {
96
+ title: "Reviewing",
97
+ summary: "Mergelight is reviewing this pull request with Codex on your own plan. This usually takes 2 to 15 minutes.",
98
+ },
99
+ });
100
+ return run?.id ?? null;
101
+ }
102
+ /** Puts the summary comment on the PR before anything else, so it comes first. Later rounds edit it in place. */
103
+ export async function startSummary(api, number) {
104
+ const { summary } = await readPrReviewState(api, number);
105
+ return summary ?? (await api.write("POST", `issues/${number}/comments`, { body: pendingSummaryBody() }));
106
+ }
107
+ /** A first review that couldn't run shouldn't say "Reviewing" forever, and an older failure shouldn't outlive a newer reason. Real results stay. */
108
+ export async function failSummary(api, number, notice) {
109
+ const { summary } = await readPrReviewState(api, number);
110
+ if (summary && (isPendingSummary(summary.body) || isFailedSummary(summary.body)))
111
+ await api.write("PATCH", `issues/comments/${summary.id}`, { body: failedSummaryBody(notice) });
112
+ }
113
+ /**
114
+ * A review that couldn't run ends neutral, so a broken login doesn't block
115
+ * merging. The workflow job itself still fails, so it's visible.
116
+ */
117
+ export async function failCheck(api, checkRunId, headSha, reason, log, title = CHECK_FAILED_TITLE) {
118
+ await writeCheck(api, checkRunId, headSha, {
119
+ status: "completed",
120
+ conclusion: "neutral",
121
+ completed_at: new Date().toISOString(),
122
+ output: {
123
+ title,
124
+ summary: `${reason.slice(0, 2000)}\n\nThis check is neutral so a broken review doesn't block merging. The workflow log has the details.`,
125
+ },
126
+ }, log);
127
+ }
128
+ export async function publishReview(review, opts = {}) {
129
+ const log = opts.log ?? (() => { });
130
+ const api = new GitHub(review.pr.owner, review.pr.repo, opts);
131
+ const n = review.pr.number;
132
+ // What's already on the PR: the summary comment with last round's state, and inline findings.
133
+ const { summary: existing, state: prev, marks, reviewComments } = await readPrReviewState(api, n);
134
+ // The summary goes up before the inline comments, so it stays on top of them.
135
+ const summary = existing ?? (await api.write("POST", `issues/${n}/comments`, { body: pendingSummaryBody() }));
136
+ const urls = new Map();
137
+ for (const c of reviewComments) {
138
+ if (!trustedComment(c))
139
+ continue;
140
+ const id = c.body.match(FINDING_MARKER_RE)?.[1];
141
+ if (id && !urls.has(id))
142
+ urls.set(id, c.html_url);
143
+ }
144
+ // New top picks go inline, if their lines are in the diff. A finding from an earlier
145
+ // round stays where it is, even if the verifier ranks it higher now: the reader has
146
+ // seen it, and a new comment on old code is just more noise.
147
+ const lines = await commentableLines(api, n);
148
+ const seen = new Set(prev?.findings.map((f) => f.id));
149
+ const fresh = review.findings.filter((f) => f.tier === "post" && !urls.has(f.id) && !seen.has(f.id));
150
+ const inline = [];
151
+ const outside = [];
152
+ for (const f of fresh) {
153
+ const at = place(lines, f.file, f.line, f.end_line);
154
+ if (!at) {
155
+ outside.push(f);
156
+ continue;
157
+ }
158
+ inline.push({
159
+ f,
160
+ comment: {
161
+ path: f.file,
162
+ side: "RIGHT",
163
+ line: at.line,
164
+ ...(at.start_line ? { start_line: at.start_line, start_side: "RIGHT" } : {}),
165
+ body: inlineBody(f),
166
+ },
167
+ });
168
+ }
169
+ if (inline.length) {
170
+ const body = {
171
+ commit_id: review.pr.head_sha,
172
+ event: "COMMENT",
173
+ comments: inline.map((i) => i.comment),
174
+ };
175
+ try {
176
+ const posted = await api.write("POST", `pulls/${n}/reviews`, body);
177
+ if (posted) {
178
+ for (const c of await api.list(`pulls/${n}/reviews/${posted.id}/comments`)) {
179
+ const id = c.body.match(FINDING_MARKER_RE)?.[1];
180
+ if (id)
181
+ urls.set(id, c.html_url);
182
+ }
183
+ }
184
+ }
185
+ catch (err) {
186
+ // One line GitHub won't take rejects the whole review. List them in the summary instead.
187
+ log(`Inline review rejected, listing findings in the summary instead: ${err.message}`);
188
+ outside.push(...inline.map((i) => i.f));
189
+ inline.length = 0;
190
+ }
191
+ }
192
+ const { handled, resolved } = await settleThreads(api, review, log);
193
+ const state = nextState(prev, review.findings, review.pr.head_sha, review.light, urls);
194
+ const summaryInput = { review, state, urls, marks, handled, update: await newerVersion(api) };
195
+ const commentBody = summaryBody(summaryInput);
196
+ let summaryUrl = summary?.html_url ?? null;
197
+ if (summary) {
198
+ await api.write("PATCH", `issues/comments/${summary.id}`, { body: commentBody });
199
+ }
200
+ else {
201
+ const created = await api.write("POST", `issues/${n}/comments`, { body: commentBody });
202
+ summaryUrl = created?.html_url ?? null;
203
+ }
204
+ const light = LIGHT[review.light];
205
+ const check = {
206
+ status: "completed",
207
+ conclusion: checkConclusion(review.light, opts.yellowBlocks),
208
+ completed_at: new Date().toISOString(),
209
+ ...(summaryUrl ? { details_url: summaryUrl } : {}),
210
+ output: {
211
+ title: `${light.dot} ${light.label}: ${headline(review, handled.size)}`,
212
+ summary: summaryBody(summaryInput, false).replace(SUMMARY_MARKER, "").slice(0, 65_000),
213
+ },
214
+ };
215
+ await writeCheck(api, opts.checkRunId, review.pr.head_sha, check, log);
216
+ await syncApproval(api, n, review.light, review.pr.head_sha, log);
217
+ return { summaryUrl, inline: inline.length, outside: outside.length, resolved, light: review.light };
218
+ }
219
+ export const APPROVAL_MARKER = "<!-- mergelight:approval -->";
220
+ /** Only these post Mergelight's reviews, so a person's review that quotes the marker is never touched. */
221
+ const OWN_REVIEWERS = new Set(["mergelight-ai[bot]", "github-actions[bot]"]);
222
+ /**
223
+ * Green approves the pull request, like a reviewer would. Anything else takes back an approval Mergelight gave
224
+ * earlier, so an old approval can't stand next to a newer finding. Yellow and red never request changes, so a
225
+ * review from Mergelight never blocks a merge by itself. Failures only log: the check already carries the result.
226
+ */
227
+ export async function syncApproval(api, number, light, headSha, log = () => { }) {
228
+ try {
229
+ const ours = (await api.list(`pulls/${number}/reviews`)).filter((r) => OWN_REVIEWERS.has(r.user?.login ?? "") && (r.body ?? "").includes(APPROVAL_MARKER));
230
+ const standing = ours.filter((r) => r.state === "APPROVED");
231
+ if (light === "green") {
232
+ if (standing.some((r) => r.commit_id === headSha))
233
+ return "already";
234
+ await api.write("POST", `pulls/${number}/reviews`, {
235
+ commit_id: headSha,
236
+ event: "APPROVE",
237
+ body: `${APPROVAL_MARKER}\n🟢 Mergelight found nothing that needs fixing in this commit.`,
238
+ });
239
+ return "approved";
240
+ }
241
+ for (const r of standing) {
242
+ await api.write("PUT", `pulls/${number}/reviews/${r.id}/dismissals`, { message: "A newer Mergelight review found something to look at.", event: "DISMISS" });
243
+ }
244
+ return standing.length ? "dismissed" : "none";
245
+ }
246
+ catch (err) {
247
+ log(`Couldn't update Mergelight's approval: ${err instanceof Error ? err.message : String(err)}`);
248
+ return "none";
249
+ }
250
+ }
251
+ export async function reviewThreads(api, number) {
252
+ const data = await api.graphql(`query($owner: String!, $repo: String!, $number: Int!) {
253
+ repository(owner: $owner, name: $repo) {
254
+ pullRequest(number: $number) {
255
+ reviewThreads(first: 100) {
256
+ nodes { id isResolved isOutdated resolvedBy { login } comments(first: 50) { nodes { databaseId body authorAssociation author { __typename } } } }
257
+ }
258
+ }
259
+ }
260
+ }`, { owner: api.owner, repo: api.repo, number });
261
+ return data?.repository.pullRequest.reviewThreads.nodes ?? [];
262
+ }
263
+ export const resolveThread = (api, id) => api.graphql(`mutation($id: ID!) { resolveReviewThread(input: { threadId: $id }) { thread { id } } }`, { id }, true);
264
+ const unresolveThread = (api, id) => api.graphql(`mutation($id: ID!) { unresolveReviewThread(input: { threadId: $id }) { thread { id } } }`, { id }, true);
265
+ /**
266
+ * Brings inline comments in line with the threads: fixed or resolved ones get a
267
+ * checkmark and a dropdown, and reopened or returning ones go back to the
268
+ * original. Returns what counts as handled now, for the summary.
269
+ */
270
+ async function settleThreads(api, review, log) {
271
+ const handled = new Map();
272
+ let resolved = 0;
273
+ let threads;
274
+ try {
275
+ threads = await reviewThreads(api, review.pr.number);
276
+ }
277
+ catch (err) {
278
+ log(`Couldn't read review threads: ${err.message}`);
279
+ return { handled, resolved };
280
+ }
281
+ const open = new Set(review.findings.map((f) => f.id));
282
+ const sha = review.pr.head_sha.slice(0, 7);
283
+ const canWrite = writeAccess(api);
284
+ const edit = (commentId, body) => api.write("PATCH", `pulls/comments/${commentId}`, { body }).catch((err) => log(`Couldn't update a comment: ${err.message}`));
285
+ for (const t of threads) {
286
+ const found = threadFinding(t);
287
+ if (!found)
288
+ continue;
289
+ const by = t.resolvedBy?.login ?? null;
290
+ const action = threadAction(t, open);
291
+ if (!action) {
292
+ const was = readClosed(found.body);
293
+ if (was)
294
+ handled.set(found.id, { how: was.how, by, sha: was.sha });
295
+ continue;
296
+ }
297
+ if (action.kind === "fixed") {
298
+ let isResolved = !action.resolve;
299
+ if (action.resolve) {
300
+ try {
301
+ await resolveThread(api, t.id);
302
+ isResolved = true;
303
+ resolved++;
304
+ }
305
+ catch (err) {
306
+ // GitHub only lets a workflow token resolve threads with contents: write. The comment still says it's handled.
307
+ const hint = /not accessible by integration/i.test(err.message) ? "the workflow needs contents: write for that" : err.message;
308
+ log(`Couldn't mark a fixed thread resolved: ${hint}`);
309
+ }
310
+ }
311
+ handled.set(found.id, { how: "fixed", sha });
312
+ await edit(found.commentId, closedBody(found.body, { how: "fixed", resolved: isResolved, sha }));
313
+ }
314
+ else if (action.kind === "resolved") {
315
+ // A workflow token sees a private org member as CONTRIBUTOR, so ask who can write instead of trusting the association.
316
+ if (!by || !(await canWrite(by)))
317
+ continue;
318
+ handled.set(found.id, { how: "resolved", by });
319
+ await edit(found.commentId, closedBody(found.body, { how: "resolved", resolved: true, by }));
320
+ }
321
+ else {
322
+ if (action.kind === "regressed" && action.unresolve) {
323
+ await unresolveThread(api, t.id).catch((err) => log(`Couldn't reopen a thread: ${err.message}`));
324
+ }
325
+ await edit(found.commentId, reopenedBody(found.body, action.kind === "reopened"));
326
+ }
327
+ }
328
+ return { handled, resolved };
329
+ }
330
+ /** A yellow check. A review that couldn't run also ends neutral, but an approval says nothing about that. */
331
+ export function isYellowCheck(check) {
332
+ return check.conclusion === "action_required" || (check.conclusion === "neutral" && Boolean(check.output.title?.startsWith(LIGHT.yellow.dot)));
333
+ }
334
+ /** The newest Mergelight check on a commit. */
335
+ export async function latestCheck(api, sha) {
336
+ const runs = await api.get(`commits/${sha}/check-runs?check_name=${CHECK_NAME}`);
337
+ return [...runs.check_runs].sort((a, b) => b.id - a.id)[0];
338
+ }
339
+ /** When someone approves a PR with a yellow check, it turns green. A blocked check (a bug to fix) stays red. */
340
+ export async function approveCheck(api, number, by) {
341
+ const pr = await api.get(`pulls/${number}`);
342
+ const latest = await latestCheck(api, pr.head.sha);
343
+ if (!latest)
344
+ return "No Mergelight check on this commit.";
345
+ if (!isYellowCheck(latest))
346
+ return `Mergelight check is ${latest.conclusion ?? "still running"}, nothing to approve.`;
347
+ const body = {
348
+ conclusion: "success",
349
+ output: {
350
+ title: `🟢 Approved by ${by}`,
351
+ summary: `Approved by ${by} after the Mergelight review.\n\n${latest.output.summary ?? ""}`.slice(0, 65_000),
352
+ },
353
+ };
354
+ try {
355
+ await api.write("PATCH", `check-runs/${latest.id}`, body);
356
+ }
357
+ catch {
358
+ await api.write("POST", "check-runs", {
359
+ name: CHECK_NAME,
360
+ head_sha: pr.head.sha,
361
+ status: "completed",
362
+ completed_at: new Date().toISOString(),
363
+ ...body,
364
+ });
365
+ }
366
+ return `Mergelight check approved by ${by}.`;
367
+ }
@@ -0,0 +1,82 @@
1
+ import { gunzipSync, gzipSync } from "node:zlib";
2
+ const MARKER = "mergelight:state:v1";
3
+ /** State shares a comment with the summary, and GitHub caps a comment at 65,536 characters. */
4
+ const MAX_STATE = 40_000;
5
+ const pack = (state) => `<!-- ${MARKER} ${gzipSync(JSON.stringify(state)).toString("base64")} -->`;
6
+ function withoutDetails(f) {
7
+ const { likelihood: _l, what_breaks: _w, fix: _f, fix_cost: _c, verdict: _v, rank: _r, ...rest } = f;
8
+ return rest;
9
+ }
10
+ function withoutHow(f) {
11
+ const { how: _, ...rest } = withoutDetails(f);
12
+ return rest;
13
+ }
14
+ /** Past any real review the details go first: a comment GitHub refuses would lose the whole round. */
15
+ export function encodeState(state) {
16
+ const packed = pack(state);
17
+ if (packed.length <= MAX_STATE)
18
+ return packed;
19
+ const slimmer = pack({ ...state, findings: state.findings.map(withoutDetails) });
20
+ return slimmer.length <= MAX_STATE ? slimmer : pack({ ...state, findings: state.findings.map(withoutHow) });
21
+ }
22
+ /** The encoded state exactly as it sits in a comment, to carry over into a new body. */
23
+ export function stateBlock(body) {
24
+ return body?.match(new RegExp(`<!-- ${MARKER} [A-Za-z0-9+/=]+ -->`))?.[0] ?? null;
25
+ }
26
+ export function decodeState(body) {
27
+ const m = body?.match(new RegExp(`<!-- ${MARKER} ([A-Za-z0-9+/=]+) -->`));
28
+ if (!m)
29
+ return null;
30
+ try {
31
+ const raw = Buffer.from(m[1], "base64");
32
+ // Reviews from before the state was gzipped are plain base64 JSON.
33
+ const json = raw[0] === 0x1f && raw[1] === 0x8b ? gunzipSync(raw, { maxOutputLength: 5_000_000 }) : raw;
34
+ const state = JSON.parse(json.toString("utf8"));
35
+ return state.v === 1 ? state : null;
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ /** Merge this review into the previous state. */
42
+ export function nextState(prev, findings, sha, light, urls) {
43
+ const round = (prev?.round ?? 0) + 1;
44
+ const current = new Set(findings.map((f) => f.id));
45
+ const before = new Map((prev?.findings ?? []).map((f) => [f.id, f]));
46
+ const merged = findings.map((f) => {
47
+ const url = urls.get(f.id) ?? before.get(f.id)?.url;
48
+ return {
49
+ id: f.id,
50
+ tier: f.tier,
51
+ class: f.class,
52
+ severity: f.severity,
53
+ title: f.title,
54
+ file: f.file,
55
+ line: f.line,
56
+ end_line: f.end_line,
57
+ how: f.how_it_happens,
58
+ likelihood: f.likelihood,
59
+ what_breaks: f.what_breaks,
60
+ fix: f.fix,
61
+ fix_cost: f.fix_cost,
62
+ verdict: f.verdict,
63
+ rank: f.rank ?? null,
64
+ first_round: before.get(f.id)?.first_round ?? round,
65
+ status: "open",
66
+ ...(url ? { url } : {}),
67
+ };
68
+ });
69
+ for (const old of prev?.findings ?? []) {
70
+ if (!current.has(old.id))
71
+ merged.push({ ...withoutDetails(old), status: "gone" });
72
+ }
73
+ return {
74
+ v: 1,
75
+ round,
76
+ rounds: [
77
+ ...(prev?.rounds ?? []).slice(-19),
78
+ { sha, at: new Date().toISOString(), light, posted: findings.filter((f) => f.tier === "post").length },
79
+ ],
80
+ findings: merged,
81
+ };
82
+ }
package/dist/render.js ADDED
@@ -0,0 +1,106 @@
1
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
2
+ const paint = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
3
+ const bold = paint("1");
4
+ const dim = paint("2");
5
+ const red = paint("31");
6
+ const yellow = paint("33");
7
+ const green = paint("32");
8
+ const LIGHT = {
9
+ red: { dot: "🔴", label: "Blocked", color: red },
10
+ yellow: { dot: "🟡", label: "Needs a human", color: yellow },
11
+ green: { dot: "🟢", label: "Safe to merge", color: green },
12
+ };
13
+ function duration(ms) {
14
+ const s = Math.round(ms / 1000);
15
+ return s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s` : `${s}s`;
16
+ }
17
+ function wrap(text, indent, width = 92) {
18
+ const words = text.replace(/\s+/g, " ").trim().split(" ");
19
+ const lines = [];
20
+ let line = "";
21
+ for (const w of words) {
22
+ if (line && (indent.length + line.length + 1 + w.length) > width) {
23
+ lines.push(line);
24
+ line = w;
25
+ }
26
+ else {
27
+ line = line ? `${line} ${w}` : w;
28
+ }
29
+ }
30
+ if (line)
31
+ lines.push(line);
32
+ return lines.map((l, i) => (i === 0 ? l : indent + l)).join("\n");
33
+ }
34
+ function where(f) {
35
+ return f.end_line > f.line ? `${f.file}:${f.line}-${f.end_line}` : `${f.file}:${f.line}`;
36
+ }
37
+ function full(f, dot, label) {
38
+ const pad = " ";
39
+ return [
40
+ `${dot} ${bold(label)} ${bold(f.title)}`,
41
+ ` ${dim(where(f))} ${dim(`${f.likelihood} · ${f.severity} · fix ${f.fix_cost}`)}`,
42
+ ` How ${wrap(f.how_it_happens, pad)}`,
43
+ ` Breaks ${wrap(f.what_breaks, pad)}`,
44
+ ` Fix ${wrap(f.fix, pad)}`,
45
+ ` ${bold(`→ ${f.verdict}`)}`,
46
+ ].join("\n");
47
+ }
48
+ function kind(f) {
49
+ if (f.class === "bug")
50
+ return f.severity === "minor" ? { dot: "🟠", label: "Minor bug" } : { dot: "🔴", label: "Bug" };
51
+ if (f.class === "risk")
52
+ return { dot: "🟡", label: "Risk" };
53
+ return { dot: "⚪", label: "Small fix" };
54
+ }
55
+ export function renderTerminal(r) {
56
+ const light = LIGHT[r.light];
57
+ // Top picks first, most important first. Older reviews without picks post everything marked post.
58
+ const posted = r.findings.filter((f) => f.tier === "post").sort((a, b) => (a.rank ?? 9) - (b.rank ?? 9));
59
+ const listed = r.findings.filter((f) => f.tier === "list");
60
+ const decisions = r.findings.filter((f) => f.tier === "decision");
61
+ const hardening = r.findings.filter((f) => f.tier === "hardening");
62
+ const counts = [
63
+ `${posted.length} to fix first`,
64
+ `${listed.length} more worth fixing`,
65
+ `${decisions.length} decision${decisions.length === 1 ? "" : "s"}`,
66
+ `${hardening.length} hardening`,
67
+ ].join(", ");
68
+ const words = (...parts) => parts.filter(Boolean).join(" ");
69
+ const hunters = words(r.provider.name, r.provider.model, r.provider.effort);
70
+ const checker = r.verifier && r.verifier.name !== r.provider.name ? `, checked by ${words(r.verifier.name, r.verifier.model)}` : "";
71
+ const out = [];
72
+ out.push(`${bold("Mergelight")} ${dim("·")} ${r.pr.owner}/${r.pr.repo}#${r.pr.number} ${dim("·")} ${r.pr.title}`);
73
+ out.push(`${light.dot} ${light.color(bold(light.label))} ${dim(`(${counts})`)} ${dim(`· ${hunters}${checker} · ${duration(r.stats.duration_ms)} · ${r.pr.head_sha.slice(0, 7)}`)}`);
74
+ const checked = r.trace.candidates.length;
75
+ out.push(dim(`Checked ${checked} candidate${checked === 1 ? "" : "s"}, rejected ${r.trace.rejected.length}${r.trace.failed_hunters.length ? `, hunters failed: ${r.trace.failed_hunters.join(", ")}` : ""}`));
76
+ out.push("");
77
+ out.push(wrap(r.summary, ""));
78
+ for (const f of posted) {
79
+ const k = kind(f);
80
+ out.push("", full(f, k.dot, k.label));
81
+ }
82
+ if (listed.length) {
83
+ out.push("", bold(`Also worth fixing (${listed.length})`));
84
+ for (const f of listed) {
85
+ out.push(` ${kind(f).dot} ${dim(where(f))} ${f.title}`);
86
+ out.push(` ${dim(`${f.likelihood} · ${f.severity} · fix ${f.fix_cost} ·`)} ${f.verdict}`);
87
+ }
88
+ }
89
+ if (decisions.length) {
90
+ out.push("", bold(`Your call (${decisions.length})`));
91
+ for (const f of decisions) {
92
+ out.push(` ${dim(where(f))} ${f.title}`);
93
+ out.push(` ${dim(`${f.likelihood} · fix ${f.fix_cost} ·`)} ${f.verdict}`);
94
+ }
95
+ }
96
+ if (hardening.length) {
97
+ out.push("", dim(`⚪ Hardening, not worth fixing by default (${hardening.length})`));
98
+ for (const f of hardening) {
99
+ out.push(dim(` ${where(f)} ${f.title}`));
100
+ out.push(dim(` → ${f.verdict}`));
101
+ }
102
+ }
103
+ if (!r.findings.length)
104
+ out.push("", green("Nothing worth fixing."));
105
+ return out.join("\n");
106
+ }