newpr 1.0.2 → 1.0.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "newpr",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "AI-powered large PR review tool - understand PRs with 1000+ lines of changes",
5
5
  "module": "src/cli/index.ts",
6
6
  "type": "module",
@@ -46,9 +46,9 @@ export async function publishStack(input: PublishInput): Promise<StackPublishRes
46
46
  ? `[${order}/${total}] ${gc.pr_title}`
47
47
  : `[Stack ${order}/${total}] ${gc.group_id}`;
48
48
 
49
- const body = buildPrBody(gc.group_id, order, total, exec_result, pr_meta);
49
+ const placeholder = buildPlaceholderBody(gc.group_id, order, total, pr_meta);
50
50
 
51
- const prResult = await Bun.$`gh pr create --repo ${ghRepo} --base ${prBase} --head ${gc.branch_name} --title ${title} --body ${body} --draft`.quiet().nothrow();
51
+ const prResult = await Bun.$`gh pr create --repo ${ghRepo} --base ${prBase} --head ${gc.branch_name} --title ${title} --body ${placeholder} --draft`.quiet().nothrow();
52
52
 
53
53
  if (prResult.exitCode === 0) {
54
54
  const prUrl = prResult.stdout.toString().trim();
@@ -68,29 +68,84 @@ export async function publishStack(input: PublishInput): Promise<StackPublishRes
68
68
  }
69
69
  }
70
70
 
71
+ await updatePrBodies(ghRepo, prs, pr_meta);
72
+
71
73
  return { branches, prs };
72
74
  }
73
75
 
