@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/dist/review.js ADDED
@@ -0,0 +1,271 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { actionsUsedBy, fetchActionSources, notRead } from "./action-source.js";
5
+ import { fetchPullRequest } from "./github.js";
6
+ import { buildHunterPrompt, buildSweepPrompt, buildVerifierPrompt, focusesFor, } from "./prompt.js";
7
+ import { LimitError, LoginError } from "./providers/codex.js";
8
+ import { claudeAvailable, runModel, TOOLING } from "./providers/index.js";
9
+ import { candidateSchema, reviewSchema } from "./schema.js";
10
+ import { cacheDir, prepareWorkspace } from "./workspace.js";
11
+ /**
12
+ * Stable id for a finding: the file plus the code it points at, ignoring
13
+ * whitespace. It survives line shifts, so later review rounds can tell an old
14
+ * finding from a new one.
15
+ */
16
+ function fingerprint(dir, f) {
17
+ let code = "";
18
+ try {
19
+ const lines = readFileSync(join(dir, f.file), "utf8").split("\n");
20
+ const start = Math.max(1, Math.min(f.line, f.end_line));
21
+ const end = Math.min(lines.length, Math.max(f.line, f.end_line), start + 30);
22
+ code = lines.slice(start - 1, end).join("\n").replace(/\s+/g, " ").trim();
23
+ }
24
+ catch {
25
+ code = f.title.toLowerCase();
26
+ }
27
+ return createHash("sha1").update(`${f.file}\n${f.class}\n${code}`).digest("hex").slice(0, 10);
28
+ }
29
+ /**
30
+ * Gives each finding its id. One the verifier matched to an earlier round keeps
31
+ * that round's id and title, so it stays one comment however the model words or
32
+ * locates it this time: a fingerprint alone changes whenever the model cites
33
+ * slightly different lines.
34
+ */
35
+ export function assignIds(findings, previous, fingerprintOf) {
36
+ const known = new Map(previous.map((p) => [p.id, p]));
37
+ const used = new Set();
38
+ const matched = findings.map((f) => {
39
+ const prior = f.same_as && !used.has(f.same_as) ? known.get(f.same_as) : undefined;
40
+ if (prior)
41
+ used.add(prior.id);
42
+ return prior;
43
+ });
44
+ return findings.map(({ same_as: _, ...f }, i) => {
45
+ const prior = matched[i];
46
+ if (prior)
47
+ return { ...f, id: prior.id, title: prior.title };
48
+ let id = fingerprintOf(f);
49
+ // Two findings on the same code still need their own ids.
50
+ for (let n = 1; used.has(id); n++)
51
+ id = createHash("sha1").update(`${id}${n}`).digest("hex").slice(0, 10);
52
+ used.add(id);
53
+ return { ...f, id };
54
+ });
55
+ }
56
+ /**
57
+ * Only the verifier's top picks (at most 3 per PR) get their own comment. Other
58
+ * findings it says to fix are listed in the summary, decide becomes a "your
59
+ * call" line and leave is collapsed. In the eval, among problems only Mergelight
60
+ * raised, teams fixed 38% of Opus's top picks and 14% of its other findings.
61
+ * Posting everything it would fix meant about 4.5 comments per PR, and all of
62
+ * Codex's about 6.
63
+ */
64
+ export function tierOf(f) {
65
+ // Teams fixed few of these: 19% of minor risks and 10% of rare findings, against 45% of minor bugs. They stay
66
+ // readable in the collapsed section, and a major bug is never hidden, because it decides the red light.
67
+ const majorBug = f.class === "bug" && f.severity !== "minor";
68
+ if (!majorBug && ((f.class === "risk" && f.severity === "minor") || f.likelihood === "rare"))
69
+ return "hardening";
70
+ if (f.rank)
71
+ return "post";
72
+ if (f.recommendation === "fix")
73
+ return "list";
74
+ if (f.recommendation === "decide")
75
+ return "decision";
76
+ return "hardening";
77
+ }
78
+ /** Red if a major bug must be fixed, yellow if anything else is posted or needs a decision, green otherwise. */
79
+ export function mergeLight(findings) {
80
+ if (findings.some((f) => f.class === "bug" && f.severity !== "minor" && f.recommendation === "fix"))
81
+ return "red";
82
+ if (findings.some((f) => f.tier !== "hardening"))
83
+ return "yellow";
84
+ return "green";
85
+ }
86
+ /** Tests and docs aren't swept: missing tests are never reported, and docs rarely hold real problems. */
87
+ const SKIP_SWEEP = /(^|\/)(test|tests|__tests__|e2e|docs?)\/|\.(spec|test)\.[cm]?[jt]sx?$|\.(md|mdx|txt)$/i;
88
+ const SWEEP_CHUNK = 12;
89
+ const SWEEP_MAX_RUNS = 3;
90
+ function normalizePath(p) {
91
+ return p.replace(/^\.\//, "").replace(/^\/+/, "");
92
+ }
93
+ const ORDER = { bug: 0, risk: 1, hardening: 2 };
94
+ /**
95
+ * High, not the Codex default. In the eval, gpt-6.1-sol (whose own default is
96
+ * low) found 41 of 51 fixed problems at high, 32 at medium and 29 at low.
97
+ */
98
+ export const DEFAULT_EFFORT = "high";
99
+ /** Tells the prompts which tools the agent has. Without a shell it can't run git diff, so a big diff goes into the checkout. */
100
+ function forProvider(dir, input, provider) {
101
+ const tooling = TOOLING[provider];
102
+ if (tooling !== "files" || !input.diffTruncated)
103
+ return { ...input, tooling };
104
+ const diffFile = ".mergelight/pr.diff";
105
+ mkdirSync(join(dir, ".mergelight"), { recursive: true });
106
+ writeFileSync(join(dir, diffFile), input.diff);
107
+ return { ...input, tooling, diffFile };
108
+ }
109
+ /**
110
+ * The verifier: tries to disprove every candidate, merges duplicates, rates the
111
+ * rest and picks the few that matter most. Exported so the eval can replay the
112
+ * same candidates through another model.
113
+ */
114
+ export async function verifyCandidates(dir, input, found, opts) {
115
+ const result = await runModel(opts.provider, {
116
+ cwd: dir,
117
+ prompt: buildVerifierPrompt(forProvider(dir, input, opts.provider), found),
118
+ schema: reviewSchema,
119
+ model: opts.model,
120
+ effort: opts.effort,
121
+ logFile: opts.logFile,
122
+ onCommand: opts.onCommand,
123
+ });
124
+ const review = result.output;
125
+ const top = [...new Set((review.top ?? []).filter((n) => Number.isInteger(n) && n >= 1 && n <= review.findings.length))].slice(0, 3);
126
+ const findings = assignIds(review.findings, input.previous ?? [], (f) => fingerprint(dir, f))
127
+ .map((f, i) => {
128
+ const rank = top.includes(i + 1) ? top.indexOf(i + 1) + 1 : null;
129
+ return { ...f, tier: tierOf({ ...f, rank }), rank };
130
+ })
131
+ .sort((a, b) => ORDER[a.class] - ORDER[b.class] || a.file.localeCompare(b.file) || a.line - b.line);
132
+ return { summary: review.summary, findings, rejected: review.rejected, model: result.model, cost_usd: result.cost_usd ?? null };
133
+ }
134
+ export async function reviewPullRequest(ref, opts = {}) {
135
+ const started = Date.now();
136
+ const progress = opts.onProgress ?? (() => { });
137
+ const effort = opts.effort ?? DEFAULT_EFFORT;
138
+ const provider = opts.provider ?? "codex";
139
+ const hunterModel = provider === "claude" ? (opts.model ?? "opus") : opts.model;
140
+ progress(`Reading ${ref.owner}/${ref.repo}#${ref.number}`);
141
+ const pr = await fetchPullRequest(ref);
142
+ progress("Checking out the code");
143
+ const ws = await prepareWorkspace(pr, opts.at);
144
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
145
+ const log = (step) => join(cacheDir(), "logs", `${pr.owner}-${pr.repo}-${pr.number}-${stamp}-${step}.log`);
146
+ try {
147
+ const input = {
148
+ pr,
149
+ mergeBase: ws.mergeBase,
150
+ headSha: ws.headSha,
151
+ diffStat: ws.diffStat,
152
+ diff: ws.diff,
153
+ diffTruncated: ws.diffTruncated,
154
+ previous: opts.previous,
155
+ };
156
+ // Step 1: hunters, one per area, in parallel. One failing doesn't sink the review.
157
+ const addedTo = ws.changedFiles.filter((f) => f.added > 0).map((f) => f.path);
158
+ const focuses = focusesFor(addedTo);
159
+ let unread = [];
160
+ if (focuses.includes("supply")) {
161
+ const uses = actionsUsedBy(ws.dir, addedTo);
162
+ if (uses.length)
163
+ progress("Reading the source of the actions these workflows use");
164
+ input.actionSources = await fetchActionSources(uses);
165
+ unread = notRead(input.actionSources);
166
+ }
167
+ let done = 0;
168
+ progress(`Hunting for problems (0 of ${focuses.length} areas done)`);
169
+ const hunts = await Promise.allSettled(focuses.map(async (focus) => {
170
+ const result = await runModel(provider, {
171
+ cwd: ws.dir,
172
+ prompt: buildHunterPrompt(forProvider(ws.dir, input, provider), focus),
173
+ schema: candidateSchema,
174
+ model: hunterModel,
175
+ effort,
176
+ logFile: log(`hunt-${focus}`),
177
+ });
178
+ done++;
179
+ progress(`Hunting for problems (${done} of ${focuses.length} areas done)`);
180
+ return { focus, candidates: result.output.candidates, model: result.model };
181
+ }));
182
+ const failed = focuses.filter((_, i) => hunts[i].status === "rejected");
183
+ if (failed.length === focuses.length) {
184
+ const reasons = hunts.map((h) => h.reason);
185
+ throw reasons.find((r) => r instanceof LoginError) ?? reasons.find((r) => r instanceof LimitError) ?? reasons[0];
186
+ }
187
+ const found = hunts.flatMap((h) => h.status === "fulfilled" ? h.value.candidates.map((c) => ({ ...c, focus: h.value.focus })) : []);
188
+ // Step 1b: sweep changed files no hunter reported anything in.
189
+ const covered = new Set(found.map((c) => normalizePath(c.file)));
190
+ const uncovered = ws.changedFiles
191
+ .filter((f) => f.added > 0 && !covered.has(f.path) && !SKIP_SWEEP.test(f.path))
192
+ .sort((a, b) => b.added - a.added)
193
+ .slice(0, SWEEP_CHUNK * SWEEP_MAX_RUNS)
194
+ .map((f) => f.path);
195
+ if (uncovered.length) {
196
+ const chunks = [];
197
+ for (let i = 0; i < uncovered.length; i += SWEEP_CHUNK)
198
+ chunks.push(uncovered.slice(i, i + SWEEP_CHUNK));
199
+ progress(`Sweeping ${uncovered.length} file${uncovered.length === 1 ? "" : "s"} the hunters didn't mention`);
200
+ const sweeps = await Promise.allSettled(chunks.map((files, i) => runModel(provider, {
201
+ cwd: ws.dir,
202
+ prompt: buildSweepPrompt(forProvider(ws.dir, input, provider), files),
203
+ schema: candidateSchema,
204
+ model: hunterModel,
205
+ effort,
206
+ logFile: log(`sweep-${i + 1}`),
207
+ })));
208
+ sweeps.forEach((s, i) => {
209
+ if (s.status === "fulfilled") {
210
+ for (const c of s.value.output.candidates)
211
+ found.push({ ...c, focus: "sweep" });
212
+ }
213
+ else {
214
+ failed.push(`sweep-${i + 1}`);
215
+ }
216
+ });
217
+ }
218
+ // Step 2: the verifier tries to disprove each candidate and sorts the rest.
219
+ // Opus checks better than Codex: in the eval it posted a third less and kept the same finds.
220
+ const verifyWith = opts.verifyWith ?? (provider === "claude" || (await claudeAvailable()) ? "claude" : "codex");
221
+ if (!opts.verifyWith && verifyWith === "codex")
222
+ progress("Claude Code isn't installed, so Codex checks the findings");
223
+ progress(`Checking ${found.length} candidate${found.length === 1 ? "" : "s"}${verifyWith === "codex" ? "" : ` with ${verifyWith}`}`);
224
+ const verified = await verifyCandidates(ws.dir, input, found, {
225
+ provider: verifyWith,
226
+ model: verifyWith === "codex" ? (opts.verifyModel ?? opts.model) : (opts.verifyModel ?? "opus"),
227
+ effort,
228
+ logFile: log("verify"),
229
+ onCommand: (cmd) => progress(`Checking ${found.length} candidates: ${cmd.replace(/\s+/g, " ").slice(0, 80)}`, true),
230
+ });
231
+ const firstHunt = hunts.find((h) => h.status === "fulfilled");
232
+ const servedModel = firstHunt?.status === "fulfilled" ? firstHunt.value.model : null;
233
+ return {
234
+ schema_version: 1,
235
+ pr: {
236
+ owner: pr.owner,
237
+ repo: pr.repo,
238
+ number: pr.number,
239
+ title: pr.title,
240
+ url: pr.url,
241
+ base_sha: ws.mergeBase,
242
+ head_sha: ws.headSha,
243
+ },
244
+ provider: { name: provider, model: servedModel, effort },
245
+ verifier: { name: verifyWith, model: verified.model, effort, cost_usd: verified.cost_usd },
246
+ light: mergeLight(verified.findings),
247
+ summary: verified.summary,
248
+ findings: verified.findings,
249
+ ...(unread.length ? { not_read: unread } : {}),
250
+ stats: {
251
+ duration_ms: Date.now() - started,
252
+ diff_chars: ws.diff.length,
253
+ files_changed: pr.changedFiles,
254
+ additions: pr.additions,
255
+ deletions: pr.deletions,
256
+ },
257
+ trace: {
258
+ candidates: found,
259
+ rejected: verified.rejected,
260
+ failed_hunters: failed,
261
+ },
262
+ created_at: new Date().toISOString(),
263
+ };
264
+ }
265
+ finally {
266
+ if (opts.keep)
267
+ progress(`Kept the checkout at ${ws.dir}`);
268
+ else
269
+ await ws.cleanup();
270
+ }
271
+ }
package/dist/run.js ADDED
@@ -0,0 +1,57 @@
1
+ import { spawn } from "node:child_process";
2
+ export class CommandError extends Error {
3
+ command;
4
+ result;
5
+ constructor(command, result) {
6
+ const detail = (result.stderr || result.stdout).trim().split("\n").slice(-8).join("\n");
7
+ super(`${command} failed with exit code ${result.code}${detail ? `:\n${detail}` : ""}`);
8
+ this.command = command;
9
+ this.result = result;
10
+ }
11
+ }
12
+ /** Run a command without a shell. Resolves with its output, whatever the exit code. */
13
+ export function run(cmd, args, opts = {}) {
14
+ return new Promise((resolve, reject) => {
15
+ const child = spawn(cmd, args, {
16
+ cwd: opts.cwd,
17
+ env: opts.env ?? process.env,
18
+ stdio: ["pipe", "pipe", "pipe"],
19
+ });
20
+ let stdout = "";
21
+ let stderr = "";
22
+ let timer;
23
+ if (opts.timeoutMs) {
24
+ timer = setTimeout(() => child.kill("SIGTERM"), opts.timeoutMs);
25
+ }
26
+ child.stdout.setEncoding("utf8");
27
+ child.stderr.setEncoding("utf8");
28
+ child.stdout.on("data", (chunk) => {
29
+ stdout += chunk;
30
+ opts.onStdout?.(chunk);
31
+ });
32
+ child.stderr.on("data", (chunk) => {
33
+ stderr += chunk;
34
+ });
35
+ child.on("error", (err) => {
36
+ if (timer)
37
+ clearTimeout(timer);
38
+ reject(err);
39
+ });
40
+ child.on("close", (code, signal) => {
41
+ if (timer)
42
+ clearTimeout(timer);
43
+ resolve({ stdout, stderr, code: code ?? (signal ? 128 : 1) });
44
+ });
45
+ child.stdin.on("error", () => {
46
+ /* the process may exit before reading all input */
47
+ });
48
+ child.stdin.end(opts.input ?? "");
49
+ });
50
+ }
51
+ /** Run a command and throw a CommandError if it exits non-zero. */
52
+ export async function runOk(cmd, args, opts = {}) {
53
+ const result = await run(cmd, args, opts);
54
+ if (result.code !== 0)
55
+ throw new CommandError(`${cmd} ${args.slice(0, 3).join(" ")}`, result);
56
+ return result;
57
+ }
package/dist/schema.js ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * JSON Schema for the model's final answer. It is passed to the provider as a
3
+ * structured output schema, so it follows the strict rules: every object lists
4
+ * all its properties as required and allows nothing else.
5
+ */
6
+ export const candidateSchema = {
7
+ type: "object",
8
+ additionalProperties: false,
9
+ required: ["candidates"],
10
+ properties: {
11
+ candidates: {
12
+ type: "array",
13
+ items: {
14
+ type: "object",
15
+ additionalProperties: false,
16
+ required: ["title", "file", "line", "end_line", "scenario", "impact", "evidence"],
17
+ properties: {
18
+ title: { type: "string" },
19
+ file: { type: "string" },
20
+ line: { type: "integer" },
21
+ end_line: { type: "integer" },
22
+ scenario: { type: "string" },
23
+ impact: { type: "string" },
24
+ evidence: { type: "string" },
25
+ },
26
+ },
27
+ },
28
+ },
29
+ };
30
+ export const reviewSchema = {
31
+ type: "object",
32
+ additionalProperties: false,
33
+ required: ["summary", "findings", "rejected", "top"],
34
+ properties: {
35
+ top: {
36
+ type: "array",
37
+ description: "Up to 3 finding numbers, counting from 1 in the order of findings, that a senior reviewer would hold the merge for, most important first.",
38
+ items: { type: "integer" },
39
+ },
40
+ rejected: {
41
+ type: "array",
42
+ description: "Candidates you rejected, with the reason.",
43
+ items: {
44
+ type: "object",
45
+ additionalProperties: false,
46
+ required: ["candidate", "reason"],
47
+ properties: {
48
+ candidate: { type: "string", description: "The candidate id, like C3." },
49
+ reason: { type: "string" },
50
+ },
51
+ },
52
+ },
53
+ summary: {
54
+ type: "string",
55
+ description: "Two plain sentences for someone who does not read code: what the pull request does, and whether it looks safe to merge.",
56
+ },
57
+ findings: {
58
+ type: "array",
59
+ items: {
60
+ type: "object",
61
+ additionalProperties: false,
62
+ required: [
63
+ "class",
64
+ "title",
65
+ "file",
66
+ "line",
67
+ "end_line",
68
+ "how_it_happens",
69
+ "likelihood",
70
+ "severity",
71
+ "what_breaks",
72
+ "fix",
73
+ "fix_cost",
74
+ "recommendation",
75
+ "verdict",
76
+ "confidence",
77
+ "same_as",
78
+ ],
79
+ properties: {
80
+ class: { type: "string", enum: ["bug", "risk", "hardening"] },
81
+ title: { type: "string", description: "Under 80 characters, plain words." },
82
+ file: { type: "string", description: "Path relative to the repository root." },
83
+ line: { type: "integer", description: "First line of the problem in the head version of the file." },
84
+ end_line: { type: "integer", description: "Last line of the problem in the head version of the file." },
85
+ how_it_happens: { type: "string", description: "The concrete trigger, in one or two plain sentences." },
86
+ likelihood: { type: "string", enum: ["common", "plausible", "rare", "theoretical"] },
87
+ severity: {
88
+ type: "string",
89
+ enum: ["major", "minor"],
90
+ description: "major: breaks something, loses data or money, or exposes data. minor: a small but real visible problem.",
91
+ },
92
+ what_breaks: { type: "string", description: "The user-visible or data consequence." },
93
+ fix: { type: "string", description: "The smallest fix that solves it, in one or two sentences." },
94
+ fix_cost: { type: "string", enum: ["trivial", "small", "medium", "large"] },
95
+ recommendation: { type: "string", enum: ["fix", "decide", "leave"] },
96
+ verdict: { type: "string", description: "One short plain sentence a non-engineer understands." },
97
+ confidence: { type: "string", enum: ["high", "medium", "low"] },
98
+ same_as: {
99
+ type: ["string", "null"],
100
+ description: "The id of the problem from an earlier review that this finding is, or null.",
101
+ },
102
+ },
103
+ },
104
+ },
105
+ },
106
+ };
package/dist/setup.js ADDED
@@ -0,0 +1,180 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { fileURLToPath } from "node:url";
7
+ import { run, runOk } from "./run.js";
8
+ export const INSTALL_URL = "https://github.com/apps/mergelight-ai/installations/new";
9
+ const SECRET = "CODEX_AUTH_JSON";
10
+ const BRANCH = "mergelight-setup";
11
+ const REFRESH = "mergelight-refresh.yml";
12
+ const WORKFLOWS = ["mergelight.yml", "mergelight-approve.yml", REFRESH];
13
+ const SKIP_MARKER = "github.event.pull_request.head.repo.full_name == github.repository";
14
+ const ACTION = "mergelightai/mergelight";
15
+ /** The note in each template saying why it isn't pinned, which stops being true once it is. */
16
+ const NOT_PINNED_NOTE = /^[ \t]*# Not pinned, on purpose[^\n]*\n(?:[ \t]*#[^\n]*\n)*/m;
17
+ /** The workflow files, adjusted for this repository, and pinned to a release if one is given. */
18
+ export function workflowFiles(skipBranches = [], pin) {
19
+ const dir = join(dirname(fileURLToPath(import.meta.url)), "..", "templates");
20
+ return WORKFLOWS.map((name) => {
21
+ let content = readFileSync(join(dir, name), "utf8");
22
+ if (name === "mergelight.yml" && skipBranches.length) {
23
+ if (!content.includes(SKIP_MARKER))
24
+ throw new Error(`${name} has changed: can't add the branches to skip.`);
25
+ // GitHub expressions quote strings with single quotes, doubled inside, and fromJSON needs double quotes.
26
+ const json = JSON.stringify(skipBranches).replace(/'/g, "''");
27
+ // A function, so a $ in a branch name stays a $.
28
+ content = content.replace(SKIP_MARKER, () => `${SKIP_MARKER} &&\n !contains(fromJSON('${json}'), github.head_ref)`);
29
+ }
30
+ if (pin) {
31
+ if (!content.includes(`${ACTION}@v0`))
32
+ throw new Error(`${name} has changed: can't pin it.`);
33
+ content = content.replace(NOT_PINNED_NOTE, (note) => {
34
+ const indent = note.match(/^[ \t]*/)[0];
35
+ return `${indent}# Pinned to a release, so new code only runs here once you merge an update.\n${indent}# Mergelight says on your pull requests when a new version is out.\n`;
36
+ }).replaceAll(`${ACTION}@v0`, () => `${ACTION}@${pin.sha} # ${pin.tag}`);
37
+ }
38
+ return { name, content };
39
+ });
40
+ }
41
+ /** The latest release, as a commit. */
42
+ async function latestPin(deps) {
43
+ const tag = (await deps.gh(["api", `repos/${ACTION}/releases/latest`, "--jq", ".tag_name"])).trim();
44
+ const sha = (await deps.gh(["api", `repos/${ACTION}/commits/${tag}`, "--jq", ".sha"])).trim();
45
+ return { sha, tag };
46
+ }
47
+ /** A ChatGPT login is only useful in CI if it can refresh itself. */
48
+ export function checkAuthJson(raw) {
49
+ let auth;
50
+ try {
51
+ auth = JSON.parse(raw);
52
+ }
53
+ catch {
54
+ throw new Error("Codex wrote a login file that can't be read. Run setup again.");
55
+ }
56
+ if (auth.auth_mode !== "chatgpt" || !auth.tokens?.refresh_token) {
57
+ throw new Error("That wasn't a ChatGPT login. Log in with your ChatGPT account, or use openai-api-key in the workflow.");
58
+ }
59
+ }
60
+ async function hasFile(deps, repo, path, ref) {
61
+ try {
62
+ const out = await deps.gh(["api", `repos/${repo}/contents/${path}?ref=${encodeURIComponent(ref)}`]);
63
+ const file = JSON.parse(out);
64
+ return { sha: file.sha, content: Buffer.from(file.content, "base64").toString("utf8") };
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ /** Puts the workflows on a branch and opens a pull request. Returns its URL, or null if there's nothing to add. */
71
+ async function openWorkflowPr(deps, opts, base) {
72
+ const { repo } = opts;
73
+ const files = workflowFiles(opts.skipBranches, opts.pin !== false ? await latestPin(deps) : undefined);
74
+ const missing = [];
75
+ for (const f of files) {
76
+ const onBase = await hasFile(deps, repo, `.github/workflows/${f.name}`, base);
77
+ if (onBase?.content !== f.content)
78
+ missing.push(f);
79
+ }
80
+ if (!missing.length)
81
+ return null;
82
+ const baseSha = (await deps.gh(["api", `repos/${repo}/git/ref/heads/${base}`, "--jq", ".object.sha"])).trim();
83
+ try {
84
+ await deps.gh(["api", "-X", "POST", `repos/${repo}/git/refs`, "-f", `ref=refs/heads/${BRANCH}`, "-f", `sha=${baseSha}`]);
85
+ }
86
+ catch {
87
+ // Left by an earlier setup. The files below are brought up to date on it.
88
+ }
89
+ for (const f of missing) {
90
+ const path = `.github/workflows/${f.name}`;
91
+ const existing = await hasFile(deps, repo, path, BRANCH);
92
+ if (existing?.content === f.content)
93
+ continue;
94
+ await deps.gh([
95
+ "api", "-X", "PUT", `repos/${repo}/contents/${path}`,
96
+ "-f", `message=chore(ci): add ${f.name}`,
97
+ "-f", `content=${Buffer.from(f.content).toString("base64")}`,
98
+ "-f", `branch=${BRANCH}`,
99
+ ...(existing ? ["-f", `sha=${existing.sha}`] : []),
100
+ ]);
101
+ }
102
+ const open = JSON.parse(await deps.gh(["api", `repos/${repo}/pulls?head=${repo.split("/")[0]}:${BRANCH}&state=open`]));
103
+ if (open[0])
104
+ return open[0].html_url;
105
+ const created = await deps.gh([
106
+ "api", "-X", "POST", `repos/${repo}/pulls`,
107
+ "-f", "title=chore(ci): add Mergelight review",
108
+ "-f", `head=${BRANCH}`,
109
+ "-f", `base=${base}`,
110
+ "-f", "body=Adds Mergelight's code review. This pull request reviews itself, so you can see it working before you merge.",
111
+ "--jq", ".html_url",
112
+ ]);
113
+ return created.trim();
114
+ }
115
+ /** Sets up one repository: the app, the Codex login, then a pull request with the workflows. */
116
+ export async function setup(opts, deps) {
117
+ const { repo, yes } = opts;
118
+ const info = JSON.parse(await deps.gh(["api", `repos/${repo}`]));
119
+ const base = opts.base ?? info.default_branch;
120
+ deps.log(`1 of 3: install the Mergelight AI app on ${repo}`);
121
+ if (yes) {
122
+ deps.log(` Skipped asking. If it isn't installed: ${INSTALL_URL}`);
123
+ }
124
+ else {
125
+ await deps.open(INSTALL_URL);
126
+ await deps.confirm(` Installed? Press Enter to continue. ${INSTALL_URL} `);
127
+ }
128
+ deps.log("2 of 3: Codex login");
129
+ const secrets = JSON.parse(await deps.gh(["secret", "list", "--repo", repo, "--json", "name"]));
130
+ if (secrets.some((s) => s.name === SECRET) && !opts.relogin) {
131
+ deps.log(` ${repo} already has one.`);
132
+ }
133
+ else {
134
+ const auth = await deps.codexLogin();
135
+ checkAuthJson(auth);
136
+ await deps.gh(["secret", "set", SECRET, "--repo", repo], auth);
137
+ deps.log(` Saved. It refreshes itself from now on.`);
138
+ }
139
+ deps.log(`3 of 3: add the workflows to ${base}`);
140
+ const url = await openWorkflowPr(deps, opts, base);
141
+ if (url)
142
+ deps.log(`\nDone. Merge this to turn it on, it's reviewing itself now:\n${url}`);
143
+ else
144
+ deps.log(`\nDone. ${repo} already has the workflows on ${base}.`);
145
+ return 0;
146
+ }
147
+ /** Logs in with a separate Codex home, so CI never shares a login with your own machine. */
148
+ export async function codexLogin() {
149
+ const home = mkdtempSync(join(tmpdir(), "mergelight-codex-login-"));
150
+ try {
151
+ writeFileSync(join(home, "config.toml"), 'cli_auth_credentials_store = "file"\n');
152
+ const code = await new Promise((resolve, reject) => {
153
+ const child = spawn("codex", ["login"], { stdio: "inherit", env: { ...process.env, CODEX_HOME: home } });
154
+ child.on("error", reject);
155
+ child.on("close", (c) => resolve(c ?? 1));
156
+ });
157
+ const file = join(home, "auth.json");
158
+ if (code !== 0 || !existsSync(file))
159
+ throw new Error("Codex login didn't finish.");
160
+ return readFileSync(file, "utf8");
161
+ }
162
+ finally {
163
+ rmSync(home, { recursive: true, force: true });
164
+ }
165
+ }
166
+ export function realDeps() {
167
+ return {
168
+ gh: async (args, input) => (await runOk("gh", args, input === undefined ? {} : { input })).stdout,
169
+ codexLogin,
170
+ open: async (url) => {
171
+ await run(process.platform === "darwin" ? "open" : "xdg-open", [url]).catch(() => undefined);
172
+ },
173
+ confirm: async (question) => {
174
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
175
+ await rl.question(question);
176
+ rl.close();
177
+ },
178
+ log: (message) => console.log(message),
179
+ };
180
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/update.js ADDED
@@ -0,0 +1,36 @@
1
+ import { readFileSync } from "node:fs";
2
+ /**
3
+ * A moving tag like @v0 gets each release by itself. A pinned commit doesn't, so the review
4
+ * tells those people when there's a newer release. It's only a hint, so anything going wrong
5
+ * here just means no hint.
6
+ */
7
+ const CURRENT = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
8
+ const parts = (version) => version.replace(/^v/, "").split(".").map(Number);
9
+ export function isNewer(candidate, than) {
10
+ const [a, b] = [parts(candidate), parts(than)];
11
+ for (let i = 0; i < 3; i++) {
12
+ const diff = (a[i] ?? 0) - (b[i] ?? 0);
13
+ if (diff)
14
+ return diff > 0;
15
+ }
16
+ return false;
17
+ }
18
+ /** The changes a release's generated notes list, without the version bumps. */
19
+ export function releaseNews(notes) {
20
+ return [...notes.matchAll(/^\* (.+?) by @\S+ in https:\/\/\S+$/gm)].map((m) => m[1]).filter((title) => !/^Release v\d/.test(title));
21
+ }
22
+ /** What a pinned run is missing, if there's a newer release. */
23
+ export async function newerVersion(api, current = CURRENT, ref = process.env.MERGELIGHT_ACTION_REF) {
24
+ if (!/^[0-9a-f]{40}$/i.test(ref ?? ""))
25
+ return null;
26
+ try {
27
+ const releases = await api.get("/repos/mergelightai/mergelight/releases?per_page=30");
28
+ const newer = releases.filter((r) => !r.draft && !r.prerelease && isNewer(r.tag_name, current)).sort((a, b) => (isNewer(a.tag_name, b.tag_name) ? -1 : 1));
29
+ if (!newer.length)
30
+ return null;
31
+ return { tag: newer[0].tag_name, current: `v${current.replace(/^v/, "")}`, news: newer.flatMap((r) => releaseNews(r.body ?? "")) };
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }