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,32 @@
1
+ /** The `matched` line plus up to `n` context lines on each side. */
2
+ export interface ContextSlice {
3
+ /** Up to `n` lines before `matched`, top-to-bottom (clamped at file start). */
4
+ before: string[];
5
+ /** The matched line's text; empty string when `line` is out of range. */
6
+ matched: string;
7
+ /** Up to `n` lines after `matched`, top-to-bottom (clamped at file end). */
8
+ after: string[];
9
+ }
10
+
11
+ /**
12
+ * Slice `n` lines of context around a 1-based `line` within `lines`.
13
+ * Out-of-range lines yield an empty `matched` and no context.
14
+ * @param lines - The file's lines (0-based array, 1-based `line`).
15
+ * @param line - 1-based line number to centre on.
16
+ * @param n - Number of context lines to include on each side.
17
+ */
18
+ export function sliceContext(
19
+ lines: readonly string[],
20
+ line: number,
21
+ n: number,
22
+ ): ContextSlice {
23
+ const idx = line - 1;
24
+ if (idx < 0 || idx >= lines.length) {
25
+ return { before: [], matched: "", after: [] };
26
+ }
27
+ return {
28
+ before: lines.slice(Math.max(0, idx - n), idx),
29
+ matched: lines[idx] ?? "",
30
+ after: lines.slice(idx + 1, idx + 1 + n),
31
+ };
32
+ }
@@ -0,0 +1,178 @@
1
+ import { hunkHash } from "./hash.ts";
2
+
3
+ /** How a file was changed in a diff. */
4
+ export type FileStatus = "added" | "deleted" | "modified" | "renamed";
5
+
6
+ /** Whether a diff line is unchanged context, an addition, or a deletion. */
7
+ export type DiffLineKind = "ctx" | "add" | "del";
8
+
9
+ /** A half-open character range `[start, end)` within a line's text. */
10
+ export interface CharRange {
11
+ /** 0-based index of the first changed character. */
12
+ start: number;
13
+ /** 0-based index one past the last changed character. */
14
+ end: number;
15
+ }
16
+
17
+ /** A single line within a hunk. */
18
+ export interface DiffLine {
19
+ /** Line kind. */
20
+ kind: DiffLineKind;
21
+ /** 1-based line number on the base (old) side; undefined for additions. */
22
+ oldNo?: number;
23
+ /** 1-based line number on the head (new) side; undefined for deletions. */
24
+ newNo?: number;
25
+ /** Line text without the leading +/-/space marker. */
26
+ text: string;
27
+ /**
28
+ * Intra-line changed character ranges (from the word-diff pass), for
29
+ * highlighting the exact edited words. Absent on unchanged lines and on
30
+ * near-total rewrites (where the whole-line background suffices).
31
+ */
32
+ changes?: CharRange[];
33
+ }
34
+
35
+ /** A contiguous block of changes within a file. */
36
+ export interface Hunk {
37
+ /** The raw `@@ ... @@` header line (including any section heading). */
38
+ header: string;
39
+ /** 1-based start line on the base side. */
40
+ oldStart: number;
41
+ /** Line count on the base side. */
42
+ oldLines: number;
43
+ /** 1-based start line on the head side. */
44
+ newStart: number;
45
+ /** Line count on the head side. */
46
+ newLines: number;
47
+ /** The hunk's lines in order. */
48
+ lines: DiffLine[];
49
+ /** Content hash identifying this hunk (independent of line numbers). */
50
+ hash: string;
51
+ }
52
+
53
+ /** A single file's changes within a diff. */
54
+ export interface FileDiff {
55
+ /** Path on the base side (equals newPath unless renamed/added). */
56
+ oldPath: string;
57
+ /** Path on the head side (equals oldPath unless renamed/deleted). */
58
+ newPath: string;
59
+ /** How the file changed. */
60
+ status: FileStatus;
61
+ /** True for binary files (no textual hunks are produced). */
62
+ isBinary: boolean;
63
+ /** The file's hunks (empty for binary / pure-rename files). */
64
+ hunks: Hunk[];
65
+ }
66
+
67
+ /**
68
+ * Parse `git diff` unified output into structured per-file diffs with per-hunk
69
+ * content hashes. Pure — performs no git or filesystem access.
70
+ */
71
+ export function parseUnifiedDiff(text: string): FileDiff[] {
72
+ return splitFileChunks(text).map(parseFileChunk);
73
+ }
74
+
75
+ /** Split raw diff text into per-file line groups, each starting at `diff --git`. */
76
+ function splitFileChunks(text: string): string[][] {
77
+ const chunks: string[][] = [];
78
+ let current: string[] | null = null;
79
+ for (const line of text.split("\n")) {
80
+ if (line.startsWith("diff --git ")) {
81
+ current = [line];
82
+ chunks.push(current);
83
+ } else if (current) {
84
+ current.push(line);
85
+ }
86
+ }
87
+ return chunks;
88
+ }
89
+
90
+ /** Strip an `a/` or `b/` prefix; return null for `/dev/null`. */
91
+ function pathOf(raw: string): string | null {
92
+ if (raw === "/dev/null") return null;
93
+ return raw.startsWith("a/") || raw.startsWith("b/") ? raw.slice(2) : raw;
94
+ }
95
+
96
+ function parseFileChunk(lines: string[]): FileDiff {
97
+ const gitLine: string = lines[0] ?? "";
98
+ const gm = /^diff --git a\/(.+) b\/(.+)$/.exec(gitLine);
99
+ const meta = { oldPath: gm?.[1] ?? "", newPath: gm?.[2] ?? "", status: "modified" as FileStatus, isBinary: false };
100
+
101
+ const hunkStart: number = lines.findIndex((l) => l.startsWith("@@ "));
102
+ const headerLines: string[] = hunkStart === -1 ? lines.slice(1) : lines.slice(1, hunkStart);
103
+ applyHeaderLines(headerLines, meta);
104
+
105
+ const hashPath: string = meta.status === "deleted" ? meta.oldPath : meta.newPath;
106
+ const hunks: Hunk[] = hunkStart === -1 ? [] : parseHunks(lines.slice(hunkStart), hashPath);
107
+ return { ...meta, hunks };
108
+ }
109
+
110
+ /** Mutate a local metadata accumulator from a file's header lines. */
111
+ function applyHeaderLines(headerLines: string[], meta: { oldPath: string; newPath: string; status: FileStatus; isBinary: boolean }): void {
112
+ for (const line of headerLines) {
113
+ if (line.startsWith("new file mode")) meta.status = "added";
114
+ else if (line.startsWith("deleted file mode")) meta.status = "deleted";
115
+ else if (line.startsWith("rename from ")) { meta.oldPath = line.slice(12); meta.status = "renamed"; }
116
+ else if (line.startsWith("rename to ")) { meta.newPath = line.slice(10); meta.status = "renamed"; }
117
+ else if (line.startsWith("Binary files ")) meta.isBinary = true;
118
+ else if (line.startsWith("--- ")) applyOldSide(pathOf(line.slice(4)), meta);
119
+ else if (line.startsWith("+++ ")) applyNewSide(pathOf(line.slice(4)), meta);
120
+ }
121
+ }
122
+
123
+ function applyOldSide(path: string | null, meta: { oldPath: string; status: FileStatus }): void {
124
+ if (path === null) meta.status = "added";
125
+ else meta.oldPath = path;
126
+ }
127
+
128
+ function applyNewSide(path: string | null, meta: { newPath: string; status: FileStatus }): void {
129
+ if (path === null) meta.status = "deleted";
130
+ else meta.newPath = path;
131
+ }
132
+
133
+ /** Parse the hunk region of a file into Hunk objects. */
134
+ function parseHunks(lines: string[], hashPath: string): Hunk[] {
135
+ const hunks: Hunk[] = [];
136
+ let i = 0;
137
+ while (i < lines.length) {
138
+ const line: string | undefined = lines[i];
139
+ if (line === undefined || !line.startsWith("@@ ")) { i++; continue; }
140
+ i++;
141
+ const body: string[] = [];
142
+ while (i < lines.length && !(lines[i] ?? "").startsWith("@@ ")) {
143
+ body.push(lines[i] ?? "");
144
+ i++;
145
+ }
146
+ hunks.push(buildHunk(line, body, hashPath));
147
+ }
148
+ return hunks;
149
+ }
150
+
151
+ /** Parse an `@@ -a,b +c,d @@` header; counts default to 1 when omitted. */
152
+ function parseHunkHeader(header: string): Pick<Hunk, "oldStart" | "oldLines" | "newStart" | "newLines"> {
153
+ const m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(header);
154
+ if (!m) throw new Error(`Malformed hunk header: ${header}`);
155
+ return {
156
+ oldStart: Number(m[1]),
157
+ oldLines: m[2] ? Number(m[2]) : 1,
158
+ newStart: Number(m[3]),
159
+ newLines: m[4] ? Number(m[4]) : 1,
160
+ };
161
+ }
162
+
163
+ function buildHunk(header: string, body: string[], hashPath: string): Hunk {
164
+ const ranges = parseHunkHeader(header);
165
+ let oldNo: number = ranges.oldStart;
166
+ let newNo: number = ranges.newStart;
167
+ const parsed: DiffLine[] = [];
168
+ const hashBody: string[] = [];
169
+ for (const raw of body) {
170
+ if (raw.startsWith("\\")) continue; // ""
171
+ const marker: string = raw[0] ?? "";
172
+ const text: string = raw.slice(1);
173
+ if (marker === "+") { parsed.push({ kind: "add", oldNo: undefined, newNo, text }); hashBody.push(raw); newNo++; }
174
+ else if (marker === "-") { parsed.push({ kind: "del", oldNo, newNo: undefined, text }); hashBody.push(raw); oldNo++; }
175
+ else if (marker === " ") { parsed.push({ kind: "ctx", oldNo, newNo, text }); hashBody.push(raw); oldNo++; newNo++; }
176
+ }
177
+ return { header, ...ranges, lines: parsed, hash: hunkHash(hashPath, hashBody.join("\n")) };
178
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * A code entity from `sem entities <path> --json` — a function, method, class,
3
+ * type, etc. Used to enumerate all definitions of a name (sem's `context` only
4
+ * ever resolves one, so listing all definitions goes through `entities`).
5
+ */
6
+ export interface SemEntity {
7
+ /**
8
+ * Repo-relative file the entity is in. Present when listing a directory
9
+ * (`sem entities .`); "" when listing a single file (the file is implied by
10
+ * the path argument — the caller fills it in).
11
+ */
12
+ file: string;
13
+ /** Entity name (the symbol). */
14
+ name: string;
15
+ /** Entity kind, e.g. "function", "method", "class", "type". */
16
+ type: string;
17
+ /** 1-based first line of the entity. */
18
+ startLine: number;
19
+ /** 1-based last line of the entity (inclusive). */
20
+ endLine: number;
21
+ /** Full id of the enclosing entity (e.g. `file::class::Foo`), or null. */
22
+ parentId: string | null;
23
+ }
24
+
25
+ /** Read a number, or a fallback, from an unknown record property. */
26
+ function num(value: unknown, fallback: number): number {
27
+ return typeof value === "number" ? value : fallback;
28
+ }
29
+
30
+ /**
31
+ * Parse a `sem entities --json` payload (an array of raw entities) into typed
32
+ * {@link SemEntity} records. Returns [] on any shape error rather than throwing.
33
+ */
34
+ export function parseEntities(raw: string): SemEntity[] {
35
+ let parsed: unknown;
36
+ try {
37
+ parsed = JSON.parse(raw);
38
+ } catch {
39
+ return [];
40
+ }
41
+ if (!Array.isArray(parsed)) return [];
42
+ const out: SemEntity[] = [];
43
+ for (const item of parsed) {
44
+ if (typeof item !== "object" || item === null) continue;
45
+ const rec: Record<string, unknown> = { ...item };
46
+ if (typeof rec.name !== "string" || typeof rec.type !== "string") continue;
47
+ const parentId: string | null = typeof rec.parent_id === "string" ? rec.parent_id : null;
48
+ out.push({
49
+ file: typeof rec.file === "string" ? rec.file : "",
50
+ name: rec.name,
51
+ type: rec.type,
52
+ startLine: num(rec.start_line, 1),
53
+ endLine: num(rec.end_line, num(rec.start_line, 1)),
54
+ parentId,
55
+ });
56
+ }
57
+ return out;
58
+ }
59
+
60
+ /**
61
+ * The entities that are definitions of `name`: an exact name match, excluding
62
+ * non-code entities (markdown headings). Partial-name matches are excluded.
63
+ */
64
+ export function matchEntities(entities: readonly SemEntity[], name: string): SemEntity[] {
65
+ return entities.filter((e) => e.name === name && e.type !== "heading");
66
+ }
67
+
68
+ /**
69
+ * The inclusive 1-based `[startLine, endLine]` slice of `lines` as a body
70
+ * string, plus its first non-empty line (a readable header for the preview).
71
+ * Bounds are clamped to the available lines.
72
+ */
73
+ export function sliceBody(lines: string[], startLine: number, endLine: number): { body: string; matched: string } {
74
+ const from: number = Math.max(1, startLine);
75
+ const to: number = Math.min(lines.length, endLine);
76
+ const slice: string[] = lines.slice(from - 1, to);
77
+ const body: string = slice.join("\n");
78
+ const matched: string = slice.find((l) => l.trim() !== "") ?? "";
79
+ return { body, matched };
80
+ }
81
+
82
+ /**
83
+ * A human scope label for a definition: `Parent.name` when the entity is nested
84
+ * (e.g. a method of a class), otherwise the bare `name`. The parent name is the
85
+ * last `::`-delimited segment of the parent id.
86
+ */
87
+ export function scopeLabel(parentId: string | null, name: string): string {
88
+ if (parentId === null || parentId === "") return name;
89
+ const segments: string[] = parentId.split("::");
90
+ const parent: string = segments[segments.length - 1] ?? "";
91
+ return parent === "" ? name : `${parent}.${name}`;
92
+ }
@@ -0,0 +1,43 @@
1
+ /** Characters that mark a "word boundary" in a path/filename. */
2
+ const BOUNDARY = new Set(["/", "_", "-", ".", " "]);
3
+
4
+ /**
5
+ * Score how well `query` fuzzy-matches `target` (case-insensitive subsequence).
6
+ * Higher is better; contiguous runs and matches at word boundaries score more.
7
+ *
8
+ * @returns The score, or `null` if `query` is not a subsequence of `target`.
9
+ */
10
+ export function fuzzyScore(query: string, target: string): number | null {
11
+ if (query.length === 0) return 0;
12
+ const q: string = query.toLowerCase();
13
+ const t: string = target.toLowerCase();
14
+
15
+ let score = 0;
16
+ let qi = 0;
17
+ let prevMatch = -2;
18
+ for (let ti = 0; ti < t.length && qi < q.length; ti++) {
19
+ if (t[ti] !== q[qi]) continue;
20
+ score += 1;
21
+ if (ti === prevMatch + 1) score += 8; // contiguous run (weighted above boundaries)
22
+ if (ti === 0 || BOUNDARY.has(t[ti - 1] ?? "")) score += 6; // word boundary
23
+ prevMatch = ti;
24
+ qi++;
25
+ }
26
+ return qi === q.length ? score : null;
27
+ }
28
+
29
+ /**
30
+ * Filter and rank items by fuzzy match against `query`. An empty query returns
31
+ * the items unchanged; otherwise non-matches are dropped and matches are sorted
32
+ * by score (desc), breaking ties by shorter item then original order.
33
+ */
34
+ export function fuzzyFilter(query: string, items: readonly string[]): string[] {
35
+ if (query.length === 0) return [...items];
36
+ const scored: Array<{ item: string; score: number; index: number }> = [];
37
+ items.forEach((item, index) => {
38
+ const score = fuzzyScore(query, item);
39
+ if (score !== null) scored.push({ item, score, index });
40
+ });
41
+ scored.sort((a, b) => b.score - a.score || a.item.length - b.item.length || a.index - b.index);
42
+ return scored.map((s) => s.item);
43
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Pure heuristic: does a repo-relative path look like test code?
3
+ *
4
+ * Matches a `__tests__/` directory, a `.test.`/`.spec.` filename infix, or a
5
+ * `test/` or `tests/` path segment. Segment matching is anchored on `/`
6
+ * boundaries so `latest/`, `contest/`, or `spectacular.ts` do NOT match.
7
+ *
8
+ * @param path - Repository-relative file path (forward-slash separated).
9
+ */
10
+ export function isTestPath(path: string): boolean {
11
+ return (
12
+ path.includes("__tests__/") ||
13
+ path.includes(".test.") ||
14
+ path.includes(".spec.") ||
15
+ /(^|\/)tests?\//.test(path)
16
+ );
17
+ }
18
+
19
+ /**
20
+ * Whether a result's file should be treated as test/generated (hideable via
21
+ * the UI's "exclude tests & generated" toggle). Combines the caller-supplied
22
+ * lockfile/generated verdict (which uses backend glob patterns) with the pure
23
+ * test-path heuristic.
24
+ *
25
+ * @param path - Repository-relative file path.
26
+ * @param isLockfileOrGenerated - Result of the backend lockfile-glob matcher.
27
+ */
28
+ export function isTestOrGenerated(
29
+ path: string,
30
+ isLockfileOrGenerated: boolean,
31
+ ): boolean {
32
+ return isLockfileOrGenerated || isTestPath(path);
33
+ }
Binary file
@@ -0,0 +1,20 @@
1
+ import { basename } from "node:path";
2
+
3
+ /**
4
+ * Decide whether a file path is a lock / generated file that should be
5
+ * hideable via the UI toggle.
6
+ *
7
+ * A path matches if any glob matches the full path, or (for convenience) the
8
+ * bare filename — so `package-lock.json` matches `web/package-lock.json`, and
9
+ * `*.min.js` matches `dist/app.min.js`.
10
+ *
11
+ * @param path Repository-relative file path.
12
+ * @param patterns Glob patterns (from built-in defaults + user config).
13
+ */
14
+ export function isLockfile(path: string, patterns: readonly string[]): boolean {
15
+ const name: string = basename(path);
16
+ return patterns.some((pattern) => {
17
+ const glob = new Bun.Glob(pattern);
18
+ return glob.match(path) || glob.match(name);
19
+ });
20
+ }
@@ -0,0 +1,59 @@
1
+ import { join } from "node:path";
2
+ import { homedir } from "node:os";
3
+ import type { PullRequestRef } from "./url.ts";
4
+
5
+ /**
6
+ * Environment injection for path resolution — lets tests supply env/home
7
+ * without touching real globals.
8
+ */
9
+ export interface PathEnv {
10
+ /** Environment variables to read (defaults to `process.env`). */
11
+ env?: Record<string, string | undefined>;
12
+ /** Home directory (defaults to `os.homedir()`). */
13
+ home?: string;
14
+ }
15
+
16
+ /** Per-PR directory name, e.g. `pr_withastro_astro_17360`. */
17
+ function prSlug(ref: PullRequestRef): string {
18
+ return `pr_${ref.owner}_${ref.repo}_${ref.number}`;
19
+ }
20
+
21
+ function resolveEnv(opts?: PathEnv): { env: Record<string, string | undefined>; home: string } {
22
+ return { env: opts?.env ?? process.env, home: opts?.home ?? homedir() };
23
+ }
24
+
25
+ /**
26
+ * Root of mergie's per-PR persistent data:
27
+ * `$XDG_DATA_HOME/mergie/pr_<owner>_<repo>_<n>`, falling back to
28
+ * `~/.local/share/mergie/...` when `XDG_DATA_HOME` is unset.
29
+ */
30
+ export function dataDir(ref: PullRequestRef, opts?: PathEnv): string {
31
+ const { env, home } = resolveEnv(opts);
32
+ const base: string = env.XDG_DATA_HOME || join(home, ".local", "share");
33
+ return join(base, "mergie", prSlug(ref));
34
+ }
35
+
36
+ /**
37
+ * mergie's config directory: `$XDG_CONFIG_HOME/mergie`, falling back to
38
+ * `~/.config/mergie`.
39
+ */
40
+ export function configDir(opts?: PathEnv): string {
41
+ const { env, home } = resolveEnv(opts);
42
+ const base: string = env.XDG_CONFIG_HOME || join(home, ".config");
43
+ return join(base, "mergie");
44
+ }
45
+
46
+ /** Reusable clone directory for a PR (inside its data dir). */
47
+ export function cloneDir(ref: PullRequestRef, opts?: PathEnv): string {
48
+ return join(dataDir(ref, opts), "clone");
49
+ }
50
+
51
+ /** Directory holding AI-generated artifacts for a PR. */
52
+ export function artifactsDir(ref: PullRequestRef, opts?: PathEnv): string {
53
+ return join(dataDir(ref, opts), "artifacts");
54
+ }
55
+
56
+ /** SQLite database file path for a PR. */
57
+ export function dbPath(ref: PullRequestRef, opts?: PathEnv): string {
58
+ return join(dataDir(ref, opts), "mergie.db");
59
+ }
@@ -0,0 +1,81 @@
1
+ /** A pull request's commit topology for range resolution. */
2
+ export interface PrCommits {
3
+ /** The "before-PR" baseline (merge-base with target); a valid range start. */
4
+ baselineSha: string;
5
+ /** PR commit SHAs, ordered oldest → newest. */
6
+ commits: string[];
7
+ }
8
+
9
+ /** A range the user has marked reviewed. */
10
+ export interface ReviewedRange {
11
+ /** Range start SHA (excluded baseline). */
12
+ startSha: string;
13
+ /** Range end SHA. */
14
+ endSha: string;
15
+ /** Creation timestamp (ms) — used to find the most recent. */
16
+ createdAt: number;
17
+ }
18
+
19
+ /** A commit range: diff is startSha → endSha (start excluded). */
20
+ export interface Range {
21
+ /** Baseline SHA (its own changes are excluded). */
22
+ startSha: string;
23
+ /** End SHA (inclusive). */
24
+ endSha: string;
25
+ }
26
+
27
+ /** All SHAs that are valid range endpoints for a PR. */
28
+ function knownShas(pr: PrCommits): Set<string> {
29
+ return new Set<string>([pr.baselineSha, ...pr.commits]);
30
+ }
31
+
32
+ /** Ordinal position of a SHA (baseline = -1, commits 0..n-1); -Infinity if unknown. */
33
+ function ordinal(sha: string, pr: PrCommits): number {
34
+ if (sha === pr.baselineSha) return -1;
35
+ const idx: number = pr.commits.indexOf(sha);
36
+ return idx === -1 ? Number.NEGATIVE_INFINITY : idx;
37
+ }
38
+
39
+ /** The head (latest) SHA of the PR. */
40
+ function headSha(pr: PrCommits): string {
41
+ return pr.commits.at(-1) ?? pr.baselineSha;
42
+ }
43
+
44
+ /**
45
+ * Resolve the range shown when a PR first opens: the most recently reviewed
46
+ * range's end → head. If no non-stale reviewed range exists, the whole PR
47
+ * (baseline → head).
48
+ */
49
+ export function resolveDefaultRange(pr: PrCommits, reviewed: ReviewedRange[]): Range {
50
+ const head: string = headSha(pr);
51
+ const known: Set<string> = knownShas(pr);
52
+ const latestValid: ReviewedRange | undefined = [...reviewed]
53
+ .sort((a, b) => b.createdAt - a.createdAt)
54
+ .find((r) => known.has(r.endSha));
55
+ return { startSha: latestValid?.endSha ?? pr.baselineSha, endSha: head };
56
+ }
57
+
58
+ /**
59
+ * True if the exact range (both endpoints) has already been marked reviewed.
60
+ * Used to reflect reviewed status on the "mark reviewed" control, since
61
+ * re-marking an already-reviewed range is a silent no-op.
62
+ */
63
+ export function isRangeReviewed(range: Range, reviewed: ReviewedRange[]): boolean {
64
+ return reviewed.some((r) => r.startSha === range.startSha && r.endSha === range.endSha);
65
+ }
66
+
67
+ /** True if either endpoint of a range is no longer present in the PR. */
68
+ export function isStale(range: Range, pr: PrCommits): boolean {
69
+ const known: Set<string> = knownShas(pr);
70
+ return !known.has(range.startSha) || !known.has(range.endSha);
71
+ }
72
+
73
+ /**
74
+ * Validate a candidate range: both endpoints must exist and start must come
75
+ * strictly before end in commit order.
76
+ */
77
+ export function isValidRange(startSha: string, endSha: string, pr: PrCommits): boolean {
78
+ const known: Set<string> = knownShas(pr);
79
+ if (!known.has(startSha) || !known.has(endSha)) return false;
80
+ return ordinal(startSha, pr) < ordinal(endSha, pr);
81
+ }
@@ -0,0 +1,36 @@
1
+ /** An inclusive, 1-based line span `[start, end]`. */
2
+ export type Span = [number, number];
3
+
4
+ /** Escape a string for safe use as a literal inside a RegExp. */
5
+ function escapeRegExp(text: string): string {
6
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7
+ }
8
+
9
+ /**
10
+ * Find the 1-based line numbers within `span` where `symbol` appears as a
11
+ * whole word (word-boundary match). A `symbol` that is only a substring of a
12
+ * longer identifier (e.g. `foo` inside `fooBar`) does NOT match.
13
+ *
14
+ * The span is inclusive and 1-based; it is clamped to the file's bounds.
15
+ *
16
+ * @param lines - The file's lines (0-based array, 1-based line numbers).
17
+ * @param span - Inclusive `[start, end]` line range to search within.
18
+ * @param symbol - The identifier to look for.
19
+ * @returns Ascending, de-duplicated line numbers containing a real reference.
20
+ */
21
+ export function findReferences(
22
+ lines: readonly string[],
23
+ span: Span,
24
+ symbol: string,
25
+ ): number[] {
26
+ const [start, end] = span;
27
+ const from = Math.max(1, start);
28
+ const to = Math.min(lines.length, end);
29
+ const pattern = new RegExp(`\\b${escapeRegExp(symbol)}\\b`);
30
+ const hits: number[] = [];
31
+ for (let line = from; line <= to; line++) {
32
+ const text = lines[line - 1];
33
+ if (text !== undefined && pattern.test(text)) hits.push(line);
34
+ }
35
+ return hits;
36
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * A parsed reference to a GitHub pull request, extracted from a PR URL.
3
+ */
4
+ export interface PullRequestRef {
5
+ /** Git host, e.g. "github.com". */
6
+ host: string;
7
+ /** Repository owner / organisation, e.g. "withastro". */
8
+ owner: string;
9
+ /** Repository name, e.g. "astro". */
10
+ repo: string;
11
+ /** Pull request number (positive integer). */
12
+ number: number;
13
+ }
14
+
15
+ /**
16
+ * Parse a GitHub pull-request URL into its components.
17
+ *
18
+ * Tolerant of trailing segments (`/changes`, `/files`, a trailing slash) and
19
+ * of URL fragments/queries — only the `owner/repo/pull/<number>` portion is
20
+ * significant.
21
+ *
22
+ * @param input A pull-request URL such as
23
+ * `https://github.com/withastro/astro/pull/17360/changes`.
24
+ * @returns The parsed {@link PullRequestRef}.
25
+ * @throws If the input is not a URL, or is not a `/pull/<number>` URL.
26
+ */
27
+ export function parsePrUrl(input: string): PullRequestRef {
28
+ let url: URL;
29
+ try {
30
+ url = new URL(input);
31
+ } catch {
32
+ throw new Error(`Invalid PR URL: ${input}`);
33
+ }
34
+
35
+ const segments: string[] = url.pathname.split("/").filter(Boolean);
36
+ const [owner, repo, kind, rawNumber] = segments;
37
+
38
+ if (!owner || !repo || kind !== "pull" || !rawNumber) {
39
+ throw new Error(`Not a pull-request URL: ${input}`);
40
+ }
41
+ if (!/^\d+$/.test(rawNumber)) {
42
+ throw new Error(`Invalid PR number in URL: ${input}`);
43
+ }
44
+ const number: number = Number.parseInt(rawNumber, 10);
45
+ if (number <= 0) {
46
+ throw new Error(`Invalid PR number in URL: ${input}`);
47
+ }
48
+
49
+ return { host: url.hostname, owner, repo, number };
50
+ }