pulls.review 0.1.0 → 0.2.0
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/README.md +16 -0
- package/bin.mjs +2 -0
- package/package.json +17 -9
- package/dist/index.mjs +0 -149
package/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# pulls.review
|
|
2
|
+
|
|
3
|
+
Review local git changes in the browser, grouped and summarized like a pull request on [pulls.review](https://pulls.review).
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx pulls.review # the current branch against the default branch (or a ref picker on main)
|
|
7
|
+
npx pulls.review main...feat # what feat adds since it forked from main
|
|
8
|
+
npx pulls.review v1..v2 # the tree diff between two revisions
|
|
9
|
+
npx pulls.review feat # a branch against the default branch
|
|
10
|
+
npx pulls.review HEAD # one commit against its parent
|
|
11
|
+
npx pulls.review --worktree # uncommitted changes against HEAD, untracked files included
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Run it inside a git repository. It serves on `localhost` through [devframe](https://github.com/devframes/devframe) and opens the page for the argument; any other branch, range or commit can be picked from `/`. The tab is trusted with a one-time code printed in the terminal. Reviewed marks and AI results are cached in `.git/pulls-review`.
|
|
15
|
+
|
|
16
|
+
To analyze a GitHub pull request in CI, use [`@pulls.review/actions`](../actions).
|
package/bin.mjs
ADDED
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pulls.review",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
5
|
-
"description": "
|
|
4
|
+
"version": "0.2.0",
|
|
5
|
+
"description": "Review local git changes - the working tree, a commit or any range - grouped and summarized in the browser.",
|
|
6
6
|
"author": "Anthony Fu <hi@antfu.me>",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"funding": "https://github.com/sponsors/antfu",
|
|
@@ -16,30 +16,38 @@
|
|
|
16
16
|
"keywords": [
|
|
17
17
|
"pull-request",
|
|
18
18
|
"code-review",
|
|
19
|
-
"
|
|
20
|
-
"
|
|
19
|
+
"git",
|
|
20
|
+
"devframe"
|
|
21
21
|
],
|
|
22
|
+
"exports": {
|
|
23
|
+
"./devframe": "./dist/devframe.mjs"
|
|
24
|
+
},
|
|
22
25
|
"bin": {
|
|
23
|
-
"pulls.review": "./
|
|
24
|
-
"pulls-review": "./
|
|
26
|
+
"pulls.review": "./bin.mjs",
|
|
27
|
+
"pulls-review": "./bin.mjs"
|
|
25
28
|
},
|
|
26
29
|
"files": [
|
|
30
|
+
"bin.mjs",
|
|
27
31
|
"dist"
|
|
28
32
|
],
|
|
29
33
|
"engines": {
|
|
30
34
|
"node": ">=20"
|
|
31
35
|
},
|
|
32
36
|
"dependencies": {
|
|
33
|
-
"@pulls.review/core": "^0.
|
|
34
|
-
"
|
|
37
|
+
"@pulls.review/core": "^0.2.0",
|
|
38
|
+
"devframe": "^1.2.0",
|
|
39
|
+
"h3": "^2.0.1-rc.32",
|
|
40
|
+
"unstorage": "^1.17.5"
|
|
35
41
|
},
|
|
36
42
|
"devDependencies": {
|
|
37
43
|
"@types/node": "^26.6.4",
|
|
44
|
+
"tsdown": "^0.23.0",
|
|
38
45
|
"typescript": "^6.0.3",
|
|
39
46
|
"vitest": "^5.0.3"
|
|
40
47
|
},
|
|
41
48
|
"scripts": {
|
|
42
|
-
"build": "tsdown",
|
|
49
|
+
"build": "tsdown && pnpm -F @pulls.review/app run build:local",
|
|
50
|
+
"dev": "node bin.mjs",
|
|
43
51
|
"typecheck": "tsc --noEmit",
|
|
44
52
|
"test": "vitest run"
|
|
45
53
|
}
|
package/dist/index.mjs
DELETED
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
|
-
import process from "node:process";
|
|
4
|
-
import { parseArgs } from "node:util";
|
|
5
|
-
import { githubTokenFromEnv, llmSettingsFromEnv } from "@pulls.review/core/env";
|
|
6
|
-
import { runLlmAnalysis } from "@pulls.review/core/llm";
|
|
7
|
-
import { Diagnostic, formatDiagnostic } from "nostics";
|
|
8
|
-
import { DEFAULT_LOCALE, isLocale } from "@pulls.review/core/locales";
|
|
9
|
-
import { resolveModel } from "@pulls.review/core/analyze";
|
|
10
|
-
import { diagnostics } from "@pulls.review/core/diagnostics";
|
|
11
|
-
import { createGithubPullRequestSource } from "@pulls.review/core/github";
|
|
12
|
-
import { staticCredentials } from "@pulls.review/core/types";
|
|
13
|
-
//#region src/config.ts
|
|
14
|
-
/** The first value that is set and not blank: an Action passes unset inputs as `''`. */
|
|
15
|
-
function first(...values) {
|
|
16
|
-
return values.find((value) => value);
|
|
17
|
-
}
|
|
18
|
-
function resolveGithubToken(env) {
|
|
19
|
-
const token = githubTokenFromEnv(env);
|
|
20
|
-
if (!token) throw new Error("A GitHub token is required: set GITHUB_TOKEN (or PULLS_REVIEW_GITHUB_TOKEN).");
|
|
21
|
-
return token;
|
|
22
|
-
}
|
|
23
|
-
function resolveLocale(env, flags = {}) {
|
|
24
|
-
const requested = first(flags.locale, env.PULLS_REVIEW_LOCALE);
|
|
25
|
-
if (requested === void 0) return DEFAULT_LOCALE;
|
|
26
|
-
if (!isLocale(requested)) throw new Error(`Unsupported locale "${requested}".`);
|
|
27
|
-
return requested;
|
|
28
|
-
}
|
|
29
|
-
/**
|
|
30
|
-
* `owner/repo#123` or a github.com PR URL; in a workflow run, the PR the event is about.
|
|
31
|
-
*/
|
|
32
|
-
function resolveTarget(arg, env, event) {
|
|
33
|
-
if (arg) {
|
|
34
|
-
const match = arg.match(/^(?:https:\/\/github\.com\/)?([^/\s]+)\/([^/#\s]+)(?:#|\/pull\/)(\d+)\/?$/);
|
|
35
|
-
if (!match) throw new Error(`Cannot read a pull request from "${arg}": expected owner/repo#123 or a github.com pull request URL.`);
|
|
36
|
-
return {
|
|
37
|
-
owner: match[1],
|
|
38
|
-
repo: match[2],
|
|
39
|
-
number: match[3]
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
const [owner, repo] = env.GITHUB_REPOSITORY?.split("/") ?? [];
|
|
43
|
-
const number = event?.pull_request?.number;
|
|
44
|
-
if (!owner || !repo || number === void 0) throw new Error("No pull request given. Pass owner/repo#123, or run on a pull_request event in GitHub Actions.");
|
|
45
|
-
return {
|
|
46
|
-
owner,
|
|
47
|
-
repo,
|
|
48
|
-
number: String(number)
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
//#endregion
|
|
52
|
-
//#region src/run.ts
|
|
53
|
-
/** The author GitHub shows for a workflow's `GITHUB_TOKEN`, which cannot look itself up at `/user`. */
|
|
54
|
-
const ACTIONS_BOT_LOGIN = "github-actions[bot]";
|
|
55
|
-
function describeProgress(progress) {
|
|
56
|
-
switch (progress.kind) {
|
|
57
|
-
case "thinking": return `[${progress.step}] thinking`;
|
|
58
|
-
case "reading": return `[${progress.step}] reading ${progress.paths.length} file${progress.paths.length === 1 ? "" : "s"}`;
|
|
59
|
-
case "organizing": return `[${progress.step}] organizing groups`;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
/**
|
|
63
|
-
* Fetches the PR, analyzes it and upserts the shared-analysis comment, exactly like the
|
|
64
|
-
* site's "share as comment" - one comment per author, found by its marker. A comment
|
|
65
|
-
* already carrying the PR's current head sha is left alone, so re-runs on an unchanged
|
|
66
|
-
* PR cost no model call.
|
|
67
|
-
*/
|
|
68
|
-
async function run({ pr, githubToken, llm, locale, log }, analyze) {
|
|
69
|
-
const resolved = resolveModel(llm);
|
|
70
|
-
if (!resolved) throw diagnostics.llmNotConfigured();
|
|
71
|
-
const source = createGithubPullRequestSource(pr, staticCredentials(githubToken));
|
|
72
|
-
const [diff, login, comments] = await Promise.all([
|
|
73
|
-
source.fetch(),
|
|
74
|
-
source.viewer().then((viewer) => viewer?.login ?? ACTIONS_BOT_LOGIN, () => ACTIONS_BOT_LOGIN),
|
|
75
|
-
source.sharing.list()
|
|
76
|
-
]);
|
|
77
|
-
const headSha = diff.head?.sha ?? "";
|
|
78
|
-
const existing = comments.find((comment) => comment.login === login);
|
|
79
|
-
if (existing && existing.analysis.headSha === headSha) return {
|
|
80
|
-
status: "up-to-date",
|
|
81
|
-
url: existing.url
|
|
82
|
-
};
|
|
83
|
-
log(`Analyzing ${pr.owner}/${pr.repo}#${pr.number} (${diff.files.length} files) with ${resolved.model.provider}/${resolved.model.id}`);
|
|
84
|
-
const { result } = await analyze(diff, resolved, locale, { onProgress: (progress) => log(describeProgress(progress)) });
|
|
85
|
-
return {
|
|
86
|
-
status: "posted",
|
|
87
|
-
url: (await source.sharing.upsert(login, {
|
|
88
|
-
headSha,
|
|
89
|
-
result
|
|
90
|
-
}, existing)).url
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
//#endregion
|
|
94
|
-
//#region src/index.ts
|
|
95
|
-
const HELP = `Usage: pulls.review [owner/repo#123 | https://github.com/owner/repo/pull/123] [options]
|
|
96
|
-
|
|
97
|
-
Analyzes the pull request with an LLM and posts (or updates) the grouped summary as
|
|
98
|
-
a PR comment that pulls.review picks up. Without a target, reads the pull_request
|
|
99
|
-
event of the running GitHub Actions job.
|
|
100
|
-
|
|
101
|
-
Options:
|
|
102
|
-
--provider <name> gateway | anthropic | openai-compatible (PULLS_REVIEW_PROVIDER)
|
|
103
|
-
--model <id> model id for the provider (PULLS_REVIEW_MODEL)
|
|
104
|
-
--locale <tag> language of the summaries, default en (PULLS_REVIEW_LOCALE)
|
|
105
|
-
-h, --help
|
|
106
|
-
|
|
107
|
-
Environment:
|
|
108
|
-
GITHUB_TOKEN token with pull-requests: write (PULLS_REVIEW_GITHUB_TOKEN)
|
|
109
|
-
AI_GATEWAY_API_KEY Vercel AI Gateway key (PULLS_REVIEW_API_KEY)
|
|
110
|
-
ANTHROPIC_API_KEY Anthropic key (PULLS_REVIEW_API_KEY)
|
|
111
|
-
OPENAI_API_KEY OpenAI-compatible key (PULLS_REVIEW_API_KEY)
|
|
112
|
-
OPENAI_BASE_URL OpenAI-compatible endpoint (PULLS_REVIEW_BASE_URL)
|
|
113
|
-
`;
|
|
114
|
-
function readEvent(path) {
|
|
115
|
-
return path ? JSON.parse(readFileSync(path, "utf-8")) : void 0;
|
|
116
|
-
}
|
|
117
|
-
async function main() {
|
|
118
|
-
const { values: flags, positionals } = parseArgs({
|
|
119
|
-
options: {
|
|
120
|
-
provider: { type: "string" },
|
|
121
|
-
model: { type: "string" },
|
|
122
|
-
locale: { type: "string" },
|
|
123
|
-
help: {
|
|
124
|
-
type: "boolean",
|
|
125
|
-
short: "h"
|
|
126
|
-
}
|
|
127
|
-
},
|
|
128
|
-
allowPositionals: true
|
|
129
|
-
});
|
|
130
|
-
if (flags.help) {
|
|
131
|
-
console.log(HELP);
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
const env = process.env;
|
|
135
|
-
const outcome = await run({
|
|
136
|
-
pr: resolveTarget(positionals[0], env, readEvent(env.GITHUB_EVENT_PATH)),
|
|
137
|
-
githubToken: resolveGithubToken(env),
|
|
138
|
-
llm: llmSettingsFromEnv(env, flags),
|
|
139
|
-
locale: resolveLocale(env, flags),
|
|
140
|
-
log: (line) => console.log(line)
|
|
141
|
-
}, runLlmAnalysis);
|
|
142
|
-
console.log(outcome.status === "up-to-date" ? `Comment already covers the current head: ${outcome.url}` : `Posted ${outcome.url}`);
|
|
143
|
-
}
|
|
144
|
-
main().catch((error) => {
|
|
145
|
-
console.error(error instanceof Diagnostic ? formatDiagnostic(error) : error instanceof Error ? error.message : String(error));
|
|
146
|
-
process.exitCode = 1;
|
|
147
|
-
});
|
|
148
|
-
//#endregion
|
|
149
|
-
export {};
|