74
- function buildPrBody(
76
+ async function updatePrBodies(ghRepo: string, prs: PrInfo[], prMeta: PrMeta): Promise<void> {
77
+ if (prs.length === 0) return;
78
+
79
+ for (let i = 0; i < prs.length; i++) {
80
+ const pr = prs[i]!;
81
+ const body = buildFullBody(pr, i, prs, prMeta);
82
+
83
+ await Bun.$`gh pr edit ${pr.number} --repo ${ghRepo} --body ${body}`.quiet().nothrow();
84
+ }
85
+ }
86
+
87
+ function buildPlaceholderBody(
75
88
  groupId: string,
76
89
  order: number,
77
90
  total: number,
78
- _execResult: StackExecResult,
79
91
  prMeta: PrMeta,
80
92
  ): string {
81
- const prevPr = order > 1 ? `Previous: Stack ${order - 1}/${total}` : "Previous: (base branch)";
82
- const nextPr = order < total ? `Next: Stack ${order + 1}/${total}` : "Next: (top of stack)";
83
-
84
- const lines = [
85
- `> This is part of a stacked PR chain created by [newpr](${prMeta.pr_url})`,
86
- `>`,
87
- `> **Stack order**: ${order}/${total}`,
88
- `> **${prevPr}** | **${nextPr}**`,
93
+ return [
94
+ `> This is part of a stacked PR chain created by [newpr](https://github.com/jiwonMe/newpr).`,
95
+ `> Stack order: ${order}/${total} — body will be updated with links shortly.`,
89
96
  ``,
90
97
  `## ${groupId}`,
91
98
  ``,
92
99
  `*From PR #${prMeta.pr_number}: ${prMeta.pr_title}*`,
93
- ];
100
+ ].join("\n");
101
+ }
102
+
103
+ function buildFullBody(
104
+ current: PrInfo,
105
+ index: number,
106
+ allPrs: PrInfo[],
107
+ prMeta: PrMeta,
108
+ ): string {
109
+ const total = allPrs.length;
110
+ const order = index + 1;
111
+
112
+ const stackTable = allPrs.map((pr, i) => {
113
+ const num = i + 1;
114
+ const isCurrent = i === index;
115
+ const marker = isCurrent ? "👉" : statusEmoji(i, index);
116
+ const link = `[#${pr.number}](${pr.url})`;
117
+ const titleText = pr.title.replace(/^\[\d+\/\d+\]\s*/, "");
118
+ return `| ${marker} | ${num}/${total} | ${link} | ${titleText} |`;
119
+ }).join("\n");
120
+
121
+ const prev = index > 0
122
+ ? `⬅️ Previous: [#${allPrs[index - 1]!.number}](${allPrs[index - 1]!.url})`
123
+ : "⬅️ Previous: base branch";
124
+ const next = index < total - 1
125
+ ? `➡️ Next: [#${allPrs[index + 1]!.number}](${allPrs[index + 1]!.url})`
126
+ : "➡️ Next: top of stack";
127
+
128
+ return [
129
+ `> **Stack ${order}/${total}** — This PR is part of a stacked PR chain created by [newpr](https://github.com/jiwonMe/newpr).`,
130
+ `> Source: #${prMeta.pr_number} ${prMeta.pr_title}`,
131
+ ``,
132
+ `### 📚 Stack Navigation`,
133
+ ``,
134
+ `| | Order | PR | Title |`,
135
+ `|---|---|---|---|`,
136
+ stackTable,
137
+ ``,
138
+ `${prev} | ${next}`,
139
+ ``,
140
+ `---`,
141
+ ``,
142
+ `## ${current.group_id}`,
143
+ ``,
144
+ `*From PR [#${prMeta.pr_number}](${prMeta.pr_url}): ${prMeta.pr_title}*`,
145
+ ].join("\n");
146
+ }
94
147
 
95
- return lines.join("\n");
148
+ function statusEmoji(prIndex: number, currentIndex: number): string {
149
+ if (prIndex < currentIndex) return "✅";
150
+ return "⬜";
96
151
  }
@@ -0,0 +1,115 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { mkdirSync } from "node:fs";
5
+
6
+ const GA_ID = "G-L3SL6T6JQ1";
7
+ const GA_SECRET = "Sier1nbXS2-eX2TR3j1kZQ";
8
+ const MP_ENDPOINT = `https://www.google-analytics.com/mp/collect?measurement_id=${GA_ID}&api_secret=${GA_SECRET}`;
9
+
10
+ const CONFIG_DIR = join(homedir(), ".newpr");
11
+ const TELEMETRY_FILE = join(CONFIG_DIR, "telemetry.json");
12
+
13
+ interface TelemetryConfig {
14
+ client_id: string;
15
+ consent: "granted" | "denied" | "pending";
16
+ }
17
+
18
+ let config: TelemetryConfig | null = null;
19
+
20
+ function ensureDir(): void {
21
+ mkdirSync(CONFIG_DIR, { recursive: true });
22
+ }
23
+
24
+ async function loadConfig(): Promise<TelemetryConfig> {
25
+ if (config) return config;
26
+ try {
27
+ const file = Bun.file(TELEMETRY_FILE);
28
+ if (await file.exists()) {
29
+ const data = JSON.parse(await file.text()) as Partial<TelemetryConfig>;
30
+ config = {
31
+ client_id: data.client_id || randomUUID(),
32
+ consent: data.consent === "granted" || data.consent === "denied" ? data.consent : "pending",
33
+ };
34
+ } else {
35
+ config = { client_id: randomUUID(), consent: "pending" };
36
+ }
37
+ } catch {
38
+ config = { client_id: randomUUID(), consent: "pending" };
39
+ }
40
+ await saveConfig();
41
+ return config;
42
+ }
43
+
44
+ async function saveConfig(): Promise<void> {
45
+ if (!config) return;
46
+ ensureDir();
47
+ await Bun.write(TELEMETRY_FILE, `${JSON.stringify(config, null, 2)}\n`);
48
+ }
49
+
50
+ export async function getTelemetryConsent(): Promise<"granted" | "denied" | "pending"> {
51
+ const cfg = await loadConfig();
52
+ return cfg.consent;
53
+ }
54
+
55
+ export async function setTelemetryConsent(consent: "granted" | "denied"): Promise<void> {
56
+ const cfg = await loadConfig();
57
+ cfg.consent = consent;
58
+ await saveConfig();
59
+ }
60
+
61
+ async function sendEvents(events: Array<{ name: string; params?: Record<string, string | number | boolean> }>): Promise<void> {
62
+ const cfg = await loadConfig();
63
+ if (cfg.consent !== "granted") return;
64
+
65
+ const body = {
66
+ client_id: cfg.client_id,
67
+ events,
68
+ };
69
+
70
+ try {
71
+ await fetch(MP_ENDPOINT, {
72
+ method: "POST",
73
+ headers: { "Content-Type": "application/json" },
74
+ body: JSON.stringify(body),
75
+ });
76
+ } catch {
77
+ // Fire-and-forget — network failures are silent
78
+ }
79
+ }
80
+
81
+ function track(name: string, params?: Record<string, string | number | boolean>): void {
82
+ sendEvents([{ name, params }]);
83
+ }
84
+
85
+ export const telemetry = {
86
+ serverStarted: (version: string) =>
87
+ track("server_started", { version, platform: process.platform, arch: process.arch }),
88
+
89
+ analysisStarted: (fileCount: number) =>
90
+ track("analysis_started", { file_count: fileCount }),
91
+
92
+ analysisCompleted: (fileCount: number, durationSec: number) =>
93
+ track("analysis_completed", { file_count: fileCount, duration_sec: durationSec }),
94
+
95
+ analysisError: (errorType: string) =>
96
+ track("analysis_error", { error_type: errorType.slice(0, 100) }),
97
+
98
+ chatSent: () =>
99
+ track("chat_sent"),
100
+
101
+ chatCompleted: (durationSec: number, hasTools: boolean) =>
102
+ track("chat_completed", { duration_sec: durationSec, has_tools: hasTools }),
103
+
104
+ reviewSubmitted: (event: string) =>
105
+ track("review_submitted", { review_event: event }),
106
+
107
+ stackStarted: () =>
108
+ track("stack_started"),
109
+
110
+ stackCompleted: (groupCount: number) =>
111
+ track("stack_completed", { group_count: groupCount }),
112
+
113
+ stackPublished: (prCount: number) =>
114
+ track("stack_published", { pr_count: prCount }),
115
+ };
@@ -202,6 +202,7 @@ export function App() {
202
202
  onTabChange={handleTabChange}
203
203
  onReanalyze={(prUrl: string) => { analysis.start(prUrl); }}
204
204
  enabledPlugins={features.enabledPlugins}
205
+ onTrackAnalysis={bgAnalyses.track}
205
206
  />
206
207
  )}
