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,195 @@
1
+ import type { Database } from "bun:sqlite";
2
+
3
+ /**
4
+ * The kind of comment — whether it targets a whole hunk or a line range.
5
+ *
6
+ * - `'hunk'` — comment on the entire hunk.
7
+ * - `'lines'` — comment on a specific line range.
8
+ */
9
+ export type CommentKind = "hunk" | "lines";
10
+
11
+ /**
12
+ * Which side of the diff the comment applies to.
13
+ *
14
+ * - `'LEFT'` — the base (start) version of the file.
15
+ * - `'RIGHT'` — the head (end) version of the file.
16
+ */
17
+ export type CommentSide = "LEFT" | "RIGHT";
18
+
19
+ /**
20
+ * Input required to create a new comment. Timestamps must be supplied by the
21
+ * caller so they remain deterministic in tests.
22
+ */
23
+ export interface CommentInput {
24
+ /** Whether the comment targets a whole hunk or a line range. */
25
+ kind: CommentKind;
26
+ /** Which diff side the comment appears on. */
27
+ side: CommentSide;
28
+ /** Repo-relative file path. */
29
+ path: string;
30
+ /** Content hash of {path, side, exact line text}. Controls visibility. */
31
+ anchorHash: string;
32
+ /** First line of the range (1-based, nullable for hunk-level comments). */
33
+ startLine: number | null;
34
+ /** Last line of the range (1-based, nullable for hunk-level comments). */
35
+ endLine: number | null;
36
+ /** SHA of the commit that was the head when the comment was made. */
37
+ madeAtSha: string;
38
+ /** Markdown body text. */
39
+ body: string;
40
+ /** Creation timestamp in milliseconds. */
41
+ createdAt: number;
42
+ /** Last-updated timestamp in milliseconds. */
43
+ updatedAt: number;
44
+ /** GitHub comment id when posted; null otherwise. */
45
+ githubId: string | null;
46
+ /** GitHub URL when posted; null otherwise. */
47
+ githubUrl: string | null;
48
+ /** GitHub id of the comment being replied to; null for top-level. */
49
+ inReplyToGithubId: string | null;
50
+ }
51
+
52
+ /**
53
+ * A fully-hydrated comment row as returned by the repository.
54
+ * Extends {@link CommentInput} with the auto-generated id.
55
+ */
56
+ export interface CommentRow extends CommentInput {
57
+ /** Auto-increment primary key. */
58
+ id: number;
59
+ }
60
+
61
+ /** Persistence for user-authored comments. */
62
+ export interface CommentsRepo {
63
+ /** Persist a new comment and return its auto-generated id. */
64
+ create(input: CommentInput): number;
65
+ /** Fetch a comment by id; returns null if not found. */
66
+ get(id: number): CommentRow | null;
67
+ /** Update the body and updatedAt of an existing comment. No-op if not found. */
68
+ update(id: number, fields: { body: string; updatedAt: number }): void;
69
+ /** Delete a comment by id. No-op if not found. */
70
+ remove(id: number): void;
71
+ /** All comments whose anchorHash matches. */
72
+ listByAnchor(anchorHash: string): CommentRow[];
73
+ /** Every comment in the database. */
74
+ listAll(): CommentRow[];
75
+ /** Record that a comment was posted to GitHub; updates githubId + githubUrl. */
76
+ setGithub(id: number, fields: { githubId: string; githubUrl: string }): void;
77
+ /** Clear GitHub association from a comment (sets githubId/githubUrl to null). */
78
+ clearGithub(id: number): void;
79
+ }
80
+
81
+ /** Raw DB row for the comment table. */
82
+ interface CommentDbRow {
83
+ id: number;
84
+ kind: string;
85
+ side: string;
86
+ path: string;
87
+ anchor_hash: string;
88
+ start_line: number | null;
89
+ end_line: number | null;
90
+ made_at_sha: string;
91
+ body: string;
92
+ created_at: number;
93
+ updated_at: number;
94
+ github_id: string | null;
95
+ github_url: string | null;
96
+ in_reply_to_github_id: string | null;
97
+ }
98
+
99
+ /** Map a raw DB row to the public {@link CommentRow} shape. */
100
+ function toRow(r: CommentDbRow): CommentRow {
101
+ return {
102
+ id: r.id,
103
+ kind: r.kind as CommentKind,
104
+ side: r.side as CommentSide,
105
+ path: r.path,
106
+ anchorHash: r.anchor_hash,
107
+ startLine: r.start_line,
108
+ endLine: r.end_line,
109
+ madeAtSha: r.made_at_sha,
110
+ body: r.body,
111
+ createdAt: r.created_at,
112
+ updatedAt: r.updated_at,
113
+ githubId: r.github_id,
114
+ githubUrl: r.github_url,
115
+ inReplyToGithubId: r.in_reply_to_github_id,
116
+ };
117
+ }
118
+
119
+ /** Create a {@link CommentsRepo} backed by the given database. */
120
+ export function commentsRepo(db: Database): CommentsRepo {
121
+ const insert = db.query<{ id: number }, [string, string, string, string, number | null, number | null, string, string, number, number, string | null, string | null, string | null]>(`
122
+ INSERT INTO comment
123
+ (kind, side, path, anchor_hash, start_line, end_line, made_at_sha,
124
+ body, created_at, updated_at, github_id, github_url, in_reply_to_github_id)
125
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
126
+ RETURNING id
127
+ `);
128
+
129
+ const selectOne = db.query<CommentDbRow, [number]>(
130
+ "SELECT * FROM comment WHERE id = ?",
131
+ );
132
+
133
+ const updateStmt = db.query(
134
+ "UPDATE comment SET body = ?, updated_at = ? WHERE id = ?",
135
+ );
136
+
137
+ const del = db.query("DELETE FROM comment WHERE id = ?");
138
+
139
+ const byAnchor = db.query<CommentDbRow, [string]>(
140
+ "SELECT * FROM comment WHERE anchor_hash = ?",
141
+ );
142
+
143
+ const all = db.query<CommentDbRow, []>("SELECT * FROM comment");
144
+
145
+ const setGithubStmt = db.query(
146
+ "UPDATE comment SET github_id = ?, github_url = ? WHERE id = ?",
147
+ );
148
+
149
+ const clearGithubStmt = db.query(
150
+ "UPDATE comment SET github_id = NULL, github_url = NULL WHERE id = ?",
151
+ );
152
+
153
+ return {
154
+ create(input) {
155
+ const result = insert.get(
156
+ input.kind,
157
+ input.side,
158
+ input.path,
159
+ input.anchorHash,
160
+ input.startLine,
161
+ input.endLine,
162
+ input.madeAtSha,
163
+ input.body,
164
+ input.createdAt,
165
+ input.updatedAt,
166
+ input.githubId,
167
+ input.githubUrl,
168
+ input.inReplyToGithubId,
169
+ );
170
+ return result!.id;
171
+ },
172
+ get(id) {
173
+ const row = selectOne.get(id);
174
+ return row ? toRow(row) : null;
175
+ },
176
+ update(id, fields) {
177
+ updateStmt.run(fields.body, fields.updatedAt, id);
178
+ },
179
+ remove(id) {
180
+ del.run(id);
181
+ },
182
+ listByAnchor(anchorHash) {
183
+ return byAnchor.all(anchorHash).map(toRow);
184
+ },
185
+ listAll() {
186
+ return all.all().map(toRow);
187
+ },
188
+ setGithub(id, fields) {
189
+ setGithubStmt.run(fields.githubId, fields.githubUrl, id);
190
+ },
191
+ clearGithub(id) {
192
+ clearGithubStmt.run(id);
193
+ },
194
+ };
195
+ }
@@ -0,0 +1,166 @@
1
+ import type { Database } from "bun:sqlite";
2
+
3
+ /**
4
+ * An inbound GitHub inline comment (or reply) cached from the GitHub API.
5
+ * Used both as the input shape for writes and as the row shape for reads,
6
+ * because the primary key (`githubId`) comes from GitHub, not auto-increment.
7
+ */
8
+ export interface GithubCommentRow {
9
+ /** GitHub's stable comment id — the primary key. */
10
+ githubId: string;
11
+ /** Repo-relative file path the comment was made on. */
12
+ path: string | null;
13
+ /** Diff side (`'LEFT'` or `'RIGHT'`). */
14
+ side: string | null;
15
+ /** Line number the comment is anchored to (1-based). */
16
+ line: number | null;
17
+ /** First line of a multi-line comment span; null for single-line. */
18
+ startLine: number | null;
19
+ /** The commit SHA the comment was posted against. */
20
+ commitSha: string | null;
21
+ /** Markdown body of the comment. */
22
+ body: string;
23
+ /** GitHub username of the comment author. */
24
+ author: string | null;
25
+ /**
26
+ * When the comment was created on GitHub, as a Unix timestamp in
27
+ * milliseconds. May be null if the API response omits it.
28
+ */
29
+ createdAt: number | null;
30
+ /** GitHub id of the parent comment if this is a reply; null otherwise. */
31
+ inReplyTo: string | null;
32
+ /** When this row was last synced from GitHub (ms). */
33
+ syncedAt: number;
34
+ }
35
+
36
+ /** Persistence for the inbound GitHub inline comment cache. */
37
+ export interface GithubCommentsRepo {
38
+ /**
39
+ * Insert or fully replace a GitHub comment row keyed by githubId.
40
+ * Uses `INSERT OR REPLACE` — all fields are overwritten on conflict.
41
+ */
42
+ upsert(row: GithubCommentRow): void;
43
+ /**
44
+ * Replace the entire table with the provided rows in a single transaction.
45
+ * Clears all existing rows first, then bulk-inserts the new set.
46
+ */
47
+ replaceAll(rows: GithubCommentRow[]): void;
48
+ /** Every GitHub comment in the cache. */
49
+ listAll(): GithubCommentRow[];
50
+ /** All GitHub comments on the given file path. */
51
+ listByPath(path: string): GithubCommentRow[];
52
+ /** Delete the cached row for a GitHub comment id. No-op if absent. */
53
+ remove(githubId: string): void;
54
+ /** Update only the body of the cached row for a GitHub comment id. No-op if absent. */
55
+ updateBody(githubId: string, body: string): void;
56
+ }
57
+
58
+ /** Raw DB row for the github_comment table. */
59
+ interface GithubCommentDbRow {
60
+ github_id: string;
61
+ path: string | null;
62
+ side: string | null;
63
+ line: number | null;
64
+ start_line: number | null;
65
+ commit_sha: string | null;
66
+ body: string;
67
+ author: string | null;
68
+ created_at: number | null;
69
+ in_reply_to: string | null;
70
+ synced_at: number;
71
+ }
72
+
73
+ /** Map a raw DB row to the public {@link GithubCommentRow} shape. */
74
+ function toRow(r: GithubCommentDbRow): GithubCommentRow {
75
+ return {
76
+ githubId: r.github_id,
77
+ path: r.path,
78
+ side: r.side,
79
+ line: r.line,
80
+ startLine: r.start_line,
81
+ commitSha: r.commit_sha,
82
+ body: r.body,
83
+ author: r.author,
84
+ createdAt: r.created_at,
85
+ inReplyTo: r.in_reply_to,
86
+ syncedAt: r.synced_at,
87
+ };
88
+ }
89
+
90
+ const INSERT_SQL = `
91
+ INSERT OR REPLACE INTO github_comment
92
+ (github_id, path, side, line, start_line, commit_sha, body, author, created_at, in_reply_to, synced_at)
93
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
94
+ `;
95
+
96
+ /** Create a {@link GithubCommentsRepo} backed by the given database. */
97
+ export function githubCommentsRepo(db: Database): GithubCommentsRepo {
98
+ const upsertStmt = db.query<
99
+ void,
100
+ [string, string | null, string | null, number | null, number | null, string | null, string, string | null, number | null, string | null, number]
101
+ >(INSERT_SQL);
102
+
103
+ const all = db.query<GithubCommentDbRow, []>("SELECT * FROM github_comment");
104
+
105
+ const byPath = db.query<GithubCommentDbRow, [string]>(
106
+ "SELECT * FROM github_comment WHERE path = ?",
107
+ );
108
+
109
+ const clearAll = db.query("DELETE FROM github_comment");
110
+
111
+ const deleteOne = db.query("DELETE FROM github_comment WHERE github_id = ?");
112
+
113
+ const updateBodyStmt = db.query("UPDATE github_comment SET body = ? WHERE github_id = ?");
114
+
115
+ const replaceAllFn = db.transaction((rows: GithubCommentRow[]) => {
116
+ clearAll.run();
117
+ for (const row of rows) {
118
+ upsertStmt.run(
119
+ row.githubId,
120
+ row.path,
121
+ row.side,
122
+ row.line,
123
+ row.startLine,
124
+ row.commitSha,
125
+ row.body,
126
+ row.author,
127
+ row.createdAt,
128
+ row.inReplyTo,
129
+ row.syncedAt,
130
+ );
131
+ }
132
+ });
133
+
134
+ return {
135
+ upsert(row) {
136
+ upsertStmt.run(
137
+ row.githubId,
138
+ row.path,
139
+ row.side,
140
+ row.line,
141
+ row.startLine,
142
+ row.commitSha,
143
+ row.body,
144
+ row.author,
145
+ row.createdAt,
146
+ row.inReplyTo,
147
+ row.syncedAt,
148
+ );
149
+ },
150
+ replaceAll(rows) {
151
+ replaceAllFn(rows);
152
+ },
153
+ listAll() {
154
+ return all.all().map(toRow);
155
+ },
156
+ listByPath(path) {
157
+ return byPath.all(path).map(toRow);
158
+ },
159
+ remove(githubId) {
160
+ deleteOne.run(githubId);
161
+ },
162
+ updateBody(githubId, body) {
163
+ updateBodyStmt.run(body, githubId);
164
+ },
165
+ };
166
+ }
@@ -0,0 +1,36 @@
1
+ import type { Database } from "bun:sqlite";
2
+
3
+ /** Persistence for per-hunk viewed state, keyed by hunk content hash. */
4
+ export interface HunkViewsRepo {
5
+ /** Mark a hunk viewed at the given timestamp (ms); idempotent. */
6
+ markViewed(hunkHash: string, at: number): void;
7
+ /** Remove a hunk's viewed state. */
8
+ unmarkViewed(hunkHash: string): void;
9
+ /** Whether a hunk hash is currently marked viewed. */
10
+ isViewed(hunkHash: string): boolean;
11
+ /** All currently-viewed hunk hashes. */
12
+ viewedHashes(): string[];
13
+ }
14
+
15
+ /** Create a {@link HunkViewsRepo} backed by the given database. */
16
+ export function hunkViewsRepo(db: Database): HunkViewsRepo {
17
+ const insert = db.query("INSERT OR REPLACE INTO hunk_view (hunk_hash, viewed_at) VALUES (?, ?)");
18
+ const del = db.query("DELETE FROM hunk_view WHERE hunk_hash = ?");
19
+ const one = db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM hunk_view WHERE hunk_hash = ?");
20
+ const all = db.query<{ hunk_hash: string }, []>("SELECT hunk_hash FROM hunk_view");
21
+
22
+ return {
23
+ markViewed(hunkHash, at) {
24
+ insert.run(hunkHash, at);
25
+ },
26
+ unmarkViewed(hunkHash) {
27
+ del.run(hunkHash);
28
+ },
29
+ isViewed(hunkHash) {
30
+ return (one.get(hunkHash)?.n ?? 0) > 0;
31
+ },
32
+ viewedHashes() {
33
+ return all.all().map((r) => r.hunk_hash);
34
+ },
35
+ };
36
+ }
@@ -0,0 +1,75 @@
1
+ import type { Database } from "bun:sqlite";
2
+
3
+ /**
4
+ * A single reviewed commit range record as returned by the repository.
5
+ */
6
+ export interface ReviewedRangeRow {
7
+ /** Auto-increment primary key. */
8
+ id: number;
9
+ /** The baseline (exclusive) commit SHA. */
10
+ startSha: string;
11
+ /** The end (inclusive) commit SHA. */
12
+ endSha: string;
13
+ /** Unix timestamp (ms) when this range was recorded. */
14
+ createdAt: number;
15
+ }
16
+
17
+ /** Persistence for reviewed commit ranges keyed by (startSha, endSha). */
18
+ export interface ReviewedRangesRepo {
19
+ /**
20
+ * Record a reviewed range. Duplicate (startSha, endSha) pairs are silently
21
+ * ignored (INSERT OR IGNORE) — the original record is preserved.
22
+ */
23
+ add(startSha: string, endSha: string, createdAt: number): void;
24
+ /** All recorded ranges ordered by createdAt ascending. */
25
+ list(): ReviewedRangeRow[];
26
+ /** Delete a range by its id. No-op if the id does not exist. */
27
+ remove(id: number): void;
28
+ /** Delete the range matching an exact (startSha, endSha) pair. No-op if absent. */
29
+ removeByRange(startSha: string, endSha: string): void;
30
+ }
31
+
32
+ /** Internal DB row shape for reviewed_range queries. */
33
+ interface RangeDbRow {
34
+ id: number;
35
+ start_sha: string;
36
+ end_sha: string;
37
+ created_at: number;
38
+ }
39
+
40
+ /** Map a raw DB row to the public {@link ReviewedRangeRow} shape. */
41
+ function toRow(r: RangeDbRow): ReviewedRangeRow {
42
+ return {
43
+ id: r.id,
44
+ startSha: r.start_sha,
45
+ endSha: r.end_sha,
46
+ createdAt: r.created_at,
47
+ };
48
+ }
49
+
50
+ /** Create a {@link ReviewedRangesRepo} backed by the given database. */
51
+ export function reviewedRangesRepo(db: Database): ReviewedRangesRepo {
52
+ const insert = db.query(
53
+ "INSERT OR IGNORE INTO reviewed_range (start_sha, end_sha, created_at) VALUES (?, ?, ?)",
54
+ );
55
+ const selectAll = db.query<RangeDbRow, []>(
56
+ "SELECT id, start_sha, end_sha, created_at FROM reviewed_range ORDER BY created_at ASC",
57
+ );
58
+ const del = db.query("DELETE FROM reviewed_range WHERE id = ?");
59
+ const delByRange = db.query("DELETE FROM reviewed_range WHERE start_sha = ? AND end_sha = ?");
60
+
61
+ return {
62
+ add(startSha, endSha, createdAt) {
63
+ insert.run(startSha, endSha, createdAt);
64
+ },
65
+ list() {
66
+ return selectAll.all().map(toRow);
67
+ },
68
+ remove(id) {
69
+ del.run(id);
70
+ },
71
+ removeByRange(startSha, endSha) {
72
+ delByRange.run(startSha, endSha);
73
+ },
74
+ };
75
+ }
@@ -0,0 +1,97 @@
1
+ /** Current database schema version. Bump when SCHEMA_SQL changes. */
2
+ export const SCHEMA_VERSION = 1;
3
+
4
+ /**
5
+ * Full schema DDL for a per-PR mergie database. All statements use
6
+ * `IF NOT EXISTS` so applying them is idempotent. See docs/PLAN.md for the
7
+ * data model rationale.
8
+ */
9
+ export const SCHEMA_SQL = `
10
+ CREATE TABLE IF NOT EXISTS meta (
11
+ key TEXT PRIMARY KEY,
12
+ value TEXT NOT NULL
13
+ );
14
+
15
+ CREATE TABLE IF NOT EXISTS hunk_view (
16
+ hunk_hash TEXT PRIMARY KEY,
17
+ viewed_at INTEGER NOT NULL
18
+ );
19
+
20
+ CREATE TABLE IF NOT EXISTS reviewed_range (
21
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
22
+ start_sha TEXT NOT NULL,
23
+ end_sha TEXT NOT NULL,
24
+ created_at INTEGER NOT NULL,
25
+ UNIQUE (start_sha, end_sha)
26
+ );
27
+
28
+ CREATE TABLE IF NOT EXISTS comment (
29
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
30
+ kind TEXT NOT NULL, -- 'hunk' | 'lines'
31
+ side TEXT NOT NULL, -- 'LEFT' | 'RIGHT'
32
+ path TEXT NOT NULL,
33
+ anchor_hash TEXT NOT NULL,
34
+ start_line INTEGER,
35
+ end_line INTEGER,
36
+ made_at_sha TEXT NOT NULL,
37
+ body TEXT NOT NULL,
38
+ created_at INTEGER NOT NULL,
39
+ updated_at INTEGER NOT NULL,
40
+ github_id TEXT,
41
+ github_url TEXT,
42
+ in_reply_to_github_id TEXT
43
+ );
44
+ CREATE INDEX IF NOT EXISTS idx_comment_anchor ON comment (anchor_hash);
45
+
46
+ CREATE TABLE IF NOT EXISTS github_comment (
47
+ github_id TEXT PRIMARY KEY,
48
+ path TEXT,
49
+ side TEXT,
50
+ line INTEGER,
51
+ start_line INTEGER,
52
+ commit_sha TEXT,
53
+ body TEXT NOT NULL,
54
+ author TEXT,
55
+ created_at INTEGER,
56
+ in_reply_to TEXT,
57
+ synced_at INTEGER NOT NULL
58
+ );
59
+
60
+ CREATE TABLE IF NOT EXISTS ai_review (
61
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
62
+ start_sha TEXT NOT NULL,
63
+ end_sha TEXT NOT NULL,
64
+ model TEXT NOT NULL,
65
+ template TEXT,
66
+ prompt TEXT,
67
+ body TEXT NOT NULL,
68
+ created_at INTEGER NOT NULL
69
+ );
70
+
71
+ CREATE TABLE IF NOT EXISTS chat_session (
72
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
73
+ scope_kind TEXT NOT NULL, -- 'hunk' | 'file'
74
+ scope_ref TEXT NOT NULL, -- hunk hash or file path
75
+ title TEXT NOT NULL,
76
+ model TEXT NOT NULL,
77
+ created_at INTEGER NOT NULL
78
+ );
79
+
80
+ CREATE TABLE IF NOT EXISTS chat_message (
81
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
82
+ session_id INTEGER NOT NULL REFERENCES chat_session (id),
83
+ role TEXT NOT NULL, -- 'user' | 'assistant'
84
+ content TEXT NOT NULL,
85
+ created_at INTEGER NOT NULL
86
+ );
87
+
88
+ CREATE TABLE IF NOT EXISTS artifact (
89
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
90
+ range_start_sha TEXT NOT NULL,
91
+ range_end_sha TEXT NOT NULL,
92
+ session_id INTEGER,
93
+ rel_path TEXT NOT NULL,
94
+ title TEXT NOT NULL,
95
+ created_at INTEGER NOT NULL
96
+ );
97
+ `;
@@ -0,0 +1,137 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { configDir, type PathEnv } from "./paths.ts";
4
+
5
+ /** A selectable Claude model. */
6
+ export interface ModelChoice {
7
+ /** Model id passed to the Agent SDK, e.g. "claude-opus-4-8". */
8
+ id: string;
9
+ /** Human-friendly label shown in the picker. */
10
+ label: string;
11
+ }
12
+
13
+ /** An AI-review prompt template. */
14
+ export interface ReviewTemplate {
15
+ /** Stable identifier. */
16
+ id: string;
17
+ /** Display title. */
18
+ title: string;
19
+ /** The prompt text seeding the review. */
20
+ prompt: string;
21
+ }
22
+
23
+ /** Fully-resolved mergie configuration (defaults merged with user config). */
24
+ export interface MergieConfig {
25
+ /** Glob patterns identifying lock/generated files (defaults + user, deduped). */
26
+ lockfilePatterns: string[];
27
+ /** Selectable models for chat and reviews. */
28
+ models: ModelChoice[];
29
+ /** Available AI-review templates. */
30
+ templates: ReviewTemplate[];
31
+ }
32
+
33
+ /** Built-in lock/generated-file patterns; user patterns extend these. */
34
+ const DEFAULT_LOCKFILE_PATTERNS: readonly string[] = [
35
+ "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "npm-shrinkwrap.json",
36
+ "bun.lock", "bun.lockb", "Cargo.lock", "go.sum", "composer.lock",
37
+ "Gemfile.lock", "poetry.lock", "Pipfile.lock", "*.min.js", "*.min.css",
38
+ ];
39
+
40
+ /** Built-in model choices. */
41
+ const DEFAULT_MODELS: readonly ModelChoice[] = [
42
+ { id: "claude-opus-4-8", label: "Opus 4.8" },
43
+ { id: "claude-sonnet-4-6", label: "Sonnet 4.6" },
44
+ { id: "claude-haiku-4-5-20251001", label: "Haiku 4.5" },
45
+ ];
46
+
47
+ /** Built-in AI-review templates (from SPEC). */
48
+ const DEFAULT_TEMPLATES: readonly ReviewTemplate[] = [
49
+ {
50
+ id: "key-decisions",
51
+ title: "Key decisions",
52
+ prompt: "Analyse the diff and list the key decisions made in these changes, then reduce them to the essential ones.",
53
+ },
54
+ {
55
+ id: "adversarial",
56
+ title: "Adversarial bug pass",
57
+ prompt: "Perform an adversarial pass over the diff to find bugs and any obvious performance issues.",
58
+ },
59
+ ];
60
+
61
+ /** The default configuration, with no user overrides applied. */
62
+ export function defaultConfig(): MergieConfig {
63
+ return {
64
+ lockfilePatterns: [...DEFAULT_LOCKFILE_PATTERNS],
65
+ models: DEFAULT_MODELS.map((m) => ({ ...m })),
66
+ templates: DEFAULT_TEMPLATES.map((t) => ({ ...t })),
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Parse a TOML config string and merge it over the defaults. Lock patterns
72
+ * extend the defaults (deduped); models and templates replace the defaults
73
+ * when present. Unknown keys are ignored.
74
+ */
75
+ export function parseConfig(tomlText: string): MergieConfig {
76
+ const raw: unknown = Bun.TOML.parse(tomlText);
77
+ const rec: Record<string, unknown> = isRecord(raw) ? raw : {};
78
+
79
+ const userPatterns: string[] = stringArray(rec.lockfilePatterns) ?? [];
80
+ const models = parseModels(rec.models);
81
+ const templates = parseTemplates(rec.templates);
82
+ const base = defaultConfig();
83
+
84
+ return {
85
+ lockfilePatterns: dedupe([...base.lockfilePatterns, ...userPatterns]),
86
+ models: models ?? base.models,
87
+ templates: templates ?? base.templates,
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Load configuration from disk, merging the user's config file (if present)
93
+ * over the defaults.
94
+ *
95
+ * @param opts.path Override the config file path (defaults to
96
+ * `<configDir>/config.toml`).
97
+ */
98
+ export function loadConfig(opts?: { path?: string } & PathEnv): MergieConfig {
99
+ const path: string = opts?.path ?? join(configDir(opts), "config.toml");
100
+ if (!existsSync(path)) return defaultConfig();
101
+ return parseConfig(readFileSync(path, "utf8"));
102
+ }
103
+
104
+ function dedupe(items: string[]): string[] {
105
+ return [...new Set(items)];
106
+ }
107
+
108
+ function isRecord(v: unknown): v is Record<string, unknown> {
109
+ return typeof v === "object" && v !== null && !Array.isArray(v);
110
+ }
111
+
112
+ function stringArray(v: unknown): string[] | undefined {
113
+ if (!Array.isArray(v)) return undefined;
114
+ return v.filter((x): x is string => typeof x === "string");
115
+ }
116
+
117
+ function parseModels(v: unknown): ModelChoice[] | undefined {
118
+ if (!Array.isArray(v)) return undefined;
119
+ const out: ModelChoice[] = [];
120
+ for (const item of v) {
121
+ if (isRecord(item) && typeof item.id === "string") {
122
+ out.push({ id: item.id, label: typeof item.label === "string" ? item.label : item.id });
123
+ }
124
+ }
125
+ return out;
126
+ }
127
+
128
+ function parseTemplates(v: unknown): ReviewTemplate[] | undefined {
129
+ if (!Array.isArray(v)) return undefined;
130
+ const out: ReviewTemplate[] = [];
131
+ for (const item of v) {
132
+ if (isRecord(item) && typeof item.id === "string" && typeof item.title === "string" && typeof item.prompt === "string") {
133
+ out.push({ id: item.id, title: item.title, prompt: item.prompt });
134
+ }
135
+ }
136
+ return out;
137
+ }