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,12 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="mergie">
2
+ <rect width="32" height="32" rx="7" fill="#ffffff"/>
3
+ <!-- added line: green "+" and bar -->
4
+ <line x1="8" y1="8" x2="8" y2="12" stroke="#2da44e" stroke-width="2" stroke-linecap="round"/>
5
+ <line x1="6" y1="10" x2="10" y2="10" stroke="#2da44e" stroke-width="2" stroke-linecap="round"/>
6
+ <rect x="13" y="8.5" width="13" height="3" rx="1.5" fill="#2da44e"/>
7
+ <!-- removed line: red "−" and bar -->
8
+ <line x1="6" y1="16" x2="10" y2="16" stroke="#cf222e" stroke-width="2" stroke-linecap="round"/>
9
+ <rect x="13" y="14.5" width="10" height="3" rx="1.5" fill="#cf222e"/>
10
+ <!-- context line -->
11
+ <rect x="13" y="20.5" width="8" height="3" rx="1.5" fill="#c8d1da"/>
12
+ </svg>
@@ -0,0 +1,14 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
+ <title>mergie</title>
8
+ <script type="module" crossorigin src="/assets/index-Cp_Gdwf2.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-4bq8mkyV.css">
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ </body>
14
+ </html>
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "mergie-cli",
3
+ "version": "0.1.0",
4
+ "description": "Review GitHub pull requests from a fast local web UI, driven by a small CLI.",
5
+ "license": "GPL-3.0-or-later",
6
+ "author": "Ayush Poddar",
7
+ "homepage": "https://github.com/ayushpoddar/mergie#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ayushpoddar/mergie.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/ayushpoddar/mergie/issues"
14
+ },
15
+ "keywords": [
16
+ "github",
17
+ "pull-request",
18
+ "code-review",
19
+ "diff",
20
+ "cli",
21
+ "bun",
22
+ "review"
23
+ ],
24
+ "type": "module",
25
+ "engines": {
26
+ "bun": ">=1.2.0"
27
+ },
28
+ "bin": {
29
+ "mergie": "./bin/mergie.ts"
30
+ },
31
+ "files": [
32
+ "bin",
33
+ "src",
34
+ "dist",
35
+ "vite.config.ts",
36
+ "tsconfig.json"
37
+ ],
38
+ "scripts": {
39
+ "start": "bun run bin/mergie.ts",
40
+ "dev:web": "vite",
41
+ "build:web": "vite build",
42
+ "prepack": "bun run build:web",
43
+ "typecheck": "tsc --noEmit",
44
+ "test": "bun test",
45
+ "test:e2e": "bun test e2e"
46
+ },
47
+ "dependencies": {
48
+ "@anthropic-ai/claude-agent-sdk": "0.3.177",
49
+ "@tanstack/react-query": "5.101.0",
50
+ "@trpc/client": "11.17.0",
51
+ "@trpc/react-query": "11.17.0",
52
+ "@trpc/server": "11.17.0",
53
+ "highlight.js": "11.11.1",
54
+ "react": "19.2.7",
55
+ "react-dom": "19.2.7",
56
+ "react-markdown": "10.1.0",
57
+ "remark-gfm": "4.0.1",
58
+ "zod": "4.4.3"
59
+ },
60
+ "devDependencies": {
61
+ "@types/bun": "1.3.14",
62
+ "@types/react": "19.2.17",
63
+ "@types/react-dom": "19.2.3",
64
+ "@vitejs/plugin-react": "4.7.0",
65
+ "typescript": "5.9.3",
66
+ "vite": "7.3.5"
67
+ }
68
+ }
@@ -0,0 +1,63 @@
1
+ /** A parsed CLI command. */
2
+ export type Command =
3
+ | { kind: "open"; noOpen: boolean }
4
+ | { kind: "review"; url: string; noOpen: boolean }
5
+ | { kind: "reload"; noOpen: boolean }
6
+ | { kind: "stop" }
7
+ | { kind: "status" };
8
+
9
+ /** Usage text shown on parse errors. */
10
+ export const USAGE =
11
+ "Usage: mergie [--pr <pull-request-url>] [--no-open] | mergie reload | mergie stop | mergie status";
12
+
13
+ /** The flag that suppresses auto-opening the browser tab. */
14
+ const NO_OPEN = "--no-open";
15
+
16
+ /**
17
+ * Parse mergie's CLI arguments into a {@link Command}.
18
+ *
19
+ * With no arguments mergie opens the home picker (no PR selected). `--pr <url>`
20
+ * deep-links into a PR; `reload` restarts the daemon; `--no-open` (valid on any
21
+ * open flow) skips launching the browser.
22
+ *
23
+ * @param argv Arguments after the executable/script (e.g. `process.argv.slice(2)`).
24
+ * @throws If the arguments are unknown, `--pr` has no value, or `--no-open` is
25
+ * combined with a non-open command (`stop`/`status`).
26
+ */
27
+ export function parseArgs(argv: string[]): Command {
28
+ const noOpen: boolean = argv.includes(NO_OPEN);
29
+ const rest: string[] = argv.filter((a) => a !== NO_OPEN);
30
+ const first: string | undefined = rest[0];
31
+
32
+ if (first === "stop" || first === "status") {
33
+ if (noOpen) throw new Error(`${NO_OPEN} is not valid with ${first}.\n${USAGE}`);
34
+ return { kind: first };
35
+ }
36
+
37
+ if (first === undefined) return { kind: "open", noOpen };
38
+ if (first === "reload") return { kind: "reload", noOpen };
39
+
40
+ const url: string | undefined = prUrlFrom(first, rest[1]);
41
+ if (url !== undefined) return { kind: "review", url, noOpen };
42
+
43
+ throw new Error(`Unknown command: ${first}\n${USAGE}`);
44
+ }
45
+
46
+ /**
47
+ * Extract a PR URL from a `--pr <url>` pair or a `--pr=<url>` token, or return
48
+ * undefined if `first` is not a `--pr` flag.
49
+ *
50
+ * @throws If a `--pr` flag is present but its value is empty.
51
+ */
52
+ function prUrlFrom(first: string, next: string | undefined): string | undefined {
53
+ if (first === "--pr") {
54
+ if (next === undefined || next.length === 0) throw new Error(`Missing PR URL.\n${USAGE}`);
55
+ return next;
56
+ }
57
+ if (first.startsWith("--pr=")) {
58
+ const url: string = first.slice("--pr=".length);
59
+ if (url.length === 0) throw new Error(`Missing PR URL.\n${USAGE}`);
60
+ return url;
61
+ }
62
+ return undefined;
63
+ }
@@ -0,0 +1,32 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { dirname, join } from "node:path";
3
+
4
+ /** Port the daemon binds to when `MERGIE_PORT` is unset or invalid. */
5
+ export const DEFAULT_PORT = 4517;
6
+
7
+ /**
8
+ * Resolve the daemon port from the environment. Honours a valid `MERGIE_PORT`
9
+ * (an integer in 1–65535), otherwise falls back to {@link DEFAULT_PORT}. This
10
+ * lets a second instance (e.g. a dev worktree) run on its own port without
11
+ * colliding with the primary daemon.
12
+ */
13
+ export function resolvePort(env: Record<string, string | undefined> = process.env): number {
14
+ const raw: string | undefined = env.MERGIE_PORT;
15
+ const n: number = raw ? Number(raw) : NaN;
16
+ return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : DEFAULT_PORT;
17
+ }
18
+
19
+ /** Port the daemon binds to (overridable via `MERGIE_PORT`). */
20
+ export const DAEMON_PORT: number = resolvePort();
21
+
22
+ /** Base URL of the local daemon. */
23
+ export const DAEMON_URL = `http://localhost:${DAEMON_PORT}`;
24
+
25
+ /** Repository root (two levels up from this file: src/cli → root). */
26
+ export const ROOT: string = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
27
+
28
+ /** Path to the built web UI served by the daemon. */
29
+ export const DIST_DIR: string = join(ROOT, "dist", "web");
30
+
31
+ /** Path to the CLI entry script (used to spawn the daemon process). */
32
+ export const BIN_PATH: string = join(ROOT, "bin", "mergie.ts");
@@ -0,0 +1,42 @@
1
+ import { createTRPCClient, httpBatchLink } from "@trpc/client";
2
+ import type { AppRouter } from "@/daemon/router.ts";
3
+ import { BIN_PATH, DAEMON_PORT, DAEMON_URL } from "./constants.ts";
4
+
5
+ /** A typed tRPC client for the local daemon. */
6
+ export type DaemonClient = ReturnType<typeof createTRPCClient<AppRouter>>;
7
+
8
+ /** Build a tRPC client pointed at the local daemon. */
9
+ export function makeClient(url: string = DAEMON_URL): DaemonClient {
10
+ return createTRPCClient<AppRouter>({ links: [httpBatchLink({ url: `${url}/trpc` })] });
11
+ }
12
+
13
+ /** Whether a daemon is already answering on the given URL. */
14
+ export async function isHealthy(url: string = DAEMON_URL): Promise<boolean> {
15
+ try {
16
+ const res = await fetch(`${url}/trpc/health`, { signal: AbortSignal.timeout(1000) });
17
+ return res.ok;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Ensure a daemon is running: if none is healthy, spawn a detached daemon
25
+ * process and wait until it responds (or time out).
26
+ */
27
+ export async function ensureDaemon(url: string = DAEMON_URL): Promise<void> {
28
+ if (await isHealthy(url)) return;
29
+ Bun.spawn(["bun", "run", BIN_PATH, "__daemon"], {
30
+ stdout: "ignore",
31
+ stderr: "ignore",
32
+ stdin: "ignore",
33
+ env: { ...process.env, MERGIE_PORT: String(DAEMON_PORT) },
34
+ }).unref();
35
+
36
+ const deadline: number = performance.now() + 15000;
37
+ while (performance.now() < deadline) {
38
+ await Bun.sleep(200);
39
+ if (await isHealthy(url)) return;
40
+ }
41
+ throw new Error("Daemon did not become healthy within 15s");
42
+ }
@@ -0,0 +1,11 @@
1
+ import { bunRunner, type CommandRunner } from "@/services/exec.ts";
2
+
3
+ /**
4
+ * Open a URL in the default browser (macOS `open`).
5
+ *
6
+ * @param url URL to open.
7
+ * @param runner Command runner (injectable for tests).
8
+ */
9
+ export async function openBrowser(url: string, runner: CommandRunner = bunRunner): Promise<void> {
10
+ await runner.run("open", [url]);
11
+ }
package/src/cli/run.ts ADDED
@@ -0,0 +1,76 @@
1
+ import type { Command } from "./args.ts";
2
+ import { DAEMON_URL } from "./constants.ts";
3
+ import { ensureDaemon, isHealthy, makeClient } from "./daemonClient.ts";
4
+ import { openBrowser } from "./openBrowser.ts";
5
+
6
+ /** Execute a parsed CLI {@link Command}. */
7
+ export async function runCli(cmd: Command): Promise<void> {
8
+ if (cmd.kind === "review") return runReview(cmd.url, cmd.noOpen);
9
+ if (cmd.kind === "open") return runOpen(cmd.noOpen);
10
+ if (cmd.kind === "reload") return runReload(cmd.noOpen);
11
+ if (cmd.kind === "stop") return runStop();
12
+ return runStatus();
13
+ }
14
+
15
+ /** Open the home picker (no PR selected). */
16
+ async function runOpen(noOpen: boolean): Promise<void> {
17
+ await ensureDaemon();
18
+ await maybeOpen(`${DAEMON_URL}/`, noOpen);
19
+ }
20
+
21
+ /** Load a PR by URL and open it directly. */
22
+ async function runReview(url: string, noOpen: boolean): Promise<void> {
23
+ await ensureDaemon();
24
+ const pr = await makeClient().loadPr.mutate({ url });
25
+ console.log(`Loaded ${pr.owner}/${pr.repo} #${pr.number}: ${pr.title}`);
26
+ await maybeOpen(`${DAEMON_URL}/?pr=${pr.id}`, noOpen);
27
+ }
28
+
29
+ /** Restart the daemon (stop, wait for exit, then open the home picker). */
30
+ async function runReload(noOpen: boolean): Promise<void> {
31
+ await stopAndWait();
32
+ await runOpen(noOpen);
33
+ }
34
+
35
+ async function runStop(): Promise<void> {
36
+ if (!(await isHealthy())) {
37
+ console.log("Daemon is not running.");
38
+ return;
39
+ }
40
+ await makeClient().stop.mutate();
41
+ console.log("Daemon stopping.");
42
+ }
43
+
44
+ async function runStatus(): Promise<void> {
45
+ if (!(await isHealthy())) {
46
+ console.log("Daemon is not running.");
47
+ return;
48
+ }
49
+ const health = await makeClient().health.query();
50
+ console.log(`Daemon running. ${health.prs.length} PR(s) loaded.`);
51
+ for (const pr of health.prs) {
52
+ console.log(` - ${pr.owner}/${pr.repo} #${pr.number}: ${pr.title}`);
53
+ }
54
+ }
55
+
56
+ /** Open a URL in the browser unless suppressed, always logging the target. */
57
+ async function maybeOpen(target: string, noOpen: boolean): Promise<void> {
58
+ if (noOpen) {
59
+ console.log(`Ready at ${target}`);
60
+ return;
61
+ }
62
+ console.log(`Opening ${target}`);
63
+ await openBrowser(target);
64
+ }
65
+
66
+ /** Ask a running daemon to stop, then poll until it stops answering. */
67
+ async function stopAndWait(): Promise<void> {
68
+ if (!(await isHealthy())) return;
69
+ await makeClient().stop.mutate();
70
+ const deadline: number = performance.now() + 15000;
71
+ while (performance.now() < deadline) {
72
+ await Bun.sleep(200);
73
+ if (!(await isHealthy())) return;
74
+ }
75
+ throw new Error("Daemon did not stop within 15s");
76
+ }
@@ -0,0 +1,71 @@
1
+ import type { ChatRange } from "./registry.ts";
2
+
3
+ /** Lifecycle state of an AI review the UI can observe. */
4
+ export type AiReviewState = "running" | "done" | "failed";
5
+
6
+ /** Observable status of one AI review, keyed by its commit range. */
7
+ export interface AiReviewStatus {
8
+ /** Range baseline SHA. */
9
+ startSha: string;
10
+ /** Range end SHA. */
11
+ endSha: string;
12
+ /** Current lifecycle state. */
13
+ state: AiReviewState;
14
+ /** Persisted review row id once done; null while running or on failure. */
15
+ reviewId: number | null;
16
+ /** Error message when failed; null otherwise. */
17
+ error: string | null;
18
+ }
19
+
20
+ /**
21
+ * Per-PR tracker of in-flight and recently-completed AI reviews, keyed by
22
+ * commit range. Lets the UI show a persistent "review in progress / ready /
23
+ * failed" indicator independent of whichever popup started the run. A completed
24
+ * (done/failed) entry lingers until explicitly dismissed (e.g. after the user
25
+ * clicks through to the result); a running entry cannot be dismissed.
26
+ */
27
+ export interface AiReviewTracker {
28
+ /** Mark a range's review as running (resets any prior state for that range). */
29
+ start(range: ChatRange): void;
30
+ /** Transition a running range → done, recording the persisted review id. */
31
+ finish(range: ChatRange, reviewId: number): void;
32
+ /** Transition a running range → failed, recording an error message. */
33
+ fail(range: ChatRange, error: string): void;
34
+ /** Remove a completed (done/failed) entry; running entries are kept. */
35
+ dismiss(range: ChatRange): void;
36
+ /** Snapshot of all tracked statuses. */
37
+ list(): AiReviewStatus[];
38
+ }
39
+
40
+ /** Map key for a range. */
41
+ function key(range: ChatRange): string {
42
+ return `${range.start}:${range.end}`;
43
+ }
44
+
45
+ /** Create an {@link AiReviewTracker}. */
46
+ export function createAiReviewTracker(): AiReviewTracker {
47
+ const statuses = new Map<string, AiReviewStatus>();
48
+
49
+ return {
50
+ start(range) {
51
+ statuses.set(key(range), {
52
+ startSha: range.start, endSha: range.end, state: "running", reviewId: null, error: null,
53
+ });
54
+ },
55
+ finish(range, reviewId) {
56
+ const cur = statuses.get(key(range));
57
+ if (!cur) return;
58
+ statuses.set(key(range), { ...cur, state: "done", reviewId, error: null });
59
+ },
60
+ fail(range, error) {
61
+ const cur = statuses.get(key(range));
62
+ if (!cur) return;
63
+ statuses.set(key(range), { ...cur, state: "failed", reviewId: null, error });
64
+ },
65
+ dismiss(range) {
66
+ const cur = statuses.get(key(range));
67
+ if (cur && cur.state !== "running") statuses.delete(key(range));
68
+ },
69
+ list: () => [...statuses.values()],
70
+ };
71
+ }
@@ -0,0 +1,119 @@
1
+ import type { CommentRow, CommentSide } from "@/db/repositories/comments.ts";
2
+ import type { GithubThread } from "./githubThreads.ts";
3
+
4
+ /**
5
+ * Where a comment originates:
6
+ * - `'local'` — authored in mergie, not (yet) posted to GitHub.
7
+ * - `'posted'` — authored in mergie and posted to GitHub.
8
+ * - `'github'` — authored on GitHub (by anyone) and fetched into mergie.
9
+ */
10
+ export type CommentOrigin = "local" | "posted" | "github";
11
+
12
+ /**
13
+ * A single comment row for the unified "All comments" view, merged from the
14
+ * local comment store and the fetched GitHub inline-comment threads.
15
+ */
16
+ export interface AllCommentEntry {
17
+ /** Stable unique key: `local:<id>` or `gh:<githubId>`. */
18
+ key: string;
19
+ /** Where the comment came from. */
20
+ origin: CommentOrigin;
21
+ /** True when authored by the current user. */
22
+ mine: boolean;
23
+ /** Display author: "You" when mine, else the GitHub login. */
24
+ author: string;
25
+ /** Repo-relative file path (null if GitHub omitted it). */
26
+ path: string | null;
27
+ /** Diff side the comment anchors to (null if unknown). */
28
+ side: CommentSide | null;
29
+ /** Human location string, e.g. "whole hunk", "line 5", "lines 5–8". */
30
+ location: string;
31
+ /** Markdown body. */
32
+ body: string;
33
+ /** Creation time (Unix ms); null if unknown. */
34
+ createdAt: number | null;
35
+ /** Number of replies (GitHub threads); 0 for local/posted without a thread. */
36
+ replyCount: number;
37
+ /** Local comment id for post/delete actions; null for github-origin entries. */
38
+ localId: number | null;
39
+ /** Head SHA when the comment was made (for the in-context link); null for github-origin. */
40
+ madeAtSha: string | null;
41
+ /** URL of the comment on GitHub if it exists there; null otherwise. */
42
+ githubUrl: string | null;
43
+ /** GitHub comment id when the comment lives on GitHub (posted or fetched); null for local drafts. */
44
+ githubId: string | null;
45
+ }
46
+
47
+ /** Human location string for a local comment. */
48
+ function localLocation(c: CommentRow): string {
49
+ if (c.kind === "hunk") return "whole hunk";
50
+ if (c.startLine === null || c.endLine === null) return "lines ?";
51
+ return c.startLine === c.endLine ? `line ${c.startLine}` : `lines ${c.startLine}–${c.endLine}`;
52
+ }
53
+
54
+ /** Map a local comment row to a unified entry. */
55
+ function fromLocal(c: CommentRow, replyCount: number): AllCommentEntry {
56
+ return {
57
+ key: `local:${c.id}`,
58
+ origin: c.githubId !== null ? "posted" : "local",
59
+ mine: true,
60
+ author: "You",
61
+ path: c.path,
62
+ side: c.side,
63
+ location: localLocation(c),
64
+ body: c.body,
65
+ createdAt: c.createdAt,
66
+ replyCount,
67
+ localId: c.id,
68
+ madeAtSha: c.madeAtSha,
69
+ githubUrl: c.githubUrl,
70
+ githubId: c.githubId,
71
+ };
72
+ }
73
+
74
+ /** Map an un-matched GitHub thread (not authored in mergie) to a unified entry. */
75
+ function fromThread(t: GithubThread, viewer: string): AllCommentEntry {
76
+ const mine: boolean = viewer.length > 0 && t.root.author === viewer;
77
+ return {
78
+ key: `gh:${t.root.githubId}`,
79
+ origin: "github",
80
+ mine,
81
+ author: mine ? "You" : t.root.author,
82
+ path: t.path,
83
+ side: t.side,
84
+ location: t.line === null ? "line ?" : `line ${t.line}`,
85
+ body: t.root.body,
86
+ createdAt: t.root.createdAt,
87
+ replyCount: t.replies.length,
88
+ localId: null,
89
+ madeAtSha: null,
90
+ githubUrl: t.root.htmlUrl,
91
+ githubId: t.root.githubId,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Merge local comments and fetched GitHub threads into one unified list for the
97
+ * "All comments" view. A mergie comment that was posted to GitHub and then
98
+ * fetched back is shown once (as the local/posted entry, carrying its thread's
99
+ * reply count) — matching the diff view's de-duplication by GitHub id. Result
100
+ * is sorted newest-first by creation time.
101
+ *
102
+ * @param local All locally-stored comments.
103
+ * @param threads Grouped, fetched GitHub inline-comment threads (roots+replies).
104
+ * @param viewer The current user's GitHub login (empty string if unknown).
105
+ */
106
+ export function mergeAllComments(local: CommentRow[], threads: GithubThread[], viewer: string): AllCommentEntry[] {
107
+ const threadByRootId = new Map<string, GithubThread>(threads.map((t) => [t.root.githubId, t]));
108
+ const postedIds = new Set<string>(local.map((c) => c.githubId).filter((id): id is string => id !== null));
109
+
110
+ const entries: AllCommentEntry[] = [
111
+ ...local.map((c) => fromLocal(c, c.githubId !== null ? (threadByRootId.get(c.githubId)?.replies.length ?? 0) : 0)),
112
+ ...threads.filter((t) => !postedIds.has(t.root.githubId)).map((t) => fromThread(t, viewer)),
113
+ ];
114
+
115
+ return entries
116
+ .map((e, i) => ({ e, i }))
117
+ .sort((a, b) => (b.e.createdAt ?? 0) - (a.e.createdAt ?? 0) || a.i - b.i)
118
+ .map(({ e }) => e);
119
+ }
@@ -0,0 +1,47 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import type { ArtifactInput } from "@/db/repositories/artifacts.ts";
4
+
5
+ /** List every file under `dir` recursively, as `/`-joined relative paths. */
6
+ export function listRelFiles(dir: string): string[] {
7
+ if (!existsSync(dir)) return [];
8
+ const out: string[] = [];
9
+ const walk = (current: string): void => {
10
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
11
+ const abs: string = join(current, entry.name);
12
+ if (entry.isDirectory()) walk(abs);
13
+ else if (entry.isFile()) out.push(relative(dir, abs));
14
+ }
15
+ };
16
+ walk(dir);
17
+ return out;
18
+ }
19
+
20
+ /** Fixed context for a batch of captured artifacts. */
21
+ export interface CaptureContext {
22
+ /** Range baseline SHA the artifacts belong to. */
23
+ rangeStartSha: string;
24
+ /** Range end SHA the artifacts belong to. */
25
+ rangeEndSha: string;
26
+ /** The session that produced them. */
27
+ sessionId: number;
28
+ /** Creation timestamp (ms). */
29
+ now: number;
30
+ }
31
+
32
+ /**
33
+ * Compute the artifact rows for files that appeared in `dir` since the `before`
34
+ * snapshot of relative paths. The title defaults to the file's relative path.
35
+ */
36
+ export function newArtifacts(dir: string, before: Set<string>, ctx: CaptureContext): ArtifactInput[] {
37
+ return listRelFiles(dir)
38
+ .filter((rel) => !before.has(rel))
39
+ .map((rel) => ({
40
+ rangeStartSha: ctx.rangeStartSha,
41
+ rangeEndSha: ctx.rangeEndSha,
42
+ sessionId: ctx.sessionId,
43
+ relPath: rel,
44
+ title: rel,
45
+ createdAt: ctx.now,
46
+ }));
47
+ }
@@ -0,0 +1,54 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { bunRunner, type CommandRunner } from "@/services/exec.ts";
4
+
5
+ /** Dependencies for building the web UI. */
6
+ export interface BuildUiDeps {
7
+ /** Command runner to execute the build (defaults to the real Bun-backed runner). */
8
+ runner?: CommandRunner;
9
+ /** Checkout root holding `package.json` / `vite.config` — the build's cwd. */
10
+ root: string;
11
+ /** Progress/error sink (defaults to `console.error`). */
12
+ log?: (message: string) => void;
13
+ /**
14
+ * Whether the build toolchain (vite) is available under `root`. Defaults to
15
+ * probing `root/node_modules/.bin/vite`. Published installs ship a prebuilt UI
16
+ * and omit the toolchain, so this is `false` for them.
17
+ */
18
+ hasBuildTooling?: () => boolean;
19
+ }
20
+
21
+ /**
22
+ * Ensure the daemon has a web UI to serve. In a development checkout (the build
23
+ * toolchain is installed) this rebuilds the React UI (vite → `dist/web`) so the
24
+ * daemon serves the current interface on cold start. In a published install the
25
+ * toolchain is absent and a prebuilt `dist/web` ships in the package, so there is
26
+ * nothing to build — it serves the bundled UI as-is.
27
+ *
28
+ * Never throws: a failed build logs and resolves `false`, so the daemon still
29
+ * starts serving whatever build already exists (a stale UI beats no tool).
30
+ *
31
+ * @returns `true` if the UI is ready (freshly built or already bundled), `false`
32
+ * if a build was attempted and failed.
33
+ */
34
+ export async function buildWebUi(deps: BuildUiDeps): Promise<boolean> {
35
+ const runner: CommandRunner = deps.runner ?? bunRunner;
36
+ const log: (message: string) => void = deps.log ?? ((m) => console.error(m));
37
+ const hasBuildTooling: () => boolean =
38
+ deps.hasBuildTooling ?? (() => existsSync(join(deps.root, "node_modules", ".bin", "vite")));
39
+
40
+ // Published install: no toolchain to rebuild with; serve the bundled UI.
41
+ if (!hasBuildTooling()) {
42
+ log("mergie: serving prebuilt web UI.");
43
+ return true;
44
+ }
45
+
46
+ log("mergie: building web UI…");
47
+ const result = await runner.run("bun", ["run", "build:web"], { cwd: deps.root });
48
+ if (result.exitCode !== 0) {
49
+ log(`mergie: web UI build failed (exit ${result.exitCode}); serving the existing build.\n${result.stderr}`);
50
+ return false;
51
+ }
52
+ log("mergie: web UI build complete.");
53
+ return true;
54
+ }
@@ -0,0 +1,35 @@
1
+ import type { ChatRole } from "@/db/repositories/chatSessions.ts";
2
+
3
+ /** Max length of a derived session title (including the trailing ellipsis). */
4
+ const TITLE_CAP = 52;
5
+
6
+ /**
7
+ * Derive a short session title from the first user prompt: whitespace is
8
+ * collapsed and the text is truncated to {@link TITLE_CAP} with an ellipsis.
9
+ * Falls back to "New chat" when the prompt is empty.
10
+ */
11
+ export function sessionTitle(prompt: string): string {
12
+ const flat: string = prompt.replace(/\s+/g, " ").trim();
13
+ if (flat.length === 0) return "New chat";
14
+ return flat.length > TITLE_CAP ? `${flat.slice(0, TITLE_CAP - 1)}…` : flat;
15
+ }
16
+
17
+ /** A minimal chat message for transcript rendering. */
18
+ export interface TranscriptMessage {
19
+ /** Who sent it. */
20
+ role: ChatRole;
21
+ /** Message content. */
22
+ content: string;
23
+ }
24
+
25
+ /**
26
+ * Render a session's messages into a single prompt for the agent. A first-turn
27
+ * (single user message) is passed through verbatim; multi-turn sessions become
28
+ * a `User:`/`Assistant:`-labelled transcript so the agent has prior context.
29
+ */
30
+ export function chatTranscript(messages: TranscriptMessage[]): string {
31
+ if (messages.length === 1 && messages[0]?.role === "user") return messages[0].content;
32
+ return messages
33
+ .map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content}`)
34
+ .join("\n\n");
35
+ }