@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.
- package/LICENSE.md +110 -0
- package/README.md +113 -2
- package/dist/action-source.js +107 -0
- package/dist/cli.js +252 -0
- package/dist/connect-claude.js +157 -0
- package/dist/connect.js +319 -0
- package/dist/github.js +65 -0
- package/dist/init.js +92 -0
- package/dist/mcp/findings.js +247 -0
- package/dist/mcp/server.js +168 -0
- package/dist/prompt.js +257 -0
- package/dist/providers/claude.js +102 -0
- package/dist/providers/codex.js +225 -0
- package/dist/providers/index.js +8 -0
- package/dist/publish/api.js +63 -0
- package/dist/publish/closed.js +88 -0
- package/dist/publish/diff-lines.js +50 -0
- package/dist/publish/handled.js +80 -0
- package/dist/publish/markdown.js +231 -0
- package/dist/publish/publish.js +367 -0
- package/dist/publish/state.js +82 -0
- package/dist/render.js +106 -0
- package/dist/review.js +271 -0
- package/dist/run.js +57 -0
- package/dist/schema.js +106 -0
- package/dist/setup.js +180 -0
- package/dist/types.js +1 -0
- package/dist/update.js +36 -0
- package/dist/workspace.js +137 -0
- package/package.json +36 -4
- package/scripts/app-token.mjs +69 -0
- package/scripts/mask-login.mjs +26 -0
- package/scripts/refresh-login.mjs +171 -0
- package/scripts/save-claude-login.sh +22 -0
- package/scripts/save-login.sh +30 -0
- package/skills/mergelight/SKILL.md +66 -0
- package/templates/mergelight-approve.yml +21 -0
- package/templates/mergelight-refresh.yml +23 -0
- package/templates/mergelight.yml +42 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { trustedComment } from "./handled.js";
|
|
2
|
+
import { FINDING_MARKER_RE } from "./markdown.js";
|
|
3
|
+
/** Older reviews answered a fixed thread with a reply carrying this marker. */
|
|
4
|
+
const GONE_MARKER = "<!-- mergelight:gone -->";
|
|
5
|
+
/** Someone reopened a thread Mergelight closed. It stays open until a person resolves it again. */
|
|
6
|
+
const REOPENED_MARKER = "<!-- mergelight:reopened -->";
|
|
7
|
+
const CLOSED_RE = /<!-- mergelight:closed how=(fixed|resolved) resolved=([01])(?: sha=([a-f0-9]+))? -->/;
|
|
8
|
+
const SUMMARY_END = "</summary>\n\n";
|
|
9
|
+
const DETAILS_END = "\n\n</details>";
|
|
10
|
+
export function readClosed(body) {
|
|
11
|
+
const m = body.match(CLOSED_RE);
|
|
12
|
+
return m ? { how: m[1], resolved: m[2] === "1", sha: m[3] ?? null } : null;
|
|
13
|
+
}
|
|
14
|
+
/** The comment with a checkmark on top and the original behind a dropdown, named after the finding. */
|
|
15
|
+
export function closedBody(body, c) {
|
|
16
|
+
const marker = body.match(FINDING_MARKER_RE)?.[0] ?? "";
|
|
17
|
+
const original = body.replace(FINDING_MARKER_RE, "").replace(REOPENED_MARKER, "").trim();
|
|
18
|
+
const label = (original.split("\n")[0] ?? "Original comment").replace(/\*\*/g, "").replace(/</g, "<");
|
|
19
|
+
const why = c.how === "fixed"
|
|
20
|
+
? `The code changed and Mergelight no longer finds this${c.sha ? ` in \`${c.sha}\`` : ""}.`
|
|
21
|
+
: `Resolved${c.by ? ` by ${c.by}` : ""}.`;
|
|
22
|
+
return [
|
|
23
|
+
marker,
|
|
24
|
+
`<!-- mergelight:closed how=${c.how} resolved=${c.resolved ? 1 : 0}${c.sha ? ` sha=${c.sha}` : ""} -->`,
|
|
25
|
+
`✅ **Handled.** ${why}`,
|
|
26
|
+
"",
|
|
27
|
+
`<details><summary>${label}${SUMMARY_END}${original}${DETAILS_END}`,
|
|
28
|
+
].join("\n");
|
|
29
|
+
}
|
|
30
|
+
/** Back to the original comment. */
|
|
31
|
+
export function reopenedBody(body, keepOpen) {
|
|
32
|
+
const marker = body.match(FINDING_MARKER_RE)?.[0] ?? "";
|
|
33
|
+
const start = body.indexOf(SUMMARY_END);
|
|
34
|
+
const end = body.lastIndexOf(DETAILS_END);
|
|
35
|
+
const original = start >= 0 && end > start ? body.slice(start + SUMMARY_END.length, end) : body;
|
|
36
|
+
return [marker, ...(keepOpen ? [REOPENED_MARKER] : []), original].join("\n");
|
|
37
|
+
}
|
|
38
|
+
/** The finding a thread is about, if it's one of Mergelight's own. */
|
|
39
|
+
export function threadFinding(t) {
|
|
40
|
+
const first = t.comments.nodes[0];
|
|
41
|
+
if (!first)
|
|
42
|
+
return null;
|
|
43
|
+
if (!trustedComment({ user: { type: first.author?.__typename }, author_association: first.authorAssociation }))
|
|
44
|
+
return null;
|
|
45
|
+
const id = first.body.match(FINDING_MARKER_RE)?.[1];
|
|
46
|
+
return id ? { id, commentId: first.databaseId, body: first.body } : null;
|
|
47
|
+
}
|
|
48
|
+
/** What this round should do to a thread, given the findings the review still reports. */
|
|
49
|
+
export function threadAction(t, open) {
|
|
50
|
+
const found = threadFinding(t);
|
|
51
|
+
if (!found)
|
|
52
|
+
return null;
|
|
53
|
+
const closed = readClosed(found.body);
|
|
54
|
+
const answeredBefore = t.comments.nodes.some((c) => c.body.includes(GONE_MARKER));
|
|
55
|
+
if (closed) {
|
|
56
|
+
if (open.has(found.id))
|
|
57
|
+
return { kind: "regressed", unresolve: closed.how === "fixed" && t.isResolved };
|
|
58
|
+
// resolved=0 means GitHub wouldn't let the workflow resolve it, so an open thread doesn't mean anyone reopened it.
|
|
59
|
+
return !t.isResolved && closed.resolved ? { kind: "reopened" } : null;
|
|
60
|
+
}
|
|
61
|
+
if (t.isResolved) {
|
|
62
|
+
if (open.has(found.id))
|
|
63
|
+
return null;
|
|
64
|
+
return answeredBefore ? { kind: "fixed", resolve: false } : { kind: "resolved" };
|
|
65
|
+
}
|
|
66
|
+
if (answeredBefore || found.body.includes(REOPENED_MARKER))
|
|
67
|
+
return null;
|
|
68
|
+
return t.isOutdated && !open.has(found.id) ? { kind: "fixed", resolve: true } : null;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Findings whose thread someone with write access resolved, with who did it. The
|
|
72
|
+
* verifier hears about these as a team decision, so a pull request's own author
|
|
73
|
+
* can't talk it out of a finding: GitHub lets them resolve threads too.
|
|
74
|
+
*/
|
|
75
|
+
export async function resolvedByPeople(threads, canWrite) {
|
|
76
|
+
const out = new Map();
|
|
77
|
+
for (const t of threads) {
|
|
78
|
+
const found = threadFinding(t);
|
|
79
|
+
const by = t.resolvedBy?.login;
|
|
80
|
+
if (!found || !t.isResolved || !by)
|
|
81
|
+
continue;
|
|
82
|
+
if (readClosed(found.body)?.how === "fixed" || t.comments.nodes.some((c) => c.body.includes(GONE_MARKER)))
|
|
83
|
+
continue;
|
|
84
|
+
if (await canWrite(by))
|
|
85
|
+
out.set(found.id, by);
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export function parsePatch(patch) {
|
|
2
|
+
const hunks = [];
|
|
3
|
+
let current = null;
|
|
4
|
+
let right = 0;
|
|
5
|
+
for (const line of patch.split("\n")) {
|
|
6
|
+
const header = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
7
|
+
if (header) {
|
|
8
|
+
right = Number(header[1]);
|
|
9
|
+
current = { start: right, end: right - 1, lines: new Set() };
|
|
10
|
+
hunks.push(current);
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
if (!current || line.startsWith("\\"))
|
|
14
|
+
continue;
|
|
15
|
+
if (line.startsWith("-"))
|
|
16
|
+
continue;
|
|
17
|
+
// Context and added lines both exist on the right side.
|
|
18
|
+
current.lines.add(right);
|
|
19
|
+
current.end = right;
|
|
20
|
+
right++;
|
|
21
|
+
}
|
|
22
|
+
return hunks.filter((h) => h.lines.size > 0);
|
|
23
|
+
}
|
|
24
|
+
export async function commentableLines(api, number) {
|
|
25
|
+
const files = await api.list(`pulls/${number}/files`);
|
|
26
|
+
const map = new Map();
|
|
27
|
+
for (const f of files)
|
|
28
|
+
if (f.patch)
|
|
29
|
+
map.set(f.filename, parsePatch(f.patch));
|
|
30
|
+
return map;
|
|
31
|
+
}
|
|
32
|
+
/** Where to put a finding's inline comment, or null if its lines aren't in the diff. */
|
|
33
|
+
export function place(map, file, line, endLine) {
|
|
34
|
+
const hunks = map.get(file);
|
|
35
|
+
if (!hunks)
|
|
36
|
+
return null;
|
|
37
|
+
const lo = Math.min(line, endLine);
|
|
38
|
+
const hi = Math.max(line, endLine);
|
|
39
|
+
for (const h of hunks) {
|
|
40
|
+
if (h.lines.has(lo) && h.lines.has(hi))
|
|
41
|
+
return hi > lo ? { start_line: lo, line: hi } : { line: hi };
|
|
42
|
+
}
|
|
43
|
+
// Fall back to the nearest commentable line inside the finding's range.
|
|
44
|
+
for (const h of hunks) {
|
|
45
|
+
for (let l = hi; l >= lo; l--)
|
|
46
|
+
if (h.lines.has(l))
|
|
47
|
+
return { line: l };
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether Mergelight's hidden markers in this comment count. Anyone can write a
|
|
3
|
+
* comment containing them, and they steer what gets posted and what the verifier
|
|
4
|
+
* reads, so a passer-by's comment must not be taken for ours.
|
|
5
|
+
*/
|
|
6
|
+
export function trustedComment(c) {
|
|
7
|
+
return c.user?.type === "Bot" || ["OWNER", "MEMBER", "COLLABORATOR"].includes(c.author_association ?? "");
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Who can write to the repository. A workflow token sees a private org member as
|
|
11
|
+
* CONTRIBUTOR, so the association alone would silently drop their marks.
|
|
12
|
+
*/
|
|
13
|
+
export function writeAccess(api) {
|
|
14
|
+
const known = new Map();
|
|
15
|
+
return (login) => {
|
|
16
|
+
let answer = known.get(login);
|
|
17
|
+
if (!answer) {
|
|
18
|
+
answer = api
|
|
19
|
+
.get(`collaborators/${encodeURIComponent(login)}/permission`)
|
|
20
|
+
.then((r) => r.permission === "admin" || r.permission === "write")
|
|
21
|
+
.catch(() => false);
|
|
22
|
+
known.set(login, answer);
|
|
23
|
+
}
|
|
24
|
+
return answer;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const MARK_RE = /<!-- mergelight:handled:v1 ([A-Za-z0-9+/=]+) -->/;
|
|
28
|
+
export const MAX_NOTE = 1000;
|
|
29
|
+
export function encodeMark(ids, as, note, sha) {
|
|
30
|
+
return `<!-- mergelight:handled:v1 ${Buffer.from(JSON.stringify({ ids, as, note, sha })).toString("base64")} -->`;
|
|
31
|
+
}
|
|
32
|
+
export function decodeMark(body) {
|
|
33
|
+
const m = body?.match(MARK_RE);
|
|
34
|
+
if (!m)
|
|
35
|
+
return null;
|
|
36
|
+
try {
|
|
37
|
+
const { ids, as, note, sha } = JSON.parse(Buffer.from(m[1], "base64").toString("utf8"));
|
|
38
|
+
if (!Array.isArray(ids) || (as !== "fixed" && as !== "wont_fix"))
|
|
39
|
+
return null;
|
|
40
|
+
return {
|
|
41
|
+
ids: ids.filter((id) => typeof id === "string" && /^[a-f0-9]+$/.test(id)),
|
|
42
|
+
as,
|
|
43
|
+
note: typeof note === "string" ? note.slice(0, MAX_NOTE) : null,
|
|
44
|
+
sha: typeof sha === "string" ? sha : null,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** The latest mark per finding, from comments by people who can write to the repository. */
|
|
52
|
+
export async function readMarks(comments, canWrite) {
|
|
53
|
+
const marked = comments
|
|
54
|
+
.map((comment) => ({ comment, mark: decodeMark(comment.body) }))
|
|
55
|
+
.filter((c) => c.mark)
|
|
56
|
+
.sort((a, b) => Date.parse(a.comment.created_at ?? "") - Date.parse(b.comment.created_at ?? ""));
|
|
57
|
+
const marks = new Map();
|
|
58
|
+
for (const { comment, mark } of marked) {
|
|
59
|
+
const by = comment.user?.login ?? "";
|
|
60
|
+
if (!trustedComment(comment) && !(by && (await canWrite(by))))
|
|
61
|
+
continue;
|
|
62
|
+
for (const id of mark.ids) {
|
|
63
|
+
marks.set(id, { id, as: mark.as, note: mark.note, sha: mark.sha, by, at: comment.created_at ?? "", url: comment.html_url });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return marks;
|
|
67
|
+
}
|
|
68
|
+
export function findingState(f, mark, lastReviewAt) {
|
|
69
|
+
if (mark?.as === "wont_fix")
|
|
70
|
+
return "wont_fix";
|
|
71
|
+
if (f.status === "gone")
|
|
72
|
+
return "gone";
|
|
73
|
+
if (!mark)
|
|
74
|
+
return "open";
|
|
75
|
+
return markedSince(mark, lastReviewAt) ? "fix_pending" : "still_found";
|
|
76
|
+
}
|
|
77
|
+
/** True if the latest review published before this mark, so it can't have taken it into account. */
|
|
78
|
+
export function markedSince(mark, lastReviewAt) {
|
|
79
|
+
return !lastReviewAt || Date.parse(mark.at) > Date.parse(lastReviewAt);
|
|
80
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { encodeState } from "./state.js";
|
|
2
|
+
export const SUMMARY_MARKER = "<!-- mergelight:summary -->";
|
|
3
|
+
export const PENDING_MARKER = "<!-- mergelight:pending -->";
|
|
4
|
+
export const findingMarker = (id) => `<!-- mergelight:finding id=${id} -->`;
|
|
5
|
+
export const FINDING_MARKER_RE = /<!-- mergelight:finding id=([a-f0-9]+) -->/;
|
|
6
|
+
/** GitHub's limit for a comment body and a check run summary. */
|
|
7
|
+
const MAX_BODY = 65_000;
|
|
8
|
+
/**
|
|
9
|
+
* Model text goes into comments, so it must not be able to fake our hidden
|
|
10
|
+
* markers or ping people.
|
|
11
|
+
*/
|
|
12
|
+
export function clean(text) {
|
|
13
|
+
return text
|
|
14
|
+
.replace(/<!--/g, "<!--")
|
|
15
|
+
.replace(/(^|[^\w`])@(?=[A-Za-z0-9-])/g, "$1@")
|
|
16
|
+
.trim();
|
|
17
|
+
}
|
|
18
|
+
export function label(f) {
|
|
19
|
+
if (f.class === "bug")
|
|
20
|
+
return f.severity === "minor" ? { dot: "🟠", name: "Minor bug" } : { dot: "🔴", name: "Bug" };
|
|
21
|
+
if (f.class === "risk")
|
|
22
|
+
return { dot: "🟡", name: "Risk" };
|
|
23
|
+
return { dot: "⚪", name: "Small fix" };
|
|
24
|
+
}
|
|
25
|
+
/** Display order: bugs, then risks, then minor bugs, then small fixes. */
|
|
26
|
+
export function rank(f) {
|
|
27
|
+
if (f.class === "bug")
|
|
28
|
+
return f.severity === "minor" ? 2 : 0;
|
|
29
|
+
return f.class === "risk" ? 1 : 3;
|
|
30
|
+
}
|
|
31
|
+
const where = (f) => `\`${f.file}:${f.end_line > f.line ? `${f.line}-${f.end_line}` : f.line}\``;
|
|
32
|
+
export function inlineBody(f) {
|
|
33
|
+
const { dot, name } = label(f);
|
|
34
|
+
return [
|
|
35
|
+
findingMarker(f.id),
|
|
36
|
+
`${dot} **${name}** · **${clean(f.title)}**`,
|
|
37
|
+
"",
|
|
38
|
+
clean(f.how_it_happens),
|
|
39
|
+
"",
|
|
40
|
+
`**What breaks:** ${clean(f.what_breaks)}`,
|
|
41
|
+
"",
|
|
42
|
+
`**Smallest fix** (${f.fix_cost}): ${clean(f.fix)}`,
|
|
43
|
+
"",
|
|
44
|
+
`**→ ${clean(f.verdict)}**`,
|
|
45
|
+
"",
|
|
46
|
+
`<sub>${f.likelihood} · ${f.severity} · fix ${f.fix_cost}</sub>`,
|
|
47
|
+
].join("\n");
|
|
48
|
+
}
|
|
49
|
+
/** Posted when a review starts, so the summary is the first thing on the pull request. */
|
|
50
|
+
export function pendingSummaryBody() {
|
|
51
|
+
return [SUMMARY_MARKER, PENDING_MARKER, "### ⏳ Mergelight: Reviewing", "", "This usually takes 2 to 15 minutes. This comment updates when the review is done."].join("\n");
|
|
52
|
+
}
|
|
53
|
+
export const isPendingSummary = (body) => Boolean(body?.includes(PENDING_MARKER));
|
|
54
|
+
export const loginNotice = (plan, reason) => ({ heading: `⚠️ Mergelight: the ${plan} login didn't work`, body: reason });
|
|
55
|
+
export function limitNotice(plan, resets) {
|
|
56
|
+
const reason = `This pull request wasn't reviewed, because your ${plan} plan has no usage left.`;
|
|
57
|
+
return {
|
|
58
|
+
heading: `⏳ Mergelight: your ${plan} plan hit its limit`,
|
|
59
|
+
body: resets ? `${reason}\n\n**Resets:** ${resets}\n\nPush a new commit after that, and the review runs again.` : `${reason}\n\nPush a new commit once your plan has usage again, and the review runs again.`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Marks a summary that only says a run failed, so the next failure can say what's true now. */
|
|
63
|
+
export const FAILED_MARKER = "<!-- mergelight:failed -->";
|
|
64
|
+
const GENERAL_FAILURE = ["### ⚠️ Mergelight couldn't review this pull request", "", "The Mergelight check has the reason. The next push tries again."].join("\n");
|
|
65
|
+
export function failedSummaryBody(notice) {
|
|
66
|
+
if (notice)
|
|
67
|
+
return [SUMMARY_MARKER, FAILED_MARKER, `### ${notice.heading}`, "", notice.body].join("\n");
|
|
68
|
+
return [SUMMARY_MARKER, FAILED_MARKER, GENERAL_FAILURE].join("\n");
|
|
69
|
+
}
|
|
70
|
+
/** Only at the very start, because a summary with results can quote text from the pull request. Earlier releases wrote no marker. */
|
|
71
|
+
export const isFailedSummary = (body) => Boolean(body?.startsWith(`${SUMMARY_MARKER}\n${FAILED_MARKER}\n`) || body?.trim() === `${SUMMARY_MARKER}\n${GENERAL_FAILURE}`);
|
|
72
|
+
export const LIGHT = {
|
|
73
|
+
red: { dot: "🔴", label: "Blocked" },
|
|
74
|
+
yellow: { dot: "🟡", label: "Needs a human" },
|
|
75
|
+
green: { dot: "🟢", label: "Safe to merge" },
|
|
76
|
+
};
|
|
77
|
+
function duration(ms) {
|
|
78
|
+
const s = Math.round(ms / 1000);
|
|
79
|
+
return s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`;
|
|
80
|
+
}
|
|
81
|
+
function plural(n, word) {
|
|
82
|
+
return `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
83
|
+
}
|
|
84
|
+
/** One line on what the review found, for the summary and the check title. */
|
|
85
|
+
export function headline(r, handled = 0) {
|
|
86
|
+
const toFix = r.findings.filter((f) => f.tier === "post" || f.tier === "list");
|
|
87
|
+
const bugs = toFix.filter((f) => f.class === "bug" && f.severity !== "minor").length;
|
|
88
|
+
const minor = toFix.filter((f) => f.class === "bug" && f.severity === "minor").length;
|
|
89
|
+
const risks = toFix.filter((f) => f.class !== "bug").length;
|
|
90
|
+
const decisions = r.findings.filter((f) => f.tier === "decision").length;
|
|
91
|
+
const parts = [];
|
|
92
|
+
if (bugs)
|
|
93
|
+
parts.push(plural(bugs, "bug"));
|
|
94
|
+
if (risks)
|
|
95
|
+
parts.push(plural(risks, "risk"));
|
|
96
|
+
if (minor)
|
|
97
|
+
parts.push(plural(minor, "minor bug"));
|
|
98
|
+
const fix = parts.length ? `${parts.join(", ")} to fix` : "";
|
|
99
|
+
const call = decisions ? `${decisions} for you to decide` : "";
|
|
100
|
+
return [fix, call].filter(Boolean).join(", ") || (handled ? "Nothing left to fix" : "Nothing worth fixing");
|
|
101
|
+
}
|
|
102
|
+
/** A finding this review still reports, though someone marked it handled before the review finished. */
|
|
103
|
+
function markTag(mark) {
|
|
104
|
+
if (!mark)
|
|
105
|
+
return "";
|
|
106
|
+
return mark.as === "fixed" ? " _(marked fixed, still found)_" : " _(marked won't fix, still reported)_";
|
|
107
|
+
}
|
|
108
|
+
const escapeHtml = (text) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
109
|
+
/** A title as HTML: escaped, with its `code` kept, and no one mentioned by accident. */
|
|
110
|
+
const inlineHtml = (text) => clean(escapeHtml(text)).replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
111
|
+
/** A line for a finding worth fixing. Without an inline comment to link to, the details go here. */
|
|
112
|
+
function fixLine(f, urls, mark) {
|
|
113
|
+
const { dot, name } = label(f);
|
|
114
|
+
const url = urls.get(f.id);
|
|
115
|
+
const tag = markTag(mark);
|
|
116
|
+
if (url)
|
|
117
|
+
return [`- ${dot} ${name}: [${clean(f.title)}](${url}) ${where(f)}${tag}`];
|
|
118
|
+
// Markdown doesn't work inside <summary>, so this line is HTML.
|
|
119
|
+
const markNote = mark ? ` <i>(${mark.as === "fixed" ? "marked fixed, still found" : "marked won't fix, still reported"})</i>` : "";
|
|
120
|
+
return [
|
|
121
|
+
"",
|
|
122
|
+
`<details><summary>${dot} ${name}: <b>${inlineHtml(f.title)}</b> <code>${escapeHtml(where(f).replaceAll("`", ""))}</code>${markNote}</summary>`,
|
|
123
|
+
"",
|
|
124
|
+
clean(f.how_it_happens),
|
|
125
|
+
"",
|
|
126
|
+
`**What breaks:** ${clean(f.what_breaks)}`,
|
|
127
|
+
"",
|
|
128
|
+
`**Smallest fix** (${f.fix_cost}): ${clean(f.fix)}`,
|
|
129
|
+
"",
|
|
130
|
+
`**→ ${clean(f.verdict)}**`,
|
|
131
|
+
"",
|
|
132
|
+
"</details>",
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
const NEWS_SHOWN = 4;
|
|
136
|
+
/** At the top of the comment, because a pinned repo only gets new versions if someone notices and acts. */
|
|
137
|
+
function updateBanner({ tag, current, news }) {
|
|
138
|
+
const more = news.length - NEWS_SHOWN;
|
|
139
|
+
return [
|
|
140
|
+
"> [!IMPORTANT]",
|
|
141
|
+
`> **You're missing out: Mergelight ${tag} is out, and this repo is on ${current}.**`,
|
|
142
|
+
...(news.length ? [">", ...news.slice(0, NEWS_SHOWN).map((n) => `> - ${clean(n)}`), ...(more > 0 ? [`> - and ${more} more`] : [])] : []),
|
|
143
|
+
">",
|
|
144
|
+
"> **[Update now](https://mergelight.dev/setup/)**. It's one click, and only the version lines change.",
|
|
145
|
+
"",
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
/** Says what the review couldn't see, so a quiet result isn't taken to cover it. The names are checked against a strict pattern before they get here. */
|
|
149
|
+
export function notReadLine(list) {
|
|
150
|
+
if (!list?.length)
|
|
151
|
+
return null;
|
|
152
|
+
const names = (items) => {
|
|
153
|
+
const shown = items.slice(0, 4).map((i) => `\`${i.uses.replace(/[^\w./@-]/g, "")}\``);
|
|
154
|
+
return items.length > 4 ? `${shown.join(", ")} and ${items.length - 4} more` : shown.join(", ");
|
|
155
|
+
};
|
|
156
|
+
const programs = list.filter((i) => i.why === "program");
|
|
157
|
+
const missing = list.filter((i) => i.why === "unreadable");
|
|
158
|
+
const parts = [
|
|
159
|
+
programs.length ? `the code that ${names(programs)} ${programs.length === 1 ? "runs" : "run"} (a Node or Docker action, so only ${programs.length === 1 ? "its definition was" : "their definitions were"} read)` : "",
|
|
160
|
+
missing.length ? `${names(missing)} (couldn't be read)` : "",
|
|
161
|
+
].filter(Boolean);
|
|
162
|
+
return `<sub>Not read: ${parts.join(", and ")}.</sub>`;
|
|
163
|
+
}
|
|
164
|
+
export function summaryBody({ review: r, state, urls, marks = new Map(), handled = new Map(), update }, withState = true) {
|
|
165
|
+
const light = LIGHT[r.light];
|
|
166
|
+
const top = r.findings.filter((f) => f.tier === "post").sort((a, b) => (a.rank ?? 9) - (b.rank ?? 9) || rank(a) - rank(b));
|
|
167
|
+
const listed = r.findings.filter((f) => f.tier === "list").sort((a, b) => rank(a) - rank(b));
|
|
168
|
+
const decisions = r.findings.filter((f) => f.tier === "decision");
|
|
169
|
+
const leftAlone = r.findings.filter((f) => f.tier === "hardening");
|
|
170
|
+
const dropped = state.findings.filter((f) => f.status === "gone" && marks.get(f.id)?.as === "wont_fix");
|
|
171
|
+
const done = state.findings.filter((f) => dropped.includes(f) || (f.status === "gone" && handled.has(f.id)));
|
|
172
|
+
const gone = state.round > 1 ? state.findings.filter((f) => f.status === "gone" && f.tier !== "hardening" && !done.includes(f)) : [];
|
|
173
|
+
const out = [SUMMARY_MARKER, ...(update ? updateBanner(update) : []), `### ${light.dot} Mergelight: ${light.label}`, "", clean(r.summary), "", `**${headline(r, done.length)}**`];
|
|
174
|
+
if (top.length) {
|
|
175
|
+
out.push("", "#### To fix");
|
|
176
|
+
for (const f of top)
|
|
177
|
+
out.push(...fixLine(f, urls, marks.get(f.id)));
|
|
178
|
+
}
|
|
179
|
+
if (listed.length) {
|
|
180
|
+
// With nothing above it, the count is the heading already, and "also" would point at an empty section.
|
|
181
|
+
out.push("", ...(top.length ? ["#### Also worth fixing"] : []));
|
|
182
|
+
for (const f of listed)
|
|
183
|
+
out.push(...fixLine(f, urls, marks.get(f.id)));
|
|
184
|
+
}
|
|
185
|
+
if (decisions.length) {
|
|
186
|
+
out.push("", "#### Your call", "Real, but whether to fix it is a trade-off for you to make.", "");
|
|
187
|
+
for (const f of decisions) {
|
|
188
|
+
out.push(`- **${clean(f.title)}** ${where(f)}: ${clean(f.verdict)}${markTag(marks.get(f.id))}`);
|
|
189
|
+
out.push(` <details><summary>How it happens</summary>`, "", ` ${clean(f.how_it_happens)} ${clean(f.what_breaks)}`, "", ` Fix (${f.fix_cost}): ${clean(f.fix)}`, " </details>");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (done.length) {
|
|
193
|
+
out.push("", `<details><summary>✅ Handled: ${done.length}</summary>`, "");
|
|
194
|
+
for (const f of done) {
|
|
195
|
+
const mark = marks.get(f.id);
|
|
196
|
+
const h = handled.get(f.id);
|
|
197
|
+
const how = mark?.as === "wont_fix"
|
|
198
|
+
? `not fixing. ${clean(mark.note ?? "No reason given.")} (${mark.by})`
|
|
199
|
+
: h?.how === "fixed"
|
|
200
|
+
? `fixed${h.sha ? ` in \`${h.sha}\`` : ""}`
|
|
201
|
+
: `resolved${h?.by ? ` by ${h.by}` : ""}`;
|
|
202
|
+
const url = urls.get(f.id) ?? f.url;
|
|
203
|
+
out.push(`- ✅ ${url ? `[${clean(f.title)}](${url})` : clean(f.title)} \`${f.file}:${f.line}\`: ${how}`);
|
|
204
|
+
}
|
|
205
|
+
out.push("", "</details>");
|
|
206
|
+
}
|
|
207
|
+
if (leftAlone.length) {
|
|
208
|
+
out.push("", `<details><summary>Left alone: ${plural(leftAlone.length, "smaller finding")}</summary>`, "");
|
|
209
|
+
for (const f of leftAlone)
|
|
210
|
+
out.push(`- ${clean(f.title)} ${where(f)}: ${clean(f.verdict)}`);
|
|
211
|
+
out.push("", "</details>");
|
|
212
|
+
}
|
|
213
|
+
if (gone.length) {
|
|
214
|
+
out.push("", `<details><summary>No longer found since the last review: ${gone.length}</summary>`, "");
|
|
215
|
+
for (const f of gone)
|
|
216
|
+
out.push(`- ${clean(f.title)} \`${f.file}:${f.line}\``);
|
|
217
|
+
out.push("", "</details>");
|
|
218
|
+
}
|
|
219
|
+
const unread = notReadLine(r.not_read);
|
|
220
|
+
if (unread)
|
|
221
|
+
out.push("", unread);
|
|
222
|
+
const who = (p) => `${p.name === "codex" ? "Codex" : "Claude"}${p.model ? ` ${p.model.replace(/^claude-/, "")}` : ""}`;
|
|
223
|
+
const checkedBy = r.verifier && r.verifier.name !== r.provider.name ? `, checked by ${who(r.verifier)},` : "";
|
|
224
|
+
out.push("", `<sub>Round ${state.round} · reviewed \`${r.pr.head_sha.slice(0, 7)}\` with ${who(r.provider)}${checkedBy} on your own plan · ${duration(r.stats.duration_ms)}</sub>`);
|
|
225
|
+
const tail = withState ? `\n${encodeState(state)}` : "";
|
|
226
|
+
const room = MAX_BODY - tail.length;
|
|
227
|
+
let body = out.join("\n");
|
|
228
|
+
if (body.length > room)
|
|
229
|
+
body = `${body.slice(0, room - 200)}\n\n_Summary cut short: too many findings to list._`;
|
|
230
|
+
return `${body}${tail}`;
|
|
231
|
+
}
|