mergie-cli 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.
Files changed (140) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +91 -0
  3. package/bin/mergie-dev +18 -0
  4. package/bin/mergie.ts +44 -0
  5. package/dist/web/assets/index-4bq8mkyV.css +1 -0
  6. package/dist/web/assets/index-Cp_Gdwf2.js +50 -0
  7. package/dist/web/favicon.svg +12 -0
  8. package/dist/web/index.html +14 -0
  9. package/package.json +68 -0
  10. package/src/cli/args.ts +63 -0
  11. package/src/cli/constants.ts +32 -0
  12. package/src/cli/daemonClient.ts +42 -0
  13. package/src/cli/openBrowser.ts +11 -0
  14. package/src/cli/run.ts +76 -0
  15. package/src/daemon/aiReviewTracker.ts +71 -0
  16. package/src/daemon/allComments.ts +119 -0
  17. package/src/daemon/artifactCapture.ts +47 -0
  18. package/src/daemon/buildUi.ts +54 -0
  19. package/src/daemon/chatPrompt.ts +35 -0
  20. package/src/daemon/chatSocket.ts +78 -0
  21. package/src/daemon/createRegistry.ts +569 -0
  22. package/src/daemon/githubThreads.ts +92 -0
  23. package/src/daemon/inflight.ts +49 -0
  24. package/src/daemon/postMapping.ts +94 -0
  25. package/src/daemon/registry.ts +263 -0
  26. package/src/daemon/reviewPrompt.ts +22 -0
  27. package/src/daemon/reviewService.ts +243 -0
  28. package/src/daemon/router.ts +287 -0
  29. package/src/daemon/server.ts +106 -0
  30. package/src/daemon/splitView.ts +63 -0
  31. package/src/daemon/trpc.ts +20 -0
  32. package/src/db/migrate.ts +24 -0
  33. package/src/db/repositories/aiReviews.ts +113 -0
  34. package/src/db/repositories/artifacts.ts +101 -0
  35. package/src/db/repositories/chatSessions.ts +197 -0
  36. package/src/db/repositories/comments.ts +195 -0
  37. package/src/db/repositories/githubComments.ts +166 -0
  38. package/src/db/repositories/hunkViews.ts +36 -0
  39. package/src/db/repositories/reviewedRanges.ts +75 -0
  40. package/src/db/schema.ts +97 -0
  41. package/src/domain/config.ts +137 -0
  42. package/src/domain/context.ts +32 -0
  43. package/src/domain/diff.ts +178 -0
  44. package/src/domain/entities.ts +92 -0
  45. package/src/domain/fuzzy.ts +43 -0
  46. package/src/domain/generated.ts +33 -0
  47. package/src/domain/hash.ts +0 -0
  48. package/src/domain/lockfiles.ts +20 -0
  49. package/src/domain/paths.ts +59 -0
  50. package/src/domain/ranges.ts +81 -0
  51. package/src/domain/references.ts +36 -0
  52. package/src/domain/url.ts +50 -0
  53. package/src/domain/wordDiff.ts +174 -0
  54. package/src/services/ai.ts +137 -0
  55. package/src/services/exec.ts +44 -0
  56. package/src/services/ghPr.ts +102 -0
  57. package/src/services/ghSearch.ts +135 -0
  58. package/src/services/git.ts +297 -0
  59. package/src/services/github.ts +300 -0
  60. package/src/services/symbols.ts +364 -0
  61. package/src/web/App.tsx +32 -0
  62. package/src/web/components/AiReviewIndicator.tsx +63 -0
  63. package/src/web/components/AiReviewModal.tsx +101 -0
  64. package/src/web/components/AiReviewsPanel.tsx +64 -0
  65. package/src/web/components/ChatPanel.tsx +142 -0
  66. package/src/web/components/CodePreview.tsx +63 -0
  67. package/src/web/components/CommentComposer.tsx +40 -0
  68. package/src/web/components/CommentItem.tsx +59 -0
  69. package/src/web/components/CommentsPanel.tsx +309 -0
  70. package/src/web/components/CommitRail.tsx +64 -0
  71. package/src/web/components/ConfirmButton.tsx +32 -0
  72. package/src/web/components/CopyButton.tsx +33 -0
  73. package/src/web/components/CopyIconButton.tsx +38 -0
  74. package/src/web/components/DiffFrame.tsx +133 -0
  75. package/src/web/components/DiffLines.tsx +149 -0
  76. package/src/web/components/FileFrame.tsx +45 -0
  77. package/src/web/components/FileNavigator.tsx +195 -0
  78. package/src/web/components/FileTree.tsx +45 -0
  79. package/src/web/components/FileView.tsx +53 -0
  80. package/src/web/components/GithubThreadView.tsx +56 -0
  81. package/src/web/components/HunkCard.tsx +121 -0
  82. package/src/web/components/Icons.tsx +215 -0
  83. package/src/web/components/IdentifierMenuPortal.tsx +33 -0
  84. package/src/web/components/PostMenu.tsx +63 -0
  85. package/src/web/components/PrDescription.tsx +29 -0
  86. package/src/web/components/PrLoading.tsx +45 -0
  87. package/src/web/components/PrPicker.tsx +201 -0
  88. package/src/web/components/RangeSelector.tsx +141 -0
  89. package/src/web/components/ResultsList.tsx +159 -0
  90. package/src/web/components/ReviewView.tsx +308 -0
  91. package/src/web/components/RightRail.tsx +146 -0
  92. package/src/web/components/SearchRailPanel.tsx +127 -0
  93. package/src/web/components/Switch.tsx +31 -0
  94. package/src/web/components/SwitchPrModal.tsx +28 -0
  95. package/src/web/components/SymbolLookupMenu.tsx +35 -0
  96. package/src/web/components/Toolbar.tsx +79 -0
  97. package/src/web/components/Tooltip.tsx +72 -0
  98. package/src/web/index.html +13 -0
  99. package/src/web/lib/aiReviewIndicator.ts +29 -0
  100. package/src/web/lib/anchorRow.ts +25 -0
  101. package/src/web/lib/codeSearchFetch.ts +46 -0
  102. package/src/web/lib/commentFilters.ts +36 -0
  103. package/src/web/lib/commentVisibility.ts +90 -0
  104. package/src/web/lib/composerKeys.ts +24 -0
  105. package/src/web/lib/dedupeResults.ts +37 -0
  106. package/src/web/lib/diffMarks.ts +81 -0
  107. package/src/web/lib/fileStatus.ts +12 -0
  108. package/src/web/lib/filterCodeResults.ts +42 -0
  109. package/src/web/lib/filterPrs.ts +38 -0
  110. package/src/web/lib/highlight.ts +40 -0
  111. package/src/web/lib/identifierMenu.ts +41 -0
  112. package/src/web/lib/lineSelection.ts +48 -0
  113. package/src/web/lib/navRouting.ts +40 -0
  114. package/src/web/lib/navStack.ts +109 -0
  115. package/src/web/lib/persistedFlag.ts +43 -0
  116. package/src/web/lib/prPickerModel.ts +27 -0
  117. package/src/web/lib/railState.ts +19 -0
  118. package/src/web/lib/rangeCoverage.ts +27 -0
  119. package/src/web/lib/rangeMap.ts +42 -0
  120. package/src/web/lib/resultCountLabel.ts +11 -0
  121. package/src/web/lib/reviewProgress.ts +26 -0
  122. package/src/web/lib/searchInputsKey.ts +35 -0
  123. package/src/web/lib/searchToken.ts +25 -0
  124. package/src/web/lib/splitSide.ts +13 -0
  125. package/src/web/lib/time.ts +9 -0
  126. package/src/web/lib/togglePrefs.ts +81 -0
  127. package/src/web/lib/useEscToClose.ts +17 -0
  128. package/src/web/lib/useIdentifierMenu.ts +77 -0
  129. package/src/web/lib/useNavLookup.ts +74 -0
  130. package/src/web/lib/usePageTitle.ts +15 -0
  131. package/src/web/lib/visibleFiles.ts +52 -0
  132. package/src/web/main.tsx +24 -0
  133. package/src/web/public/favicon.svg +12 -0
  134. package/src/web/state/useChat.ts +181 -0
  135. package/src/web/state/useCodeSearch.ts +198 -0
  136. package/src/web/state/useReview.ts +138 -0
  137. package/src/web/styles.css +1020 -0
  138. package/src/web/trpc.ts +5 -0
  139. package/tsconfig.json +27 -0
  140. package/vite.config.ts +29 -0
