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
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { NewprOutput } from "../../../types/output.ts";
|
|
2
|
+
import { Markdown } from "../components/Markdown.tsx";
|
|
3
|
+
|
|
4
|
+
export function StoryPanel({
|
|
5
|
+
data,
|
|
6
|
+
activeId,
|
|
7
|
+
onAnchorClick,
|
|
8
|
+
}: {
|
|
9
|
+
data: NewprOutput;
|
|
10
|
+
activeId: string | null;
|
|
11
|
+
onAnchorClick: (kind: "group" | "file", id: string) => void;
|
|
12
|
+
}) {
|
|
13
|
+
const { summary, groups, narrative } = data;
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<div className="pt-4 space-y-5">
|
|
17
|
+
<div className="space-y-3">
|
|
18
|
+
<p className="text-xs text-muted-foreground leading-relaxed">{summary.purpose}</p>
|
|
19
|
+
<div className="grid grid-cols-2 gap-4">
|
|
20
|
+
<div>
|
|
21
|
+
<span className="text-[10px] font-medium text-muted-foreground/60 uppercase tracking-wider">Scope</span>
|
|
22
|
+
<p className="text-xs text-muted-foreground mt-0.5">{summary.scope}</p>
|
|
23
|
+
</div>
|
|
24
|
+
<div>
|
|
25
|
+
<span className="text-[10px] font-medium text-muted-foreground/60 uppercase tracking-wider">Impact</span>
|
|
26
|
+
<p className="text-xs text-muted-foreground mt-0.5">{summary.impact}</p>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
<div className="flex flex-wrap gap-1.5">
|
|
30
|
+
{groups.map((g) => (
|
|
31
|
+
<button
|
|
32
|
+
key={g.name}
|
|
33
|
+
type="button"
|
|
34
|
+
onClick={() => onAnchorClick("group", g.name)}
|
|
35
|
+
className={`text-[11px] px-2 py-0.5 rounded-full font-medium transition-colors ${
|
|
36
|
+
activeId === `group:${g.name}`
|
|
37
|
+
? "bg-blue-500/20 text-blue-500 dark:text-blue-300 ring-1 ring-blue-500/40"
|
|
38
|
+
: "bg-muted hover:bg-muted/80"
|
|
39
|
+
}`}
|
|
40
|
+
>
|
|
41
|
+
{g.name}
|
|
42
|
+
</button>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<div className="border-t pt-5">
|
|
48
|
+
<Markdown onAnchorClick={onAnchorClick} activeId={activeId}>
|
|
49
|
+
{narrative}
|
|
50
|
+
</Markdown>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { PrSummary } from "../../../types/output.ts";
|
|
2
|
+
|
|
3
|
+
export function SummaryPanel({ summary }: { summary: PrSummary }) {
|
|
4
|
+
return (
|
|
5
|
+
<div className="pt-6 divide-y">
|
|
6
|
+
<div className="pb-6">
|
|
7
|
+
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-3">Purpose</h3>
|
|
8
|
+
<p className="text-sm leading-relaxed">{summary.purpose}</p>
|
|
9
|
+
</div>
|
|
10
|
+
<div className="py-6">
|
|
11
|
+
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-3">Scope</h3>
|
|
12
|
+
<p className="text-sm leading-relaxed">{summary.scope}</p>
|
|
13
|
+
</div>
|
|
14
|
+
<div className="pt-6">
|
|
15
|
+
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-3">Impact</h3>
|
|
16
|
+
<p className="text-sm leading-relaxed">{summary.impact}</p>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Slot } from "@radix-ui/react-slot";
|
|
3
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
4
|
+
import { cn } from "../../lib/utils.ts";
|
|
5
|
+
|
|
6
|
+
const buttonVariants = cva(
|
|
7
|
+
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
|
8
|
+
{
|
|
9
|
+
variants: {
|
|
10
|
+
variant: {
|
|
11
|
+
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
|
12
|
+
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
|
13
|
+
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
|
14
|
+
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
|
15
|
+
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
16
|
+
link: "text-primary underline-offset-4 hover:underline",
|
|
17
|
+
},
|
|
18
|
+
size: {
|
|
19
|
+
default: "h-9 px-4 py-2",
|
|
20
|
+
sm: "h-8 rounded-md px-3 text-xs",
|
|
21
|
+
lg: "h-10 rounded-md px-8",
|
|
22
|
+
icon: "h-9 w-9",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
defaultVariants: {
|
|
26
|
+
variant: "default",
|
|
27
|
+
size: "default",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
export interface ButtonProps
|
|
33
|
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
34
|
+
VariantProps<typeof buttonVariants> {
|
|
35
|
+
asChild?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
39
|
+
({ className, variant, size, asChild = false, ...props }, ref) => {
|
|
40
|
+
const Comp = asChild ? Slot : "button";
|
|
41
|
+
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
Button.displayName = "Button";
|
|
45
|
+
|
|
46
|
+
export { Button, buttonVariants };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "../../lib/utils.ts";
|
|
3
|
+
|
|
4
|
+
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
5
|
+
({ className, ...props }, ref) => (
|
|
6
|
+
<div ref={ref} className={cn("rounded-xl border bg-card text-card-foreground shadow-sm", className)} {...props} />
|
|
7
|
+
),
|
|
8
|
+
);
|
|
9
|
+
Card.displayName = "Card";
|
|
10
|
+
|
|
11
|
+
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
12
|
+
({ className, ...props }, ref) => (
|
|
13
|
+
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
|
14
|
+
),
|
|
15
|
+
);
|
|
16
|
+
CardHeader.displayName = "CardHeader";
|
|
17
|
+
|
|
18
|
+
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
19
|
+
({ className, ...props }, ref) => (
|
|
20
|
+
<div ref={ref} className={cn("font-semibold leading-none tracking-tight", className)} {...props} />
|
|
21
|
+
),
|
|
22
|
+
);
|
|
23
|
+
CardTitle.displayName = "CardTitle";
|
|
24
|
+
|
|
25
|
+
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
26
|
+
({ className, ...props }, ref) => (
|
|
27
|
+
<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
|
28
|
+
),
|
|
29
|
+
);
|
|
30
|
+
CardDescription.displayName = "CardDescription";
|
|
31
|
+
|
|
32
|
+
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
33
|
+
({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />,
|
|
34
|
+
);
|
|
35
|
+
CardContent.displayName = "CardContent";
|
|
36
|
+
|
|
37
|
+
export { Card, CardHeader, CardTitle, CardDescription, CardContent };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
|
3
|
+
import { cn } from "../../lib/utils.ts";
|
|
4
|
+
|
|
5
|
+
const ScrollArea = React.forwardRef<
|
|
6
|
+
React.ComponentRef<typeof ScrollAreaPrimitive.Root>,
|
|
7
|
+
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
|
8
|
+
>(({ className, children, ...props }, ref) => (
|
|
9
|
+
<ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
|
|
10
|
+
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
|
11
|
+
{children}
|
|
12
|
+
</ScrollAreaPrimitive.Viewport>
|
|
13
|
+
<ScrollBar />
|
|
14
|
+
<ScrollAreaPrimitive.Corner />
|
|
15
|
+
</ScrollAreaPrimitive.Root>
|
|
16
|
+
));
|
|
17
|
+
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
|
18
|
+
|
|
19
|
+
const ScrollBar = React.forwardRef<
|
|
20
|
+
React.ComponentRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
|
21
|
+
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
|
22
|
+
>(({ className, orientation = "vertical", ...props }, ref) => (
|
|
23
|
+
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
|
24
|
+
ref={ref}
|
|
25
|
+
orientation={orientation}
|
|
26
|
+
className={cn(
|
|
27
|
+
"flex touch-none select-none transition-colors",
|
|
28
|
+
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
|
|
29
|
+
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
|
30
|
+
className,
|
|
31
|
+
)}
|
|
32
|
+
{...props}
|
|
33
|
+
>
|
|
34
|
+
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
|
35
|
+
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
|
36
|
+
));
|
|
37
|
+
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
|
38
|
+
|
|
39
|
+
export { ScrollArea, ScrollBar };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
|
3
|
+
import { cn } from "../../lib/utils.ts";
|
|
4
|
+
|
|
5
|
+
const Tabs = TabsPrimitive.Root;
|
|
6
|
+
|
|
7
|
+
const TabsList = React.forwardRef<
|
|
8
|
+
React.ComponentRef<typeof TabsPrimitive.List>,
|
|
9
|
+
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
|
10
|
+
>(({ className, ...props }, ref) => (
|
|
11
|
+
<TabsPrimitive.List
|
|
12
|
+
ref={ref}
|
|
13
|
+
className={cn(
|
|
14
|
+
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
|
15
|
+
className,
|
|
16
|
+
)}
|
|
17
|
+
{...props}
|
|
18
|
+
/>
|
|
19
|
+
));
|
|
20
|
+
TabsList.displayName = TabsPrimitive.List.displayName;
|
|
21
|
+
|
|
22
|
+
const TabsTrigger = React.forwardRef<
|
|
23
|
+
React.ComponentRef<typeof TabsPrimitive.Trigger>,
|
|
24
|
+
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
|
25
|
+
>(({ className, ...props }, ref) => (
|
|
26
|
+
<TabsPrimitive.Trigger
|
|
27
|
+
ref={ref}
|
|
28
|
+
className={cn(
|
|
29
|
+
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
|
30
|
+
className,
|
|
31
|
+
)}
|
|
32
|
+
{...props}
|
|
33
|
+
/>
|
|
34
|
+
));
|
|
35
|
+
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
|
36
|
+
|
|
37
|
+
const TabsContent = React.forwardRef<
|
|
38
|
+
React.ComponentRef<typeof TabsPrimitive.Content>,
|
|
39
|
+
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
|
40
|
+
>(({ className, ...props }, ref) => (
|
|
41
|
+
<TabsPrimitive.Content
|
|
42
|
+
ref={ref}
|
|
43
|
+
className={cn(
|
|
44
|
+
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
45
|
+
className,
|
|
46
|
+
)}
|
|
47
|
+
{...props}
|
|
48
|
+
/>
|
|
49
|
+
));
|
|
50
|
+
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
|
51
|
+
|
|
52
|
+
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" class="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>newpr</title>
|
|
7
|
+
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
|
|
8
|
+
<script>document.head.appendChild(Object.assign(document.createElement("link"),{rel:"stylesheet",href:"/styles.css"}))</script>
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<div id="root"></div>
|
|
12
|
+
<script type="module" src="./client/main.tsx"></script>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import type { NewprConfig } from "../../types/config.ts";
|
|
2
|
+
import { DEFAULT_CONFIG } from "../../types/config.ts";
|
|
3
|
+
import { listSessions, loadSession } from "../../history/store.ts";
|
|
4
|
+
import { writeStoredConfig, type StoredConfig } from "../../config/store.ts";
|
|
5
|
+
import { startAnalysis, getSession, cancelAnalysis, subscribe } from "./session-manager.ts";
|
|
6
|
+
|
|
7
|
+
function json(data: unknown, status = 200): Response {
|
|
8
|
+
return new Response(JSON.stringify(data), {
|
|
9
|
+
status,
|
|
10
|
+
headers: { "Content-Type": "application/json" },
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createRoutes(token: string, config: NewprConfig) {
|
|
15
|
+
return {
|
|
16
|
+
"POST /api/analysis": async (req: Request) => {
|
|
17
|
+
const body = await req.json() as { pr: string };
|
|
18
|
+
if (!body.pr) return json({ error: "Missing 'pr' field" }, 400);
|
|
19
|
+
|
|
20
|
+
const result = startAnalysis(body.pr, token, config);
|
|
21
|
+
if ("error" in result) return json({ error: result.error }, result.status);
|
|
22
|
+
|
|
23
|
+
return json({
|
|
24
|
+
sessionId: result.sessionId,
|
|
25
|
+
eventsUrl: `/api/analysis/${result.sessionId}/events`,
|
|
26
|
+
});
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
"GET /api/analysis/:id": (req: Request) => {
|
|
30
|
+
const url = new URL(req.url);
|
|
31
|
+
const id = url.pathname.split("/").pop()!;
|
|
32
|
+
const session = getSession(id);
|
|
33
|
+
if (!session) return json({ error: "Session not found" }, 404);
|
|
34
|
+
|
|
35
|
+
return json({
|
|
36
|
+
id: session.id,
|
|
37
|
+
status: session.status,
|
|
38
|
+
startedAt: session.startedAt,
|
|
39
|
+
finishedAt: session.finishedAt,
|
|
40
|
+
error: session.error,
|
|
41
|
+
result: session.result,
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
"GET /api/analysis/:id/events": (req: Request) => {
|
|
46
|
+
const url = new URL(req.url);
|
|
47
|
+
const segments = url.pathname.split("/");
|
|
48
|
+
const id = segments[segments.length - 2]!;
|
|
49
|
+
|
|
50
|
+
const session = getSession(id);
|
|
51
|
+
if (!session) return json({ error: "Session not found" }, 404);
|
|
52
|
+
|
|
53
|
+
const stream = new ReadableStream({
|
|
54
|
+
start(controller) {
|
|
55
|
+
const encoder = new TextEncoder();
|
|
56
|
+
const send = (eventType: string, data: string) => {
|
|
57
|
+
controller.enqueue(encoder.encode(`event: ${eventType}\ndata: ${data}\n\n`));
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const unsubscribe = subscribe(id, (event) => {
|
|
61
|
+
try {
|
|
62
|
+
if ("type" in event && event.type === "done") {
|
|
63
|
+
send("done", JSON.stringify({}));
|
|
64
|
+
controller.close();
|
|
65
|
+
} else if ("type" in event && event.type === "error") {
|
|
66
|
+
send("analysis_error", JSON.stringify({ message: event.data ?? "Unknown error" }));
|
|
67
|
+
controller.close();
|
|
68
|
+
} else {
|
|
69
|
+
send("progress", JSON.stringify(event));
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
controller.close();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if (!unsubscribe) {
|
|
77
|
+
send("analysis_error", JSON.stringify({ message: "Session not found" }));
|
|
78
|
+
controller.close();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
req.signal.addEventListener("abort", () => {
|
|
82
|
+
unsubscribe?.();
|
|
83
|
+
try { controller.close(); } catch {}
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return new Response(stream, {
|
|
89
|
+
headers: {
|
|
90
|
+
"Content-Type": "text/event-stream",
|
|
91
|
+
"Cache-Control": "no-cache",
|
|
92
|
+
"Connection": "keep-alive",
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
"POST /api/analysis/:id/cancel": (req: Request) => {
|
|
98
|
+
const url = new URL(req.url);
|
|
99
|
+
const segments = url.pathname.split("/");
|
|
100
|
+
const id = segments[segments.length - 2]!;
|
|
101
|
+
const ok = cancelAnalysis(id);
|
|
102
|
+
return json({ ok });
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
"GET /api/sessions": async () => {
|
|
106
|
+
const sessions = await listSessions(50);
|
|
107
|
+
return json(sessions);
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
"GET /api/sessions/:id": async (req: Request) => {
|
|
111
|
+
const url = new URL(req.url);
|
|
112
|
+
const id = url.pathname.split("/").pop()!;
|
|
113
|
+
const data = await loadSession(id);
|
|
114
|
+
if (!data) return json({ error: "Session not found" }, 404);
|
|
115
|
+
return json(data);
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
"GET /api/me": async () => {
|
|
119
|
+
try {
|
|
120
|
+
const res = await fetch("https://api.github.com/user", {
|
|
121
|
+
headers: {
|
|
122
|
+
Authorization: `token ${token}`,
|
|
123
|
+
Accept: "application/vnd.github.v3+json",
|
|
124
|
+
"User-Agent": "newpr-cli",
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
if (!res.ok) return json({ login: null });
|
|
128
|
+
const user = await res.json() as Record<string, unknown>;
|
|
129
|
+
return json({
|
|
130
|
+
login: user.login as string,
|
|
131
|
+
avatar_url: user.avatar_url as string,
|
|
132
|
+
html_url: user.html_url as string,
|
|
133
|
+
name: (user.name as string) ?? null,
|
|
134
|
+
});
|
|
135
|
+
} catch {
|
|
136
|
+
return json({ login: null });
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
"GET /api/config": async () => {
|
|
141
|
+
return json({
|
|
142
|
+
model: config.model,
|
|
143
|
+
agent: config.agent ?? null,
|
|
144
|
+
language: config.language,
|
|
145
|
+
max_files: config.max_files,
|
|
146
|
+
timeout: config.timeout,
|
|
147
|
+
concurrency: config.concurrency,
|
|
148
|
+
has_api_key: !!config.openrouter_api_key,
|
|
149
|
+
has_github_token: !!token,
|
|
150
|
+
defaults: {
|
|
151
|
+
model: DEFAULT_CONFIG.model,
|
|
152
|
+
language: DEFAULT_CONFIG.language,
|
|
153
|
+
max_files: DEFAULT_CONFIG.max_files,
|
|
154
|
+
timeout: DEFAULT_CONFIG.timeout,
|
|
155
|
+
concurrency: DEFAULT_CONFIG.concurrency,
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
"PUT /api/config": async (req: Request) => {
|
|
161
|
+
const body = await req.json() as Partial<StoredConfig>;
|
|
162
|
+
const update: StoredConfig = {};
|
|
163
|
+
|
|
164
|
+
if (body.openrouter_api_key !== undefined) update.openrouter_api_key = body.openrouter_api_key;
|
|
165
|
+
if (body.model !== undefined) {
|
|
166
|
+
update.model = body.model;
|
|
167
|
+
config.model = body.model;
|
|
168
|
+
}
|
|
169
|
+
if (body.agent !== undefined) {
|
|
170
|
+
const val = body.agent as string;
|
|
171
|
+
if (val === "claude" || val === "opencode" || val === "codex") {
|
|
172
|
+
update.agent = val;
|
|
173
|
+
config.agent = val;
|
|
174
|
+
} else if (val === "" || val === "auto") {
|
|
175
|
+
update.agent = undefined;
|
|
176
|
+
config.agent = undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (body.language !== undefined) {
|
|
180
|
+
update.language = body.language;
|
|
181
|
+
config.language = body.language === "auto"
|
|
182
|
+
? (await import("../../config/index.ts")).detectLanguage()
|
|
183
|
+
: body.language;
|
|
184
|
+
}
|
|
185
|
+
if (body.max_files !== undefined) {
|
|
186
|
+
update.max_files = body.max_files;
|
|
187
|
+
config.max_files = body.max_files;
|
|
188
|
+
}
|
|
189
|
+
if (body.timeout !== undefined) {
|
|
190
|
+
update.timeout = body.timeout;
|
|
191
|
+
config.timeout = body.timeout;
|
|
192
|
+
}
|
|
193
|
+
if (body.concurrency !== undefined) {
|
|
194
|
+
update.concurrency = body.concurrency;
|
|
195
|
+
config.concurrency = body.concurrency;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
await writeStoredConfig(update);
|
|
199
|
+
return json({ ok: true });
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { NewprConfig } from "../../types/config.ts";
|
|
2
|
+
import type { NewprOutput } from "../../types/output.ts";
|
|
3
|
+
import type { ProgressEvent } from "../../analyzer/progress.ts";
|
|
4
|
+
import { analyzePr } from "../../analyzer/pipeline.ts";
|
|
5
|
+
import { parsePrInput } from "../../github/parse-pr.ts";
|
|
6
|
+
import { saveSession } from "../../history/store.ts";
|
|
7
|
+
|
|
8
|
+
type SessionStatus = "running" | "done" | "error" | "canceled";
|
|
9
|
+
|
|
10
|
+
interface AnalysisSession {
|
|
11
|
+
id: string;
|
|
12
|
+
status: SessionStatus;
|
|
13
|
+
events: ProgressEvent[];
|
|
14
|
+
result?: NewprOutput;
|
|
15
|
+
error?: string;
|
|
16
|
+
startedAt: number;
|
|
17
|
+
finishedAt?: number;
|
|
18
|
+
abortController: AbortController;
|
|
19
|
+
subscribers: Set<(event: ProgressEvent | { type: "done" | "error"; data?: string }) => void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const sessions = new Map<string, AnalysisSession>();
|
|
23
|
+
const MAX_CONCURRENT = 4;
|
|
24
|
+
|
|
25
|
+
function generateId(): string {
|
|
26
|
+
return `s_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function runningCount(): number {
|
|
30
|
+
let count = 0;
|
|
31
|
+
for (const s of sessions.values()) {
|
|
32
|
+
if (s.status === "running") count++;
|
|
33
|
+
}
|
|
34
|
+
return count;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getSession(id: string): AnalysisSession | undefined {
|
|
38
|
+
return sessions.get(id);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function startAnalysis(
|
|
42
|
+
prInput: string,
|
|
43
|
+
token: string,
|
|
44
|
+
config: NewprConfig,
|
|
45
|
+
): { sessionId: string } | { error: string; status: number } {
|
|
46
|
+
if (runningCount() >= MAX_CONCURRENT) {
|
|
47
|
+
return { error: "Too many concurrent analyses. Try again later.", status: 429 };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const id = generateId();
|
|
51
|
+
const abortController = new AbortController();
|
|
52
|
+
|
|
53
|
+
const session: AnalysisSession = {
|
|
54
|
+
id,
|
|
55
|
+
status: "running",
|
|
56
|
+
events: [],
|
|
57
|
+
startedAt: Date.now(),
|
|
58
|
+
abortController,
|
|
59
|
+
subscribers: new Set(),
|
|
60
|
+
};
|
|
61
|
+
sessions.set(id, session);
|
|
62
|
+
|
|
63
|
+
runPipeline(session, prInput, token, config);
|
|
64
|
+
|
|
65
|
+
return { sessionId: id };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function runPipeline(
|
|
69
|
+
session: AnalysisSession,
|
|
70
|
+
prInput: string,
|
|
71
|
+
token: string,
|
|
72
|
+
config: NewprConfig,
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
try {
|
|
75
|
+
const pr = parsePrInput(prInput);
|
|
76
|
+
const result = await analyzePr({
|
|
77
|
+
pr,
|
|
78
|
+
token,
|
|
79
|
+
config,
|
|
80
|
+
preferredAgent: config.agent,
|
|
81
|
+
onProgress: (event: ProgressEvent) => {
|
|
82
|
+
const stamped = { ...event, timestamp: event.timestamp ?? Date.now() };
|
|
83
|
+
session.events.push(stamped);
|
|
84
|
+
for (const sub of session.subscribers) {
|
|
85
|
+
sub(stamped);
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
session.status = "done";
|
|
91
|
+
session.result = result;
|
|
92
|
+
session.finishedAt = Date.now();
|
|
93
|
+
|
|
94
|
+
for (const sub of session.subscribers) {
|
|
95
|
+
sub({ type: "done" });
|
|
96
|
+
}
|
|
97
|
+
session.subscribers.clear();
|
|
98
|
+
|
|
99
|
+
await saveSession(result).catch(() => {});
|
|
100
|
+
} catch (err) {
|
|
101
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
102
|
+
session.status = "error";
|
|
103
|
+
session.error = msg;
|
|
104
|
+
session.finishedAt = Date.now();
|
|
105
|
+
|
|
106
|
+
for (const sub of session.subscribers) {
|
|
107
|
+
sub({ type: "error", data: msg });
|
|
108
|
+
}
|
|
109
|
+
session.subscribers.clear();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function cancelAnalysis(id: string): boolean {
|
|
114
|
+
const session = sessions.get(id);
|
|
115
|
+
if (!session || session.status !== "running") return false;
|
|
116
|
+
session.abortController.abort();
|
|
117
|
+
session.status = "canceled";
|
|
118
|
+
session.finishedAt = Date.now();
|
|
119
|
+
session.subscribers.clear();
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function subscribe(
|
|
124
|
+
id: string,
|
|
125
|
+
callback: (event: ProgressEvent | { type: "done" | "error"; data?: string }) => void,
|
|
126
|
+
): (() => void) | null {
|
|
127
|
+
const session = sessions.get(id);
|
|
128
|
+
if (!session) return null;
|
|
129
|
+
|
|
130
|
+
for (const past of session.events) {
|
|
131
|
+
callback(past);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (session.status === "done") {
|
|
135
|
+
callback({ type: "done" });
|
|
136
|
+
return () => {};
|
|
137
|
+
}
|
|
138
|
+
if (session.status === "error") {
|
|
139
|
+
callback({ type: "error", data: session.error });
|
|
140
|
+
return () => {};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
session.subscribers.add(callback);
|
|
144
|
+
return () => {
|
|
145
|
+
session.subscribers.delete(callback);
|
|
146
|
+
};
|
|
147
|
+
}
|