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,78 @@
1
+ import type { Workspace } from "./registry.ts";
2
+
3
+ /** The minimal registry surface the chat socket needs. */
4
+ export interface WorkspaceLookup {
5
+ /** Resolve a loaded PR's workspace by id. */
6
+ getWorkspace(id: string): Workspace | undefined;
7
+ }
8
+
9
+ /** A parsed chat streaming request from the WebSocket client. */
10
+ interface ChatRequest {
11
+ /** PR id whose workspace to run the chat in. */
12
+ id: string;
13
+ /** Chat session id. */
14
+ sessionId: number;
15
+ /** The user's prompt. */
16
+ prompt: string;
17
+ /** The commit range in view, so generated artifacts can be linked to it. */
18
+ range?: { start: string; end: string };
19
+ }
20
+
21
+ /** True when the value is a non-null object. */
22
+ function isRecord(v: unknown): v is Record<string, unknown> {
23
+ return typeof v === "object" && v !== null;
24
+ }
25
+
26
+ /** Parse a raw WS frame into a ChatRequest, or null when malformed. */
27
+ function parseRequest(raw: string): ChatRequest | null {
28
+ let v: unknown;
29
+ try {
30
+ v = JSON.parse(raw);
31
+ } catch {
32
+ return null;
33
+ }
34
+ if (isRecord(v) && typeof v.id === "string" && typeof v.sessionId === "number" && typeof v.prompt === "string") {
35
+ return { id: v.id, sessionId: v.sessionId, prompt: v.prompt, range: parseRange(v.range) };
36
+ }
37
+ return null;
38
+ }
39
+
40
+ /** Parse an optional `{start,end}` range. */
41
+ function parseRange(v: unknown): { start: string; end: string } | undefined {
42
+ if (isRecord(v) && typeof v.start === "string" && typeof v.end === "string") return { start: v.start, end: v.end };
43
+ return undefined;
44
+ }
45
+
46
+ /**
47
+ * Handle one chat streaming request: resolve the workspace, stream assistant
48
+ * text as `{type:'chunk'}` events, then a `{type:'done'}` event; any failure is
49
+ * reported as a single `{type:'error'}` event. Transport-agnostic — `send` is
50
+ * the only side-effect, so this is unit-tested without a real socket.
51
+ */
52
+ export async function handleChatMessage(
53
+ lookup: WorkspaceLookup,
54
+ raw: string,
55
+ send: (data: unknown) => void,
56
+ ): Promise<void> {
57
+ const req = parseRequest(raw);
58
+ if (!req) {
59
+ send({ type: "error", message: "Malformed chat request." });
60
+ return;
61
+ }
62
+ const workspace = lookup.getWorkspace(req.id);
63
+ if (!workspace) {
64
+ send({ type: "error", message: `PR not loaded: ${req.id}` });
65
+ return;
66
+ }
67
+ try {
68
+ await workspace.streamChat(
69
+ req.sessionId,
70
+ req.prompt,
71
+ (ev) => send(ev.kind === "activity" ? { type: "activity", text: ev.text } : { type: "chunk", text: ev.text }),
72
+ req.range,
73
+ );
74
+ send({ type: "done" });
75
+ } catch (err) {
76
+ send({ type: "error", message: err instanceof Error ? err.message : String(err) });
77
+ }
78
+ }
@@ -0,0 +1,569 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import type { Database } from "bun:sqlite";
3
+ import { join } from "node:path";
4
+ import { parsePrUrl, type PullRequestRef } from "@/domain/url.ts";
5
+ import { artifactsDir, cloneDir, dataDir, dbPath, type PathEnv } from "@/domain/paths.ts";
6
+ import { defaultConfig, loadConfig, type MergieConfig } from "@/domain/config.ts";
7
+ import { resolveDefaultRange, type Range } from "@/domain/ranges.ts";
8
+ import { commentAnchorHash } from "@/domain/hash.ts";
9
+ import { openDatabase } from "@/db/migrate.ts";
10
+ import { hunkViewsRepo } from "@/db/repositories/hunkViews.ts";
11
+ import { reviewedRangesRepo } from "@/db/repositories/reviewedRanges.ts";
12
+ import { commentsRepo } from "@/db/repositories/comments.ts";
13
+ import { githubCommentsRepo, type GithubCommentRow } from "@/db/repositories/githubComments.ts";
14
+ import { chatSessionsRepo, type ChatMessageRow, type ChatScopeKind, type ChatSessionRow } from "@/db/repositories/chatSessions.ts";
15
+ import { artifactsRepo, type ArtifactRow } from "@/db/repositories/artifacts.ts";
16
+ import { aiReviewsRepo, type AiReviewRow } from "@/db/repositories/aiReviews.ts";
17
+ import { listRelFiles, newArtifacts } from "./artifactCapture.ts";
18
+ import { buildReviewPrompt } from "./reviewPrompt.ts";
19
+ import { createInflight, type Inflight } from "./inflight.ts";
20
+ import { createAiReviewTracker, type AiReviewStatus } from "./aiReviewTracker.ts";
21
+ import type { GithubComment } from "@/services/github.ts";
22
+ import { createAiService, type AiService } from "@/services/ai.ts";
23
+ import { groupGithubThreads, type GithubThread, type GithubThreadRow } from "./githubThreads.ts";
24
+ import { mergeAllComments, type AllCommentEntry } from "./allComments.ts";
25
+ import { chatTranscript, sessionTitle } from "./chatPrompt.ts";
26
+ import { bunRunner } from "@/services/exec.ts";
27
+ import { createGitService, type CommitInfo, type GitService } from "@/services/git.ts";
28
+ import { createGhPrService, type GhPrService, type PrMeta } from "@/services/ghPr.ts";
29
+ import { createGithubService, type GithubService } from "@/services/github.ts";
30
+ import { createSymbolsService, type CodeResult, type SymbolsService } from "@/services/symbols.ts";
31
+ import { isLockfile } from "@/domain/lockfiles.ts";
32
+ import { parseUnifiedDiff } from "@/domain/diff.ts";
33
+ import { parseWordDiff, withWordChanges } from "@/domain/wordDiff.ts";
34
+ import { hunkChangedSpan, locateLineComment, toPostInput } from "./postMapping.ts";
35
+ import { buildRangeView, type FileView } from "./reviewService.ts";
36
+ import { buildSplitRows, type SplitRow } from "./splitView.ts";
37
+ import type {
38
+ AiReviewOptions, ChatRange, CommitsWithBaseline, LiveChatEvent, LoadedPr, NewComment, PostPreview, PostTarget, PrRegistry, Workspace,
39
+ } from "./registry.ts";
40
+
41
+ /** Injectable dependencies for the registry (defaults use real services). */
42
+ export interface RegistryDeps {
43
+ /** GitHub PR-metadata service. */
44
+ ghPr?: GhPrService;
45
+ /** Opens (and migrates) a database at a path. */
46
+ openDb?: (path: string) => Database;
47
+ /** Builds a git service for a clone directory. */
48
+ makeGit?: (dir: string) => GitService;
49
+ /** Resolved configuration (defaults to loading from disk). */
50
+ config?: MergieConfig;
51
+ /** Builds a GitHub inline-comment service for a PR. */
52
+ makeGithub?: (ref: { owner: string; repo: string; number: number }) => GithubService;
53
+ /**
54
+ * Builds the symbol-navigation service (sem/rg). The matcher tells the
55
+ * service which paths are lockfile/generated (used for `testOrGenerated`).
56
+ */
57
+ makeSymbols?: (isLockfileOrGenerated: (path: string) => boolean) => SymbolsService;
58
+ /** Builds the AI service (Claude Agent SDK). */
59
+ makeAi?: () => AiService;
60
+ /** Ensures a directory exists. */
61
+ ensureDir?: (dir: string) => void;
62
+ /** Path resolution environment. */
63
+ pathEnv?: PathEnv;
64
+ /** Milliseconds clock (injectable for deterministic tests). */
65
+ now?: () => number;
66
+ }
67
+
68
+ /** Stable per-PR id, e.g. `withastro_astro_17360`. */
69
+ function prId(ref: PullRequestRef): string {
70
+ return `${ref.owner}_${ref.repo}_${ref.number}`;
71
+ }
72
+
73
+ /**
74
+ * Create the real {@link PrRegistry}. Loading a PR fetches metadata from GitHub
75
+ * and opens its per-PR database; the clone happens lazily on the first
76
+ * operation that needs it (see {@link makeWorkspace}).
77
+ */
78
+ export function createPrRegistry(deps: RegistryDeps = {}): PrRegistry {
79
+ const ghPr: GhPrService = deps.ghPr ?? createGhPrService(bunRunner);
80
+ const openDb = deps.openDb ?? openDatabase;
81
+ const makeGit = deps.makeGit ?? ((dir: string) => createGitService(dir, bunRunner));
82
+ const makeGithub = deps.makeGithub ?? ((ref) => createGithubService(ref, bunRunner));
83
+ const makeSymbols = deps.makeSymbols ?? ((m: (p: string) => boolean) => createSymbolsService(bunRunner, m));
84
+ const makeAi = deps.makeAi ?? (() => createAiService());
85
+ const ensureDir = deps.ensureDir ?? ((dir: string) => { mkdirSync(dir, { recursive: true }); });
86
+ const now = deps.now ?? (() => Date.now());
87
+ const inflight: Inflight = createInflight();
88
+ const workspaces = new Map<string, Workspace>();
89
+
90
+ return {
91
+ async loadPr(url: string): Promise<LoadedPr> {
92
+ const ref: PullRequestRef = parsePrUrl(url);
93
+ const id: string = prId(ref);
94
+ const existing = workspaces.get(id);
95
+ if (existing) return existing.pr;
96
+
97
+ const meta: PrMeta = await ghPr.fetchPr(ref);
98
+ ensureDir(dataDir(ref, deps.pathEnv));
99
+ const db: Database = openDb(dbPath(ref, deps.pathEnv));
100
+ const pr: LoadedPr = {
101
+ id, url, owner: ref.owner, repo: ref.repo, number: ref.number,
102
+ title: meta.title, body: meta.body, baseRef: meta.baseRef, headRef: meta.headRef,
103
+ };
104
+ const config: MergieConfig = deps.config ?? loadConfig(deps.pathEnv) ?? defaultConfig();
105
+ const git: GitService = makeGit(cloneDir(ref, deps.pathEnv));
106
+ const github: GithubService = makeGithub({ owner: ref.owner, repo: ref.repo, number: ref.number });
107
+ const symbols: SymbolsService = makeSymbols((p) => isLockfile(p, config.lockfilePatterns));
108
+ const ai: AiService = makeAi();
109
+ const artifactsBase: string = artifactsDir(ref, deps.pathEnv);
110
+ workspaces.set(id, makeWorkspace({ pr, meta, ref, ghPr, db, git, github, symbols, ai, artifactsBase, ensureDir, inflight, config, now }));
111
+ return pr;
112
+ },
113
+
114
+ listPrs: () => [...workspaces.values()].map((w) => w.pr),
115
+ getPr: (id) => workspaces.get(id)?.pr,
116
+ getWorkspace: (id) => workspaces.get(id),
117
+
118
+ async commits(id: string): Promise<CommitInfo[]> {
119
+ const ws = workspaces.get(id);
120
+ if (!ws) throw new Error(`Unknown PR: ${id}`);
121
+ return ws.commits();
122
+ },
123
+
124
+ drainAi: (timeoutMs: number) => inflight.idle(timeoutMs),
125
+ };
126
+ }
127
+
128
+ /** Build the system prompt orienting the agent to the PR and chat scope. */
129
+ function chatSystemPrompt(pr: LoadedPr, scopeKind: ChatScopeKind, scopeRef: string, baseDir: string, artifactDir: string | null): string {
130
+ const scope: string = scopeKind === "hunk" ? `hunk ${scopeRef}` : `file ${scopeRef}`;
131
+ const lines: string[] = [
132
+ `You are helping review GitHub pull request ${pr.owner}/${pr.repo} #${pr.number}: "${pr.title}".`,
133
+ `The current working directory is the PR head checkout; the base (pre-PR) checkout is at ${baseDir}.`,
134
+ `The user is asking about ${scope}. Answer concisely in markdown; explore the code as needed.`,
135
+ ];
136
+ if (artifactDir) lines.push(`If you generate any files/artifacts (e.g. an HTML explainer), save them into ${artifactDir}.`);
137
+ return lines.join(" ");
138
+ }
139
+
140
+ /** Inputs to build a review workspace. */
141
+ interface WorkspaceInputs {
142
+ pr: LoadedPr;
143
+ meta: PrMeta;
144
+ ref: PullRequestRef;
145
+ ghPr: GhPrService;
146
+ db: Database;
147
+ git: GitService;
148
+ github: GithubService;
149
+ symbols: SymbolsService;
150
+ ai: AiService;
151
+ artifactsBase: string;
152
+ ensureDir: (dir: string) => void;
153
+ inflight: Inflight;
154
+ config: MergieConfig;
155
+ now: () => number;
156
+ }
157
+
158
+ /**
159
+ * Build a {@link Workspace} that lazily clones the repo on first use and
160
+ * exposes range/hunk/reviewed operations backed by the PR's git clone and
161
+ * database.
162
+ */
163
+ function makeWorkspace(input: WorkspaceInputs): Workspace {
164
+ const { ref, ghPr, db, git, github, symbols, ai, artifactsBase, ensureDir, inflight, config, now } = input;
165
+ // A mutable copy of the PR summary + metadata so `refresh` can update them
166
+ // without mutating the caller's objects.
167
+ const pr: LoadedPr = { ...input.pr };
168
+ let meta: PrMeta = input.meta;
169
+ const views = hunkViewsRepo(db);
170
+ const reviewed = reviewedRangesRepo(db);
171
+ const comments = commentsRepo(db);
172
+ const ghComments = githubCommentsRepo(db);
173
+ const chats = chatSessionsRepo(db);
174
+ const artifacts = artifactsRepo(db);
175
+ const aiReviews = aiReviewsRepo(db);
176
+ const aiReviewTracker = createAiReviewTracker();
177
+ const sshUrl = `git@github.com:${ref.owner}/${ref.repo}.git`;
178
+ let cloned = false;
179
+ let baselineSha = "";
180
+
181
+ async function ensureClone(): Promise<void> {
182
+ if (cloned) return;
183
+ await git.cloneOrFetch(sshUrl, [`refs/pull/${ref.number}/head`, meta.baseRef]);
184
+ baselineSha = (await git.mergeBase(`origin/${meta.baseRef}`, meta.headSha)) || meta.baseRef;
185
+ cloned = true;
186
+ }
187
+
188
+ function commitShas(): string[] {
189
+ return meta.commits.map((c) => c.sha);
190
+ }
191
+
192
+ function mappedCommits(): CommitInfo[] {
193
+ return meta.commits.map((c) => ({
194
+ sha: c.sha, shortSha: c.sha.slice(0, 7), subject: c.subject,
195
+ authorName: c.authorName, authorEmail: "", isoDate: c.isoDate,
196
+ }));
197
+ }
198
+
199
+ /** Ensure (cloning first if needed) a worktree checked out at `sha`. */
200
+ async function worktreeFor(sha: string): Promise<string> {
201
+ await ensureClone();
202
+ return git.ensureWorktree(sha || meta.headSha);
203
+ }
204
+
205
+ /** Strip a worktree-dir prefix so hit paths are repo-relative. */
206
+ function relTo(dir: string, path: string): string {
207
+ const prefix = `${dir}/`;
208
+ return path.startsWith(prefix) ? path.slice(prefix.length) : path;
209
+ }
210
+
211
+ /** Reconstruct the GitHub URL for an inline review comment from its id. */
212
+ function commentUrl(githubId: string): string {
213
+ return `https://github.com/${ref.owner}/${ref.repo}/pull/${ref.number}#discussion_r${githubId}`;
214
+ }
215
+
216
+ /** Group the cached inbound GitHub comments into threads for the range view. */
217
+ function cachedThreads(): GithubThread[] {
218
+ const rows: GithubThreadRow[] = ghComments.listAll().map((r) => ({
219
+ githubId: r.githubId, path: r.path, side: r.side, line: r.line,
220
+ body: r.body, author: r.author, createdAt: r.createdAt, inReplyTo: r.inReplyTo,
221
+ htmlUrl: commentUrl(r.githubId),
222
+ }));
223
+ return groupGithubThreads(rows);
224
+ }
225
+
226
+ // The viewer's GitHub login, used to classify which fetched GitHub comments
227
+ // are the current user's own. Cached only once successfully resolved — a
228
+ // transient failure (e.g. a cold-start timeout) must not poison the cache
229
+ // with an empty login for the daemon's lifetime, so we retry on next call.
230
+ let viewerLoginCache: string | null = null;
231
+ async function viewerLogin(): Promise<string> {
232
+ if (viewerLoginCache !== null) return viewerLoginCache;
233
+ const login: string = await github.viewer().catch(() => "");
234
+ if (login !== "") viewerLoginCache = login;
235
+ return login;
236
+ }
237
+
238
+ /** Map a service comment to its cache-row form (stamped with sync time). */
239
+ function toCacheRow(c: GithubComment, syncedAt: number): GithubCommentRow {
240
+ const createdAt: number = Date.parse(c.createdAtIso);
241
+ return {
242
+ githubId: String(c.id), path: c.path, side: c.side, line: c.line,
243
+ startLine: c.startLine, commitSha: c.commitId, body: c.body, author: c.author,
244
+ createdAt: Number.isNaN(createdAt) ? null : createdAt,
245
+ inReplyTo: c.inReplyToId === null ? null : String(c.inReplyToId), syncedAt,
246
+ };
247
+ }
248
+
249
+ /**
250
+ * Reconcile locally-stored posted comments against a fresh GitHub fetch —
251
+ * GitHub is the source of truth. A comment posted from mergie IS a GitHub
252
+ * comment: update its stored body from the GitHub copy (reflecting GitHub-side
253
+ * edits), and if its GitHub comment no longer exists (deleted on GitHub) drop
254
+ * the local row so it neither lingers nor resurrects as an editable local draft.
255
+ */
256
+ function reconcilePosted(fetched: GithubComment[]): void {
257
+ const byId = new Map<string, GithubComment>(fetched.map((c) => [String(c.id), c]));
258
+ for (const c of comments.listAll()) {
259
+ if (c.githubId === null) continue;
260
+ const gh = byId.get(c.githubId);
261
+ if (gh === undefined) comments.remove(c.id);
262
+ else if (gh.body !== c.body) comments.update(c.id, { body: gh.body, updatedAt: now() });
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Guard for GitHub-comment mutations: the cached comment must exist and be
268
+ * authored by the viewer. Others' comments are strictly read-only.
269
+ */
270
+ async function assertOwnGithubComment(githubId: string): Promise<void> {
271
+ const row = ghComments.listAll().find((r) => r.githubId === githubId);
272
+ if (row === undefined) throw new Error(`Unknown GitHub comment: ${githubId}`);
273
+ const login: string = await viewerLogin();
274
+ if (login === "" || row.author !== login) {
275
+ throw new Error("You can only edit or delete GitHub comments you authored.");
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Resolve where a stored comment would land if posted at `target`: the commit
281
+ * to anchor to and the side line number(s), by re-parsing the diff at that
282
+ * commit and re-finding the comment's content anchor. Warns if it's absent.
283
+ */
284
+ async function resolvePost(id: number, target: PostTarget): Promise<PostPreview> {
285
+ const c = comments.get(id);
286
+ if (!c) throw new Error(`Unknown comment: ${id}`);
287
+ await ensureClone();
288
+ const commitId: string = target === "head" ? meta.headSha : c.madeAtSha;
289
+ const where: string = target === "head" ? "PR head" : "range end";
290
+ const absent = (warning: string): PostPreview => ({ canPost: false, commitId, side: c.side, line: null, startLine: null, warning });
291
+
292
+ const raw: string = await git.rawDiff(baselineSha, commitId, [c.path]);
293
+ const file = parseUnifiedDiff(raw).find((f) => f.newPath === c.path || f.oldPath === c.path);
294
+ if (!file) return absent(`${c.path} is not changed at the ${where}.`);
295
+
296
+ if (c.kind === "hunk") {
297
+ const hunk = file.hunks.find((h) => h.hash === c.anchorHash);
298
+ if (!hunk) return absent(`This hunk no longer matches at the ${where}.`);
299
+ const span = hunkChangedSpan(hunk.lines, c.side);
300
+ if (!span) return absent("The hunk has no changed lines on this side.");
301
+ return { canPost: true, commitId, side: c.side, line: span.endNo, startLine: span.startNo < span.endNo ? span.startNo : null, warning: null };
302
+ }
303
+
304
+ const span = c.startLine !== null && c.endLine !== null ? c.endLine - c.startLine + 1 : 1;
305
+ for (const hunk of file.hunks) {
306
+ const loc = locateLineComment(c.path, hunk.lines, c.side, span, c.anchorHash);
307
+ if (loc) return { canPost: true, commitId, side: c.side, line: loc.endNo, startLine: loc.startNo < loc.endNo ? loc.startNo : null, warning: null };
308
+ }
309
+ return absent(`The commented line(s) are not present at the ${where}.`);
310
+ }
311
+
312
+ return {
313
+ pr,
314
+ config: () => ({ models: config.models, templates: config.templates }),
315
+ commits: mappedCommits,
316
+ async refresh(): Promise<void> {
317
+ const fresh: PrMeta = await ghPr.fetchPr(ref);
318
+ meta = fresh;
319
+ pr.title = fresh.title;
320
+ pr.body = fresh.body;
321
+ pr.baseRef = fresh.baseRef;
322
+ pr.headRef = fresh.headRef;
323
+ await git.cloneOrFetch(sshUrl, [`refs/pull/${ref.number}/head`, fresh.baseRef]);
324
+ baselineSha = (await git.mergeBase(`origin/${fresh.baseRef}`, fresh.headSha)) || fresh.baseRef;
325
+ cloned = true;
326
+ },
327
+ async commitsWithBaseline(): Promise<CommitsWithBaseline> {
328
+ await ensureClone();
329
+ return { baselineSha, commits: mappedCommits() };
330
+ },
331
+ async defaultRange(): Promise<Range> {
332
+ await ensureClone();
333
+ return resolveDefaultRange(
334
+ { baselineSha, commits: commitShas() },
335
+ reviewed.list().map((r) => ({ startSha: r.startSha, endSha: r.endSha, createdAt: r.createdAt })),
336
+ );
337
+ },
338
+ async rangeView(startSha: string, endSha: string, ignoreWhitespace?: boolean): Promise<FileView[]> {
339
+ await ensureClone();
340
+ return buildRangeView(
341
+ {
342
+ rawDiff: (s, e) => git.rawDiff(s, e, undefined, ignoreWhitespace),
343
+ wordDiff: (s, e) => git.rawWordDiff(s, e, undefined, ignoreWhitespace),
344
+ isViewed: (h) => views.isViewed(h),
345
+ lockfilePatterns: config.lockfilePatterns,
346
+ comments: comments.listAll(),
347
+ githubThreads: cachedThreads(),
348
+ },
349
+ startSha,
350
+ endSha,
351
+ );
352
+ },
353
+ setHunkViewed(hunkHash: string, viewed: boolean): void {
354
+ if (viewed) views.markViewed(hunkHash, now());
355
+ else views.unmarkViewed(hunkHash);
356
+ },
357
+ addReviewedRange(startSha: string, endSha: string): void {
358
+ reviewed.add(startSha, endSha, now());
359
+ },
360
+ removeReviewedRange(startSha: string, endSha: string): void {
361
+ reviewed.removeByRange(startSha, endSha);
362
+ },
363
+ listReviewedRanges: () => reviewed.list(),
364
+ addComment(input: NewComment): number {
365
+ const anchorHash: string = input.kind === "hunk"
366
+ ? (input.hunkHash ?? "")
367
+ : commentAnchorHash(input.path, input.side, input.lineText ?? "");
368
+ const startLine: number | null = input.kind === "lines" ? (input.lineNo ?? null) : null;
369
+ const endLine: number | null = input.kind === "lines" ? (input.endLineNo ?? input.lineNo ?? null) : null;
370
+ const t: number = now();
371
+ return comments.create({
372
+ kind: input.kind, side: input.side, path: input.path, anchorHash,
373
+ startLine, endLine, madeAtSha: input.madeAtSha, body: input.body,
374
+ createdAt: t, updatedAt: t, githubId: null, githubUrl: null, inReplyToGithubId: null,
375
+ });
376
+ },
377
+ async editComment(id: number, body: string): Promise<void> {
378
+ comments.update(id, { body, updatedAt: now() });
379
+ const c = comments.get(id);
380
+ if (c?.githubId) await github.editComment(Number(c.githubId), body);
381
+ },
382
+ async deleteComment(id: number): Promise<void> {
383
+ const c = comments.get(id);
384
+ if (c?.githubId) {
385
+ await github.deleteComment(Number(c.githubId));
386
+ // Also evict any cached synced-thread copy so it doesn't linger as a
387
+ // phantom "from GitHub" entry until the next fetch reconciles it.
388
+ ghComments.remove(c.githubId);
389
+ }
390
+ comments.remove(id);
391
+ },
392
+ listComments: () => comments.listAll(),
393
+ async listAllComments(): Promise<AllCommentEntry[]> {
394
+ return mergeAllComments(comments.listAll(), cachedThreads(), await viewerLogin());
395
+ },
396
+ postCommentPreview: (id: number, target: PostTarget) => resolvePost(id, target),
397
+ async postComment(id: number, target: PostTarget): Promise<{ githubId: string; githubUrl: string }> {
398
+ const preview: PostPreview = await resolvePost(id, target);
399
+ if (!preview.canPost || preview.line === null) throw new Error(preview.warning ?? "Cannot post comment.");
400
+ const c = comments.get(id);
401
+ if (!c) throw new Error(`Unknown comment: ${id}`);
402
+ const res = await github.postComment(toPostInput({
403
+ path: c.path, side: preview.side, body: c.body, commitId: preview.commitId,
404
+ startNo: preview.startLine ?? preview.line, endNo: preview.line,
405
+ }));
406
+ const githubId: string = String(res.id);
407
+ comments.setGithub(id, { githubId, githubUrl: res.htmlUrl });
408
+ return { githubId, githubUrl: res.htmlUrl };
409
+ },
410
+ async syncGithub(): Promise<number> {
411
+ const fetched = await github.listReviewComments();
412
+ reconcilePosted(fetched);
413
+ const syncedAt: number = now();
414
+ const rows: GithubCommentRow[] = fetched.map((c) => toCacheRow(c, syncedAt));
415
+ ghComments.replaceAll(rows);
416
+ return rows.length;
417
+ },
418
+ async replyToThread(rootGithubId: string, body: string): Promise<void> {
419
+ await github.replyToComment(Number(rootGithubId), body);
420
+ const fetched = await github.listReviewComments();
421
+ reconcilePosted(fetched);
422
+ const syncedAt: number = now();
423
+ ghComments.replaceAll(fetched.map((c) => toCacheRow(c, syncedAt)));
424
+ },
425
+ async editGithubComment(githubId: string, body: string): Promise<void> {
426
+ await assertOwnGithubComment(githubId);
427
+ await github.editComment(Number(githubId), body);
428
+ ghComments.updateBody(githubId, body);
429
+ },
430
+ async deleteGithubComment(githubId: string): Promise<void> {
431
+ await assertOwnGithubComment(githubId);
432
+ await github.deleteComment(Number(githubId));
433
+ ghComments.remove(githubId);
434
+ },
435
+ async symbolDefinition(symbol: string, sha: string, file?: string): Promise<CodeResult[]> {
436
+ const dir: string = await worktreeFor(sha);
437
+ const readFile = (path: string): Promise<string | null> => git.fileAtRef(sha, path);
438
+ const results = await symbols.definition(symbol, { cwd: dir, readFile, file });
439
+ return results.map((h) => ({ ...h, path: relTo(dir, h.path) }));
440
+ },
441
+ async symbolUsages(symbol: string, sha: string, file?: string): Promise<CodeResult[]> {
442
+ const dir: string = await worktreeFor(sha);
443
+ const readFile = (path: string): Promise<string | null> => git.fileAtRef(sha, path);
444
+ const results = await symbols.usages(symbol, { cwd: dir, readFile, file });
445
+ return results.map((h) => ({ ...h, path: relTo(dir, h.path) }));
446
+ },
447
+ async symbolSearch(word: string, sha: string, opts?: { caseSensitive?: boolean; regex?: boolean }): Promise<CodeResult[]> {
448
+ const dir: string = await worktreeFor(sha);
449
+ const results = await symbols.search(word, { cwd: dir, caseSensitive: opts?.caseSensitive, regex: opts?.regex });
450
+ return results.map((h) => ({ ...h, path: relTo(dir, h.path) }));
451
+ },
452
+ async fileAt(sha: string, path: string): Promise<string | null> {
453
+ await ensureClone();
454
+ return git.fileAtRef(sha, path);
455
+ },
456
+ createChatSession(scopeKind: ChatScopeKind, scopeRef: string, model: string, title?: string): number {
457
+ return chats.createSession({
458
+ scopeKind, scopeRef, model,
459
+ title: title ?? `${scopeKind === "hunk" ? "Hunk" : "File"} chat`,
460
+ createdAt: now(),
461
+ });
462
+ },
463
+ listChatSessions(scopeKind?: ChatScopeKind, scopeRef?: string): ChatSessionRow[] {
464
+ if (scopeKind !== undefined && scopeRef !== undefined) return chats.listSessionsByScope(scopeKind, scopeRef);
465
+ return chats.listSessions();
466
+ },
467
+ listChatMessages(sessionId: number): ChatMessageRow[] {
468
+ return chats.listMessages(sessionId);
469
+ },
470
+ async streamChat(sessionId: number, prompt: string, onEvent: (ev: LiveChatEvent) => void, range?: ChatRange): Promise<string> {
471
+ const done = inflight.begin();
472
+ try {
473
+ const session = chats.getSession(sessionId);
474
+ if (!session) throw new Error(`Unknown chat session: ${sessionId}`);
475
+ // Title a fresh session after its opening prompt so sessions are distinguishable.
476
+ if (chats.listMessages(sessionId).length === 0) chats.setTitle(sessionId, sessionTitle(prompt));
477
+ chats.addMessage({ sessionId, role: "user", content: prompt, createdAt: now() });
478
+ await ensureClone();
479
+ const [headDir, baseDir] = await Promise.all([git.ensureWorktree(meta.headSha), git.ensureWorktree(baselineSha)]);
480
+ const artifactDir: string | null = range ? join(artifactsBase, `${range.start}_${range.end}`) : null;
481
+ if (artifactDir) ensureDir(artifactDir);
482
+ const before = new Set<string>(artifactDir ? listRelFiles(artifactDir) : []);
483
+ const extraDirs: string[] = artifactDir ? [baseDir, artifactDir] : [baseDir];
484
+ const transcript: string = chatTranscript(chats.listMessages(sessionId).map((m) => ({ role: m.role, content: m.content })));
485
+ let full = "";
486
+ for await (const ev of ai.chat({
487
+ prompt: transcript, model: session.model, cwd: headDir,
488
+ additionalDirectories: extraDirs,
489
+ systemPrompt: chatSystemPrompt(pr, session.scopeKind, session.scopeRef, baseDir, artifactDir),
490
+ })) {
491
+ // `text` blocks are the authoritative reply we persist; `delta`s are the
492
+ // live token stream; `activity` narrates tool use while the agent works.
493
+ if (ev.kind === "text") full += ev.text;
494
+ else onEvent({ kind: ev.kind, text: ev.text });
495
+ }
496
+ chats.addMessage({ sessionId, role: "assistant", content: full, createdAt: now() });
497
+ if (artifactDir && range) {
498
+ const rows = newArtifacts(artifactDir, before, { rangeStartSha: range.start, rangeEndSha: range.end, sessionId, now: now() })
499
+ .map((r) => ({ ...r, relPath: join(`${range.start}_${range.end}`, r.relPath) }));
500
+ for (const row of rows) artifacts.create(row);
501
+ }
502
+ return full;
503
+ } finally {
504
+ done();
505
+ }
506
+ },
507
+ listArtifacts(range?: ChatRange): ArtifactRow[] {
508
+ return range ? artifacts.listByRange(range.start, range.end) : artifacts.listAll();
509
+ },
510
+ async runAiReview(range: ChatRange, opts: AiReviewOptions): Promise<AiReviewRow> {
511
+ const done = inflight.begin();
512
+ // Track the run at the app level so the header can show it in progress
513
+ // even after the popup that started it is closed.
514
+ aiReviewTracker.start(range);
515
+ try {
516
+ await ensureClone();
517
+ const [headDir, baseDir] = await Promise.all([git.ensureWorktree(range.end), git.ensureWorktree(range.start)]);
518
+ const template = opts.templateId ? config.templates.find((t) => t.id === opts.templateId) ?? null : null;
519
+ const prompt: string = buildReviewPrompt(template?.prompt ?? null, opts.prompt ?? null, range);
520
+ let body = "";
521
+ for await (const ev of ai.chat({
522
+ prompt, model: opts.model, cwd: headDir, additionalDirectories: [baseDir],
523
+ systemPrompt: `You are reviewing pull request ${pr.owner}/${pr.repo} #${pr.number}: "${pr.title}". The base checkout is at ${baseDir}.`,
524
+ })) {
525
+ // Collect the finalized reply text; `delta`/`activity` events are for live UIs.
526
+ if (ev.kind === "text") body += ev.text;
527
+ }
528
+ const id: number = aiReviews.create({
529
+ startSha: range.start, endSha: range.end, model: opts.model,
530
+ template: opts.templateId ?? null, prompt: opts.prompt ?? null, body, createdAt: now(),
531
+ });
532
+ const row = aiReviews.get(id);
533
+ if (!row) throw new Error("Failed to persist AI review.");
534
+ aiReviewTracker.finish(range, id);
535
+ return row;
536
+ } catch (err) {
537
+ aiReviewTracker.fail(range, err instanceof Error ? err.message : String(err));
538
+ throw err;
539
+ } finally {
540
+ done();
541
+ }
542
+ },
543
+ listAiReviews(range?: ChatRange): AiReviewRow[] {
544
+ return range ? aiReviews.listByRange(range.start, range.end) : aiReviews.listAll();
545
+ },
546
+ aiReviewStatuses(): AiReviewStatus[] {
547
+ return aiReviewTracker.list();
548
+ },
549
+ dismissAiReviewStatus(range: ChatRange): void {
550
+ aiReviewTracker.dismiss(range);
551
+ },
552
+ resolveArtifact(relPath: string): string | null {
553
+ const abs: string = join(artifactsBase, relPath);
554
+ const base: string = artifactsBase.endsWith("/") ? artifactsBase : `${artifactsBase}/`;
555
+ return abs.startsWith(base) ? abs : null;
556
+ },
557
+ async fileSplit(path: string, startSha: string, endSha: string, ignoreWhitespace?: boolean): Promise<SplitRow[]> {
558
+ await ensureClone();
559
+ const [raw, wordRaw] = await Promise.all([
560
+ git.fullFileDiff(startSha, endSha, path, ignoreWhitespace),
561
+ git.fullFileWordDiff(startSha, endSha, path, ignoreWhitespace),
562
+ ]);
563
+ const hunk = parseUnifiedDiff(raw)[0]?.hunks[0];
564
+ if (!hunk) return [];
565
+ const fc = parseWordDiff(wordRaw)[0];
566
+ return buildSplitRows(withWordChanges(hunk.lines, fc));
567
+ },
568
+ };
569
+ }