207
208
  {analysis.phase === "error" && (
@@ -7,13 +7,23 @@ export function AnalyticsConsent({ onDone }: { onDone: () => void }) {
7
7
 
8
8
  if (state !== "pending") return null;
9
9
 
10
+ const syncServer = (consent: "granted" | "denied") => {
11
+ fetch("/api/config", {
12
+ method: "PUT",
13
+ headers: { "Content-Type": "application/json" },
14
+ body: JSON.stringify({ telemetry_consent: consent }),
15
+ }).catch(() => {});
16
+ };
17
+
10
18
  const handleAccept = () => {
11
19
  setConsent("granted");
20
+ syncServer("granted");
12
21
  onDone();
13
22
  };
14
23
 
15
24
  const handleDecline = () => {
16
25
  setConsent("denied");
26
+ syncServer("denied");
17
27
  onDone();
18
28
  };
19
29
 
@@ -51,6 +51,7 @@ export function ResultsScreen({
51
51
  onTabChange,
52
52
  onReanalyze,
53
53
  enabledPlugins,
54
+ onTrackAnalysis,
54
55
  }: {
55
56
  data: NewprOutput;
56
57
  onBack: () => void;
@@ -61,6 +62,7 @@ export function ResultsScreen({
61
62
  onTabChange?: (tab: string) => void;
62
63
  onReanalyze?: (prUrl: string) => void;
63
64
  enabledPlugins?: string[];
65
+ onTrackAnalysis?: (analysisSessionId: string, prUrl: string) => void;
64
66
  }) {
65
67
  const { meta, summary } = data;
66
68
  const [tab, setTab] = useState<TabValue>(getInitialTab);
@@ -274,7 +276,7 @@ export function ResultsScreen({
274
276
  />
275
277
  </TabsContent>
276
278
  <TabsContent value="stack">
277
- <StackPanel sessionId={sessionId} />
279
+ <StackPanel sessionId={sessionId} onTrackAnalysis={onTrackAnalysis} />
278
280
  </TabsContent>
279
281
  <TabsContent value="slides">
280
282
  <SlidesPanel data={data} sessionId={sessionId} />
@@ -285,6 +285,11 @@ function AnalyticsToggle() {
285
285
  setConsent(next);
286
286
  setLocal(next);
287
287
  analytics.settingsChanged("analytics");
288
+ fetch("/api/config", {
289
+ method: "PUT",
290
+ headers: { "Content-Type": "application/json" },
291
+ body: JSON.stringify({ telemetry_consent: next }),
292
+ }).catch(() => {});
288
293
  };
289
294
 
290
295
  return (
@@ -133,7 +133,11 @@ function applyServerState(server: ServerStackState): Partial<StackState> {
133
133
  };
134
134
  }
135
135
 
136
- export function useStack(sessionId: string | null | undefined) {
136
+ interface UseStackOptions {
137
+ onTrackAnalysis?: (analysisSessionId: string, prUrl: string) => void;
138
+ }
139
+
140
+ export function useStack(sessionId: string | null | undefined, options?: UseStackOptions) {
137
141
  const [state, setState] = useState<StackState>({
138
142
  phase: "idle",
139
143
  error: null,
@@ -253,11 +257,29 @@ export function useStack(sessionId: string | null | undefined) {
253
257
  const data = await res.json();
254
258
  if (!res.ok) throw new Error(data.error ?? "Publishing failed");
255
259
 
260
+ const publishResult = data.publish_result as PublishResultData;
261
+
256
262
  setState((s) => ({
257
263
  ...s,
258
264
  phase: "done",
259
- publishResult: data.publish_result,
265
+ publishResult,
260
266
  }));
267
+
268
+ if (options?.onTrackAnalysis && publishResult?.prs?.length > 0) {
269
+ for (const pr of publishResult.prs) {
270
+ try {
271
+ const analysisRes = await fetch("/api/analysis", {
272
+ method: "POST",
273
+ headers: { "Content-Type": "application/json" },
274
+ body: JSON.stringify({ pr: pr.url }),
275
+ });
276
+ const analysisData = await analysisRes.json() as { sessionId?: string };
277
+ if (analysisData.sessionId) {
278
+ options.onTrackAnalysis(analysisData.sessionId, pr.url);
279
+ }
280
+ } catch {}
281
+ }
282
+ }
261
283
  } catch (err) {
262
284
  setState((s) => ({
263
285
  ...s,
@@ -265,7 +287,7 @@ export function useStack(sessionId: string | null | undefined) {
265
287
  error: err instanceof Error ? err.message : String(err),
266
288
  }));
267
289
  }
268
- }, [sessionId]);
290
+ }, [sessionId, options]);
269
291
 
270
292
  const reset = useCallback(() => {
271
293
  eventSourceRef.current?.close();
@@ -68,8 +68,13 @@ function PipelineTimeline({ phase }: { phase: StackPhase }) {
68
68
  );
69
69
  }
70
70
 
71
- export function StackPanel({ sessionId }: { sessionId?: string | null }) {
72
- const stack = useStack(sessionId);
71
+ interface StackPanelProps {
72
+ sessionId?: string | null;
73
+ onTrackAnalysis?: (analysisSessionId: string, prUrl: string) => void;
74
+ }
75
+
76
+ export function StackPanel({ sessionId, onTrackAnalysis }: StackPanelProps) {
77
+ const stack = useStack(sessionId, { onTrackAnalysis });
73
78
 
74
79
  if (stack.phase === "idle") {
75
80
  return (
@@ -17,6 +17,7 @@ import { detectAgents, runAgent } from "../../workspace/agent.ts";
17
17
  import { randomBytes } from "node:crypto";
18
18
  import { publishStack } from "../../stack/publish.ts";
19
19
  import { startStack, getStackState, cancelStack, subscribeStack, restoreCompletedStacks } from "./stack-manager.ts";
20
+ import { getTelemetryConsent, setTelemetryConsent, telemetry } from "../../telemetry/index.ts";
20
21
 
21
22
  function json(data: unknown, status = 200): Response {
22
23
  return new Response(JSON.stringify(data), {
@@ -610,6 +611,7 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
610
611
  const pluginList = getAllPlugins().map((p) => ({ id: p.id, name: p.name }));
611
612
  const enabledPlugins = stored.enabled_plugins ?? pluginList.map((p) => p.id);
612
613
  const agents = await detectAgents();
614
+ const telemetryConsent = await getTelemetryConsent();
613
615
  return json({
614
616
  model: config.model,
615
617
  agent: config.agent ?? null,
@@ -622,6 +624,7 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
622
624
  has_github_token: !!token,
623
625
  enabled_plugins: enabledPlugins,
624
626
  available_plugins: pluginList,
627
+ telemetry_consent: telemetryConsent,
625
628
  defaults: {
626
629
  model: DEFAULT_CONFIG.model,
627
630
  language: DEFAULT_CONFIG.language,
@@ -673,6 +676,11 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
673
676
  update.enabled_plugins = (body as Record<string, unknown>).enabled_plugins as string[];
674
677
  }
675
678
 
679
+ const telemetryConsentVal = (body as Record<string, unknown>).telemetry_consent as string | undefined;
680
+ if (telemetryConsentVal === "granted" || telemetryConsentVal === "denied") {
681
+ await setTelemetryConsent(telemetryConsentVal);
682
+ }
683
+
676
684
  await writeStoredConfig(update);
677
685
  return json({ ok: true });
678
686
  },
@@ -748,6 +756,7 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
748
756
  return json({ error: errBody.message ?? `GitHub API error: ${res.status}` }, res.status);
749
757
  }
750
758
  const data = await res.json() as { id: number; state: string; html_url: string };
759
+ telemetry.reviewSubmitted(body.event);
751
760
  return json({ ok: true, id: data.id, state: data.state, html_url: data.html_url });
752
761
  } catch (err) {
753
762
  return json({ error: err instanceof Error ? err.message : String(err) }, 500);
@@ -4,6 +4,7 @@ import type { ProgressEvent } from "../../analyzer/progress.ts";
4
4
  import { analyzePr } from "../../analyzer/pipeline.ts";
5
5
  import { parsePrInput } from "../../github/parse-pr.ts";
6
6
  import { saveSession, savePatchesSidecar } from "../../history/store.ts";
7
+ import { telemetry } from "../../telemetry/index.ts";
7
8
 
8
9
  type SessionStatus = "running" | "done" | "error" | "canceled";
9
10
 
@@ -65,6 +66,7 @@ export function startAnalysis(
65
66
  };
66
67
  sessions.set(id, session);
67
68
 
69
+ telemetry.analysisStarted(0);
68
70
  runPipeline(session, prInput, token, config);
69
71
 
70
72
  return { sessionId: id };
@@ -100,6 +102,9 @@ async function runPipeline(
100
102
  session.result = result;
101
103
  session.finishedAt = Date.now();
102
104
 
105
+ const durationSec = Math.round((session.finishedAt - session.startedAt) / 1000);
106
+ telemetry.analysisCompleted(result.files?.length ?? 0, durationSec);
107
+
103
108
  for (const sub of session.subscribers) {
104
109
  sub({ type: "done" });
105
110
  }
@@ -118,6 +123,8 @@ async function runPipeline(
118
123
  session.error = msg;
119
124
  session.finishedAt = Date.now();
120
125
 
126
+ telemetry.analysisError(msg);
127
+
121
128
  for (const sub of session.subscribers) {
122
129
  sub({ type: "error", data: msg });
123
130
  }
package/src/web/server.ts CHANGED
@@ -5,6 +5,7 @@ import index from "./index.html";
5
5
 
6
6
  import type { PreflightResult } from "../cli/preflight.ts";
7
7
  import { getVersion } from "../version.ts";
8
+ import { telemetry } from "../telemetry/index.ts";
8
9
 
9
10
  interface WebServerOptions {
10
11
  port: number;
@@ -211,6 +212,8 @@ export async function startWebServer(options: WebServerOptions): Promise<void> {
211
212
 
212
213
  const url = `http://localhost:${server.port}`;
213
214
 
215
+ telemetry.serverStarted(getVersion());
216
+
214
217
  const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
215
218
  const bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
216
219
  const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`;