@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
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
|
-
#
|
|
1
|
+
# Mergelight
|
|
2
2
|
|
|
3
|
-
|
|
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));
|