@@ -0,0 +1,364 @@
1
+ import { bunRunner } from "@/services/exec.ts";
2
+ import type { CommandRunner } from "@/services/exec.ts";
3
+ import { sliceContext } from "@/domain/context.ts";
4
+ import { findReferences } from "@/domain/references.ts";
5
+ import { isTestOrGenerated } from "@/domain/generated.ts";
6
+ import { parseEntities, matchEntities, sliceBody, scopeLabel, type SemEntity } from "@/domain/entities.ts";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Public types
10
+ // ---------------------------------------------------------------------------
11
+
12
+ /** The kind of a code result — how it was found. */
13
+ export type CodeResultKind = "definition" | "usage" | "search";
14
+
15
+ /**
16
+ * A unified code result for the symbol/search UI. Every navigation and search
17
+ * path (sem definition, sem usages, rg search) maps to this single shape.
18
+ */
19
+ export interface CodeResult {
20
+ /** Repo-relative path to the file containing the result. */
21
+ path: string;
22
+ /**
23
+ * 1-based line number of the real reference/definition line. For a
24
+ * definition this is the entity's first line; for a usage the actual
25
+ * reference line inside the dependent; for a search the matching line.
26
+ */
27
+ line: number;
28
+ /** Context lines before `matched`, top-to-bottom (empty for definitions). */
29
+ before: string[];
30
+ /** The reference/definition/match line text (no trailing newline stripping). */
31
+ matched: string;
32
+ /** Context lines after `matched`, top-to-bottom (empty for definitions). */
33
+ after: string[];
34
+ /** Full definition body — populated for `kind: "definition"` only. */
35
+ body?: string;
36
+ /**
37
+ * Entity scope label — the entity name/type for a definition, or the
38
+ * dependent entity's name for a usage. Undefined for search hits.
39
+ */
40
+ scope?: string;
41
+ /** How this result was found. */
42
+ kind: CodeResultKind;
43
+ /**
44
+ * Backend-computed: whether this file is test or generated code (lockfile /
45
+ * generated globs OR the test-path heuristic), for the UI's hide toggle.
46
+ */
47
+ testOrGenerated: boolean;
48
+ }
49
+
50
+ /**
51
+ * Reads the full text of a repo-relative file at the resolved checkout.
52
+ * Returns null when the file does not exist there.
53
+ */
54
+ export type ReadFile = (path: string) => Promise<string | null>;
55
+
56
+ /** Options shared by the sem-backed lookups (definition/usages). */
57
+ export interface SemLookupOptions {
58
+ /** The checkout directory to run `sem` in. */
59
+ cwd: string;
60
+ /** Reads a repo-relative file's content at the same checkout. */
61
+ readFile: ReadFile;
62
+ /** Restricts sem to these file extensions (e.g. [".ts"]). */
63
+ fileExts?: string[];
64
+ /** Optional repo-relative file scope hint → passed as `--file`. */
65
+ file?: string;
66
+ }
67
+
68
+ /** Options for the rg-backed literal/regex search. */
69
+ export interface SearchOptions {
70
+ /** The directory to search in. */
71
+ cwd: string;
72
+ /** Context lines to include on each side of a match (default 3). */
73
+ contextLines?: number;
74
+ /** Treat the query as a regex (drops `-F`). Default false (literal). */
75
+ regex?: boolean;
76
+ /** Case-sensitive match (drops `-i`). Default false (case-insensitive). */
77
+ caseSensitive?: boolean;
78
+ }
79
+
80
+ /** The symbol/search service returned by {@link createSymbolsService}. */
81
+ export interface SymbolsService {
82
+ /**
83
+ * List ALL definitions of a name by enumerating entities via
84
+ * `sem entities <scope> --json` (sem's `context` only ever resolves one). The
85
+ * scope is the given `file` when set, else the repo (`.`); if the file defines
86
+ * nothing by this name (e.g. the click was a cross-file reference) it retries
87
+ * repo-wide. Each entity's body is read from its file (its line span). Returns
88
+ * [] on sem failure.
89
+ */
90
+ definition(symbol: string, opts: SemLookupOptions): Promise<CodeResult[]>;
91
+
92
+ /**
93
+ * Resolve a symbol's usages via `sem impact <symbol> --dependents --json`.
94
+ * For each dependent, reads its file and finds the REAL reference line(s)
95
+ * within the dependent's span (not the dependent's declaration). Same
96
+ * `--file` scope + retry behaviour as {@link definition}.
97
+ */
98
+ usages(symbol: string, opts: SemLookupOptions): Promise<CodeResult[]>;
99
+
100
+ /**
101
+ * Literal (default) or regex search via `rg --json`. Distinguishes rg exit 2
102
+ * (bad regex → throws {@link BadRegexError}) from exit 1 (no matches → []).
103
+ */
104
+ search(word: string, opts: SearchOptions): Promise<CodeResult[]>;
105
+ }
106
+
107
+ /** Thrown when `rg` reports a syntactically invalid regex (exit code 2). */
108
+ export class BadRegexError extends Error {
109
+ constructor(message = "Invalid search pattern.") {
110
+ super(message);
111
+ this.name = "BadRegexError";
112
+ }
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Internal sem JSON shapes (non-exported)
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /** A dependent entity in the `sem impact --dependents --json` response. */
120
+ interface SemDependent {
121
+ file: string;
122
+ name: string;
123
+ type: string;
124
+ /** Inclusive [start, end] line numbers (1-based) of the dependent entity. */
125
+ lines?: [number, number];
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Internal rg JSON shapes (non-exported)
130
+ // ---------------------------------------------------------------------------
131
+
132
+ /** A line seen within a file block, tagged match vs context. */
133
+ interface RgBlockLine {
134
+ /** 1-based line number. */
135
+ no: number;
136
+ /** Full line text. */
137
+ text: string;
138
+ /** True for a match line, false for a context line. */
139
+ isMatch: boolean;
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Parsing helpers
144
+ // ---------------------------------------------------------------------------
145
+
146
+ /** Parse JSON, returning null on failure (no throw). */
147
+ function tryParse(raw: string): unknown {
148
+ try {
149
+ return JSON.parse(raw);
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
154
+
155
+ /** Extract `dependents` from a `sem impact` payload, or [] on any shape error. */
156
+ function semDependents(raw: string): SemDependent[] {
157
+ const parsed = tryParse(raw);
158
+ if (
159
+ typeof parsed !== "object" ||
160
+ parsed === null ||
161
+ !("dependents" in parsed) ||
162
+ !Array.isArray(parsed.dependents)
163
+ ) {
164
+ return [];
165
+ }
166
+ return parsed.dependents;
167
+ }
168
+
169
+ /** Read the property `key` from an unknown value, or undefined. */
170
+ function prop(value: unknown, key: string): unknown {
171
+ if (typeof value !== "object" || value === null || !(key in value)) return undefined;
172
+ const record: Record<string, unknown> = { ...value };
173
+ return record[key];
174
+ }
175
+
176
+ /** Read a string property, or undefined if absent/not a string. */
177
+ function strProp(value: unknown, key: string): string | undefined {
178
+ const v = prop(value, key);
179
+ return typeof v === "string" ? v : undefined;
180
+ }
181
+
182
+ /** Read a number property, or undefined if absent/not a number. */
183
+ function numProp(value: unknown, key: string): number | undefined {
184
+ const v = prop(value, key);
185
+ return typeof v === "number" ? v : undefined;
186
+ }
187
+
188
+ /** The repo-relative path from an rg event's `data.path.text`, or undefined. */
189
+ function eventPath(event: unknown): string | undefined {
190
+ return strProp(prop(prop(event, "data"), "path"), "text");
191
+ }
192
+
193
+ /**
194
+ * Group the rg JSON stream into per-file blocks of match/context lines. A new
195
+ * block starts on each `begin`; lines accumulate until the next `begin`/`end`.
196
+ */
197
+ function rgBlocks(raw: string): Array<{ path: string; lines: RgBlockLine[] }> {
198
+ const blocks: Array<{ path: string; lines: RgBlockLine[] }> = [];
199
+ let current: { path: string; lines: RgBlockLine[] } | null = null;
200
+ for (const line of raw.split("\n")) {
201
+ if (line.trim() === "") continue;
202
+ const event = tryParse(line);
203
+ const type = strProp(event, "type");
204
+ if (type === "begin") {
205
+ current = { path: eventPath(event) ?? "", lines: [] };
206
+ blocks.push(current);
207
+ } else if ((type === "match" || type === "context") && current !== null) {
208
+ const data = prop(event, "data");
209
+ const text = strProp(prop(data, "lines"), "text");
210
+ const no = numProp(data, "line_number");
211
+ if (text !== undefined && no !== undefined) {
212
+ current.lines.push({ no, text, isMatch: type === "match" });
213
+ }
214
+ }
215
+ }
216
+ return blocks;
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Factory
221
+ // ---------------------------------------------------------------------------
222
+
223
+ /**
224
+ * Creates a SymbolsService backed by `sem` and `rg` subprocesses.
225
+ * @param runner - CommandRunner for spawning subprocesses (fake in tests).
226
+ * @param isLockfileOrGenerated - Path→boolean matcher for lockfile/generated
227
+ * files (wired by the caller to the config glob matcher). Combined with the
228
+ * pure test-path heuristic to set `testOrGenerated` on each result.
229
+ */
230
+ export function createSymbolsService(
231
+ runner: CommandRunner = bunRunner,
232
+ isLockfileOrGenerated: (path: string) => boolean = () => false,
233
+ ): SymbolsService {
234
+ /** Compute the `testOrGenerated` flag for a repo-relative path. */
235
+ function flag(path: string): boolean {
236
+ return isTestOrGenerated(path, isLockfileOrGenerated(path));
237
+ }
238
+
239
+ /**
240
+ * Run `sem <argv>` with an optional `--file` scope, retrying unscoped when
241
+ * the scoped call produced no usable entries.
242
+ * @returns Raw stdout of the call that produced entries (or the last call).
243
+ */
244
+ async function semWithScope(
245
+ base: string[],
246
+ cwd: string,
247
+ file: string | undefined,
248
+ hasEntries: (stdout: string) => boolean,
249
+ ): Promise<string> {
250
+ if (file !== undefined) {
251
+ const scoped = await runner.run("sem", [...base, "--file", file], { cwd });
252
+ if (scoped.exitCode === 0 && hasEntries(scoped.stdout)) return scoped.stdout;
253
+ }
254
+ const res = await runner.run("sem", base, { cwd });
255
+ return res.exitCode === 0 ? res.stdout : "";
256
+ }
257
+
258
+ /** Run `sem entities <scope> --json`; returns "" on failure. */
259
+ async function runEntities(scope: string, cwd: string): Promise<string> {
260
+ const res = await runner.run("sem", ["entities", scope, "--json"], { cwd });
261
+ return res.exitCode === 0 ? res.stdout : "";
262
+ }
263
+
264
+ /** Map one entity to a definition result, reading its body from the file. */
265
+ async function entityToDefinition(e: SemEntity, readFile: ReadFile): Promise<CodeResult> {
266
+ const scope = scopeLabel(e.parentId, e.name);
267
+ const testOrGenerated = flag(e.file);
268
+ const content = await readFile(e.file);
269
+ if (content === null) {
270
+ return { path: e.file, line: e.startLine, before: [], matched: e.name, after: [], body: "", scope, kind: "definition", testOrGenerated };
271
+ }
272
+ const { body, matched } = sliceBody(content.split("\n"), e.startLine, e.endLine);
273
+ return { path: e.file, line: e.startLine, before: [], matched, after: [], body, scope, kind: "definition", testOrGenerated };
274
+ }
275
+
276
+ /** Build usages for one dependent by finding real references in its span. */
277
+ async function usagesForDependent(
278
+ dep: SemDependent,
279
+ symbol: string,
280
+ readFile: ReadFile,
281
+ ): Promise<CodeResult[]> {
282
+ const span: [number, number] = dep.lines ?? [1, 1];
283
+ const content = await readFile(dep.file);
284
+ const scope = dep.name;
285
+ const testOrGenerated = flag(dep.file);
286
+ if (content === null) {
287
+ return [{ path: dep.file, line: span[0], before: [], matched: "", after: [], scope, kind: "usage", testOrGenerated }];
288
+ }
289
+ const lines = content.split("\n");
290
+ const refs = findReferences(lines, span, symbol);
291
+ const targets = refs.length > 0 ? refs : [span[0]];
292
+ return targets.map((refLine) => {
293
+ const slice = sliceContext(lines, refLine, 3);
294
+ return { path: dep.file, line: refLine, before: slice.before, matched: slice.matched, after: slice.after, scope, kind: "usage", testOrGenerated };
295
+ });
296
+ }
297
+
298
+ return {
299
+ async definition(symbol, opts) {
300
+ // Enumerate ALL definitions of the name (sem `context` only resolves one).
301
+ // Scope to the clicked file when given; if that file defines nothing by
302
+ // this name (e.g. the click was on a cross-file reference), fall back to a
303
+ // repo-wide enumeration so every matching definition is listed.
304
+ const file = opts.file;
305
+ const scoped: SemEntity[] = file !== undefined
306
+ ? matchEntities(parseEntities(await runEntities(file, opts.cwd)), symbol).map((e) => ({ ...e, file: e.file || file }))
307
+ : [];
308
+ const matches: SemEntity[] = scoped.length > 0
309
+ ? scoped
310
+ : matchEntities(parseEntities(await runEntities(".", opts.cwd)), symbol);
311
+ const results: CodeResult[] = [];
312
+ for (const e of matches) results.push(await entityToDefinition(e, opts.readFile));
313
+ return results;
314
+ },
315
+
316
+ async usages(symbol, opts) {
317
+ const base = ["impact", symbol, "--dependents", "--json"];
318
+ if (opts.fileExts && opts.fileExts.length > 0) base.push("--file-exts", ...opts.fileExts);
319
+ const stdout = await semWithScope(base, opts.cwd, opts.file, (s) =>
320
+ semDependents(s).length > 0,
321
+ );
322
+ const results: CodeResult[] = [];
323
+ for (const dep of semDependents(stdout)) {
324
+ results.push(...(await usagesForDependent(dep, symbol, opts.readFile)));
325
+ }
326
+ return results;
327
+ },
328
+
329
+ async search(word, opts) {
330
+ const contextLines = opts.contextLines ?? 3;
331
+ const args = ["--json", "-C", String(contextLines)];
332
+ if (opts.regex !== true) args.push("-F");
333
+ if (opts.caseSensitive !== true) args.push("-i");
334
+ args.push("--", word, opts.cwd);
335
+ const result = await runner.run("rg", args);
336
+ if (result.exitCode === 2) throw new BadRegexError();
337
+ if (result.exitCode !== 0) return [];
338
+ return searchResults(result.stdout, contextLines, flag);
339
+ },
340
+ };
341
+ }
342
+
343
+ /** Map an rg JSON stream to search `CodeResult[]` using line-adjacency context. */
344
+ function searchResults(
345
+ raw: string,
346
+ contextLines: number,
347
+ flag: (path: string) => boolean,
348
+ ): CodeResult[] {
349
+ const results: CodeResult[] = [];
350
+ for (const block of rgBlocks(raw)) {
351
+ const testOrGenerated = flag(block.path);
352
+ for (const line of block.lines) {
353
+ if (!line.isMatch) continue;
354
+ const before = block.lines
355
+ .filter((l) => l.no >= line.no - contextLines && l.no < line.no)
356
+ .map((l) => l.text);
357
+ const after = block.lines
358
+ .filter((l) => l.no > line.no && l.no <= line.no + contextLines)
359
+ .map((l) => l.text);
360
+ results.push({ path: block.path, line: line.no, before, matched: line.text, after, kind: "search", testOrGenerated });
361
+ }
362
+ }
363
+ return results;
364
+ }
@@ -0,0 +1,32 @@
1
+ import { ReviewView } from "./components/ReviewView.tsx";
2
+ import { PrPicker } from "./components/PrPicker.tsx";
3
+ import { PrLoading } from "./components/PrLoading.tsx";
4
+ import { SparkleIcon } from "./components/Icons.tsx";
5
+
6
+ /**
7
+ * Root React component. Routes on query params:
8
+ * `?pr=<id>` → review screen; `?load=<url>` → the loading gate that fetches a
9
+ * not-yet-loaded PR then deep-links into it; otherwise the home PR picker.
10
+ */
11
+ export function App(): React.JSX.Element {
12
+ const params = new URLSearchParams(window.location.search);
13
+ const prId: string | null = params.get("pr");
14
+ const loadUrl: string | null = params.get("load");
15
+ if (prId !== null) return <ReviewView prId={prId} />;
16
+ if (loadUrl !== null) return <PrLoading url={loadUrl} />;
17
+ return <Home />;
18
+ }
19
+
20
+ /** Home screen: branding plus the PR picker (no PR selected). */
21
+ function Home(): React.JSX.Element {
22
+ return (
23
+ <main className="landing">
24
+ <div className="landing-brand">
25
+ <span className="landing-mark"><SparkleIcon size={18} /></span>
26
+ <h1 className="landing-wordmark">mergie</h1>
27
+ </div>
28
+ <p className="landing-tagline">Review GitHub pull requests locally.</p>
29
+ <PrPicker />
30
+ </main>
31
+ );
32
+ }
@@ -0,0 +1,63 @@
1
+ import { trpc } from "../trpc.ts";
2
+ import { summariseAiReviewStatuses } from "@/web/lib/aiReviewIndicator.ts";
3
+ import { SparkleIcon, CheckIcon, CloseIcon } from "./Icons.tsx";
4
+ import type { RangeSel } from "../state/useReview.ts";
5
+
6
+ /** Short label for a range's SHAs. */
7
+ function rangeLabel(startSha: string, endSha: string): string {
8
+ return `${startSha.slice(0, 7)} → ${endSha.slice(0, 7)}`;
9
+ }
10
+
11
+ /**
12
+ * A persistent, app-level indicator of AI-review progress for this PR. It polls
13
+ * the daemon's per-range review statuses so it stays accurate after the review
14
+ * popup is closed and across range changes. Shows a spinner while a review runs
15
+ * (with a count when several run), a clickable "ready" state that opens the
16
+ * finished review, or a clickable "failed" state — both of which dismiss the
17
+ * status once acted on. Renders nothing when idle.
18
+ */
19
+ export function AiReviewIndicator(props: {
20
+ prId: string;
21
+ /** Open the AI-review popup on a given range (e.g. a finished review). */
22
+ onOpen: (range: RangeSel) => void;
23
+ }): React.JSX.Element | null {
24
+ const { prId, onOpen } = props;
25
+ const utils = trpc.useUtils();
26
+ const statuses = trpc.aiReviewStatuses.useQuery({ id: prId }, { refetchInterval: 2000 });
27
+ const dismiss = trpc.dismissAiReviewStatus.useMutation({
28
+ onSuccess: () => utils.aiReviewStatuses.invalidate(),
29
+ });
30
+
31
+ const summary = summariseAiReviewStatuses(statuses.data ?? []);
32
+ if (!summary) return null;
33
+ const { state, runningCount, primary } = summary;
34
+ const range: RangeSel = { start: primary.startSha, end: primary.endSha };
35
+ const label: string = rangeLabel(primary.startSha, primary.endSha);
36
+
37
+ if (state === "running") {
38
+ return (
39
+ <span className="ai-indicator ai-indicator-running" title={`AI review in progress · ${label}`}>
40
+ <span className="chat-spinner" aria-hidden="true" />
41
+ AI review running{runningCount > 1 ? ` (${runningCount})` : ""}
42
+ </span>
43
+ );
44
+ }
45
+
46
+ const act = (): void => {
47
+ onOpen(range);
48
+ dismiss.mutate({ id: prId, start: primary.startSha, end: primary.endSha });
49
+ };
50
+
51
+ if (state === "done") {
52
+ return (
53
+ <button type="button" className="ai-indicator ai-indicator-done" title={`Open the finished AI review · ${label}`} onClick={act}>
54
+ <SparkleIcon size={13} /> AI review ready <CheckIcon size={13} />
55
+ </button>
56
+ );
57
+ }
58
+ return (
59
+ <button type="button" className="ai-indicator ai-indicator-failed" title={primary.error ?? "AI review failed"} onClick={act}>
60
+ <CloseIcon size={13} /> AI review failed
61
+ </button>
62
+ );
63
+ }
@@ -0,0 +1,101 @@
1
+ import { useState } from "react";
2
+ import Markdown from "react-markdown";
3
+ import remarkGfm from "remark-gfm";
4
+ import { trpc } from "../trpc.ts";
5
+ import { formatCommitTime } from "@/web/lib/time.ts";
6
+ import { useEscToClose } from "@/web/lib/useEscToClose.ts";
7
+ import { ChevronRightIcon, CloseIcon, SparkleIcon } from "./Icons.tsx";
8
+ import type { RangeSel } from "../state/useReview.ts";
9
+
10
+ /**
11
+ * The AI-review dialog for the current commit range: an optional focus prompt,
12
+ * a template + model picker, the run action, the streamed result, and the past
13
+ * reviews recorded for this same range.
14
+ */
15
+ export function AiReviewModal(props: { prId: string; range: RangeSel; onClose: () => void }): React.JSX.Element {
16
+ const { prId, range, onClose } = props;
17
+ useEscToClose(onClose);
18
+ const utils = trpc.useUtils();
19
+ const config = trpc.config.useQuery({ id: prId });
20
+ const reviews = trpc.listAiReviews.useQuery({ id: prId, start: range.start, end: range.end });
21
+ const statuses = trpc.aiReviewStatuses.useQuery({ id: prId }, { refetchInterval: 2000 });
22
+ const invalidate = (): void => {
23
+ void utils.listAiReviews.invalidate();
24
+ void utils.aiReviewStatuses.invalidate();
25
+ };
26
+ // Fire-and-forget: the review runs in the daemon and is tracked app-wide, so
27
+ // the popup can be closed while it runs. We don't hold onto run.data.
28
+ const run = trpc.runAiReview.useMutation({ onSettled: invalidate });
29
+
30
+ const [prompt, setPrompt] = useState("");
31
+ const [templateId, setTemplateId] = useState("");
32
+ const [model, setModel] = useState("");
33
+ const models = config.data?.models ?? [];
34
+ const templates = config.data?.templates ?? [];
35
+ const activeModel: string = model || models[0]?.id || "";
36
+
37
+ // Whether a review for this exact range is currently running (started here or
38
+ // elsewhere) — so reopening the popup mid-run shows the running state.
39
+ const running: boolean = (statuses.data ?? []).some(
40
+ (s) => s.startSha === range.start && s.endSha === range.end && s.state === "running",
41
+ );
42
+
43
+ const start = (): void => {
44
+ if (!activeModel || running) return;
45
+ run.mutate({ id: prId, start: range.start, end: range.end, model: activeModel, templateId: templateId || undefined, prompt: prompt.trim() || undefined });
46
+ };
47
+
48
+ return (
49
+ <div className="modal-overlay" onClick={onClose}>
50
+ <div className="modal ai-review-modal" onClick={(e) => e.stopPropagation()}>
51
+ <header className="modal-header">
52
+ <strong className="modal-title-strong"><SparkleIcon size={16} /> AI review</strong>
53
+ <span className="split-base-head"><code>{range.start.slice(0, 7)}</code> → <code>{range.end.slice(0, 7)}</code></span>
54
+ <span className="modal-header-spacer" />
55
+ <button type="button" className="modal-close" onClick={onClose} aria-label="Close"><CloseIcon size={18} /></button>
56
+ </header>
57
+ <div className="ai-review-body">
58
+ <div className="ai-review-form">
59
+ <label>Template
60
+ <select value={templateId} onChange={(e) => setTemplateId(e.target.value)}>
61
+ <option value="">(none)</option>
62
+ {templates.map((t) => <option key={t.id} value={t.id}>{t.title}</option>)}
63
+ </select>
64
+ </label>
65
+ <label>Model
66
+ <select value={activeModel} onChange={(e) => setModel(e.target.value)}>
67
+ {models.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
68
+ </select>
69
+ </label>
70
+ <textarea
71
+ className="comment-textarea"
72
+ placeholder="Optional: focus the review…"
73
+ value={prompt}
74
+ onChange={(e) => setPrompt(e.target.value)}
75
+ />
76
+ <button type="button" className="btn btn-primary" onClick={start} disabled={running}>
77
+ {running ? "Reviewing… (this can take a while)" : "Run review"}
78
+ </button>
79
+ {running && (
80
+ <p className="notice ai-review-running">
81
+ <span className="chat-spinner" aria-hidden="true" /> Running in the background — you can close this and keep reviewing; it will finish and appear below.
82
+ </p>
83
+ )}
84
+ </div>
85
+ {run.error && <p className="notice chat-error">{run.error.message}</p>}
86
+ <div className="ai-review-past">
87
+ <div className="chat-role">Reviews for this range</div>
88
+ {reviews.data?.length ? reviews.data.map((r, i) => (
89
+ // Expand the newest review by default so a just-finished (or
90
+ // clicked-through) review is shown without an extra click.
91
+ <details key={r.id} className="ai-review-item disclosure" open={i === (reviews.data?.length ?? 0) - 1}>
92
+ <summary><ChevronRightIcon size={13} className="disclosure-chevron" /> {r.model}{r.template ? ` · ${r.template}` : ""} · {formatCommitTime(new Date(r.createdAt).toISOString())}</summary>
93
+ <div className="comment-body"><Markdown remarkPlugins={[remarkGfm]}>{r.body}</Markdown></div>
94
+ </details>
95
+ )) : <p className="notice">No reviews for this range yet.</p>}
96
+ </div>
97
+ </div>
98
+ </div>
99
+ </div>
100
+ );
101
+ }
@@ -0,0 +1,64 @@
1
+ import Markdown from "react-markdown";
2
+ import remarkGfm from "remark-gfm";
3
+ import { trpc } from "../trpc.ts";
4
+ import { formatCommitTime } from "@/web/lib/time.ts";
5
+ import { isStale } from "@/domain/ranges.ts";
6
+ import { ChevronRightIcon, SparkleIcon } from "./Icons.tsx";
7
+
8
+ /** A short single-line preview of a review body. */
9
+ function preview(body: string): string {
10
+ const flat = body.replace(/\s+/g, " ").trim();
11
+ return flat.length > 160 ? `${flat.slice(0, 159)}…` : flat;
12
+ }
13
+
14
+ /**
15
+ * The right-rail "AI reviews" panel: lists every AI review on the PR (across
16
+ * ranges), each with the range it covers and a link to open it with that range
17
+ * selected in a new tab. Stale ranges (whose commits no longer exist after a
18
+ * force-push/rebase) are flagged and cannot be opened.
19
+ */
20
+ export function AiReviewsPanel(props: { prId: string }): React.JSX.Element {
21
+ const { prId } = props;
22
+ const reviews = trpc.listAiReviews.useQuery({ id: prId });
23
+ const topo = trpc.commitsWithBaseline.useQuery({ id: prId });
24
+ const all = reviews.data ?? [];
25
+ const commitTopo = { baselineSha: topo.data?.baselineSha ?? "", commits: (topo.data?.commits ?? []).map((c) => c.sha) };
26
+ const stale = (startSha: string, endSha: string): boolean =>
27
+ topo.data !== undefined && isStale({ startSha, endSha }, commitTopo);
28
+
29
+ return (
30
+ <ul className="comment-list ai-reviews-list">
31
+ {all.map((r) => {
32
+ const isStaleReview: boolean = stale(r.startSha, r.endSha);
33
+ return (
34
+ <li key={r.id} className="comment-list-item">
35
+ <div className="comment-list-meta">
36
+ <code>{r.startSha.slice(0, 7)} → {r.endSha.slice(0, 7)}</code>
37
+ <span>{r.model}</span>
38
+ {r.template && <span className="badge">{r.template}</span>}
39
+ {isStaleReview && <span className="badge stale">stale</span>}
40
+ <span className="comment-time">{formatCommitTime(new Date(r.createdAt).toISOString())}</span>
41
+ {isStaleReview
42
+ ? <span className="notice" title="A commit in this range no longer exists (force-push/rebase)">range unavailable</span>
43
+ : <a href={`/?pr=${prId}&start=${r.startSha}&end=${r.endSha}`} target="_blank" rel="noreferrer">Open with this range ↗</a>}
44
+ </div>
45
+ {r.prompt && <div className="comment-list-body"><em>Focus: {r.prompt}</em></div>}
46
+ <details className="disclosure">
47
+ <summary><ChevronRightIcon size={14} className="disclosure-chevron" /> {preview(r.body)}</summary>
48
+ <div className="comment-body"><Markdown remarkPlugins={[remarkGfm]}>{r.body}</Markdown></div>
49
+ </details>
50
+ </li>
51
+ );
52
+ })}
53
+ {all.length === 0 && (
54
+ <li>
55
+ <div className="empty-state">
56
+ <SparkleIcon size={40} />
57
+ <p className="empty-state-title">No AI reviews yet</p>
58
+ <p className="empty-state-hint">Run an AI review on the current commit range to see it listed here.</p>
59
+ </div>
60
+ </li>
61
+ )}
62
+ </ul>
63
+ );
64
+ }