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,243 @@
1
+ import { isLockfile } from "@/domain/lockfiles.ts";
2
+ import { commentAnchorHash, type DiffSide } from "@/domain/hash.ts";
3
+ import { parseUnifiedDiff, type DiffLine, type FileStatus } from "@/domain/diff.ts";
4
+ import { parseWordDiff, withWordChanges, type FileWordChanges } from "@/domain/wordDiff.ts";
5
+ import type { CommentKind, CommentRow } from "@/db/repositories/comments.ts";
6
+ import type { GithubThread } from "./githubThreads.ts";
7
+
8
+ /** A comment resolved to a position within a hunk for rendering. */
9
+ export interface AnchoredComment {
10
+ /** Comment id. */
11
+ id: number;
12
+ /** Markdown body. */
13
+ body: string;
14
+ /** Which side the comment is on. */
15
+ side: DiffSide;
16
+ /** Whether it targets a line range or the whole hunk. */
17
+ kind: CommentKind;
18
+ /** Index into `hunk.lines` the comment sits under; -1 for a whole-hunk comment. */
19
+ lineIndex: number;
20
+ /** Creation timestamp (ms). */
21
+ createdAt: number;
22
+ /** Last-updated timestamp (ms). */
23
+ updatedAt: number;
24
+ /** GitHub URL if this comment was posted; null otherwise. */
25
+ githubUrl: string | null;
26
+ }
27
+
28
+ /** A GitHub thread resolved to a position within a hunk for rendering. */
29
+ export interface AnchoredGithubThread extends GithubThread {
30
+ /** Index into `hunk.lines` the thread sits under. */
31
+ lineIndex: number;
32
+ }
33
+
34
+ /** A hunk as presented to the UI for a range. */
35
+ export interface HunkView {
36
+ /** Content hash identifying the hunk. */
37
+ hash: string;
38
+ /** Raw hunk header. */
39
+ header: string;
40
+ /** Base-side start line. */
41
+ oldStart: number;
42
+ /** Base-side line count. */
43
+ oldLines: number;
44
+ /** Head-side start line. */
45
+ newStart: number;
46
+ /** Head-side line count. */
47
+ newLines: number;
48
+ /** The hunk's lines. */
49
+ lines: DiffLine[];
50
+ /** Whether this hunk is marked viewed. */
51
+ viewed: boolean;
52
+ /** Comments anchored within this hunk. */
53
+ comments: AnchoredComment[];
54
+ /** Synced GitHub inline threads anchored within this hunk. */
55
+ githubThreads: AnchoredGithubThread[];
56
+ }
57
+
58
+ /** A file as presented to the UI for a range. */
59
+ export interface FileView {
60
+ /** Base-side path. */
61
+ oldPath: string;
62
+ /** Head-side path. */
63
+ newPath: string;
64
+ /** How the file changed. */
65
+ status: FileStatus;
66
+ /** True for binary files (no textual hunks). */
67
+ isBinary: boolean;
68
+ /** True if the file matches a lock/generated-file pattern. */
69
+ isLockfile: boolean;
70
+ /** True when the file has hunks and all are viewed. */
71
+ viewed: boolean;
72
+ /** The file's hunks. */
73
+ hunks: HunkView[];
74
+ }
75
+
76
+ /** Collaborators needed to assemble a range view. */
77
+ export interface BuildRangeDeps {
78
+ /** Produce the raw unified diff between two commits. */
79
+ rawDiff: (startSha: string, endSha: string) => Promise<string>;
80
+ /**
81
+ * Produce the porcelain word-diff between two commits, for intra-line change
82
+ * highlighting. Optional — when absent, lines carry no word-level ranges.
83
+ */
84
+ wordDiff?: (startSha: string, endSha: string) => Promise<string>;
85
+ /** Whether a hunk hash is marked viewed. */
86
+ isViewed: (hunkHash: string) => boolean;
87
+ /** Lock/generated-file glob patterns. */
88
+ lockfilePatterns: readonly string[];
89
+ /** All stored comments (attached to matching hunks/lines). */
90
+ comments?: readonly CommentRow[];
91
+ /** Synced GitHub threads (attached to matching hunks/lines). */
92
+ githubThreads?: readonly GithubThread[];
93
+ }
94
+
95
+ /** Look up a file's word-diff changes by either its base or head path. */
96
+ class WordChangeIndex {
97
+ private readonly byPath = new Map<string, FileWordChanges>();
98
+
99
+ constructor(files: FileWordChanges[]) {
100
+ for (const f of files) {
101
+ this.byPath.set(f.newPath, f);
102
+ this.byPath.set(f.oldPath, f);
103
+ }
104
+ }
105
+
106
+ /** The changes for a file, matched on head path first then base path. */
107
+ forFile(oldPath: string, newPath: string): FileWordChanges | undefined {
108
+ return this.byPath.get(newPath) ?? this.byPath.get(oldPath);
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Assemble the per-file, per-hunk view for a commit range: parse the diff,
114
+ * annotate each hunk with viewed state + anchored comments, and each file with
115
+ * its lock-file flag and auto-viewed status.
116
+ */
117
+ export async function buildRangeView(
118
+ deps: BuildRangeDeps,
119
+ startSha: string,
120
+ endSha: string,
121
+ ): Promise<FileView[]> {
122
+ const [raw, wordRaw] = await Promise.all([
123
+ deps.rawDiff(startSha, endSha),
124
+ deps.wordDiff ? deps.wordDiff(startSha, endSha) : Promise.resolve(""),
125
+ ]);
126
+ const wordChanges = new WordChangeIndex(parseWordDiff(wordRaw));
127
+ const threads: readonly GithubThread[] = deps.githubThreads ?? [];
128
+ // Drop local comments already represented by a synced thread (posted from mergie).
129
+ const syncedRootIds = new Set<string>(threads.map((t) => t.root.githubId));
130
+ const comments: readonly CommentRow[] = (deps.comments ?? []).filter(
131
+ (c) => c.githubId === null || !syncedRootIds.has(c.githubId),
132
+ );
133
+ return parseUnifiedDiff(raw).map((file) => {
134
+ const fc = wordChanges.forFile(file.oldPath, file.newPath);
135
+ const hunks: HunkView[] = file.hunks.map((h) => ({
136
+ hash: h.hash,
137
+ header: h.header,
138
+ oldStart: h.oldStart,
139
+ oldLines: h.oldLines,
140
+ newStart: h.newStart,
141
+ newLines: h.newLines,
142
+ lines: withWordChanges(h.lines, fc),
143
+ viewed: deps.isViewed(h.hash),
144
+ comments: anchorComments(file.newPath, h.hash, h.lines, comments),
145
+ githubThreads: anchorGithubThreads(file.newPath, h.lines, threads),
146
+ }));
147
+ return {
148
+ oldPath: file.oldPath,
149
+ newPath: file.newPath,
150
+ status: file.status,
151
+ isBinary: file.isBinary,
152
+ isLockfile: isLockfile(file.newPath, deps.lockfilePatterns),
153
+ viewed: hunks.length > 0 && hunks.every((h) => h.viewed),
154
+ hunks,
155
+ };
156
+ });
157
+ }
158
+
159
+ /** Map a stored comment to its anchored view form. */
160
+ function toAnchored(c: CommentRow, lineIndex: number): AnchoredComment {
161
+ return {
162
+ id: c.id, body: c.body, side: c.side, kind: c.kind, lineIndex,
163
+ createdAt: c.createdAt, updatedAt: c.updatedAt, githubUrl: c.githubUrl,
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Find the comments that anchor to a hunk: whole-hunk comments whose anchor is
169
+ * the hunk hash, and line comments (single or multi-line) whose anchored text
170
+ * matches a contiguous block of same-side lines.
171
+ */
172
+ function anchorComments(
173
+ path: string,
174
+ hunkHash: string,
175
+ lines: DiffLine[],
176
+ comments: readonly CommentRow[],
177
+ ): AnchoredComment[] {
178
+ const out: AnchoredComment[] = [];
179
+ for (const c of comments) {
180
+ if (c.path !== path) continue;
181
+ if (c.kind === "hunk") {
182
+ if (c.anchorHash === hunkHash) out.push(toAnchored(c, -1));
183
+ continue;
184
+ }
185
+ const span: number = c.startLine !== null && c.endLine !== null ? c.endLine - c.startLine + 1 : 1;
186
+ const idx = findAnchorWindow(path, lines, c.side, span, c.anchorHash);
187
+ if (idx !== null) out.push(toAnchored(c, idx));
188
+ }
189
+ return out;
190
+ }
191
+
192
+ /**
193
+ * Attach GitHub threads to a hunk: a thread anchors where the hunk has a line,
194
+ * on the thread's side, whose side-relative number equals the thread's line.
195
+ * Threads without a matching line in this range are dropped (not shown).
196
+ */
197
+ function anchorGithubThreads(
198
+ path: string,
199
+ lines: DiffLine[],
200
+ threads: readonly GithubThread[],
201
+ ): AnchoredGithubThread[] {
202
+ const out: AnchoredGithubThread[] = [];
203
+ for (const t of threads) {
204
+ if (t.path !== path || t.line === null) continue;
205
+ const lineIndex: number = lines.findIndex((l) => {
206
+ const onSide = t.side === "RIGHT" ? l.kind !== "del" : l.kind !== "add";
207
+ const no = t.side === "RIGHT" ? l.newNo : l.oldNo;
208
+ return onSide && no === t.line;
209
+ });
210
+ if (lineIndex !== -1) out.push({ ...t, lineIndex });
211
+ }
212
+ return out;
213
+ }
214
+
215
+ /** Indices of lines that participate on a given diff side, in reading order. */
216
+ function sideLineIndices(lines: DiffLine[], side: DiffSide): number[] {
217
+ const out: number[] = [];
218
+ lines.forEach((line, i) => {
219
+ const onSide = side === "RIGHT" ? line.kind === "add" || line.kind === "ctx" : line.kind === "del" || line.kind === "ctx";
220
+ if (onSide) out.push(i);
221
+ });
222
+ return out;
223
+ }
224
+
225
+ /**
226
+ * Locate a contiguous window of `span` same-side lines whose joined text hashes
227
+ * to `anchorHash`. Returns the window's first `lines` index, or null.
228
+ */
229
+ function findAnchorWindow(
230
+ path: string,
231
+ lines: DiffLine[],
232
+ side: DiffSide,
233
+ span: number,
234
+ anchorHash: string,
235
+ ): number | null {
236
+ const sideIdx: number[] = sideLineIndices(lines, side);
237
+ for (let s = 0; s + span <= sideIdx.length; s++) {
238
+ const window: number[] = sideIdx.slice(s, s + span);
239
+ const text: string = window.map((i) => lines[i]?.text ?? "").join("\n");
240
+ if (commentAnchorHash(path, side, text) === anchorHash) return window[0] ?? null;
241
+ }
242
+ return null;
243
+ }
@@ -0,0 +1,287 @@
1
+ import { z } from "zod";
2
+ import { publicProcedure, router } from "./trpc.ts";
3
+ import type { Context } from "./trpc.ts";
4
+ import type { Workspace } from "./registry.ts";
5
+
6
+ /** Resolve a loaded PR's workspace or throw a NOT_FOUND-style error. */
7
+ function workspaceOrThrow(ctx: Context, id: string): Workspace {
8
+ const ws = ctx.registry.getWorkspace(id);
9
+ if (!ws) throw new Error(`PR not loaded: ${id}`);
10
+ return ws;
11
+ }
12
+
13
+ /** Input schema carrying a PR id. */
14
+ const prInput = z.object({ id: z.string().min(1) });
15
+ /** Input schema carrying a PR id + a commit range. */
16
+ const rangeInput = z.object({ id: z.string().min(1), start: z.string().min(1), end: z.string().min(1) });
17
+
18
+ /**
19
+ * The mergie tRPC router. End-to-end typed; the React UI infers its client
20
+ * types from `AppRouter`. New feature areas (diff, comments, symbols, ai) are
21
+ * added here as nested routers.
22
+ */
23
+ export const appRouter = router({
24
+ /** Liveness + the set of loaded PRs. */
25
+ health: publicProcedure.query(({ ctx }) => ({ ok: true, prs: ctx.registry.listPrs() })),
26
+
27
+ /** List loaded PRs. */
28
+ listPrs: publicProcedure.query(({ ctx }) => ctx.registry.listPrs()),
29
+
30
+ /** Open PRs authored by / assigned to / review-requested from the viewer. */
31
+ listMyPrs: publicProcedure.query(({ ctx }) => ctx.search.listMyPrs()),
32
+
33
+ /** Load (or attach to) a PR by URL. */
34
+ loadPr: publicProcedure
35
+ .input(z.object({ url: z.string().min(1) }))
36
+ .mutation(({ ctx, input }) => ctx.registry.loadPr(input.url)),
37
+
38
+ /** Re-fetch a loaded PR (new commits / head movement) from GitHub. */
39
+ refreshPr: publicProcedure
40
+ .input(prInput)
41
+ .mutation(async ({ ctx, input }) => {
42
+ await workspaceOrThrow(ctx, input.id).refresh();
43
+ return { ok: true };
44
+ }),
45
+
46
+ /** Commits of a loaded PR, oldest → newest (no clone required). */
47
+ prCommits: publicProcedure
48
+ .input(prInput)
49
+ .query(({ ctx, input }) => ctx.registry.commits(input.id)),
50
+
51
+ /** Commit topology (baseline + commits) for the range selector. */
52
+ commitsWithBaseline: publicProcedure
53
+ .input(prInput)
54
+ .query(({ ctx, input }) => workspaceOrThrow(ctx, input.id).commitsWithBaseline()),
55
+
56
+ /** The default range to show on open. */
57
+ defaultRange: publicProcedure
58
+ .input(prInput)
59
+ .query(({ ctx, input }) => workspaceOrThrow(ctx, input.id).defaultRange()),
60
+
61
+ /** The file/hunk view for a commit range. */
62
+ rangeView: publicProcedure
63
+ .input(rangeInput.extend({ ignoreWhitespace: z.boolean().optional() }))
64
+ .query(({ ctx, input }) => workspaceOrThrow(ctx, input.id).rangeView(input.start, input.end, input.ignoreWhitespace)),
65
+
66
+ /** Set or clear a hunk's viewed state. */
67
+ setHunkViewed: publicProcedure
68
+ .input(z.object({ id: z.string().min(1), hunkHash: z.string().min(1), viewed: z.boolean() }))
69
+ .mutation(({ ctx, input }) => {
70
+ workspaceOrThrow(ctx, input.id).setHunkViewed(input.hunkHash, input.viewed);
71
+ return { ok: true };
72
+ }),
73
+
74
+ /** Mark a commit range reviewed. */
75
+ addReviewedRange: publicProcedure
76
+ .input(rangeInput)
77
+ .mutation(({ ctx, input }) => {
78
+ workspaceOrThrow(ctx, input.id).addReviewedRange(input.start, input.end);
79
+ return { ok: true };
80
+ }),
81
+
82
+ /** Un-mark a reviewed commit range. */
83
+ removeReviewedRange: publicProcedure
84
+ .input(rangeInput)
85
+ .mutation(({ ctx, input }) => {
86
+ workspaceOrThrow(ctx, input.id).removeReviewedRange(input.start, input.end);
87
+ return { ok: true };
88
+ }),
89
+
90
+ /** List reviewed ranges. */
91
+ listReviewedRanges: publicProcedure
92
+ .input(prInput)
93
+ .query(({ ctx, input }) => workspaceOrThrow(ctx, input.id).listReviewedRanges()),
94
+
95
+ /** All comments for a PR. */
96
+ listComments: publicProcedure
97
+ .input(prInput)
98
+ .query(({ ctx, input }) => workspaceOrThrow(ctx, input.id).listComments()),
99
+
100
+ /** Unified list (local + fetched GitHub, deduped) for the "All comments" view. */
101
+ listAllComments: publicProcedure
102
+ .input(prInput)
103
+ .query(({ ctx, input }) => workspaceOrThrow(ctx, input.id).listAllComments()),
104
+
105
+ /** Full-file side-by-side split rows for a path over a range. */
106
+ fileSplit: publicProcedure
107
+ .input(z.object({ id: z.string().min(1), path: z.string().min(1), start: z.string().min(1), end: z.string().min(1), ignoreWhitespace: z.boolean().optional() }))
108
+ .query(({ ctx, input }) => workspaceOrThrow(ctx, input.id).fileSplit(input.path, input.start, input.end, input.ignoreWhitespace)),
109
+
110
+ /** Create a comment on a line or a whole hunk. */
111
+ addComment: publicProcedure
112
+ .input(z.object({
113
+ id: z.string().min(1),
114
+ kind: z.enum(["lines", "hunk"]),
115
+ side: z.enum(["LEFT", "RIGHT"]),
116
+ path: z.string().min(1),
117
+ body: z.string().min(1),
118
+ madeAtSha: z.string().min(1),
119
+ lineText: z.string().optional(),
120
+ lineNo: z.number().optional(),
121
+ endLineNo: z.number().optional(),
122
+ hunkHash: z.string().optional(),
123
+ }))
124
+ .mutation(({ ctx, input }) => {
125
+ const { id, ...comment } = input;
126
+ return { id: workspaceOrThrow(ctx, id).addComment(comment) };
127
+ }),
128
+
129
+ /** Edit a comment's body (propagates to GitHub if posted). */
130
+ editComment: publicProcedure
131
+ .input(z.object({ id: z.string().min(1), commentId: z.number(), body: z.string().min(1) }))
132
+ .mutation(async ({ ctx, input }) => {
133
+ await workspaceOrThrow(ctx, input.id).editComment(input.commentId, input.body);
134
+ return { ok: true };
135
+ }),
136
+
137
+ /** Delete a comment (also deletes on GitHub if posted). */
138
+ deleteComment: publicProcedure
139
+ .input(z.object({ id: z.string().min(1), commentId: z.number() }))
140
+ .mutation(async ({ ctx, input }) => {
141
+ await workspaceOrThrow(ctx, input.id).deleteComment(input.commentId);
142
+ return { ok: true };
143
+ }),
144
+
145
+ /** Preview where a comment would post to GitHub (line + any warning). */
146
+ postCommentPreview: publicProcedure
147
+ .input(z.object({ id: z.string().min(1), commentId: z.number(), target: z.enum(["end", "head"]) }))
148
+ .query(({ ctx, input }) => workspaceOrThrow(ctx, input.id).postCommentPreview(input.commentId, input.target)),
149
+
150
+ /** Post a comment to GitHub as a single inline comment. */
151
+ postComment: publicProcedure
152
+ .input(z.object({ id: z.string().min(1), commentId: z.number(), target: z.enum(["end", "head"]) }))
153
+ .mutation(({ ctx, input }) => workspaceOrThrow(ctx, input.id).postComment(input.commentId, input.target)),
154
+
155
+ /** Pull GitHub inline comments into the local cache. */
156
+ syncGithub: publicProcedure
157
+ .input(prInput)
158
+ .mutation(async ({ ctx, input }) => ({ synced: await workspaceOrThrow(ctx, input.id).syncGithub() })),
159
+
160
+ /** Reply to a GitHub inline thread by its root comment id. */
161
+ replyToThread: publicProcedure
162
+ .input(z.object({ id: z.string().min(1), rootGithubId: z.string().min(1), body: z.string().min(1) }))
163
+ .mutation(async ({ ctx, input }) => {
164
+ await workspaceOrThrow(ctx, input.id).replyToThread(input.rootGithubId, input.body);
165
+ return { ok: true };
166
+ }),
167
+
168
+ /** Edit a GitHub comment (by GitHub id) the viewer authored. */
169
+ editGithubComment: publicProcedure
170
+ .input(z.object({ id: z.string().min(1), githubId: z.string().min(1), body: z.string().min(1) }))
171
+ .mutation(async ({ ctx, input }) => {
172
+ await workspaceOrThrow(ctx, input.id).editGithubComment(input.githubId, input.body);
173
+ return { ok: true };
174
+ }),
175
+
176
+ /** Delete a GitHub comment (by GitHub id) the viewer authored. */
177
+ deleteGithubComment: publicProcedure
178
+ .input(z.object({ id: z.string().min(1), githubId: z.string().min(1) }))
179
+ .mutation(async ({ ctx, input }) => {
180
+ await workspaceOrThrow(ctx, input.id).deleteGithubComment(input.githubId);
181
+ return { ok: true };
182
+ }),
183
+
184
+ /** Definition of a symbol via `sem`, at a commit's checkout (optional file scope). */
185
+ symbolDefinition: publicProcedure
186
+ .input(z.object({ id: z.string().min(1), symbol: z.string().min(1), sha: z.string().min(1), file: z.string().min(1).optional() }))
187
+ .query(({ ctx, input }) => workspaceOrThrow(ctx, input.id).symbolDefinition(input.symbol, input.sha, input.file)),
188
+
189
+ /** Usages of a symbol via `sem`, at a commit's checkout (optional file scope). */
190
+ symbolUsages: publicProcedure
191
+ .input(z.object({ id: z.string().min(1), symbol: z.string().min(1), sha: z.string().min(1), file: z.string().min(1).optional() }))
192
+ .query(({ ctx, input }) => workspaceOrThrow(ctx, input.id).symbolUsages(input.symbol, input.sha, input.file)),
193
+
194
+ /** Literal (default) or regex word search via `rg`, at a commit's checkout. */
195
+ symbolSearch: publicProcedure
196
+ .input(z.object({
197
+ id: z.string().min(1), word: z.string().min(1), sha: z.string().min(1),
198
+ caseSensitive: z.boolean().optional(), regex: z.boolean().optional(),
199
+ }))
200
+ .query(({ ctx, input }) => workspaceOrThrow(ctx, input.id).symbolSearch(input.word, input.sha, { caseSensitive: input.caseSensitive, regex: input.regex })),
201
+
202
+ /** Full text of a file at a commit (for a symbol result's full-file popup). */
203
+ fileAt: publicProcedure
204
+ .input(z.object({ id: z.string().min(1), sha: z.string().min(1), path: z.string().min(1) }))
205
+ .query(({ ctx, input }) => workspaceOrThrow(ctx, input.id).fileAt(input.sha, input.path)),
206
+
207
+ /** The selectable models + review templates from config. */
208
+ config: publicProcedure
209
+ .input(prInput)
210
+ .query(({ ctx, input }) => workspaceOrThrow(ctx, input.id).config()),
211
+
212
+ /** Start an AI chat session scoped to a hunk or file. */
213
+ createChatSession: publicProcedure
214
+ .input(z.object({
215
+ id: z.string().min(1),
216
+ scopeKind: z.enum(["hunk", "file"]),
217
+ scopeRef: z.string().min(1),
218
+ model: z.string().min(1),
219
+ title: z.string().optional(),
220
+ }))
221
+ .mutation(({ ctx, input }) => ({
222
+ sessionId: workspaceOrThrow(ctx, input.id).createChatSession(input.scopeKind, input.scopeRef, input.model, input.title),
223
+ })),
224
+
225
+ /** List chat sessions, optionally scoped to a hunk/file. */
226
+ listChatSessions: publicProcedure
227
+ .input(z.object({ id: z.string().min(1), scopeKind: z.enum(["hunk", "file"]).optional(), scopeRef: z.string().optional() }))
228
+ .query(({ ctx, input }) => workspaceOrThrow(ctx, input.id).listChatSessions(input.scopeKind, input.scopeRef)),
229
+
230
+ /** Messages for a chat session. */
231
+ listChatMessages: publicProcedure
232
+ .input(z.object({ id: z.string().min(1), sessionId: z.number() }))
233
+ .query(({ ctx, input }) => workspaceOrThrow(ctx, input.id).listChatMessages(input.sessionId)),
234
+
235
+ /** AI-generated artifacts, optionally scoped to a commit range. */
236
+ listArtifacts: publicProcedure
237
+ .input(z.object({ id: z.string().min(1), start: z.string().optional(), end: z.string().optional() }))
238
+ .query(({ ctx, input }) => {
239
+ const range = input.start && input.end ? { start: input.start, end: input.end } : undefined;
240
+ return workspaceOrThrow(ctx, input.id).listArtifacts(range);
241
+ }),
242
+
243
+ /** Run an AI review of a commit range (blocks until complete). */
244
+ runAiReview: publicProcedure
245
+ .input(z.object({
246
+ id: z.string().min(1),
247
+ start: z.string().min(1),
248
+ end: z.string().min(1),
249
+ model: z.string().min(1),
250
+ templateId: z.string().optional(),
251
+ prompt: z.string().optional(),
252
+ }))
253
+ .mutation(({ ctx, input }) => workspaceOrThrow(ctx, input.id).runAiReview(
254
+ { start: input.start, end: input.end },
255
+ { model: input.model, templateId: input.templateId, prompt: input.prompt },
256
+ )),
257
+
258
+ /** List AI reviews, optionally scoped to a commit range. */
259
+ listAiReviews: publicProcedure
260
+ .input(z.object({ id: z.string().min(1), start: z.string().optional(), end: z.string().optional() }))
261
+ .query(({ ctx, input }) => {
262
+ const range = input.start && input.end ? { start: input.start, end: input.end } : undefined;
263
+ return workspaceOrThrow(ctx, input.id).listAiReviews(range);
264
+ }),
265
+
266
+ /** In-flight / recently-completed AI-review statuses for this PR (for the header indicator). */
267
+ aiReviewStatuses: publicProcedure
268
+ .input(z.object({ id: z.string().min(1) }))
269
+ .query(({ ctx, input }) => workspaceOrThrow(ctx, input.id).aiReviewStatuses()),
270
+
271
+ /** Dismiss a completed AI-review status once the user has acted on it. */
272
+ dismissAiReviewStatus: publicProcedure
273
+ .input(z.object({ id: z.string().min(1), start: z.string().min(1), end: z.string().min(1) }))
274
+ .mutation(({ ctx, input }) => {
275
+ workspaceOrThrow(ctx, input.id).dismissAiReviewStatus({ start: input.start, end: input.end });
276
+ return { dismissed: true };
277
+ }),
278
+
279
+ /** Stop the daemon. */
280
+ stop: publicProcedure.mutation(({ ctx }) => {
281
+ ctx.requestStop();
282
+ return { stopping: true };
283
+ }),
284
+ });
285
+
286
+ /** Type of the mergie router, consumed by the typed client. */
287
+ export type AppRouter = typeof appRouter;
@@ -0,0 +1,106 @@
1
+ import { join, normalize } from "node:path";
2
+ import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
3
+ import { appRouter } from "./router.ts";
4
+ import { handleChatMessage } from "./chatSocket.ts";
5
+ import type { ServerWebSocket } from "bun";
6
+ import type { Context } from "./trpc.ts";
7
+ import type { PrRegistry } from "./registry.ts";
8
+ import { createGhSearchService, type GhSearchService } from "@/services/ghSearch.ts";
9
+
10
+ /** A running daemon instance. */
11
+ export interface DaemonHandle {
12
+ /** The bound port. */
13
+ port: number;
14
+ /** Base URL, e.g. `http://localhost:4517`. */
15
+ url: string;
16
+ /** Stop the server. */
17
+ stop: () => void;
18
+ }
19
+
20
+ /** Options for starting the daemon. */
21
+ export interface StartDaemonOptions {
22
+ /** Port to bind (0 = random, useful for tests). */
23
+ port: number;
24
+ /** The PR registry backing the API. */
25
+ registry: PrRegistry;
26
+ /** GitHub PR-search service (defaults to the real `gh`-backed service). */
27
+ search?: GhSearchService;
28
+ /** Called when the `stop` procedure runs (defaults to stopping the server). */
29
+ requestStop?: () => void;
30
+ /** Directory of built static UI assets to serve (optional). */
31
+ staticDir?: string;
32
+ }
33
+
34
+ /** tRPC endpoint prefix. */
35
+ const TRPC_PREFIX = "/trpc";
36
+
37
+ /** WebSocket path for streaming AI chat. */
38
+ const CHAT_WS_PATH = "/ws/chat";
39
+
40
+ /**
41
+ * Start the mergie daemon: serves the tRPC API under `/trpc` and, if
42
+ * `staticDir` is given, the built React UI for all other routes (SPA
43
+ * fallback to `index.html`).
44
+ */
45
+ export async function startDaemon(opts: StartDaemonOptions): Promise<DaemonHandle> {
46
+ const ctx: Context = {
47
+ registry: opts.registry,
48
+ search: opts.search ?? createGhSearchService(),
49
+ requestStop: () => opts.requestStop?.(),
50
+ };
51
+
52
+ const server = Bun.serve({
53
+ port: opts.port,
54
+ async fetch(req: Request, srv): Promise<Response | undefined> {
55
+ const url = new URL(req.url);
56
+ if (url.pathname === CHAT_WS_PATH) {
57
+ return srv.upgrade(req) ? undefined : new Response("Upgrade failed", { status: 400 });
58
+ }
59
+ if (url.pathname === "/artifact") {
60
+ return serveArtifact(url, opts.registry);
61
+ }
62
+ if (url.pathname === TRPC_PREFIX || url.pathname.startsWith(`${TRPC_PREFIX}/`)) {
63
+ return fetchRequestHandler({
64
+ endpoint: TRPC_PREFIX,
65
+ req,
66
+ router: appRouter,
67
+ createContext: () => Promise.resolve(ctx),
68
+ });
69
+ }
70
+ return serveStatic(url.pathname, opts.staticDir);
71
+ },
72
+ websocket: {
73
+ async message(ws: ServerWebSocket, message: string | Buffer): Promise<void> {
74
+ await handleChatMessage(opts.registry, String(message), (data) => ws.send(JSON.stringify(data)));
75
+ },
76
+ },
77
+ });
78
+
79
+ const boundPort: number = server.port ?? opts.port;
80
+ return {
81
+ port: boundPort,
82
+ url: `http://localhost:${boundPort}`,
83
+ stop: () => server.stop(true),
84
+ };
85
+ }
86
+
87
+ /** Serve an AI-generated artifact file (`/artifact?id=<prId>&path=<relPath>`). */
88
+ async function serveArtifact(url: URL, registry: PrRegistry): Promise<Response> {
89
+ const id: string | null = url.searchParams.get("id");
90
+ const relPath: string | null = url.searchParams.get("path");
91
+ if (!id || !relPath) return new Response("Bad request", { status: 400 });
92
+ const ws = registry.getWorkspace(id);
93
+ const abs: string | null = ws ? ws.resolveArtifact(relPath) : null;
94
+ if (!abs) return new Response("Not found", { status: 404 });
95
+ const file = Bun.file(abs);
96
+ return (await file.exists()) ? new Response(file) : new Response("Not found", { status: 404 });
97
+ }
98
+
99
+ /** Serve a static file from `staticDir`, falling back to `index.html` (SPA). */
100
+ async function serveStatic(pathname: string, staticDir?: string): Promise<Response> {
101
+ if (!staticDir) return new Response("Not found", { status: 404 });
102
+ const rel: string = normalize(pathname).replace(/^(\.\.[/\\])+/, "");
103
+ const candidate = Bun.file(join(staticDir, rel === "/" ? "index.html" : rel));
104
+ if (await candidate.exists()) return new Response(candidate);
105
+ return new Response(Bun.file(join(staticDir, "index.html")));
106
+ }