@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 ADDED
@@ -0,0 +1,110 @@
1
+ # Functional Source License, Version 1.1, MIT Future License
2
+
3
+ ## Abbreviation
4
+
5
+ FSL-1.1-MIT
6
+
7
+ ## Notice
8
+
9
+ Copyright 2026 Thomas Egelund
10
+
11
+ ## Terms and Conditions
12
+
13
+ ### Licensor ("We")
14
+
15
+ The party offering the Software under these Terms and Conditions.
16
+
17
+ ### The Software
18
+
19
+ The "Software" is each version of the software that we make available under
20
+ these Terms and Conditions, as indicated by our inclusion of these Terms and
21
+ Conditions with the Software.
22
+
23
+ ### License Grant
24
+
25
+ Subject to your compliance with this License Grant and the Patents,
26
+ Redistribution and Trademark clauses below, we hereby grant you the right to
27
+ use, copy, modify, create derivative works, publicly perform, publicly display
28
+ and redistribute the Software for any Permitted Purpose identified below.
29
+
30
+ ### Permitted Purpose
31
+
32
+ A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
33
+ means making the Software available to others in a commercial product or
34
+ service that:
35
+
36
+ 1. substitutes for the Software;
37
+
38
+ 2. substitutes for any other product or service we offer using the Software
39
+ that exists as of the date we make the Software available; or
40
+
41
+ 3. offers the same or substantially similar functionality as the Software.
42
+
43
+ Permitted Purposes specifically include using the Software:
44
+
45
+ 1. for your internal use and access;
46
+
47
+ 2. for non-commercial education;
48
+
49
+ 3. for non-commercial research; and
50
+
51
+ 4. in connection with professional services that you provide to a licensee
52
+ using the Software in accordance with these Terms and Conditions.
53
+
54
+ ### Patents
55
+
56
+ To the extent your use for a Permitted Purpose would necessarily infringe our
57
+ patents, the license grant above includes a license under our patents. If you
58
+ make a claim against any party that the Software infringes or contributes to
59
+ the infringement of any patent, then your patent license to the Software ends
60
+ immediately.
61
+
62
+ ### Redistribution
63
+
64
+ The Terms and Conditions apply to all copies, modifications and derivatives of
65
+ the Software.
66
+
67
+ If you redistribute any copies, modifications or derivatives of the Software,
68
+ you must include a copy of or a link to these Terms and Conditions and not
69
+ remove any copyright notices provided in or with the Software.
70
+
71
+ ### Disclaimer
72
+
73
+ THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
74
+ IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
75
+ PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
76
+
77
+ IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
78
+ SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
79
+ EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
80
+
81
+ ### Trademarks
82
+
83
+ Except for displaying the License Details and identifying us as the origin of
84
+ the Software, you have no right under these Terms and Conditions to use our
85
+ trademarks, trade names, service marks or product names.
86
+
87
+ ## Grant of Future License
88
+
89
+ We hereby irrevocably grant you an additional license to use the Software under
90
+ the MIT license that is effective on the second anniversary of the date we make
91
+ the Software available. On or after that date, you may use the Software under
92
+ the MIT license, in which case the following will apply:
93
+
94
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
95
+ this software and associated documentation files (the "Software"), to deal in
96
+ the Software without restriction, including without limitation the rights to
97
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
98
+ of the Software, and to permit persons to whom the Software is furnished to do
99
+ so, subject to the following conditions:
100
+
101
+ The above copyright notice and this permission notice shall be included in all
102
+ copies or substantial portions of the Software.
103
+
104
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
105
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
106
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
107
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
108
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
109
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
110
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,114 @@
1
- # Temporary Holding Version
1
+ # Mergelight
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ AI code review that runs on the ChatGPT or Claude plan you already pay for. Mergelight reviews a GitHub pull request, tries to disprove every problem it finds before you see it, and tells you which ones are worth fixing.
4
+
5
+ ## Start here
6
+
7
+ **Reviews on every pull request.** Set it up at [mergelight.dev/setup](https://mergelight.dev/setup). It runs in your own GitHub Actions, on your own plan. See the [GitHub Action guide](https://github.com/mergelightai/mergelight/blob/main/docs/github-action.md).
8
+
9
+ **Findings in your coding agent.** One command adds the MCP server and the skill to Claude Code, Codex and Cursor:
10
+
11
+ ```bash
12
+ npx @mergelight/cli init
13
+ ```
14
+
15
+ Then ask your agent: "Handle the Mergelight findings on this PR." See [Mergelight in your coding agent](https://github.com/mergelightai/mergelight/blob/main/docs/agents.md).
16
+
17
+ **One review from your terminal.** It uses your Codex login, or your Claude login with `--provider claude`.
18
+
19
+ ```bash
20
+ npx @mergelight/cli review https://github.com/owner/repo/pull/123
21
+ ```
22
+
23
+ ## What a review says
24
+
25
+ Every finding is one of four kinds:
26
+
27
+ - 🔴 **Bug:** happens in normal use and breaks something that matters. Fix it before merging.
28
+ - 🟠 **Minor bug:** happens in normal use, but the damage is small, like a wrong label or a missing translation.
29
+ - 🟡 **Risk:** real, but needs specific conditions, like a race or an unusual account setup.
30
+ - ⚪ **Hardening:** doesn't happen in real use. Extra safety, left alone by default.
31
+
32
+ How likely a problem is and how bad it is are rated separately, so a small problem that really happens is a minor bug, not hardening. Style, nitpicks and "add a test" comments are never posted. Text users see is not style: wrong or missing labels, messages and translations are reported. Each finding says how it would actually happen, what breaks, what the smallest fix costs, and whether that fix is worth it.
33
+
34
+ ## Requirements
35
+
36
+ - Node 20 or newer
37
+ - [GitHub CLI](https://cli.github.com), logged in (`gh auth login`)
38
+ - The [Codex CLI](https://github.com/openai/codex), logged in with your ChatGPT plan (`codex login`). It looks for the problems by default.
39
+ - Or [Claude Code](https://claude.com/claude-code), logged in with your Claude plan. With only Claude Code installed, add `--provider claude` to every `review` command.
40
+ - With both installed, Codex looks for problems and Claude checks them. `--provider claude` makes Claude do both.
41
+
42
+ ## Usage
43
+
44
+ ```bash
45
+ npx @mergelight/cli review https://github.com/owner/repo/pull/123
46
+ ```
47
+
48
+ Installed with `npm i -g @mergelight/cli`, the command is `mergelight`. From a clone of the repository: `npm install`, `npm run build`, then `node dist/cli.js` in place of `mergelight`.
49
+
50
+ Other ways to name the pull request: `owner/repo#123`, or just `123` inside a clone of the repository.
51
+
52
+ | Option | What it does |
53
+ |---|---|
54
+ | `--at <sha>` | Review an earlier commit of the PR |
55
+ | `--json` | Print the review as JSON |
56
+ | `--out <file>` | Also save the JSON review to a file |
57
+ | `--provider <name>` | Who looks for problems: `codex` (default) or `claude` |
58
+ | `--model <name>` | Model to use (default: your Codex config, or `opus` with Claude) |
59
+ | `--effort <level>` | Reasoning effort: `low`, `medium` or `high` (default: `high`) |
60
+ | `--verify-with <name>` | Who checks the findings: `claude` (default when Claude Code is installed) or `codex` |
61
+ | `--verify-model <name>` | Model for the checker, like `claude-opus-5-5` (default for Claude: `opus`) |
62
+ | `--keep` | Keep the temporary checkout |
63
+ | `--post` | Post the review to the PR: inline findings, one summary comment and a **Mergelight** check. GitHub only lets Apps write checks, so a personal `gh` login gets the comments and no check. The [GitHub Action](https://github.com/mergelightai/mergelight/blob/main/docs/github-action.md) gets all three |
64
+ | `--dry-run` | With `--post`, `post` or `approve`: print what would be posted and change nothing |
65
+ | `--yellow-blocks` | With `--post` or `post`: a 🟡 check waits for an approval instead of being neutral |
66
+
67
+ Two more commands, used by the GitHub Action:
68
+
69
+ - `mergelight post review.json` posts a review saved with `--out`.
70
+ - `mergelight approve owner/repo#123 --by login` turns a 🟡 "needs a human" check green after someone approves the PR. A 🔴 check stays red.
71
+
72
+ `mergelight init` adds Mergelight to your coding agents, and `mergelight mcp` is the MCP server they start, so Claude Code, Codex or Cursor can read a PR's findings and mark them handled. `mergelight setup owner/repo` sets up the GitHub Action from a terminal.
73
+
74
+ The exit code is `2` when the review finds a bug that blocks the merge, `1` on errors and `0` otherwise. With `--post` it is `0` unless the review fails, since the check carries the verdict. On a personal `gh` login, where GitHub doesn't allow checks, the summary comment carries it instead.
75
+
76
+ ## How it works
77
+
78
+ 1. Fetches the PR with `gh` into a small cached mirror in `~/.cache/mergelight` and checks out the commit under review in a temporary folder.
79
+ 2. Three hunters run in parallel, each a `codex exec` in a read-only sandbox, or Claude Code with file tools only, with one area to cover: access (who can see and do what), money and data, and behavior (does the change do what the PR says, everywhere it's used). A fourth covers CI and supply chain, and only runs when a PR touches workflows, container, deploy or dependency files. They're told to be thorough and to check the code before reporting.
80
+ 3. A sweep pass reads the changed files no hunter reported anything in, up to 36 files in runs of 12, so big PRs aren't only reviewed where the hunters happened to look. Tests and docs are skipped.
81
+ 4. A verifier tries to disprove every candidate. It runs on Claude Code with the latest Opus when Claude Code is installed, and on Codex otherwise. It merges candidates only when they share the same cause and the same fix, and rates the rest: class, how likely, how bad, what the fix costs, and whether to fix it, decide or leave it. Then it picks at most 3 a senior reviewer would hold the merge for.
82
+ 5. Only those top picks get their own comment. Everything else it would **fix** is listed under **also worth fixing** in the summary. **Decide** becomes a one-line **your call**, for real trade-offs. **Leave** is collapsed under hardening. With `--post`, later review rounds hand the verifier what earlier rounds found. It checks each one again, so a finding keeps its id and its one comment even if it's worded differently or points at other lines, and "no longer found" means it was checked, not just missed. It also sees what the team marked as fixed or won't fix: a fix gets checked like anything else, and a decision not to fix is respected unless a major bug still stands.
83
+
84
+ On 32 real pull requests across two repositories, Opus's top picks came to about 2 comments per PR, and the team fixed 65% of the problems they describe. The team never saw these comments, but another bot had raised the same problem in 37 of the 63 cases. Among problems only Mergelight raised, the team fixed 38% of the top picks on its own and 14% of the other findings worth fixing, so the picks are a real signal, on a small sample. Posting everything the verifier would fix meant about 4.5 comments per PR with Opus and 6 with Codex.
85
+
86
+ The light is red when a major bug must be fixed, yellow when anything else is worth fixing or needs a decision, and green otherwise.
87
+
88
+ Plugins, MCP servers, hooks and web search are switched off for every Codex run, so code in a pull request can't reach your other tools. Claude Code runs with only Read, Grep and Glob, confined to the checkout, with plugins, hooks, MCP servers and skills off, and anything that would ask for permission is denied. Every Codex run uses high reasoning effort unless you pass `--effort`, whatever your Codex config says. It matters: in our eval, gpt-6.1-sol at its default low effort found about a third fewer real problems. A review takes about 2 to 15 minutes depending on the size of the PR, around 7 for a typical one.
89
+
90
+ Nothing is sent to Mergelight. The review runs on your own Codex or Claude login, on your machine or in your own GitHub Actions.
91
+
92
+ ## Eval
93
+
94
+ `eval/` compares Mergelight with CodeRabbit and Copilot on PRs they already reviewed, and with what the team did about each comment. Results are written to `~/.cache/mergelight/eval`, never to this repository.
95
+
96
+ ```bash
97
+ node eval/collect.mjs owner/repo --since 2026-10-01 # comments other bots left, per PR
98
+ node eval/run.mjs owner/repo --count 20 # Mergelight reviews the same commits
99
+ node eval/judge.mjs owner/repo # Codex judges which comments the team fixed before merging
100
+ node eval/score.mjs owner/repo # per reviewer: comments, comments fixed, fixed issues found
101
+ node eval/replay.mjs owner/repo --from v2 --tag opus --with claude # same candidates, another verifier
102
+ ```
103
+
104
+ `compare.mjs` is a cruder, faster version of the judge. It only checks whether the commented lines changed, which misses fixes that land a few lines away.
105
+
106
+ ## License
107
+
108
+ Mergelight is under the [Functional Source License](https://github.com/mergelightai/mergelight/blob/main/LICENSE.md) (FSL-1.1-MIT). In plain words:
109
+
110
+ - You can use it, alone or in your company, and you can change it, fork it and share it.
111
+ - You can't offer it, or something built from it, as a commercial product or service that takes its place.
112
+ - Each version becomes MIT two years after it's released.
113
+
114
+ Changes are welcome as pull requests. See [CONTRIBUTING.md](https://github.com/mergelightai/mergelight/blob/main/CONTRIBUTING.md).
@@ -0,0 +1,107 @@
1
+ import { lstatSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ /** A Node or Docker action's definition only names what it runs. */
4
+ const RUNS_A_PROGRAM = /^[ \t]*using:[ \t]*["']?(?:node\d*|docker)\b/m;
5
+ export function notRead(sources) {
6
+ return sources.flatMap((s) => {
7
+ if (!s.files.length)
8
+ return [{ uses: s.uses, why: "unreadable" }];
9
+ return RUNS_A_PROGRAM.test(s.files[0].text) ? [{ uses: s.uses, why: "program" }] : [];
10
+ });
11
+ }
12
+ /** The only places GitHub runs these from. */
13
+ const WORKFLOW_FILE = /^\.github\/workflows\/[^/]+\.ya?ml$|^\.github\/actions\/(.+\/)?action\.ya?ml$/;
14
+ const USES = /^[ \t]*(?:-[ \t]+)?uses:[ \t]*["']?([^"'\s#]+)/gm;
15
+ const REMOTE = /^([\w.-]+)\/([\w.-]+)((?:\/[\w.-]+)*)@([\w./-]+)$/;
16
+ /** Scripts a composite action runs from its own folder. */
17
+ const OWN_SCRIPT = /(?:GITHUB_ACTION_PATH|github\.action_path)(?:\s*\}\}|\})?["']?\/([\w./-]+)/g;
18
+ /** GitHub's own actions are widely read, and reading them would crowd out the rest. */
19
+ const WELL_KNOWN = new Set(["actions", "github"]);
20
+ /** The action running this review. A customer can't fix its code, so a finding about it would only be noise on their pull request. */
21
+ const OURS = /^mergelightai\/mergelight(?:\/|@)/i;
22
+ const MAX_ACTIONS = 6;
23
+ const MAX_SCRIPTS = 6;
24
+ const MAX_FILE = 30_000;
25
+ /** All of it goes in the verifier's prompt, whose failure ends the review, so a pull request mustn't be able to overfill it. */
26
+ const MAX_TOTAL = 90_000;
27
+ /** The other people's actions and reusable workflows these changed files use, in the order they appear. */
28
+ export function actionsUsedBy(dir, changedPaths) {
29
+ const used = new Set();
30
+ for (const path of changedPaths.filter((p) => WORKFLOW_FILE.test(p))) {
31
+ let text;
32
+ try {
33
+ // A pull request can make this a symlink to anywhere, and a regular file is all a workflow can be.
34
+ if (!lstatSync(join(dir, path)).isFile())
35
+ continue;
36
+ text = readFileSync(join(dir, path), "utf8");
37
+ }
38
+ catch {
39
+ continue;
40
+ }
41
+ for (const [, uses] of text.matchAll(USES)) {
42
+ const remote = REMOTE.exec(uses);
43
+ if (remote && !WELL_KNOWN.has(remote[1].toLowerCase()) && !OURS.test(uses) && !uses.includes(".."))
44
+ used.add(uses);
45
+ }
46
+ }
47
+ return [...used];
48
+ }
49
+ /**
50
+ * Reads without a token, so public code is all this can reach. A pull request chooses these names,
51
+ * and must not be able to pull someone's private repository into a prompt with the reader's access.
52
+ */
53
+ async function readFile(owner, repo, ref, path, fetchImpl) {
54
+ try {
55
+ const res = await fetchImpl(`https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${path}`, { signal: AbortSignal.timeout(15_000) });
56
+ if (!res.ok)
57
+ return null;
58
+ const text = await res.text();
59
+ return text.length > MAX_FILE ? `${text.slice(0, MAX_FILE)}\n(cut off here, the file is longer)` : text;
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
65
+ /**
66
+ * Fetches each action's definition and the scripts it runs from its own folder. Anything that can't be
67
+ * read, or is past the limits, comes back with no files, so a review never fails over it and reviewers
68
+ * know what they didn't see.
69
+ */
70
+ export async function fetchActionSources(uses, fetchImpl = fetch) {
71
+ const sources = await Promise.all(uses.map(async (use, i) => {
72
+ if (i >= MAX_ACTIONS)
73
+ return { uses: use, files: [] };
74
+ const [, owner, repo, sub, ref] = REMOTE.exec(use);
75
+ const folder = sub.replace(/^\//, "");
76
+ const prefix = folder && `${folder}/`;
77
+ const reusable = /\.ya?ml$/.test(folder);
78
+ let main = null;
79
+ for (const path of reusable ? [folder] : [`${prefix}action.yml`, `${prefix}action.yaml`]) {
80
+ const text = await readFile(owner, repo, ref, path, fetchImpl);
81
+ if (text !== null) {
82
+ main = { path, text };
83
+ break;
84
+ }
85
+ }
86
+ if (!main)
87
+ return { uses: use, files: [] };
88
+ const scripts = [...new Set([...main.text.matchAll(OWN_SCRIPT)].map((m) => m[1]))].filter((p) => !p.includes("..")).slice(0, MAX_SCRIPTS);
89
+ const read = await Promise.all(scripts.map(async (script) => {
90
+ const path = `${prefix}${script}`;
91
+ const text = await readFile(owner, repo, ref, path, fetchImpl);
92
+ return text === null ? null : { path, text };
93
+ }));
94
+ return { uses: use, files: [main, ...read.filter((f) => f !== null)] };
95
+ }));
96
+ let room = MAX_TOTAL;
97
+ return sources.map((source) => {
98
+ const files = source.files.filter((f) => {
99
+ if (f.text.length > room)
100
+ return false;
101
+ room -= f.text.length;
102
+ return true;
103
+ });
104
+ // Without its definition, the scripts left over say little.
105
+ return { ...source, files: files[0] === source.files[0] ? files : [] };
106
+ });
107
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, writeFileSync } from "node:fs";
3
+ import { parseArgs } from "node:util";
4
+ import { currentRepo, fetchPullRequest, parsePrRef } from "./github.js";
5
+ import { serve } from "./mcp/server.js";
6
+ import { GitHub } from "./publish/api.js";
7
+ import { approveCheck, checkLimitTitle, checkLoginTitle, failCheck, failSummary, previousFindings, publishReview, startCheck, startSummary } from "./publish/publish.js";
8
+ import { LimitError, LoginError, loginFailureMessage } from "./providers/codex.js";
9
+ import { limitNotice, loginNotice } from "./publish/markdown.js";
10
+ import { renderTerminal } from "./render.js";
11
+ import { reviewPullRequest } from "./review.js";
12
+ import { askForApp, connect, githubConnect } from "./connect.js";
13
+ import { init, realInitDeps } from "./init.js";
14
+ import { realDeps, setup } from "./setup.js";
15
+ const HELP = `Mergelight: AI code review on the plan you already pay for.
16
+
17
+ Usage
18
+ mergelight review <pr> [options] Review a pull request
19
+ mergelight post <review.json> Post a saved review to its pull request
20
+ mergelight approve <pr> --by <login> Turn a "needs a human" check green after approval
21
+ mergelight init Add Mergelight to your coding agents: Claude Code, Codex and Cursor
22
+ mergelight mcp Run the MCP server, so your coding agent can read findings
23
+ mergelight setup <owner/repo> Set up the GitHub Action on a repository
24
+ mergelight connect <pr> In the Action: connect ChatGPT from the pull request
25
+
26
+ <pr> is a pull request URL, owner/repo#123, or a number inside a clone.
27
+
28
+ Options
29
+ --repo <owner/name> Repository for a bare PR number
30
+ --at <sha> Review this commit of the PR instead of the latest
31
+ --post Post the review to the PR: summary comment, inline findings, check
32
+ (the check needs a GitHub App, a personal gh login skips it)
33
+ --dry-run With --post, post or approve: print what would be posted, change nothing
34
+ --yellow-blocks With --post or post: a "needs a human" check waits for an approval
35
+ --json Print the review as JSON
36
+ --out <file> Also write the JSON review to a file
37
+ --provider <name> Who hunts for problems: codex or claude (default: codex)
38
+ --model <name> Model for the hunters (default: your Codex config, or opus on Claude)
39
+ --effort <level> Reasoning effort: low, medium, high (default: high)
40
+ --verify-with <name> Who checks the findings: claude or codex
41
+ (default: claude when Claude Code is installed)
42
+ --verify-model <name> Model for the checker (default for claude: opus)
43
+ --keep Keep the temporary checkout
44
+ --by <login> Who approved, for approve
45
+ --base <branch> For setup: add the workflows to this branch (default: the default branch)
46
+ --skip-branches <a,b> For setup: don't review pull requests from these branches
47
+ --relogin For setup: log in to Codex again
48
+ --no-pin For setup: follow @v0 and get each release by itself, instead of pinning to the latest
49
+ --yes For setup: don't stop to ask whether the app is installed
50
+ --no-save For connect: there's nowhere to save a login, so ask for the app instead
51
+ -h, --help Show this help
52
+
53
+ Reviews run on your own logged-in Codex CLI, and Claude Code if you check with it.
54
+ Nothing is sent to Mergelight.
55
+ `;
56
+ function progressPrinter() {
57
+ const tty = Boolean(process.stderr.isTTY);
58
+ const started = Date.now();
59
+ let last = "";
60
+ const draw = () => {
61
+ if (!tty || !last)
62
+ return;
63
+ const s = Math.round((Date.now() - started) / 1000);
64
+ const line = `${last} (${s}s)`;
65
+ process.stderr.write(`\r\x1b[2K${line.slice(0, (process.stderr.columns ?? 100) - 1)}`);
66
+ };
67
+ const ticker = tty ? setInterval(draw, 1000) : undefined;
68
+ return {
69
+ onProgress(message, transient) {
70
+ if (tty) {
71
+ last = message;
72
+ draw();
73
+ }
74
+ else if (!transient) {
75
+ process.stderr.write(`${message}\n`);
76
+ }
77
+ },
78
+ stop() {
79
+ if (ticker)
80
+ clearInterval(ticker);
81
+ if (tty)
82
+ process.stderr.write("\r\x1b[2K");
83
+ },
84
+ };
85
+ }
86
+ const log = (line) => process.stderr.write(`${line}\n`);
87
+ async function main() {
88
+ const { values, positionals } = parseArgs({
89
+ allowPositionals: true,
90
+ options: {
91
+ repo: { type: "string" },
92
+ at: { type: "string" },
93
+ post: { type: "boolean" },
94
+ "dry-run": { type: "boolean" },
95
+ "yellow-blocks": { type: "boolean" },
96
+ json: { type: "boolean" },
97
+ out: { type: "string" },
98
+ model: { type: "string" },
99
+ effort: { type: "string" },
100
+ "verify-with": { type: "string" },
101
+ provider: { type: "string" },
102
+ "verify-model": { type: "string" },
103
+ keep: { type: "boolean" },
104
+ by: { type: "string" },
105
+ base: { type: "string" },
106
+ "skip-branches": { type: "string" },
107
+ relogin: { type: "boolean" },
108
+ pin: { type: "boolean" },
109
+ "no-pin": { type: "boolean" },
110
+ yes: { type: "boolean" },
111
+ "no-save": { type: "boolean" },
112
+ help: { type: "boolean", short: "h" },
113
+ },
114
+ });
115
+ const [command, target] = positionals;
116
+ if (values.help || !command) {
117
+ process.stdout.write(HELP);
118
+ return values.help ? 0 : 1;
119
+ }
120
+ if (command === "setup") {
121
+ if (!target || !/^[\w.-]+\/[\w.-]+$/.test(target)) {
122
+ log("mergelight: name the repository, like: mergelight setup owner/repo");
123
+ return 1;
124
+ }
125
+ try {
126
+ return await setup({
127
+ repo: target,
128
+ base: values.base,
129
+ skipBranches: values["skip-branches"]?.split(",").map((b) => b.trim()).filter(Boolean),
130
+ relogin: values.relogin,
131
+ pin: !values["no-pin"],
132
+ yes: values.yes,
133
+ }, realDeps());
134
+ }
135
+ catch (err) {
136
+ log(`mergelight: ${err instanceof Error ? err.message : String(err)}`);
137
+ return 1;
138
+ }
139
+ }
140
+ if (command === "init") {
141
+ try {
142
+ return await init(realInitDeps());
143
+ }
144
+ catch (err) {
145
+ log(`mergelight: ${err instanceof Error ? err.message : String(err)}`);
146
+ return 1;
147
+ }
148
+ }
149
+ if (command === "mcp") {
150
+ await serve();
151
+ return 0;
152
+ }
153
+ if (!["review", "post", "approve", "connect"].includes(command) || !target) {
154
+ process.stderr.write(HELP);
155
+ return 1;
156
+ }
157
+ const dryRun = Boolean(values["dry-run"]);
158
+ const provider = values.provider;
159
+ if (provider !== undefined && provider !== "codex" && provider !== "claude") {
160
+ log(`mergelight: --provider must be codex or claude, not "${provider}"`);
161
+ return 1;
162
+ }
163
+ const verifyWith = values["verify-with"];
164
+ if (verifyWith !== undefined && verifyWith !== "codex" && verifyWith !== "claude") {
165
+ log(`mergelight: --verify-with must be codex or claude, not "${verifyWith}"`);
166
+ return 1;
167
+ }
168
+ if (command === "post") {
169
+ const review = JSON.parse(readFileSync(target, "utf8"));
170
+ const result = await publishReview(review, { dryRun, log, yellowBlocks: values["yellow-blocks"] });
171
+ log(`Posted: ${result.inline} inline, ${result.outside} in the summary only, ${result.resolved} resolved. ${result.summaryUrl ?? ""}`);
172
+ return 0;
173
+ }
174
+ const ref = parsePrRef(target, values.repo ?? (await currentRepo()));
175
+ if (command === "connect") {
176
+ try {
177
+ const api = new GitHub(ref.owner, ref.repo, { log });
178
+ const { headSha } = await fetchPullRequest(ref);
179
+ if (values["no-save"]) {
180
+ await askForApp(api, ref.number, headSha);
181
+ log("Install the Mergelight AI app: without it there's nowhere to save the ChatGPT login.");
182
+ return 1;
183
+ }
184
+ return await connect(githubConnect(api, ref.number, headSha, log));
185
+ }
186
+ catch (err) {
187
+ log(`mergelight: ${err instanceof Error ? err.message : String(err)}`);
188
+ return 1;
189
+ }
190
+ }
191
+ if (command === "approve") {
192
+ if (!values.by) {
193
+ log("approve needs --by <login>");
194
+ return 1;
195
+ }
196
+ log(await approveCheck(new GitHub(ref.owner, ref.repo, { dryRun, log }), ref.number, values.by));
197
+ return 0;
198
+ }
199
+ // review
200
+ const api = new GitHub(ref.owner, ref.repo, { dryRun, log });
201
+ let checkRunId = null;
202
+ let headSha = values.at ?? null;
203
+ if (values.post) {
204
+ headSha ??= (await fetchPullRequest(ref)).headSha;
205
+ checkRunId = await startCheck(api, headSha);
206
+ await startSummary(api, ref.number).catch((e) => log(`Couldn't post the summary yet: ${e.message}`));
207
+ }
208
+ const progress = progressPrinter();
209
+ try {
210
+ // The login step already found this login can't be refreshed, so don't send every reviewer to find out.
211
+ if (process.env.MERGELIGHT_LOGIN_LOST === "1")
212
+ throw new LoginError(loginFailureMessage(process.env.SECRET_NAME || undefined));
213
+ const review = await reviewPullRequest(ref, {
214
+ previous: values.post ? await previousFindings(api, ref.number) : undefined,
215
+ at: values.at,
216
+ provider,
217
+ model: values.model,
218
+ effort: values.effort,
219
+ verifyWith,
220
+ verifyModel: values["verify-model"],
221
+ keep: values.keep,
222
+ onProgress: progress.onProgress,
223
+ });
224
+ progress.stop();
225
+ const json = JSON.stringify(review, null, 2);
226
+ if (values.out)
227
+ writeFileSync(values.out, `${json}\n`);
228
+ process.stdout.write(values.json ? `${json}\n` : `${renderTerminal(review)}\n`);
229
+ if (values.post) {
230
+ const result = await publishReview(review, { dryRun, log, checkRunId, yellowBlocks: values["yellow-blocks"] });
231
+ log(`Posted: ${result.inline} inline, ${result.outside} in the summary only, ${result.resolved} resolved. ${result.summaryUrl ?? ""}`);
232
+ // The check carries the verdict; the job itself only fails on errors.
233
+ return 0;
234
+ }
235
+ return review.light === "red" ? 2 : 0;
236
+ }
237
+ catch (err) {
238
+ progress.stop();
239
+ const message = err instanceof Error ? err.message : String(err);
240
+ log(`mergelight: ${message}`);
241
+ if (values.post && headSha) {
242
+ const login = err instanceof LoginError ? err : null;
243
+ const limit = err instanceof LimitError ? err : null;
244
+ const title = limit ? checkLimitTitle(limit.plan) : login ? checkLoginTitle(login.plan) : undefined;
245
+ const notice = limit ? limitNotice(limit.plan, limit.resets) : login ? loginNotice(login.plan, message) : undefined;
246
+ await failCheck(api, checkRunId, headSha, message, log, title).catch((e) => log(`Couldn't update the check: ${e.message}`));
247
+ await failSummary(api, ref.number, notice).catch((e) => log(`Couldn't update the summary: ${e.message}`));
248
+ }
249
+ return 1;
250
+ }
251
+ }
252
+ main().then((code) => process.exit(code));