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,297 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { bunRunner } from "@/services/exec.ts";
4
+ import type { CommandRunner } from "@/services/exec.ts";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Types
8
+ // ---------------------------------------------------------------------------
9
+
10
+ /**
11
+ * Structured information about a single git commit.
12
+ */
13
+ export interface CommitInfo {
14
+ /** Full 40-character commit SHA. */
15
+ sha: string;
16
+ /** Abbreviated 7-character commit SHA. */
17
+ shortSha: string;
18
+ /** The first line of the commit message. */
19
+ subject: string;
20
+ /** Display name of the commit author. */
21
+ authorName: string;
22
+ /** Email address of the commit author. */
23
+ authorEmail: string;
24
+ /**
25
+ * ISO 8601 timestamp of the author date, e.g. `2024-06-01T09:00:00+00:00`.
26
+ * Includes timezone offset so callers can display local time.
27
+ */
28
+ isoDate: string;
29
+ }
30
+
31
+ /**
32
+ * The git service provides all git operations needed for reviewing a PR.
33
+ * Every command is executed with `git -C <cloneDir>` so the instance is
34
+ * bound to one local repository directory.
35
+ */
36
+ export interface GitService {
37
+ /**
38
+ * Ensure the repository is available locally and up to date.
39
+ *
40
+ * - If `<cloneDir>/.git` is absent: runs `git clone <sshUrl> <cloneDir>`.
41
+ * - If present: runs `git -C <cloneDir> fetch origin <refs...>`.
42
+ *
43
+ * Throws if the git process exits with a non-zero code; the error message
44
+ * includes stderr so callers can surface the reason.
45
+ *
46
+ * @param sshUrl - SSH remote URL, e.g. `git@github.com:org/repo.git`.
47
+ * @param refs - Ref names / refspecs to fetch (e.g. `["main", "refs/pull/1/head"]`).
48
+ */
49
+ cloneOrFetch(sshUrl: string, refs: string[]): Promise<void>;
50
+
51
+ /**
52
+ * List commits in the range `startSha..endSha`, ordered oldest → newest.
53
+ *
54
+ * The `startSha` commit itself is excluded (standard git two-dot range
55
+ * semantics). Returns an empty array when the range contains no commits.
56
+ *
57
+ * @param startSha - Exclusive lower bound (SHA or any git revision).
58
+ * @param endSha - Inclusive upper bound (SHA or any git revision).
59
+ */
60
+ listCommits(startSha: string, endSha: string): Promise<CommitInfo[]>;
61
+
62
+ /**
63
+ * Compute the best common ancestor of two commits.
64
+ *
65
+ * Equivalent to `git merge-base a b`. Returns the trimmed SHA string.
66
+ *
67
+ * @param a - First commit SHA or ref.
68
+ * @param b - Second commit SHA or ref.
69
+ */
70
+ mergeBase(a: string, b: string): Promise<string>;
71
+
72
+ /**
73
+ * Return the raw text content of a file at a specific commit.
74
+ *
75
+ * Runs `git show <sha>:<path>`. Returns `null` when the command exits
76
+ * non-zero (file absent at that ref, deleted, or binary unreadable as text).
77
+ *
78
+ * @param sha - Commit SHA (or any git revision).
79
+ * @param path - Repo-relative file path.
80
+ */
81
+ fileAtRef(sha: string, path: string): Promise<string | null>;
82
+
83
+ /**
84
+ * Produce a unified diff between two commits, optionally restricted to a
85
+ * subset of file paths.
86
+ *
87
+ * Runs `git diff <startSha> <endSha> [-- <paths...>]`. Returns the raw
88
+ * unified-diff string (suitable for passing to `parseUnifiedDiff`). Returns
89
+ * an empty string when there are no differences.
90
+ *
91
+ * @param startSha - Base commit SHA.
92
+ * @param endSha - Head commit SHA.
93
+ * @param paths - Optional list of paths to restrict the diff to.
94
+ * @param ignoreWhitespace - When true, collapse whitespace-only changes
95
+ * (passes `--ignore-all-space` to git).
96
+ */
97
+ rawDiff(startSha: string, endSha: string, paths?: string[], ignoreWhitespace?: boolean): Promise<string>;
98
+
99
+ /**
100
+ * Like {@link rawDiff}, but in `--word-diff=porcelain` form (parsed by
101
+ * `parseWordDiff` into intra-line changed ranges). Returns an empty string
102
+ * when there are no differences.
103
+ */
104
+ rawWordDiff(startSha: string, endSha: string, paths?: string[], ignoreWhitespace?: boolean): Promise<string>;
105
+
106
+ /**
107
+ * Full-file unified diff for a single path with all context expanded (large
108
+ * `-U`), suitable for a split full-file view. Returns the raw diff string.
109
+ */
110
+ fullFileDiff(startSha: string, endSha: string, path: string, ignoreWhitespace?: boolean): Promise<string>;
111
+
112
+ /**
113
+ * Like {@link fullFileDiff}, but in `--word-diff=porcelain` form for the
114
+ * split full-file view's intra-line highlighting.
115
+ */
116
+ fullFileWordDiff(startSha: string, endSha: string, path: string, ignoreWhitespace?: boolean): Promise<string>;
117
+
118
+ /**
119
+ * Ensure a detached git worktree exists with the tree checked out at `sha`,
120
+ * and return its absolute path. Worktrees are cached per-SHA under a `wt`
121
+ * directory beside the clone; a symbol lookup can then run `sem`/`rg` against
122
+ * the on-disk files of that exact commit without disturbing the main clone.
123
+ *
124
+ * @param sha - Commit SHA to check out.
125
+ */
126
+ ensureWorktree(sha: string): Promise<string>;
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Format string constants
131
+ // ---------------------------------------------------------------------------
132
+
133
+ /** Field separator between commit fields — ASCII Unit Separator (rarely appears in commit data). */
134
+ const FIELD_SEP = "\x1f" as const;
135
+
136
+ /** Record separator between commits — ASCII Record Separator. */
137
+ const RECORD_SEP = "\x1e" as const;
138
+
139
+ /**
140
+ * git log --format string that produces one record per commit.
141
+ * Fields: %H (full sha) %x1f %h (short sha) %x1f %s (subject) %x1f
142
+ * %an (author name) %x1f %ae (author email) %x1f %aI (ISO 8601 author date)
143
+ * Records are separated by %x1e.
144
+ */
145
+ const LOG_FORMAT =
146
+ `--format=%H${FIELD_SEP}%h${FIELD_SEP}%s${FIELD_SEP}%an${FIELD_SEP}%ae${FIELD_SEP}%aI${RECORD_SEP}` as const;
147
+
148
+ /**
149
+ * Extra `git diff` flags to emit the porcelain word-diff. The regex makes each
150
+ * identifier run one token and every other non-space character its own token,
151
+ * so highlighting lands on whole words rather than whitespace-delimited chunks.
152
+ */
153
+ const WORD_DIFF_ARGS = ["--word-diff=porcelain", "--word-diff-regex=[A-Za-z0-9_]+|[^[:space:]]"] as const;
154
+
155
+ /**
156
+ * `git diff` option that collapses whitespace-only differences: a line whose
157
+ * only change is indentation/spacing stops showing as changed, and a hunk that
158
+ * was purely whitespace disappears. Equivalent to GitHub's "Hide whitespace".
159
+ */
160
+ const IGNORE_WHITESPACE_ARG = "--ignore-all-space" as const;
161
+
162
+ /** The whitespace-ignoring arg as a 0- or 1-element array, for arg splicing. */
163
+ function wsArgs(ignoreWhitespace: boolean | undefined): readonly string[] {
164
+ return ignoreWhitespace ? [IGNORE_WHITESPACE_ARG] : [];
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Internal helpers
169
+ // ---------------------------------------------------------------------------
170
+
171
+ /** Parse a single record (already split on RECORD_SEP) into CommitInfo. */
172
+ function parseRecord(record: string): CommitInfo | null {
173
+ const record_ = record.trim();
174
+ if (record_ === "") return null;
175
+ const parts = record_.split(FIELD_SEP);
176
+ const [sha, shortSha, subject, authorName, authorEmail, isoDate] = parts;
177
+ if (!sha || !shortSha || !subject || !authorName || !authorEmail || !isoDate) return null;
178
+ return { sha, shortSha, subject, authorName, authorEmail, isoDate };
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Factory
183
+ // ---------------------------------------------------------------------------
184
+
185
+ /**
186
+ * Create a `GitService` bound to `cloneDir`, using `runner` for subprocess
187
+ * execution. Defaults to the real Bun-backed runner.
188
+ *
189
+ * @param cloneDir - Absolute path to the local repository directory.
190
+ * @param runner - Command runner to use; defaults to `bunRunner`.
191
+ */
192
+ export function createGitService(
193
+ cloneDir: string,
194
+ runner: CommandRunner = bunRunner,
195
+ ): GitService {
196
+ return {
197
+ async cloneOrFetch(sshUrl, refs) {
198
+ const hasGit = existsSync(`${cloneDir}/.git`);
199
+ const args: string[] = hasGit
200
+ ? ["-C", cloneDir, "fetch", "origin", ...refs]
201
+ : ["clone", sshUrl, cloneDir];
202
+
203
+ const result = await runner.run("git", args);
204
+ if (result.exitCode !== 0) {
205
+ throw new Error(`git operation failed: ${result.stderr}`);
206
+ }
207
+ },
208
+
209
+ async listCommits(startSha, endSha) {
210
+ const result = await runner.run("git", [
211
+ "-C",
212
+ cloneDir,
213
+ "log",
214
+ LOG_FORMAT,
215
+ `${startSha}..${endSha}`,
216
+ ]);
217
+
218
+ if (result.exitCode !== 0) {
219
+ throw new Error(`git log failed: ${result.stderr}`);
220
+ }
221
+
222
+ const commits: CommitInfo[] = result.stdout
223
+ .split(RECORD_SEP)
224
+ .map(parseRecord)
225
+ .filter((c): c is CommitInfo => c !== null)
226
+ .reverse(); // git log is newest-first; we want oldest-first
227
+
228
+ return commits;
229
+ },
230
+
231
+ async mergeBase(a, b) {
232
+ const result = await runner.run("git", ["-C", cloneDir, "merge-base", a, b]);
233
+ if (result.exitCode !== 0) {
234
+ throw new Error(`git merge-base failed: ${result.stderr}`);
235
+ }
236
+ return result.stdout.trim();
237
+ },
238
+
239
+ async fileAtRef(sha, path) {
240
+ const result = await runner.run("git", ["-C", cloneDir, "show", `${sha}:${path}`]);
241
+ if (result.exitCode !== 0) return null;
242
+ return result.stdout;
243
+ },
244
+
245
+ async rawDiff(startSha, endSha, paths, ignoreWhitespace) {
246
+ const args: string[] = ["-C", cloneDir, "diff", ...wsArgs(ignoreWhitespace), startSha, endSha];
247
+ if (paths !== undefined && paths.length > 0) {
248
+ args.push("--", ...paths);
249
+ }
250
+ const result = await runner.run("git", args);
251
+ if (result.exitCode !== 0) {
252
+ throw new Error(`git diff failed: ${result.stderr}`);
253
+ }
254
+ return result.stdout;
255
+ },
256
+
257
+ async rawWordDiff(startSha, endSha, paths, ignoreWhitespace) {
258
+ const args: string[] = ["-C", cloneDir, "diff", ...wsArgs(ignoreWhitespace), ...WORD_DIFF_ARGS, startSha, endSha];
259
+ if (paths !== undefined && paths.length > 0) {
260
+ args.push("--", ...paths);
261
+ }
262
+ const result = await runner.run("git", args);
263
+ if (result.exitCode !== 0) {
264
+ throw new Error(`git word-diff failed: ${result.stderr}`);
265
+ }
266
+ return result.stdout;
267
+ },
268
+
269
+ async fullFileDiff(startSha, endSha, path, ignoreWhitespace) {
270
+ const args: string[] = ["-C", cloneDir, "diff", ...wsArgs(ignoreWhitespace), startSha, endSha, "-U1000000", "--", path];
271
+ const result = await runner.run("git", args);
272
+ if (result.exitCode !== 0) {
273
+ throw new Error(`git diff failed: ${result.stderr}`);
274
+ }
275
+ return result.stdout;
276
+ },
277
+
278
+ async fullFileWordDiff(startSha, endSha, path, ignoreWhitespace) {
279
+ const args: string[] = ["-C", cloneDir, "diff", ...wsArgs(ignoreWhitespace), ...WORD_DIFF_ARGS, startSha, endSha, "-U1000000", "--", path];
280
+ const result = await runner.run("git", args);
281
+ if (result.exitCode !== 0) {
282
+ throw new Error(`git word-diff failed: ${result.stderr}`);
283
+ }
284
+ return result.stdout;
285
+ },
286
+
287
+ async ensureWorktree(sha) {
288
+ const wt: string = join(dirname(cloneDir), "wt", sha);
289
+ if (existsSync(wt)) return wt;
290
+ const result = await runner.run("git", ["-C", cloneDir, "worktree", "add", "--force", "--detach", wt, sha]);
291
+ if (result.exitCode !== 0) {
292
+ throw new Error(`git worktree add failed: ${result.stderr}`);
293
+ }
294
+ return wt;
295
+ },
296
+ };
297
+ }
@@ -0,0 +1,300 @@
1
+ import { bunRunner, type CommandRunner } from "@/services/exec.ts";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Public types
5
+ // ---------------------------------------------------------------------------
6
+
7
+ /**
8
+ * A single inline diff comment fetched from GitHub.
9
+ * Maps from the GitHub REST API's pull request review comment object.
10
+ */
11
+ export interface GithubComment {
12
+ /** GitHub's numeric comment ID. */
13
+ id: number;
14
+ /** File path the comment is anchored to. */
15
+ path: string;
16
+ /** Which side of the diff this comment sits on. */
17
+ side: "LEFT" | "RIGHT";
18
+ /**
19
+ * The last line of the commented range (or the single line for a single-line
20
+ * comment). Null when GitHub omits it (shouldn't happen for inline comments,
21
+ * but modelled defensively).
22
+ */
23
+ line: number | null;
24
+ /**
25
+ * First line of a multi-line comment span. Null for single-line comments.
26
+ */
27
+ startLine: number | null;
28
+ /** The commit SHA the comment was made against. */
29
+ commitId: string;
30
+ /** Markdown body of the comment. */
31
+ body: string;
32
+ /** GitHub login of the comment author. */
33
+ author: string;
34
+ /** ISO-8601 creation timestamp. */
35
+ createdAtIso: string;
36
+ /**
37
+ * ID of the parent comment if this is a reply; null for root comments.
38
+ */
39
+ inReplyToId: number | null;
40
+ /** The diff hunk context string returned by GitHub. */
41
+ diffHunk: string;
42
+ /** Full URL to view the comment on GitHub. */
43
+ htmlUrl: string;
44
+ }
45
+
46
+ /**
47
+ * A root comment plus all its direct replies, ordered by creation time.
48
+ */
49
+ export interface CommentThread {
50
+ /** The top-level (non-reply) comment that started the thread. */
51
+ root: GithubComment;
52
+ /** All replies to the root, sorted oldest-first. */
53
+ replies: GithubComment[];
54
+ }
55
+
56
+ /**
57
+ * Input for posting a new inline comment to GitHub.
58
+ * When `startLine` is provided the comment spans from `startLine` to `line`
59
+ * (a whole-hunk multi-line comment).
60
+ */
61
+ export interface PostCommentInput {
62
+ /** Markdown body to post. */
63
+ body: string;
64
+ /** The commit SHA to anchor the comment to. */
65
+ commitId: string;
66
+ /** File path being commented on. */
67
+ path: string;
68
+ /** Which side of the diff to post on. */
69
+ side: "LEFT" | "RIGHT";
70
+ /** The last (or only) line of the comment range. */
71
+ line: number;
72
+ /** First line for a multi-line span. Omit for a single-line comment. */
73
+ startLine?: number;
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Internal raw shape from the GitHub API
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /** Raw shape of one item in the GitHub pull request review comments array. */
81
+ interface RawGithubComment {
82
+ id: number;
83
+ path: string;
84
+ side: string;
85
+ line: number | null;
86
+ start_line: number | null;
87
+ commit_id: string;
88
+ body: string;
89
+ user: { login: string };
90
+ created_at: string;
91
+ in_reply_to_id: number | null;
92
+ diff_hunk: string;
93
+ html_url: string;
94
+ }
95
+
96
+ /** Minimal shape of the response from posting/replying a comment. */
97
+ interface RawPostedComment {
98
+ id: number;
99
+ html_url: string;
100
+ }
101
+
102
+ /** The GitHub inline-comment service bound to one pull request. */
103
+ export type GithubService = ReturnType<typeof createGithubService>;
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Service factory
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /**
110
+ * Creates a GitHub service bound to a specific pull request.
111
+ * All GitHub API calls are made via `gh api` through the injected runner.
112
+ *
113
+ * @param ref - Owner, repo, and PR number identifying the pull request.
114
+ * @param runner - Command runner to use; defaults to the real Bun runner.
115
+ */
116
+ export function createGithubService(
117
+ ref: { owner: string; repo: string; number: number },
118
+ runner: CommandRunner = bunRunner,
119
+ ) {
120
+ const { owner, repo, number: prNumber } = ref;
121
+ const base = `repos/${owner}/${repo}`;
122
+
123
+ /** Throws a descriptive error if the command result indicates failure. */
124
+ function assertSuccess(stderr: string, exitCode: number): void {
125
+ if (exitCode !== 0) {
126
+ throw new Error(stderr || `gh exited with code ${exitCode}`);
127
+ }
128
+ }
129
+
130
+ /** Normalises the raw GitHub side string to the typed union. */
131
+ function normaliseSide(side: string): "LEFT" | "RIGHT" {
132
+ return side === "LEFT" ? "LEFT" : "RIGHT";
133
+ }
134
+
135
+ /** Maps one raw GitHub comment object to a typed GithubComment. */
136
+ function mapComment(raw: RawGithubComment): GithubComment {
137
+ return {
138
+ id: raw.id,
139
+ path: raw.path,
140
+ side: normaliseSide(raw.side),
141
+ line: raw.line,
142
+ startLine: raw.start_line,
143
+ commitId: raw.commit_id,
144
+ body: raw.body,
145
+ author: raw.user.login,
146
+ createdAtIso: raw.created_at,
147
+ inReplyToId: raw.in_reply_to_id,
148
+ diffHunk: raw.diff_hunk,
149
+ htmlUrl: raw.html_url,
150
+ };
151
+ }
152
+
153
+ return {
154
+ /**
155
+ * The authenticated user's GitHub login (via `gh api user --jq .login`).
156
+ * Returns an empty string if the call fails (offline / unauthenticated) so
157
+ * callers can degrade gracefully rather than throw.
158
+ */
159
+ async viewer(): Promise<string> {
160
+ const { stdout, exitCode } = await runner.run("gh", ["api", "user", "--jq", ".login"]);
161
+ return exitCode === 0 ? stdout.trim() : "";
162
+ },
163
+
164
+ /**
165
+ * Fetches all inline review comments for the pull request.
166
+ * Follows pagination automatically via `--paginate`.
167
+ */
168
+ async listReviewComments(): Promise<GithubComment[]> {
169
+ const { stdout, stderr, exitCode } = await runner.run("gh", [
170
+ "api",
171
+ `${base}/pulls/${prNumber}/comments`,
172
+ "--paginate",
173
+ ]);
174
+ assertSuccess(stderr, exitCode);
175
+ const raw = JSON.parse(stdout) as RawGithubComment[];
176
+ return raw.map(mapComment);
177
+ },
178
+
179
+ /**
180
+ * Groups a flat list of comments into reply threads.
181
+ * A thread has one root comment (no inReplyToId) and zero or more replies.
182
+ * Orphan replies (whose parent is absent from the list) become their own
183
+ * single-comment thread to avoid silently dropping data.
184
+ * Replies within each thread are sorted oldest-first.
185
+ */
186
+ buildThreads(comments: GithubComment[]): CommentThread[] {
187
+ const byId = new Map<number, GithubComment>(comments.map((c) => [c.id, c]));
188
+ const replyBuckets = new Map<number, GithubComment[]>();
189
+ const roots: GithubComment[] = [];
190
+ const orphans: GithubComment[] = [];
191
+
192
+ for (const comment of comments) {
193
+ if (comment.inReplyToId === null) {
194
+ roots.push(comment);
195
+ } else if (byId.has(comment.inReplyToId)) {
196
+ const bucket = replyBuckets.get(comment.inReplyToId) ?? [];
197
+ bucket.push(comment);
198
+ replyBuckets.set(comment.inReplyToId, bucket);
199
+ } else {
200
+ orphans.push(comment);
201
+ }
202
+ }
203
+
204
+ const threads: CommentThread[] = roots.map((root) => ({
205
+ root,
206
+ replies: (replyBuckets.get(root.id) ?? []).sort(
207
+ (a, b) => a.createdAtIso.localeCompare(b.createdAtIso),
208
+ ),
209
+ }));
210
+
211
+ // Orphans form solo threads so no comment is silently dropped.
212
+ for (const orphan of orphans) {
213
+ threads.push({ root: orphan, replies: [] });
214
+ }
215
+
216
+ return threads;
217
+ },
218
+
219
+ /**
220
+ * Posts a new inline comment on the pull request.
221
+ * Passing `startLine` causes a multi-line (whole-hunk) comment spanning
222
+ * `startLine` through `line` on the same side.
223
+ */
224
+ async postComment(input: PostCommentInput): Promise<{ id: number; htmlUrl: string }> {
225
+ const args = [
226
+ "api",
227
+ `${base}/pulls/${prNumber}/comments`,
228
+ "--method",
229
+ "POST",
230
+ "-f",
231
+ `body=${input.body}`,
232
+ "-f",
233
+ `commit_id=${input.commitId}`,
234
+ "-f",
235
+ `path=${input.path}`,
236
+ "-f",
237
+ `side=${input.side}`,
238
+ "-F",
239
+ `line=${input.line}`,
240
+ ];
241
+
242
+ if (input.startLine !== undefined) {
243
+ args.push("-F", `start_line=${input.startLine}`, "-f", `start_side=${input.side}`);
244
+ }
245
+
246
+ const { stdout, stderr, exitCode } = await runner.run("gh", args);
247
+ assertSuccess(stderr, exitCode);
248
+ const raw = JSON.parse(stdout) as RawPostedComment;
249
+ return { id: raw.id, htmlUrl: raw.html_url };
250
+ },
251
+
252
+ /**
253
+ * Updates the body of an existing inline comment.
254
+ */
255
+ async editComment(id: number, body: string): Promise<void> {
256
+ const { stderr, exitCode } = await runner.run("gh", [
257
+ "api",
258
+ `${base}/pulls/comments/${id}`,
259
+ "--method",
260
+ "PATCH",
261
+ "-f",
262
+ `body=${body}`,
263
+ ]);
264
+ assertSuccess(stderr, exitCode);
265
+ },
266
+
267
+ /**
268
+ * Permanently deletes an inline comment from GitHub.
269
+ */
270
+ async deleteComment(id: number): Promise<void> {
271
+ const { stderr, exitCode } = await runner.run("gh", [
272
+ "api",
273
+ `${base}/pulls/comments/${id}`,
274
+ "--method",
275
+ "DELETE",
276
+ ]);
277
+ assertSuccess(stderr, exitCode);
278
+ },
279
+
280
+ /**
281
+ * Posts a reply to an existing inline comment thread.
282
+ */
283
+ async replyToComment(
284
+ inReplyToId: number,
285
+ body: string,
286
+ ): Promise<{ id: number; htmlUrl: string }> {
287
+ const { stdout, stderr, exitCode } = await runner.run("gh", [
288
+ "api",
289
+ `${base}/pulls/${prNumber}/comments/${inReplyToId}/replies`,
290
+ "--method",
291
+ "POST",
292
+ "-f",
293
+ `body=${body}`,
294
+ ]);
295
+ assertSuccess(stderr, exitCode);
296
+ const raw = JSON.parse(stdout) as RawPostedComment;
297
+ return { id: raw.id, htmlUrl: raw.html_url };
298
+ },
299
+ };
300
+ }