newpr 0.1.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 +189 -0
- package/package.json +78 -0
- package/src/analyzer/errors.ts +22 -0
- package/src/analyzer/pipeline.ts +299 -0
- package/src/analyzer/progress.ts +69 -0
- package/src/cli/args.ts +192 -0
- package/src/cli/auth.ts +82 -0
- package/src/cli/history-cmd.ts +64 -0
- package/src/cli/index.ts +115 -0
- package/src/cli/pretty.ts +79 -0
- package/src/config/index.ts +103 -0
- package/src/config/store.ts +50 -0
- package/src/diff/chunker.ts +30 -0
- package/src/diff/parser.ts +116 -0
- package/src/diff/stats.ts +37 -0
- package/src/github/auth.ts +16 -0
- package/src/github/fetch-diff.ts +24 -0
- package/src/github/fetch-pr.ts +90 -0
- package/src/github/parse-pr.ts +39 -0
- package/src/history/store.ts +96 -0
- package/src/history/types.ts +15 -0
- package/src/llm/claude-code-client.ts +134 -0
- package/src/llm/client.ts +240 -0
- package/src/llm/prompts.ts +176 -0
- package/src/llm/response-parser.ts +71 -0
- package/src/tui/App.tsx +97 -0
- package/src/tui/Footer.tsx +34 -0
- package/src/tui/Header.tsx +27 -0
- package/src/tui/HelpOverlay.tsx +46 -0
- package/src/tui/InputBar.tsx +65 -0
- package/src/tui/Loading.tsx +192 -0
- package/src/tui/Shell.tsx +384 -0
- package/src/tui/TabBar.tsx +31 -0
- package/src/tui/commands.ts +75 -0
- package/src/tui/narrative-parser.ts +143 -0
- package/src/tui/panels/FilesPanel.tsx +134 -0
- package/src/tui/panels/GroupsPanel.tsx +140 -0
- package/src/tui/panels/NarrativePanel.tsx +102 -0
- package/src/tui/panels/StoryPanel.tsx +296 -0
- package/src/tui/panels/SummaryPanel.tsx +59 -0
- package/src/tui/panels/WalkthroughPanel.tsx +149 -0
- package/src/tui/render.tsx +62 -0
- package/src/tui/theme.ts +44 -0
- package/src/types/config.ts +19 -0
- package/src/types/diff.ts +36 -0
- package/src/types/github.ts +28 -0
- package/src/types/output.ts +59 -0
- package/src/web/client/App.tsx +121 -0
- package/src/web/client/components/AppShell.tsx +203 -0
- package/src/web/client/components/DetailPane.tsx +141 -0
- package/src/web/client/components/ErrorScreen.tsx +119 -0
- package/src/web/client/components/InputScreen.tsx +41 -0
- package/src/web/client/components/LoadingTimeline.tsx +179 -0
- package/src/web/client/components/Markdown.tsx +109 -0
- package/src/web/client/components/ResizeHandle.tsx +45 -0
- package/src/web/client/components/ResultsScreen.tsx +185 -0
- package/src/web/client/components/SettingsPanel.tsx +299 -0
- package/src/web/client/hooks/useAnalysis.ts +153 -0
- package/src/web/client/hooks/useGithubUser.ts +24 -0
- package/src/web/client/hooks/useSessions.ts +17 -0
- package/src/web/client/hooks/useTheme.ts +34 -0
- package/src/web/client/main.tsx +12 -0
- package/src/web/client/panels/FilesPanel.tsx +85 -0
- package/src/web/client/panels/GroupsPanel.tsx +62 -0
- package/src/web/client/panels/NarrativePanel.tsx +9 -0
- package/src/web/client/panels/StoryPanel.tsx +54 -0
- package/src/web/client/panels/SummaryPanel.tsx +20 -0
- package/src/web/components/ui/button.tsx +46 -0
- package/src/web/components/ui/card.tsx +37 -0
- package/src/web/components/ui/scroll-area.tsx +39 -0
- package/src/web/components/ui/tabs.tsx +52 -0
- package/src/web/index.html +14 -0
- package/src/web/lib/utils.ts +6 -0
- package/src/web/server/routes.ts +202 -0
- package/src/web/server/session-manager.ts +147 -0
- package/src/web/server.ts +96 -0
- package/src/web/styles/globals.css +91 -0
- package/src/workspace/agent.ts +317 -0
- package/src/workspace/explore.ts +82 -0
- package/src/workspace/repo-cache.ts +69 -0
- package/src/workspace/types.ts +30 -0
- package/src/workspace/worktree.ts +129 -0
package/src/cli/args.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { AgentToolName } from "../workspace/types.ts";
|
|
2
|
+
|
|
3
|
+
export interface CliArgs {
|
|
4
|
+
command: "shell" | "review" | "auth" | "history" | "web" | "help" | "version";
|
|
5
|
+
prInput?: string;
|
|
6
|
+
repo?: string;
|
|
7
|
+
model?: string;
|
|
8
|
+
output: "tui" | "json" | "stream-json" | "pretty";
|
|
9
|
+
verbose: boolean;
|
|
10
|
+
noClone: boolean;
|
|
11
|
+
agent?: AgentToolName;
|
|
12
|
+
port?: number;
|
|
13
|
+
subArgs: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function printHelp(): void {
|
|
17
|
+
const help = `
|
|
18
|
+
newpr - AI-powered large PR review tool
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
newpr # launch interactive shell
|
|
22
|
+
newpr <pr-url> # launch shell with PR pre-loaded
|
|
23
|
+
newpr --web [--port 3000] # launch web UI
|
|
24
|
+
newpr review <pr-url> --json # non-interactive JSON output
|
|
25
|
+
newpr history # list past review sessions
|
|
26
|
+
newpr history show <id> # show full JSON for a session
|
|
27
|
+
newpr history clear # clear all history
|
|
28
|
+
newpr auth [--key <api-key>]
|
|
29
|
+
newpr auth status
|
|
30
|
+
newpr auth logout
|
|
31
|
+
newpr help
|
|
32
|
+
newpr version
|
|
33
|
+
|
|
34
|
+
Examples:
|
|
35
|
+
newpr # interactive shell
|
|
36
|
+
newpr https://github.com/owner/repo/pull/123 # shell + auto-analyze
|
|
37
|
+
newpr --web --port 8080 # web UI on port 8080
|
|
38
|
+
newpr review owner/repo#123 --json # pipe-friendly JSON
|
|
39
|
+
newpr review 123 --repo owner/repo --no-clone # diff-only (no git clone)
|
|
40
|
+
newpr auth --key sk-or-xxx
|
|
41
|
+
|
|
42
|
+
Options:
|
|
43
|
+
--web Launch web UI instead of TUI
|
|
44
|
+
--port <number> Port for web server (default: 3000)
|
|
45
|
+
|
|
46
|
+
Options (review mode):
|
|
47
|
+
--repo <owner/repo> Repository (required when using PR number only)
|
|
48
|
+
--model <model> Override LLM model (default: anthropic/claude-sonnet-4.5)
|
|
49
|
+
--agent <tool> Preferred agent: claude | opencode | codex (default: auto)
|
|
50
|
+
--no-clone Skip git clone, diff-only analysis (faster, less context)
|
|
51
|
+
--json Output raw JSON (for piping/scripting)
|
|
52
|
+
--stream-json Stream progress as NDJSON, then emit result
|
|
53
|
+
--output <format> Output format: tui (default) | json | stream-json | pretty
|
|
54
|
+
--verbose Show progress on stderr (non-TUI modes)
|
|
55
|
+
-h, --help Show this help
|
|
56
|
+
-v, --version Show version
|
|
57
|
+
|
|
58
|
+
Environment Variables:
|
|
59
|
+
OPENROUTER_API_KEY Required. Your OpenRouter API key.
|
|
60
|
+
GITHUB_TOKEN Optional. Falls back to gh CLI token.
|
|
61
|
+
NEWPR_MODEL Default model override.
|
|
62
|
+
NEWPR_MAX_FILES Max files to analyze (default: 100).
|
|
63
|
+
NEWPR_TIMEOUT Timeout per LLM call in seconds (default: 120).
|
|
64
|
+
NEWPR_CONCURRENCY Parallel LLM calls (default: 5).
|
|
65
|
+
`.trim();
|
|
66
|
+
|
|
67
|
+
console.log(help);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const DEFAULTS = { output: "tui" as const, verbose: false, noClone: false, subArgs: [] as string[] };
|
|
71
|
+
|
|
72
|
+
function looksLikePrInput(s: string): boolean {
|
|
73
|
+
return (
|
|
74
|
+
s.startsWith("http://") ||
|
|
75
|
+
s.startsWith("https://") ||
|
|
76
|
+
s.includes("#") ||
|
|
77
|
+
/^\d+$/.test(s) ||
|
|
78
|
+
/^[^/]+\/[^/]+#\d+$/.test(s)
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseAgentName(val: string | undefined): AgentToolName | undefined {
|
|
83
|
+
if (val === "claude" || val === "opencode" || val === "codex") return val;
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function parseArgs(argv: string[]): CliArgs {
|
|
88
|
+
const args = argv.slice(2);
|
|
89
|
+
|
|
90
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
91
|
+
printHelp();
|
|
92
|
+
return { command: "help", ...DEFAULTS };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (args.includes("-v") || args.includes("--version")) {
|
|
96
|
+
return { command: "version", ...DEFAULTS };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (args.includes("--web")) {
|
|
100
|
+
let port = 3000;
|
|
101
|
+
const portIdx = args.indexOf("--port");
|
|
102
|
+
if (portIdx !== -1 && args[portIdx + 1]) {
|
|
103
|
+
port = Number.parseInt(args[portIdx + 1]!, 10) || 3000;
|
|
104
|
+
}
|
|
105
|
+
return { command: "web", port, ...DEFAULTS };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (args.length === 0) {
|
|
109
|
+
return { command: "shell", ...DEFAULTS };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const command = args[0]!;
|
|
113
|
+
|
|
114
|
+
if (command === "version") {
|
|
115
|
+
return { command: "version", ...DEFAULTS };
|
|
116
|
+
}
|
|
117
|
+
if (command === "help") {
|
|
118
|
+
printHelp();
|
|
119
|
+
return { command: "help", ...DEFAULTS };
|
|
120
|
+
}
|
|
121
|
+
if (command === "auth") {
|
|
122
|
+
return { command: "auth", ...DEFAULTS, subArgs: args.slice(1) };
|
|
123
|
+
}
|
|
124
|
+
if (command === "history") {
|
|
125
|
+
return { command: "history", ...DEFAULTS, subArgs: args.slice(1) };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (command === "review") {
|
|
129
|
+
return parseReviewArgs(args.slice(1));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (looksLikePrInput(command)) {
|
|
133
|
+
return { command: "shell", prInput: command, ...DEFAULTS };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
console.error(`Unknown command: ${command}\n`);
|
|
137
|
+
printHelp();
|
|
138
|
+
return { command: "help", ...DEFAULTS };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function parseReviewArgs(args: string[]): CliArgs {
|
|
142
|
+
const prInput = args[0];
|
|
143
|
+
if (!prInput || prInput.startsWith("-")) {
|
|
144
|
+
console.error("Error: PR URL or number is required.\n");
|
|
145
|
+
printHelp();
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let repo: string | undefined;
|
|
150
|
+
let model: string | undefined;
|
|
151
|
+
let agent: AgentToolName | undefined;
|
|
152
|
+
let output: "tui" | "json" | "stream-json" | "pretty" = "tui";
|
|
153
|
+
let verbose = false;
|
|
154
|
+
let noClone = false;
|
|
155
|
+
|
|
156
|
+
for (let i = 1; i < args.length; i++) {
|
|
157
|
+
const arg = args[i];
|
|
158
|
+
switch (arg) {
|
|
159
|
+
case "--repo":
|
|
160
|
+
repo = args[++i];
|
|
161
|
+
break;
|
|
162
|
+
case "--model":
|
|
163
|
+
model = args[++i];
|
|
164
|
+
break;
|
|
165
|
+
case "--agent":
|
|
166
|
+
agent = parseAgentName(args[++i]);
|
|
167
|
+
break;
|
|
168
|
+
case "--no-clone":
|
|
169
|
+
noClone = true;
|
|
170
|
+
break;
|
|
171
|
+
case "--json":
|
|
172
|
+
output = "json";
|
|
173
|
+
break;
|
|
174
|
+
case "--stream-json":
|
|
175
|
+
output = "stream-json";
|
|
176
|
+
break;
|
|
177
|
+
case "--output": {
|
|
178
|
+
const val = args[++i];
|
|
179
|
+
if (val === "json") output = "json";
|
|
180
|
+
else if (val === "stream-json") output = "stream-json";
|
|
181
|
+
else if (val === "pretty") output = "pretty";
|
|
182
|
+
else output = "tui";
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
case "--verbose":
|
|
186
|
+
verbose = true;
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { command: "review", prInput, repo, model, agent, output, verbose, noClone, subArgs: [] };
|
|
192
|
+
}
|
package/src/cli/auth.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { readStoredConfig, writeStoredConfig, getConfigPath, deleteStoredKey } from "../config/store.ts";
|
|
2
|
+
|
|
3
|
+
async function promptForKey(): Promise<string> {
|
|
4
|
+
process.stdout.write("Enter your OpenRouter API key: ");
|
|
5
|
+
const reader = Bun.stdin.stream().getReader();
|
|
6
|
+
const { value } = await reader.read();
|
|
7
|
+
reader.releaseLock();
|
|
8
|
+
const input = value ? new TextDecoder().decode(value).trim() : "";
|
|
9
|
+
if (!input) {
|
|
10
|
+
throw new Error("No API key provided.");
|
|
11
|
+
}
|
|
12
|
+
return input;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function maskKey(key: string): string {
|
|
16
|
+
if (key.length <= 8) return "****";
|
|
17
|
+
return `${key.slice(0, 6)}...${key.slice(-4)}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function handleAuth(subArgs: string[]): Promise<void> {
|
|
21
|
+
const subcommand = subArgs[0];
|
|
22
|
+
|
|
23
|
+
if (subcommand === "status") {
|
|
24
|
+
return showStatus();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (subcommand === "logout") {
|
|
28
|
+
await deleteStoredKey("openrouter_api_key");
|
|
29
|
+
console.log("OpenRouter API key removed from config.");
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const keyFlagIdx = subArgs.indexOf("--key");
|
|
34
|
+
let key: string;
|
|
35
|
+
|
|
36
|
+
if (keyFlagIdx !== -1) {
|
|
37
|
+
const keyValue = subArgs[keyFlagIdx + 1];
|
|
38
|
+
if (!keyValue) {
|
|
39
|
+
console.error("Error: --key requires a value. Usage: newpr auth --key sk-or-...");
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
key = keyValue;
|
|
43
|
+
} else {
|
|
44
|
+
key = await promptForKey();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!key.startsWith("sk-or-")) {
|
|
48
|
+
console.error("Warning: Key doesn't start with 'sk-or-'. Are you sure this is an OpenRouter key?");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
await writeStoredConfig({ openrouter_api_key: key });
|
|
52
|
+
console.log(`API key saved to ${getConfigPath()}`);
|
|
53
|
+
console.log(`Key: ${maskKey(key)}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function showStatus(): Promise<void> {
|
|
57
|
+
const envKey = process.env.OPENROUTER_API_KEY;
|
|
58
|
+
const stored = await readStoredConfig();
|
|
59
|
+
|
|
60
|
+
console.log("Authentication Status:");
|
|
61
|
+
console.log("─".repeat(40));
|
|
62
|
+
|
|
63
|
+
if (envKey) {
|
|
64
|
+
console.log(` env OPENROUTER_API_KEY: ${maskKey(envKey)} (active)`);
|
|
65
|
+
} else {
|
|
66
|
+
console.log(" env OPENROUTER_API_KEY: not set");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (stored.openrouter_api_key) {
|
|
70
|
+
const isActive = !envKey;
|
|
71
|
+
console.log(` config file: ${maskKey(stored.openrouter_api_key)}${isActive ? " (active)" : " (overridden by env)"}`);
|
|
72
|
+
} else {
|
|
73
|
+
console.log(" config file: not set");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
console.log("");
|
|
77
|
+
console.log(`Config path: ${getConfigPath()}`);
|
|
78
|
+
|
|
79
|
+
if (!envKey && !stored.openrouter_api_key) {
|
|
80
|
+
console.log("\nNo API key configured. Run `newpr auth` to set one.");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { listSessions, loadSession, clearHistory, getHistoryPath } from "../history/store.ts";
|
|
2
|
+
|
|
3
|
+
const RISK_COLORS: Record<string, string> = {
|
|
4
|
+
low: "\x1b[32m",
|
|
5
|
+
medium: "\x1b[33m",
|
|
6
|
+
high: "\x1b[31m",
|
|
7
|
+
};
|
|
8
|
+
const RESET = "\x1b[0m";
|
|
9
|
+
const DIM = "\x1b[2m";
|
|
10
|
+
const BOLD = "\x1b[1m";
|
|
11
|
+
const CYAN = "\x1b[36m";
|
|
12
|
+
|
|
13
|
+
export async function handleHistory(subArgs: string[]): Promise<void> {
|
|
14
|
+
const sub = subArgs[0];
|
|
15
|
+
|
|
16
|
+
if (sub === "clear") {
|
|
17
|
+
await clearHistory();
|
|
18
|
+
console.log("History cleared.");
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (sub === "show") {
|
|
23
|
+
const id = subArgs[1];
|
|
24
|
+
if (!id) {
|
|
25
|
+
console.error("Usage: newpr history show <session-id>");
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
const data = await loadSession(id);
|
|
29
|
+
if (!data) {
|
|
30
|
+
console.error(`Session ${id} not found.`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
console.log(JSON.stringify(data, null, 2));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (sub === "path") {
|
|
38
|
+
console.log(getHistoryPath());
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const limit = sub ? Number.parseInt(sub, 10) : 20;
|
|
43
|
+
const sessions = await listSessions(Number.isNaN(limit) ? 20 : limit);
|
|
44
|
+
|
|
45
|
+
if (sessions.length === 0) {
|
|
46
|
+
console.log("No review history yet. Run `newpr` to analyze a PR.");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
console.log(`${BOLD}${CYAN}Review History${RESET} ${DIM}(${sessions.length} sessions)${RESET}\n`);
|
|
51
|
+
|
|
52
|
+
for (const s of sessions) {
|
|
53
|
+
const riskColor = RISK_COLORS[s.risk_level] ?? RISK_COLORS.medium;
|
|
54
|
+
const date = new Date(s.analyzed_at).toLocaleDateString();
|
|
55
|
+
console.log(
|
|
56
|
+
` ${BOLD}#${s.pr_number}${RESET} ${s.pr_title}`,
|
|
57
|
+
);
|
|
58
|
+
console.log(
|
|
59
|
+
` ${DIM}${s.repo} │ ${s.author} │ ${date} │ ${riskColor}${s.risk_level}${RESET}${DIM} │ ${s.total_files} files │ +${s.total_additions} -${s.total_deletions}${RESET}`,
|
|
60
|
+
);
|
|
61
|
+
console.log(` ${DIM}${s.summary_purpose}${RESET}`);
|
|
62
|
+
console.log(` ${DIM}id: ${s.id}${RESET}\n`);
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { parseArgs } from "./args.ts";
|
|
3
|
+
import { handleAuth } from "./auth.ts";
|
|
4
|
+
import { handleHistory } from "./history-cmd.ts";
|
|
5
|
+
import { formatPretty } from "./pretty.ts";
|
|
6
|
+
import { loadConfig } from "../config/index.ts";
|
|
7
|
+
import { getGithubToken } from "../github/auth.ts";
|
|
8
|
+
import { parsePrInput } from "../github/parse-pr.ts";
|
|
9
|
+
import { analyzePr } from "../analyzer/pipeline.ts";
|
|
10
|
+
import { createStderrProgress, createSilentProgress, createStreamJsonProgress } from "../analyzer/progress.ts";
|
|
11
|
+
import { renderLoading, renderShell } from "../tui/render.tsx";
|
|
12
|
+
|
|
13
|
+
const VERSION = "0.1.0";
|
|
14
|
+
|
|
15
|
+
async function main(): Promise<void> {
|
|
16
|
+
const args = parseArgs(process.argv);
|
|
17
|
+
|
|
18
|
+
if (args.command === "help") return;
|
|
19
|
+
|
|
20
|
+
if (args.command === "version") {
|
|
21
|
+
console.log(`newpr v${VERSION}`);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (args.command === "auth") {
|
|
26
|
+
try {
|
|
27
|
+
await handleAuth(args.subArgs);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
30
|
+
process.stderr.write(`Error: ${message}\n`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (args.command === "history") {
|
|
37
|
+
try {
|
|
38
|
+
await handleHistory(args.subArgs);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
41
|
+
process.stderr.write(`Error: ${message}\n`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (args.command === "web") {
|
|
48
|
+
try {
|
|
49
|
+
const config = await loadConfig({ model: args.model });
|
|
50
|
+
const token = await getGithubToken();
|
|
51
|
+
const { startWebServer } = await import("../web/server.ts");
|
|
52
|
+
await startWebServer({ port: args.port ?? 3000, token, config });
|
|
53
|
+
} catch (error) {
|
|
54
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
55
|
+
process.stderr.write(`Error: ${message}\n`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (args.command === "shell") {
|
|
62
|
+
try {
|
|
63
|
+
const config = await loadConfig({ model: args.model });
|
|
64
|
+
const token = await getGithubToken();
|
|
65
|
+
renderShell(token, config, args.prInput);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
68
|
+
process.stderr.write(`Error: ${message}\n`);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const config = await loadConfig({ model: args.model });
|
|
76
|
+
const token = await getGithubToken();
|
|
77
|
+
const pr = parsePrInput(args.prInput!, args.repo);
|
|
78
|
+
const pipelineOpts = {
|
|
79
|
+
pr,
|
|
80
|
+
token,
|
|
81
|
+
config,
|
|
82
|
+
noClone: args.noClone,
|
|
83
|
+
preferredAgent: args.agent ?? config.agent,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
if (args.output === "tui") {
|
|
87
|
+
const loading = await renderLoading();
|
|
88
|
+
const result = await analyzePr({
|
|
89
|
+
...pipelineOpts,
|
|
90
|
+
onProgress: (event) => loading.update(event),
|
|
91
|
+
});
|
|
92
|
+
loading.finish(result);
|
|
93
|
+
} else if (args.output === "stream-json") {
|
|
94
|
+
const progress = createStreamJsonProgress();
|
|
95
|
+
const result = await analyzePr({ ...pipelineOpts, onProgress: progress });
|
|
96
|
+
const resultLine = JSON.stringify({ type: "result", data: result });
|
|
97
|
+
process.stdout.write(`${resultLine}\n`);
|
|
98
|
+
} else {
|
|
99
|
+
const progress = args.verbose ? createStderrProgress() : createSilentProgress();
|
|
100
|
+
const result = await analyzePr({ ...pipelineOpts, onProgress: progress });
|
|
101
|
+
|
|
102
|
+
if (args.output === "pretty") {
|
|
103
|
+
console.log(formatPretty(result));
|
|
104
|
+
} else {
|
|
105
|
+
console.log(JSON.stringify(result, null, 2));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} catch (error) {
|
|
109
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
110
|
+
process.stderr.write(`Error: ${message}\n`);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
main();
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { NewprOutput } from "../types/output.ts";
|
|
2
|
+
|
|
3
|
+
const COLORS = {
|
|
4
|
+
reset: "\x1b[0m",
|
|
5
|
+
bold: "\x1b[1m",
|
|
6
|
+
dim: "\x1b[2m",
|
|
7
|
+
red: "\x1b[31m",
|
|
8
|
+
green: "\x1b[32m",
|
|
9
|
+
yellow: "\x1b[33m",
|
|
10
|
+
blue: "\x1b[34m",
|
|
11
|
+
magenta: "\x1b[35m",
|
|
12
|
+
cyan: "\x1b[36m",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const RISK_COLORS: Record<string, string> = {
|
|
16
|
+
low: COLORS.green,
|
|
17
|
+
medium: COLORS.yellow,
|
|
18
|
+
high: COLORS.red,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const GROUP_TYPE_ICONS: Record<string, string> = {
|
|
22
|
+
feature: "+",
|
|
23
|
+
refactor: "~",
|
|
24
|
+
bugfix: "!",
|
|
25
|
+
chore: "*",
|
|
26
|
+
docs: "#",
|
|
27
|
+
test: "T",
|
|
28
|
+
config: "C",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function line(char = "─", length = 60): string {
|
|
32
|
+
return char.repeat(length);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function formatPretty(output: NewprOutput): string {
|
|
36
|
+
const lines: string[] = [];
|
|
37
|
+
|
|
38
|
+
lines.push("");
|
|
39
|
+
lines.push(`${COLORS.bold}${COLORS.cyan}${line("═")}${COLORS.reset}`);
|
|
40
|
+
lines.push(`${COLORS.bold} PR #${output.meta.pr_number}: ${output.meta.pr_title}${COLORS.reset}`);
|
|
41
|
+
lines.push(`${COLORS.dim} ${output.meta.author} | ${output.meta.base_branch} <- ${output.meta.head_branch}${COLORS.reset}`);
|
|
42
|
+
lines.push(`${COLORS.dim} ${output.meta.total_files_changed} files | +${output.meta.total_additions} -${output.meta.total_deletions}${COLORS.reset}`);
|
|
43
|
+
lines.push(`${COLORS.bold}${COLORS.cyan}${line("═")}${COLORS.reset}`);
|
|
44
|
+
|
|
45
|
+
lines.push("");
|
|
46
|
+
lines.push(`${COLORS.bold}SUMMARY${COLORS.reset}`);
|
|
47
|
+
lines.push(`${line("─")}`);
|
|
48
|
+
lines.push(` Purpose: ${output.summary.purpose}`);
|
|
49
|
+
lines.push(` Scope: ${output.summary.scope}`);
|
|
50
|
+
lines.push(` Impact: ${output.summary.impact}`);
|
|
51
|
+
const riskColor = RISK_COLORS[output.summary.risk_level] ?? COLORS.yellow;
|
|
52
|
+
lines.push(` Risk: ${riskColor}${output.summary.risk_level.toUpperCase()}${COLORS.reset}`);
|
|
53
|
+
|
|
54
|
+
lines.push("");
|
|
55
|
+
lines.push(`${COLORS.bold}CHANGE GROUPS${COLORS.reset}`);
|
|
56
|
+
lines.push(`${line("─")}`);
|
|
57
|
+
for (const group of output.groups) {
|
|
58
|
+
const icon = GROUP_TYPE_ICONS[group.type] ?? "*";
|
|
59
|
+
lines.push(` ${COLORS.bold}[${icon}] ${group.name}${COLORS.reset} ${COLORS.dim}(${group.type}, ${group.files.length} files)${COLORS.reset}`);
|
|
60
|
+
lines.push(` ${group.description}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
lines.push("");
|
|
64
|
+
lines.push(`${COLORS.bold}FILES${COLORS.reset}`);
|
|
65
|
+
lines.push(`${line("─")}`);
|
|
66
|
+
for (const file of output.files) {
|
|
67
|
+
const statusIcon = file.status === "added" ? `${COLORS.green}A` : file.status === "deleted" ? `${COLORS.red}D` : file.status === "renamed" ? `${COLORS.blue}R` : `${COLORS.yellow}M`;
|
|
68
|
+
lines.push(` ${statusIcon}${COLORS.reset} ${file.path} ${COLORS.dim}(+${file.additions}/-${file.deletions})${COLORS.reset}`);
|
|
69
|
+
lines.push(` ${COLORS.dim}${file.summary}${COLORS.reset}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
lines.push("");
|
|
73
|
+
lines.push(`${COLORS.bold}NARRATIVE${COLORS.reset}`);
|
|
74
|
+
lines.push(`${line("─")}`);
|
|
75
|
+
lines.push(output.narrative);
|
|
76
|
+
lines.push("");
|
|
77
|
+
|
|
78
|
+
return lines.join("\n");
|
|
79
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { DEFAULT_CONFIG, type NewprConfig } from "../types/config.ts";
|
|
2
|
+
import { readStoredConfig, type StoredConfig } from "./store.ts";
|
|
3
|
+
|
|
4
|
+
export interface ConfigOverrides {
|
|
5
|
+
model?: string;
|
|
6
|
+
max_files?: number;
|
|
7
|
+
timeout?: number;
|
|
8
|
+
concurrency?: number;
|
|
9
|
+
language?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function parseIntOrDefault(value: string | undefined, fallback: number): number {
|
|
13
|
+
if (!value) return fallback;
|
|
14
|
+
const parsed = Number.parseInt(value, 10);
|
|
15
|
+
return Number.isNaN(parsed) ? fallback : parsed;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const LOCALE_TO_LANGUAGE: Record<string, string> = {
|
|
19
|
+
ko: "Korean",
|
|
20
|
+
ja: "Japanese",
|
|
21
|
+
zh: "Chinese",
|
|
22
|
+
es: "Spanish",
|
|
23
|
+
fr: "French",
|
|
24
|
+
de: "German",
|
|
25
|
+
pt: "Portuguese",
|
|
26
|
+
ru: "Russian",
|
|
27
|
+
it: "Italian",
|
|
28
|
+
vi: "Vietnamese",
|
|
29
|
+
th: "Thai",
|
|
30
|
+
ar: "Arabic",
|
|
31
|
+
hi: "Hindi",
|
|
32
|
+
nl: "Dutch",
|
|
33
|
+
pl: "Polish",
|
|
34
|
+
tr: "Turkish",
|
|
35
|
+
sv: "Swedish",
|
|
36
|
+
en: "English",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function detectLanguage(): string {
|
|
40
|
+
const envLang = process.env.LANG || process.env.LC_ALL || process.env.LC_MESSAGES || "";
|
|
41
|
+
const code = envLang.split(/[_.\-]/)[0]?.toLowerCase() ?? "";
|
|
42
|
+
if (code && LOCALE_TO_LANGUAGE[code]) return LOCALE_TO_LANGUAGE[code]!;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const resolved = Intl.DateTimeFormat().resolvedOptions().locale;
|
|
46
|
+
const intlCode = resolved.split("-")[0]?.toLowerCase() ?? "";
|
|
47
|
+
if (intlCode && LOCALE_TO_LANGUAGE[intlCode]) return LOCALE_TO_LANGUAGE[intlCode]!;
|
|
48
|
+
} catch {}
|
|
49
|
+
|
|
50
|
+
return "English";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function resolveLanguage(configured: string): string {
|
|
54
|
+
if (configured === "auto") return detectLanguage();
|
|
55
|
+
return configured;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function loadConfig(
|
|
59
|
+
overrides?: ConfigOverrides,
|
|
60
|
+
_readStore?: () => Promise<StoredConfig>,
|
|
61
|
+
): Promise<NewprConfig> {
|
|
62
|
+
const stored = await (_readStore ?? readStoredConfig)();
|
|
63
|
+
|
|
64
|
+
const apiKey = process.env.OPENROUTER_API_KEY || stored.openrouter_api_key;
|
|
65
|
+
if (!apiKey) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
"OPENROUTER_API_KEY is not set. Run `newpr auth` to configure, or set the environment variable.",
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const agentVal = stored.agent as NewprConfig["agent"];
|
|
72
|
+
const rawLang = process.env.NEWPR_LANGUAGE || stored.language || DEFAULT_CONFIG.language;
|
|
73
|
+
|
|
74
|
+
const config: NewprConfig = {
|
|
75
|
+
openrouter_api_key: apiKey,
|
|
76
|
+
agent: agentVal === "claude" || agentVal === "opencode" || agentVal === "codex" ? agentVal : undefined,
|
|
77
|
+
model:
|
|
78
|
+
process.env.NEWPR_MODEL || stored.model || DEFAULT_CONFIG.model,
|
|
79
|
+
max_files: parseIntOrDefault(
|
|
80
|
+
process.env.NEWPR_MAX_FILES,
|
|
81
|
+
stored.max_files ?? DEFAULT_CONFIG.max_files,
|
|
82
|
+
),
|
|
83
|
+
timeout: parseIntOrDefault(
|
|
84
|
+
process.env.NEWPR_TIMEOUT,
|
|
85
|
+
stored.timeout ?? DEFAULT_CONFIG.timeout,
|
|
86
|
+
),
|
|
87
|
+
concurrency: parseIntOrDefault(
|
|
88
|
+
process.env.NEWPR_CONCURRENCY,
|
|
89
|
+
stored.concurrency ?? DEFAULT_CONFIG.concurrency,
|
|
90
|
+
),
|
|
91
|
+
language: resolveLanguage(rawLang),
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
if (overrides) {
|
|
95
|
+
if (overrides.model) config.model = overrides.model;
|
|
96
|
+
if (overrides.max_files !== undefined) config.max_files = overrides.max_files;
|
|
97
|
+
if (overrides.timeout !== undefined) config.timeout = overrides.timeout;
|
|
98
|
+
if (overrides.concurrency !== undefined) config.concurrency = overrides.concurrency;
|
|
99
|
+
if (overrides.language) config.language = resolveLanguage(overrides.language);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return config;
|
|
103
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { mkdirSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = join(homedir(), ".newpr");
|
|
6
|
+
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
7
|
+
|
|
8
|
+
export interface StoredConfig {
|
|
9
|
+
openrouter_api_key?: string;
|
|
10
|
+
model?: string;
|
|
11
|
+
max_files?: number;
|
|
12
|
+
timeout?: number;
|
|
13
|
+
concurrency?: number;
|
|
14
|
+
language?: string;
|
|
15
|
+
agent?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function ensureDir(): void {
|
|
19
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getConfigPath(): string {
|
|
23
|
+
return CONFIG_FILE;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function readStoredConfig(): Promise<StoredConfig> {
|
|
27
|
+
try {
|
|
28
|
+
const file = Bun.file(CONFIG_FILE);
|
|
29
|
+
const exists = await file.exists();
|
|
30
|
+
if (!exists) return {};
|
|
31
|
+
const text = await file.text();
|
|
32
|
+
return JSON.parse(text) as StoredConfig;
|
|
33
|
+
} catch {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function writeStoredConfig(update: StoredConfig): Promise<void> {
|
|
39
|
+
ensureDir();
|
|
40
|
+
const existing = await readStoredConfig();
|
|
41
|
+
const merged = { ...existing, ...update };
|
|
42
|
+
await Bun.write(CONFIG_FILE, `${JSON.stringify(merged, null, 2)}\n`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function deleteStoredKey(key: keyof StoredConfig): Promise<void> {
|
|
46
|
+
const existing = await readStoredConfig();
|
|
47
|
+
delete existing[key];
|
|
48
|
+
ensureDir();
|
|
49
|
+
await Bun.write(CONFIG_FILE, `${JSON.stringify(existing, null, 2)}\n`);
|
|
50
|
+
}
|