newpr 0.2.0 → 0.3.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/package.json +1 -1
- package/src/analyzer/pipeline.ts +16 -11
- package/src/analyzer/progress.ts +2 -0
- package/src/cli/index.ts +7 -2
- package/src/cli/preflight.ts +126 -0
- package/src/github/fetch-pr.ts +11 -1
- package/src/history/store.ts +1 -0
- package/src/history/types.ts +1 -0
- package/src/llm/prompts.ts +48 -12
- package/src/llm/response-parser.ts +13 -1
- package/src/types/github.ts +3 -0
- package/src/types/output.ts +6 -0
- package/src/web/client/App.tsx +5 -3
- package/src/web/client/components/AppShell.tsx +13 -0
- package/src/web/client/components/ChatSection.tsx +2 -2
- package/src/web/client/components/DetailPane.tsx +57 -5
- package/src/web/client/components/DiffViewer.tsx +35 -1
- package/src/web/client/components/InputScreen.tsx +69 -2
- package/src/web/client/components/LoadingTimeline.tsx +19 -6
- package/src/web/client/components/Markdown.tsx +54 -1
- package/src/web/client/components/ResultsScreen.tsx +44 -3
- package/src/web/client/components/ReviewModal.tsx +187 -0
- package/src/web/client/components/SettingsPanel.tsx +62 -86
- package/src/web/client/panels/GroupsPanel.tsx +15 -2
- package/src/web/client/panels/StoryPanel.tsx +1 -1
- package/src/web/server/routes.ts +154 -14
- package/src/web/server.ts +33 -4
- package/src/web/styles/built.css +1 -1
- package/src/workspace/agent.ts +22 -6
- package/src/workspace/explore.ts +41 -16
package/package.json
CHANGED
package/src/analyzer/pipeline.ts
CHANGED
|
@@ -85,25 +85,25 @@ async function runExploration(
|
|
|
85
85
|
): Promise<ExplorationResult> {
|
|
86
86
|
const agent = await requireAgent(preferredAgent);
|
|
87
87
|
|
|
88
|
-
onProgress?.({ stage: "cloning", message:
|
|
88
|
+
onProgress?.({ stage: "cloning", message: `Cloning ${pr.owner}/${pr.repo}...` });
|
|
89
89
|
const bareRepoPath = await ensureRepo(pr.owner, pr.repo, token, (msg) => {
|
|
90
|
-
onProgress?.({ stage: "cloning", message: msg });
|
|
90
|
+
onProgress?.({ stage: "cloning", message: `📦 ${msg}` });
|
|
91
91
|
});
|
|
92
|
-
onProgress?.({ stage: "cloning", message:
|
|
92
|
+
onProgress?.({ stage: "cloning", message: `📦 ${pr.owner}/${pr.repo} cached` });
|
|
93
93
|
|
|
94
|
-
onProgress?.({ stage: "checkout", message:
|
|
94
|
+
onProgress?.({ stage: "checkout", message: `🌿 Preparing worktrees: ${baseBranch} ← PR #${pr.number}` });
|
|
95
95
|
const worktrees = await createWorktrees(
|
|
96
96
|
bareRepoPath, baseBranch, pr.number, pr.owner, pr.repo,
|
|
97
|
-
(msg) => onProgress?.({ stage: "checkout", message: msg }),
|
|
97
|
+
(msg) => onProgress?.({ stage: "checkout", message: `🌿 ${msg}` }),
|
|
98
98
|
);
|
|
99
|
-
onProgress?.({ stage: "checkout", message:
|
|
99
|
+
onProgress?.({ stage: "checkout", message: `🌿 Worktrees ready: ${baseBranch} ← PR #${pr.number}` });
|
|
100
100
|
|
|
101
|
-
onProgress?.({ stage: "exploring", message:
|
|
101
|
+
onProgress?.({ stage: "exploring", message: `🤖 ${agent.name}: exploring ${changedFiles.length} changed files...` });
|
|
102
102
|
const exploration = await exploreCodebase(
|
|
103
103
|
agent, worktrees.headPath, changedFiles, prTitle, rawDiff,
|
|
104
104
|
(msg, current, total) => onProgress?.({ stage: "exploring", message: msg, current, total }),
|
|
105
105
|
);
|
|
106
|
-
onProgress?.({ stage: "exploring", message:
|
|
106
|
+
onProgress?.({ stage: "exploring", message: `🤖 ${agent.name}: exploration complete` });
|
|
107
107
|
|
|
108
108
|
await cleanupWorktrees(bareRepoPath, pr.number, pr.owner, pr.repo).catch(() => {});
|
|
109
109
|
|
|
@@ -165,7 +165,7 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
|
|
|
165
165
|
fetchPrDiff(pr, token),
|
|
166
166
|
fetchPrComments(pr, token).catch(() => []),
|
|
167
167
|
]);
|
|
168
|
-
progress({ stage: "fetching", message: `#${prData.number} "${prData.title}" by ${prData.author} · +${prData.additions} −${prData.deletions} · ${prComments.length} comments
|
|
168
|
+
progress({ stage: "fetching", message: `#${prData.number} "${prData.title}" by ${prData.author} · +${prData.additions} −${prData.deletions} · ${prComments.length} comments`, pr_title: prData.title, pr_number: prData.number });
|
|
169
169
|
|
|
170
170
|
progress({ stage: "parsing", message: "Parsing diff..." });
|
|
171
171
|
const parsed = parseDiff(rawDiff);
|
|
@@ -259,9 +259,13 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
|
|
|
259
259
|
progress({ stage: "summarizing", message: `${summary.risk_level} risk · ${summary.purpose.slice(0, 60)}` });
|
|
260
260
|
|
|
261
261
|
progress({ stage: "narrating", message: `Writing narrative${enrichedTag}...` });
|
|
262
|
+
const fileDiffs = chunks.slice(0, 30).map((c) => ({
|
|
263
|
+
path: c.file_path,
|
|
264
|
+
diff: c.diff_content.length > 3000 ? `${c.diff_content.slice(0, 3000)}\n... (truncated)` : c.diff_content,
|
|
265
|
+
}));
|
|
262
266
|
const narrativePrompt = exploration
|
|
263
|
-
? buildEnrichedNarrativePrompt(prData.title, summary, groups, exploration, promptCtx)
|
|
264
|
-
: buildNarrativePrompt(prData.title, summary, groups, promptCtx);
|
|
267
|
+
? buildEnrichedNarrativePrompt(prData.title, summary, groups, exploration, promptCtx, fileDiffs)
|
|
268
|
+
: buildNarrativePrompt(prData.title, summary, groups, promptCtx, fileDiffs);
|
|
265
269
|
const narrativeResponse = await streamLlmCall(
|
|
266
270
|
client, narrativePrompt.system, narrativePrompt.user, "narrating", "Writing narrative...", progress,
|
|
267
271
|
);
|
|
@@ -297,6 +301,7 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
|
|
|
297
301
|
pr_title: prData.title,
|
|
298
302
|
pr_body: prData.body || undefined,
|
|
299
303
|
pr_url: prData.url,
|
|
304
|
+
pr_state: prData.state,
|
|
300
305
|
base_branch: prData.base_branch,
|
|
301
306
|
head_branch: prData.head_branch,
|
|
302
307
|
author: prData.author,
|
package/src/analyzer/progress.ts
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -10,8 +10,9 @@ import { analyzePr } from "../analyzer/pipeline.ts";
|
|
|
10
10
|
import { createStderrProgress, createSilentProgress, createStreamJsonProgress } from "../analyzer/progress.ts";
|
|
11
11
|
import { renderLoading, renderShell } from "../tui/render.tsx";
|
|
12
12
|
import { checkForUpdate, printUpdateNotice } from "./update-check.ts";
|
|
13
|
+
import { runPreflight, printPreflight } from "./preflight.ts";
|
|
13
14
|
|
|
14
|
-
const VERSION = "0.
|
|
15
|
+
const VERSION = "0.3.0";
|
|
15
16
|
|
|
16
17
|
async function main(): Promise<void> {
|
|
17
18
|
const args = parseArgs(process.argv);
|
|
@@ -51,12 +52,14 @@ async function main(): Promise<void> {
|
|
|
51
52
|
|
|
52
53
|
if (args.command === "web") {
|
|
53
54
|
try {
|
|
55
|
+
const preflight = await runPreflight();
|
|
56
|
+
printPreflight(preflight);
|
|
54
57
|
const config = await loadConfig({ model: args.model });
|
|
55
58
|
const token = await getGithubToken();
|
|
56
59
|
const updateInfo = await updatePromise;
|
|
57
60
|
if (updateInfo) printUpdateNotice(updateInfo);
|
|
58
61
|
const { startWebServer } = await import("../web/server.ts");
|
|
59
|
-
await startWebServer({ port: args.port ?? 3000, token, config, cartoon: args.cartoon });
|
|
62
|
+
await startWebServer({ port: args.port ?? 3000, token, config, cartoon: args.cartoon, preflight });
|
|
60
63
|
} catch (error) {
|
|
61
64
|
const message = error instanceof Error ? error.message : String(error);
|
|
62
65
|
process.stderr.write(`Error: ${message}\n`);
|
|
@@ -67,6 +70,8 @@ async function main(): Promise<void> {
|
|
|
67
70
|
|
|
68
71
|
if (args.command === "shell") {
|
|
69
72
|
try {
|
|
73
|
+
const preflight = await runPreflight();
|
|
74
|
+
printPreflight(preflight);
|
|
70
75
|
const config = await loadConfig({ model: args.model });
|
|
71
76
|
const token = await getGithubToken();
|
|
72
77
|
const updateInfo = await updatePromise;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { AgentToolName } from "../workspace/types.ts";
|
|
2
|
+
|
|
3
|
+
export interface ToolStatus {
|
|
4
|
+
name: string;
|
|
5
|
+
installed: boolean;
|
|
6
|
+
version?: string;
|
|
7
|
+
detail?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface PreflightResult {
|
|
11
|
+
github: ToolStatus & { authenticated: boolean; user?: string };
|
|
12
|
+
agents: ToolStatus[];
|
|
13
|
+
openrouterKey: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function which(cmd: string): Promise<string | null> {
|
|
17
|
+
try {
|
|
18
|
+
const result = await Bun.$`which ${cmd}`.text();
|
|
19
|
+
return result.trim() || null;
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function getVersion(cmd: string, flag = "--version"): Promise<string | null> {
|
|
26
|
+
try {
|
|
27
|
+
const result = await Bun.$`${cmd} ${flag} 2>&1`.text();
|
|
28
|
+
const match = result.match(/[\d]+\.[\d]+[\d.]*/);
|
|
29
|
+
return match?.[0] ?? result.trim().slice(0, 30);
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function checkGithubCli(): Promise<PreflightResult["github"]> {
|
|
36
|
+
const path = await which("gh");
|
|
37
|
+
if (!path) {
|
|
38
|
+
return { name: "gh", installed: false, authenticated: false, detail: "brew install gh" };
|
|
39
|
+
}
|
|
40
|
+
const version = await getVersion("gh");
|
|
41
|
+
try {
|
|
42
|
+
const status = await Bun.$`gh auth status 2>&1`.text();
|
|
43
|
+
const userMatch = status.match(/Logged in to github\.com account (\S+)/i)
|
|
44
|
+
?? status.match(/account (\S+)/i);
|
|
45
|
+
return {
|
|
46
|
+
name: "gh",
|
|
47
|
+
installed: true,
|
|
48
|
+
version: version ?? undefined,
|
|
49
|
+
authenticated: true,
|
|
50
|
+
user: userMatch?.[1]?.replace(/\s*\(.*/, ""),
|
|
51
|
+
};
|
|
52
|
+
} catch {
|
|
53
|
+
return { name: "gh", installed: true, version: version ?? undefined, authenticated: false, detail: "gh auth login" };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function checkAgent(name: AgentToolName): Promise<ToolStatus> {
|
|
58
|
+
const path = await which(name);
|
|
59
|
+
if (!path) return { name, installed: false };
|
|
60
|
+
const version = await getVersion(name);
|
|
61
|
+
return { name, installed: true, version: version ?? undefined };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function runPreflight(): Promise<PreflightResult> {
|
|
65
|
+
const [github, claude, opencode, codex] = await Promise.all([
|
|
66
|
+
checkGithubCli(),
|
|
67
|
+
checkAgent("claude"),
|
|
68
|
+
checkAgent("opencode"),
|
|
69
|
+
checkAgent("codex"),
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
github,
|
|
74
|
+
agents: [claude, opencode, codex],
|
|
75
|
+
openrouterKey: !!(process.env.OPENROUTER_API_KEY || await hasStoredApiKey()),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function hasStoredApiKey(): Promise<boolean> {
|
|
80
|
+
try {
|
|
81
|
+
const { readStoredConfig } = await import("../config/store.ts");
|
|
82
|
+
const stored = await readStoredConfig();
|
|
83
|
+
return !!stored.openrouter_api_key;
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function printPreflight(result: PreflightResult): void {
|
|
90
|
+
const check = "\x1b[32m✓\x1b[0m";
|
|
91
|
+
const cross = "\x1b[31m✗\x1b[0m";
|
|
92
|
+
const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
|
|
93
|
+
const bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
|
|
94
|
+
|
|
95
|
+
console.log("");
|
|
96
|
+
console.log(` ${bold("Preflight")}`);
|
|
97
|
+
console.log("");
|
|
98
|
+
|
|
99
|
+
const gh = result.github;
|
|
100
|
+
if (gh.installed && gh.authenticated) {
|
|
101
|
+
console.log(` ${check} gh ${dim(gh.version ?? "")} ${dim(`· ${gh.user ?? ""}`)}`);
|
|
102
|
+
} else if (gh.installed) {
|
|
103
|
+
console.log(` ${cross} gh ${dim(gh.version ?? "")} ${dim("· not authenticated")}`);
|
|
104
|
+
console.log(` ${dim(`run: ${gh.detail}`)}`);
|
|
105
|
+
} else {
|
|
106
|
+
console.log(` ${cross} gh ${dim("· not installed")}`);
|
|
107
|
+
console.log(` ${dim(`run: ${gh.detail}`)}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const agent of result.agents) {
|
|
111
|
+
if (agent.installed) {
|
|
112
|
+
console.log(` ${check} ${agent.name} ${dim(agent.version ?? "")}`);
|
|
113
|
+
} else {
|
|
114
|
+
console.log(` ${dim("·")} ${dim(agent.name)} ${dim("not found")}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (result.openrouterKey) {
|
|
119
|
+
console.log(` ${check} OpenRouter API key`);
|
|
120
|
+
} else {
|
|
121
|
+
console.log(` ${cross} OpenRouter API key ${dim("· not configured")}`);
|
|
122
|
+
console.log(` ${dim("run: newpr auth")}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
console.log("");
|
|
126
|
+
}
|
package/src/github/fetch-pr.ts
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
|
-
import type { GithubPrData, PrComment, PrCommit, PrIdentifier } from "../types/github.ts";
|
|
1
|
+
import type { GithubPrData, PrComment, PrCommit, PrIdentifier, PrState } from "../types/github.ts";
|
|
2
2
|
|
|
3
3
|
export function mapPrResponse(json: Record<string, unknown>): Omit<GithubPrData, "commits"> {
|
|
4
4
|
const user = json.user as Record<string, unknown> | undefined;
|
|
5
5
|
const base = json.base as Record<string, unknown> | undefined;
|
|
6
6
|
const head = json.head as Record<string, unknown> | undefined;
|
|
7
7
|
|
|
8
|
+
let state: PrState = "open";
|
|
9
|
+
if (json.draft) {
|
|
10
|
+
state = "draft";
|
|
11
|
+
} else if (json.merged) {
|
|
12
|
+
state = "merged";
|
|
13
|
+
} else if (json.state === "closed") {
|
|
14
|
+
state = "closed";
|
|
15
|
+
}
|
|
16
|
+
|
|
8
17
|
return {
|
|
9
18
|
number: json.number as number,
|
|
10
19
|
title: json.title as string,
|
|
11
20
|
body: (json.body as string) ?? "",
|
|
12
21
|
url: json.html_url as string,
|
|
22
|
+
state,
|
|
13
23
|
base_branch: (base?.ref as string) ?? "unknown",
|
|
14
24
|
head_branch: (head?.ref as string) ?? "unknown",
|
|
15
25
|
author: (user?.login as string) ?? "unknown",
|
package/src/history/store.ts
CHANGED
|
@@ -25,6 +25,7 @@ export function buildSessionRecord(id: string, data: NewprOutput): SessionRecord
|
|
|
25
25
|
pr_url: meta.pr_url,
|
|
26
26
|
pr_number: meta.pr_number,
|
|
27
27
|
pr_title: meta.pr_title,
|
|
28
|
+
pr_state: meta.pr_state,
|
|
28
29
|
repo: repoParts?.[1] ?? "unknown",
|
|
29
30
|
author: meta.author,
|
|
30
31
|
analyzed_at: meta.analyzed_at,
|
package/src/history/types.ts
CHANGED
package/src/llm/prompts.ts
CHANGED
|
@@ -83,12 +83,20 @@ export function buildGroupingPrompt(fileSummaries: FileSummaryInput[], ctx?: Pro
|
|
|
83
83
|
const discussionCtx = formatDiscussion(ctx);
|
|
84
84
|
|
|
85
85
|
return {
|
|
86
|
-
system: `You are an expert code reviewer. Group the following changed files by their semantic purpose.
|
|
87
|
-
Each group should have
|
|
88
|
-
|
|
86
|
+
system: `You are an expert code reviewer. Group the following changed files by their semantic purpose and provide detailed analysis for each group.
|
|
87
|
+
Each group should have:
|
|
88
|
+
- "name": descriptive group name
|
|
89
|
+
- "type": one of: feature, refactor, bugfix, chore, docs, test, config
|
|
90
|
+
- "description": what this group of changes does (1-2 sentences)
|
|
91
|
+
- "files": list of file paths
|
|
92
|
+
- "key_changes": 2-5 bullet points describing the most important specific changes (e.g. "Add JWT token validation middleware", "Replace REST calls with GraphQL queries")
|
|
93
|
+
- "risk": a brief risk assessment for this group (e.g. "Low - cosmetic changes only", "Medium - modifies auth flow, needs careful review", "High - changes database schema")
|
|
94
|
+
- "dependencies": list of other group names that this group depends on or interacts with (empty array if none)
|
|
95
|
+
|
|
96
|
+
A file MAY appear in multiple groups if it serves multiple purposes.
|
|
89
97
|
Use the commit history and PR discussion to understand which changes belong together logically.
|
|
90
|
-
Respond ONLY with a JSON array. Each element: {"name": "
|
|
91
|
-
The "
|
|
98
|
+
Respond ONLY with a JSON array. Each element: {"name": "...", "type": "...", "description": "...", "files": [...], "key_changes": [...], "risk": "...", "dependencies": [...]}.
|
|
99
|
+
The "type" value must be one of the English keywords listed above. File paths stay as-is.
|
|
92
100
|
Every file must appear in at least one group. No markdown, no explanation, just the JSON array.${langDirective(ctx?.language)}`,
|
|
93
101
|
user: `Changed files:\n${fileList}${commitCtx}${discussionCtx}`,
|
|
94
102
|
};
|
|
@@ -124,11 +132,22 @@ export function buildNarrativePrompt(
|
|
|
124
132
|
summary: PrSummary,
|
|
125
133
|
groups: FileGroup[],
|
|
126
134
|
ctx?: PromptContext,
|
|
135
|
+
fileDiffs?: Array<{ path: string; diff: string }>,
|
|
127
136
|
): PromptPair {
|
|
128
137
|
const groupDetails = groups
|
|
129
|
-
.map((g) =>
|
|
138
|
+
.map((g) => {
|
|
139
|
+
let detail = `### ${g.name} (${g.type})\n${g.description}\nFiles: ${g.files.join(", ")}`;
|
|
140
|
+
if (g.key_changes && g.key_changes.length > 0) {
|
|
141
|
+
detail += `\nKey changes:\n${g.key_changes.map((c) => `- ${c}`).join("\n")}`;
|
|
142
|
+
}
|
|
143
|
+
return detail;
|
|
144
|
+
})
|
|
130
145
|
.join("\n\n");
|
|
131
146
|
|
|
147
|
+
const diffContext = fileDiffs && fileDiffs.length > 0
|
|
148
|
+
? `\n\n--- FILE DIFFS (use these line numbers for [[line:...]] anchors) ---\n${fileDiffs.map((f) => `File: ${f.path}\n${f.diff}`).join("\n\n---\n\n")}`
|
|
149
|
+
: "";
|
|
150
|
+
|
|
132
151
|
const commitCtx = ctx?.commits ? formatCommitHistory(ctx.commits) : "";
|
|
133
152
|
const lang = ctx?.language && ctx.language !== "English" ? ctx.language : null;
|
|
134
153
|
|
|
@@ -141,10 +160,26 @@ Use the commit history and PR discussion to understand the development progressi
|
|
|
141
160
|
Use markdown formatting. Write 2-5 paragraphs. Do NOT use JSON. Write natural prose.
|
|
142
161
|
${lang ? `CRITICAL: Write the ENTIRE narrative in ${lang}. Every sentence must be in ${lang}. Do NOT use English except for code identifiers, file paths, and [[group:...]]/[[file:...]] tokens.` : "If the PR title is in a non-English language, write the narrative in that same language."}
|
|
143
162
|
|
|
144
|
-
IMPORTANT:
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
163
|
+
IMPORTANT: Use these anchor formats — they become clickable links in the UI:
|
|
164
|
+
|
|
165
|
+
1. Group: [[group:Group Name]] — renders as a clickable chip.
|
|
166
|
+
2. File: [[file:path/to/file.ts]] — renders as a clickable chip.
|
|
167
|
+
3. Line reference: [[line:path/to/file.ts#L42-L50]](descriptive text here) — the "descriptive text" becomes an underlined clickable link that opens the diff at that line. The line info itself is NOT shown to the user — only the descriptive text is visible.
|
|
168
|
+
|
|
169
|
+
RULES:
|
|
170
|
+
- Use EXACT group names and file paths from the context above.
|
|
171
|
+
- Every group MUST be referenced at least once with [[group:...]].
|
|
172
|
+
- For line references, ALWAYS use the form [[line:path#Lstart-Lend]](text). NEVER use bare [[line:...]] without (text).
|
|
173
|
+
- The (text) should be a natural description of what the code does, NOT the file name or line numbers. The reader should not see any line numbers — they just see underlined text they can click.
|
|
174
|
+
- Do NOT place [[file:...]] and [[line:...]] next to each other for the same file. Use [[line:...]] with descriptive text instead — it already opens the file.
|
|
175
|
+
- Aim for most sentences about code to have at least one [[line:...]](...) reference.
|
|
176
|
+
|
|
177
|
+
GOOD example:
|
|
178
|
+
"The [[group:Auth Flow]] group introduces session management. [[line:src/auth/session.ts#L15-L30]](The new validateToken function) handles JWT parsing, and [[line:src/auth/middleware.ts#L8-L12]](the auth middleware) invokes it on every request."
|
|
179
|
+
|
|
180
|
+
BAD example (DO NOT do this):
|
|
181
|
+
"The new validateToken function [[line:src/auth/session.ts#L15-L30]] in [[file:src/auth/session.ts]] handles JWT parsing."`,
|
|
182
|
+
user: `PR Title: ${prTitle}\n\nSummary:\n- Purpose: ${summary.purpose}\n- Scope: ${summary.scope}\n- Impact: ${summary.impact}\n- Risk: ${summary.risk_level}\n\nChange Groups:\n${groupDetails}${commitCtx}${discussionCtx}${diffContext}`,
|
|
148
183
|
};
|
|
149
184
|
}
|
|
150
185
|
|
|
@@ -187,15 +222,16 @@ export function buildEnrichedNarrativePrompt(
|
|
|
187
222
|
groups: FileGroup[],
|
|
188
223
|
exploration: ExplorationResult,
|
|
189
224
|
ctx?: PromptContext,
|
|
225
|
+
fileDiffs?: Array<{ path: string; diff: string }>,
|
|
190
226
|
): PromptPair {
|
|
191
|
-
const base = buildNarrativePrompt(prTitle, summary, groups, ctx);
|
|
227
|
+
const base = buildNarrativePrompt(prTitle, summary, groups, ctx, fileDiffs);
|
|
192
228
|
const context = formatCodebaseContext(exploration);
|
|
193
229
|
|
|
194
230
|
return {
|
|
195
231
|
system: `${base.system}
|
|
196
232
|
You have access to full codebase analysis. Use it to explain HOW the changes relate to existing code, not just WHAT changed.
|
|
197
233
|
Mention specific existing functions, modules, or patterns that are affected.
|
|
198
|
-
|
|
234
|
+
Use [[group:Name]], [[file:path]], and [[line:path#L42-L50]](descriptive text) as instructed above.`,
|
|
199
235
|
user: `${base.user}\n\n--- CODEBASE CONTEXT (from agentic exploration) ---\n${context}`,
|
|
200
236
|
};
|
|
201
237
|
}
|
|
@@ -40,12 +40,24 @@ export function parseGroups(raw: string): FileGroup[] {
|
|
|
40
40
|
? (rawType as GroupType)
|
|
41
41
|
: "chore";
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
const group: FileGroup = {
|
|
44
44
|
name: String(item.name ?? "Ungrouped"),
|
|
45
45
|
type,
|
|
46
46
|
description: String(item.description ?? ""),
|
|
47
47
|
files: Array.isArray(item.files) ? item.files.map(String) : [],
|
|
48
48
|
};
|
|
49
|
+
|
|
50
|
+
if (Array.isArray(item.key_changes) && item.key_changes.length > 0) {
|
|
51
|
+
group.key_changes = item.key_changes.map(String);
|
|
52
|
+
}
|
|
53
|
+
if (item.risk && typeof item.risk === "string") {
|
|
54
|
+
group.risk = item.risk;
|
|
55
|
+
}
|
|
56
|
+
if (Array.isArray(item.dependencies) && item.dependencies.length > 0) {
|
|
57
|
+
group.dependencies = item.dependencies.map(String);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return group;
|
|
49
61
|
});
|
|
50
62
|
}
|
|
51
63
|
|
package/src/types/github.ts
CHANGED
|
@@ -22,11 +22,14 @@ export interface PrComment {
|
|
|
22
22
|
html_url: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
export type PrState = "open" | "closed" | "merged" | "draft";
|
|
26
|
+
|
|
25
27
|
export interface GithubPrData {
|
|
26
28
|
number: number;
|
|
27
29
|
title: string;
|
|
28
30
|
body: string;
|
|
29
31
|
url: string;
|
|
32
|
+
state: PrState;
|
|
30
33
|
base_branch: string;
|
|
31
34
|
head_branch: string;
|
|
32
35
|
author: string;
|
package/src/types/output.ts
CHANGED
|
@@ -11,11 +11,14 @@ export type GroupType =
|
|
|
11
11
|
|
|
12
12
|
export type RiskLevel = "low" | "medium" | "high";
|
|
13
13
|
|
|
14
|
+
export type PrStateLabel = "open" | "closed" | "merged" | "draft";
|
|
15
|
+
|
|
14
16
|
export interface PrMeta {
|
|
15
17
|
pr_number: number;
|
|
16
18
|
pr_title: string;
|
|
17
19
|
pr_body?: string;
|
|
18
20
|
pr_url: string;
|
|
21
|
+
pr_state?: PrStateLabel;
|
|
19
22
|
base_branch: string;
|
|
20
23
|
head_branch: string;
|
|
21
24
|
author: string;
|
|
@@ -40,6 +43,9 @@ export interface FileGroup {
|
|
|
40
43
|
type: GroupType;
|
|
41
44
|
description: string;
|
|
42
45
|
files: string[];
|
|
46
|
+
key_changes?: string[];
|
|
47
|
+
risk?: string;
|
|
48
|
+
dependencies?: string[];
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
export interface FileChange {
|
package/src/web/client/App.tsx
CHANGED
|
@@ -59,7 +59,7 @@ export function App() {
|
|
|
59
59
|
}
|
|
60
60
|
}, [analysis.phase, analysis.sessionId]);
|
|
61
61
|
|
|
62
|
-
const handleAnchorClick = useCallback((kind: "group" | "file", id: string) => {
|
|
62
|
+
const handleAnchorClick = useCallback((kind: "group" | "file" | "line", id: string) => {
|
|
63
63
|
const key = `${kind}:${id}`;
|
|
64
64
|
setActiveId((prev) => prev === key ? null : key);
|
|
65
65
|
}, []);
|
|
@@ -68,7 +68,7 @@ export function App() {
|
|
|
68
68
|
if (!activeId || !analysis.result) return null;
|
|
69
69
|
const [kind, ...rest] = activeId.split(":");
|
|
70
70
|
const id = rest.join(":");
|
|
71
|
-
return resolveDetail(kind as "group" | "file", id, analysis.result.groups, analysis.result.files);
|
|
71
|
+
return resolveDetail(kind as "group" | "file" | "line", id, analysis.result.groups, analysis.result.files);
|
|
72
72
|
}, [activeId, analysis.result]);
|
|
73
73
|
|
|
74
74
|
function handleSessionSelect(id: string) {
|
|
@@ -88,6 +88,7 @@ export function App() {
|
|
|
88
88
|
<DetailPane target={detailTarget} sessionId={diffSessionId} prUrl={prUrl} onClose={() => setActiveId(null)} />
|
|
89
89
|
) : null;
|
|
90
90
|
|
|
91
|
+
const [activeTab, setActiveTab] = useState(() => getUrlParam("tab") ?? "story");
|
|
91
92
|
const chatState = useChatState(analysis.phase === "done" ? diffSessionId : null);
|
|
92
93
|
|
|
93
94
|
const anchorItems = useMemo<AnchorItem[]>(() => {
|
|
@@ -112,7 +113,7 @@ export function App() {
|
|
|
112
113
|
onSessionSelect={handleSessionSelect}
|
|
113
114
|
onNewAnalysis={handleNewAnalysis}
|
|
114
115
|
detailPanel={detailPanel}
|
|
115
|
-
bottomBar={analysis.phase === "done" ? <ChatInput /> : undefined}
|
|
116
|
+
bottomBar={analysis.phase === "done" && activeTab === "story" ? <ChatInput /> : undefined}
|
|
116
117
|
activeSessionId={diffSessionId}
|
|
117
118
|
>
|
|
118
119
|
{analysis.phase === "idle" && (
|
|
@@ -136,6 +137,7 @@ export function App() {
|
|
|
136
137
|
onAnchorClick={handleAnchorClick}
|
|
137
138
|
cartoonEnabled={features.cartoon}
|
|
138
139
|
sessionId={diffSessionId}
|
|
140
|
+
onTabChange={setActiveTab}
|
|
139
141
|
/>
|
|
140
142
|
)}
|
|
141
143
|
{analysis.phase === "error" && (
|
|
@@ -24,6 +24,13 @@ const RISK_DOT: Record<string, string> = {
|
|
|
24
24
|
critical: "bg-red-600",
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
+
const STATE_LABEL: Record<string, { text: string; class: string }> = {
|
|
28
|
+
open: { text: "Open", class: "text-green-600 dark:text-green-400" },
|
|
29
|
+
merged: { text: "Merged", class: "text-purple-600 dark:text-purple-400" },
|
|
30
|
+
closed: { text: "Closed", class: "text-red-600 dark:text-red-400" },
|
|
31
|
+
draft: { text: "Draft", class: "text-neutral-500" },
|
|
32
|
+
};
|
|
33
|
+
|
|
27
34
|
function formatTimeAgo(isoDate: string): string {
|
|
28
35
|
const diff = Date.now() - new Date(isoDate).getTime();
|
|
29
36
|
const minutes = Math.floor(diff / 60000);
|
|
@@ -149,6 +156,12 @@ export function AppShell({
|
|
|
149
156
|
<div className="flex items-center gap-1 mt-1 text-[10px] text-muted-foreground/50">
|
|
150
157
|
<span className="font-mono truncate">{s.repo.split("/").pop()}</span>
|
|
151
158
|
<span className="font-mono">#{s.pr_number}</span>
|
|
159
|
+
{s.pr_state && STATE_LABEL[s.pr_state] && (
|
|
160
|
+
<>
|
|
161
|
+
<span className="text-muted-foreground/20 mx-0.5">·</span>
|
|
162
|
+
<span className={STATE_LABEL[s.pr_state]!.class}>{STATE_LABEL[s.pr_state]!.text}</span>
|
|
163
|
+
</>
|
|
164
|
+
)}
|
|
152
165
|
<span className="text-muted-foreground/20 mx-0.5">·</span>
|
|
153
166
|
<span>{formatTimeAgo(s.analyzed_at)}</span>
|
|
154
167
|
</div>
|
|
@@ -243,7 +243,7 @@ function AssistantMessage({ segments, activeToolName, isStreaming, onAnchorClick
|
|
|
243
243
|
segments: ChatSegment[];
|
|
244
244
|
activeToolName?: string;
|
|
245
245
|
isStreaming?: boolean;
|
|
246
|
-
onAnchorClick?: (kind: "group" | "file", id: string) => void;
|
|
246
|
+
onAnchorClick?: (kind: "group" | "file" | "line", id: string) => void;
|
|
247
247
|
activeId?: string | null;
|
|
248
248
|
}) {
|
|
249
249
|
const hasContent = segments.some((s) => s.type === "text" && s.content);
|
|
@@ -280,7 +280,7 @@ function AssistantMessage({ segments, activeToolName, isStreaming, onAnchorClick
|
|
|
280
280
|
}
|
|
281
281
|
|
|
282
282
|
export function ChatMessages({ onAnchorClick, activeId }: {
|
|
283
|
-
onAnchorClick?: (kind: "group" | "file", id: string) => void;
|
|
283
|
+
onAnchorClick?: (kind: "group" | "file" | "line", id: string) => void;
|
|
284
284
|
activeId?: string | null;
|
|
285
285
|
}) {
|
|
286
286
|
const ctx = useContext(ChatContext);
|