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.
- package/LICENSE +674 -0
- package/README.md +91 -0
- package/bin/mergie-dev +18 -0
- package/bin/mergie.ts +44 -0
- package/dist/web/assets/index-4bq8mkyV.css +1 -0
- package/dist/web/assets/index-Cp_Gdwf2.js +50 -0
- package/dist/web/favicon.svg +12 -0
- package/dist/web/index.html +14 -0
- package/package.json +68 -0
- package/src/cli/args.ts +63 -0
- package/src/cli/constants.ts +32 -0
- package/src/cli/daemonClient.ts +42 -0
- package/src/cli/openBrowser.ts +11 -0
- package/src/cli/run.ts +76 -0
- package/src/daemon/aiReviewTracker.ts +71 -0
- package/src/daemon/allComments.ts +119 -0
- package/src/daemon/artifactCapture.ts +47 -0
- package/src/daemon/buildUi.ts +54 -0
- package/src/daemon/chatPrompt.ts +35 -0
- package/src/daemon/chatSocket.ts +78 -0
- package/src/daemon/createRegistry.ts +569 -0
- package/src/daemon/githubThreads.ts +92 -0
- package/src/daemon/inflight.ts +49 -0
- package/src/daemon/postMapping.ts +94 -0
- package/src/daemon/registry.ts +263 -0
- package/src/daemon/reviewPrompt.ts +22 -0
- package/src/daemon/reviewService.ts +243 -0
- package/src/daemon/router.ts +287 -0
- package/src/daemon/server.ts +106 -0
- package/src/daemon/splitView.ts +63 -0
- package/src/daemon/trpc.ts +20 -0
- package/src/db/migrate.ts +24 -0
- package/src/db/repositories/aiReviews.ts +113 -0
- package/src/db/repositories/artifacts.ts +101 -0
- package/src/db/repositories/chatSessions.ts +197 -0
- package/src/db/repositories/comments.ts +195 -0
- package/src/db/repositories/githubComments.ts +166 -0
- package/src/db/repositories/hunkViews.ts +36 -0
- package/src/db/repositories/reviewedRanges.ts +75 -0
- package/src/db/schema.ts +97 -0
- package/src/domain/config.ts +137 -0
- package/src/domain/context.ts +32 -0
- package/src/domain/diff.ts +178 -0
- package/src/domain/entities.ts +92 -0
- package/src/domain/fuzzy.ts +43 -0
- package/src/domain/generated.ts +33 -0
- package/src/domain/hash.ts +0 -0
- package/src/domain/lockfiles.ts +20 -0
- package/src/domain/paths.ts +59 -0
- package/src/domain/ranges.ts +81 -0
- package/src/domain/references.ts +36 -0
- package/src/domain/url.ts +50 -0
- package/src/domain/wordDiff.ts +174 -0
- package/src/services/ai.ts +137 -0
- package/src/services/exec.ts +44 -0
- package/src/services/ghPr.ts +102 -0
- package/src/services/ghSearch.ts +135 -0
- package/src/services/git.ts +297 -0
- package/src/services/github.ts +300 -0
- package/src/services/symbols.ts +364 -0
- package/src/web/App.tsx +32 -0
- package/src/web/components/AiReviewIndicator.tsx +63 -0
- package/src/web/components/AiReviewModal.tsx +101 -0
- package/src/web/components/AiReviewsPanel.tsx +64 -0
- package/src/web/components/ChatPanel.tsx +142 -0
- package/src/web/components/CodePreview.tsx +63 -0
- package/src/web/components/CommentComposer.tsx +40 -0
- package/src/web/components/CommentItem.tsx +59 -0
- package/src/web/components/CommentsPanel.tsx +309 -0
- package/src/web/components/CommitRail.tsx +64 -0
- package/src/web/components/ConfirmButton.tsx +32 -0
- package/src/web/components/CopyButton.tsx +33 -0
- package/src/web/components/CopyIconButton.tsx +38 -0
- package/src/web/components/DiffFrame.tsx +133 -0
- package/src/web/components/DiffLines.tsx +149 -0
- package/src/web/components/FileFrame.tsx +45 -0
- package/src/web/components/FileNavigator.tsx +195 -0
- package/src/web/components/FileTree.tsx +45 -0
- package/src/web/components/FileView.tsx +53 -0
- package/src/web/components/GithubThreadView.tsx +56 -0
- package/src/web/components/HunkCard.tsx +121 -0
- package/src/web/components/Icons.tsx +215 -0
- package/src/web/components/IdentifierMenuPortal.tsx +33 -0
- package/src/web/components/PostMenu.tsx +63 -0
- package/src/web/components/PrDescription.tsx +29 -0
- package/src/web/components/PrLoading.tsx +45 -0
- package/src/web/components/PrPicker.tsx +201 -0
- package/src/web/components/RangeSelector.tsx +141 -0
- package/src/web/components/ResultsList.tsx +159 -0
- package/src/web/components/ReviewView.tsx +308 -0
- package/src/web/components/RightRail.tsx +146 -0
- package/src/web/components/SearchRailPanel.tsx +127 -0
- package/src/web/components/Switch.tsx +31 -0
- package/src/web/components/SwitchPrModal.tsx +28 -0
- package/src/web/components/SymbolLookupMenu.tsx +35 -0
- package/src/web/components/Toolbar.tsx +79 -0
- package/src/web/components/Tooltip.tsx +72 -0
- package/src/web/index.html +13 -0
- package/src/web/lib/aiReviewIndicator.ts +29 -0
- package/src/web/lib/anchorRow.ts +25 -0
- package/src/web/lib/codeSearchFetch.ts +46 -0
- package/src/web/lib/commentFilters.ts +36 -0
- package/src/web/lib/commentVisibility.ts +90 -0
- package/src/web/lib/composerKeys.ts +24 -0
- package/src/web/lib/dedupeResults.ts +37 -0
- package/src/web/lib/diffMarks.ts +81 -0
- package/src/web/lib/fileStatus.ts +12 -0
- package/src/web/lib/filterCodeResults.ts +42 -0
- package/src/web/lib/filterPrs.ts +38 -0
- package/src/web/lib/highlight.ts +40 -0
- package/src/web/lib/identifierMenu.ts +41 -0
- package/src/web/lib/lineSelection.ts +48 -0
- package/src/web/lib/navRouting.ts +40 -0
- package/src/web/lib/navStack.ts +109 -0
- package/src/web/lib/persistedFlag.ts +43 -0
- package/src/web/lib/prPickerModel.ts +27 -0
- package/src/web/lib/railState.ts +19 -0
- package/src/web/lib/rangeCoverage.ts +27 -0
- package/src/web/lib/rangeMap.ts +42 -0
- package/src/web/lib/resultCountLabel.ts +11 -0
- package/src/web/lib/reviewProgress.ts +26 -0
- package/src/web/lib/searchInputsKey.ts +35 -0
- package/src/web/lib/searchToken.ts +25 -0
- package/src/web/lib/splitSide.ts +13 -0
- package/src/web/lib/time.ts +9 -0
- package/src/web/lib/togglePrefs.ts +81 -0
- package/src/web/lib/useEscToClose.ts +17 -0
- package/src/web/lib/useIdentifierMenu.ts +77 -0
- package/src/web/lib/useNavLookup.ts +74 -0
- package/src/web/lib/usePageTitle.ts +15 -0
- package/src/web/lib/visibleFiles.ts +52 -0
- package/src/web/main.tsx +24 -0
- package/src/web/public/favicon.svg +12 -0
- package/src/web/state/useChat.ts +181 -0
- package/src/web/state/useCodeSearch.ts +198 -0
- package/src/web/state/useReview.ts +138 -0
- package/src/web/styles.css +1020 -0
- package/src/web/trpc.ts +5 -0
- package/tsconfig.json +27 -0
- 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
|
+
}
|