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.
- package/LICENSE +674 -0
- package/README.md +91 -0
- package/bin/mergie-dev +18 -0
- package/bin/mergie.ts +44 -0
- package/dist/web/assets/index-4bq8mkyV.css +1 -0
- package/dist/web/assets/index-Cp_Gdwf2.js +50 -0
- package/dist/web/favicon.svg +12 -0
- package/dist/web/index.html +14 -0
- package/package.json +68 -0
- package/src/cli/args.ts +63 -0
- package/src/cli/constants.ts +32 -0
- package/src/cli/daemonClient.ts +42 -0
- package/src/cli/openBrowser.ts +11 -0
- package/src/cli/run.ts +76 -0
- package/src/daemon/aiReviewTracker.ts +71 -0
- package/src/daemon/allComments.ts +119 -0
- package/src/daemon/artifactCapture.ts +47 -0
- package/src/daemon/buildUi.ts +54 -0
- package/src/daemon/chatPrompt.ts +35 -0
- package/src/daemon/chatSocket.ts +78 -0
- package/src/daemon/createRegistry.ts +569 -0
- package/src/daemon/githubThreads.ts +92 -0
- package/src/daemon/inflight.ts +49 -0
- package/src/daemon/postMapping.ts +94 -0
- package/src/daemon/registry.ts +263 -0
- package/src/daemon/reviewPrompt.ts +22 -0
- package/src/daemon/reviewService.ts +243 -0
- package/src/daemon/router.ts +287 -0
- package/src/daemon/server.ts +106 -0
- package/src/daemon/splitView.ts +63 -0
- package/src/daemon/trpc.ts +20 -0
- package/src/db/migrate.ts +24 -0
- package/src/db/repositories/aiReviews.ts +113 -0
- package/src/db/repositories/artifacts.ts +101 -0
- package/src/db/repositories/chatSessions.ts +197 -0
- package/src/db/repositories/comments.ts +195 -0
- package/src/db/repositories/githubComments.ts +166 -0
- package/src/db/repositories/hunkViews.ts +36 -0
- package/src/db/repositories/reviewedRanges.ts +75 -0
- package/src/db/schema.ts +97 -0
- package/src/domain/config.ts +137 -0
- package/src/domain/context.ts +32 -0
- package/src/domain/diff.ts +178 -0
- package/src/domain/entities.ts +92 -0
- package/src/domain/fuzzy.ts +43 -0
- package/src/domain/generated.ts +33 -0
- package/src/domain/hash.ts +0 -0
- package/src/domain/lockfiles.ts +20 -0
- package/src/domain/paths.ts +59 -0
- package/src/domain/ranges.ts +81 -0
- package/src/domain/references.ts +36 -0
- package/src/domain/url.ts +50 -0
- package/src/domain/wordDiff.ts +174 -0
- package/src/services/ai.ts +137 -0
- package/src/services/exec.ts +44 -0
- package/src/services/ghPr.ts +102 -0
- package/src/services/ghSearch.ts +135 -0
- package/src/services/git.ts +297 -0
- package/src/services/github.ts +300 -0
- package/src/services/symbols.ts +364 -0
- package/src/web/App.tsx +32 -0
- package/src/web/components/AiReviewIndicator.tsx +63 -0
- package/src/web/components/AiReviewModal.tsx +101 -0
- package/src/web/components/AiReviewsPanel.tsx +64 -0
- package/src/web/components/ChatPanel.tsx +142 -0
- package/src/web/components/CodePreview.tsx +63 -0
- package/src/web/components/CommentComposer.tsx +40 -0
- package/src/web/components/CommentItem.tsx +59 -0
- package/src/web/components/CommentsPanel.tsx +309 -0
- package/src/web/components/CommitRail.tsx +64 -0
- package/src/web/components/ConfirmButton.tsx +32 -0
- package/src/web/components/CopyButton.tsx +33 -0
- package/src/web/components/CopyIconButton.tsx +38 -0
- package/src/web/components/DiffFrame.tsx +133 -0
- package/src/web/components/DiffLines.tsx +149 -0
- package/src/web/components/FileFrame.tsx +45 -0
- package/src/web/components/FileNavigator.tsx +195 -0
- package/src/web/components/FileTree.tsx +45 -0
- package/src/web/components/FileView.tsx +53 -0
- package/src/web/components/GithubThreadView.tsx +56 -0
- package/src/web/components/HunkCard.tsx +121 -0
- package/src/web/components/Icons.tsx +215 -0
- package/src/web/components/IdentifierMenuPortal.tsx +33 -0
- package/src/web/components/PostMenu.tsx +63 -0
- package/src/web/components/PrDescription.tsx +29 -0
- package/src/web/components/PrLoading.tsx +45 -0
- package/src/web/components/PrPicker.tsx +201 -0
- package/src/web/components/RangeSelector.tsx +141 -0
- package/src/web/components/ResultsList.tsx +159 -0
- package/src/web/components/ReviewView.tsx +308 -0
- package/src/web/components/RightRail.tsx +146 -0
- package/src/web/components/SearchRailPanel.tsx +127 -0
- package/src/web/components/Switch.tsx +31 -0
- package/src/web/components/SwitchPrModal.tsx +28 -0
- package/src/web/components/SymbolLookupMenu.tsx +35 -0
- package/src/web/components/Toolbar.tsx +79 -0
- package/src/web/components/Tooltip.tsx +72 -0
- package/src/web/index.html +13 -0
- package/src/web/lib/aiReviewIndicator.ts +29 -0
- package/src/web/lib/anchorRow.ts +25 -0
- package/src/web/lib/codeSearchFetch.ts +46 -0
- package/src/web/lib/commentFilters.ts +36 -0
- package/src/web/lib/commentVisibility.ts +90 -0
- package/src/web/lib/composerKeys.ts +24 -0
- package/src/web/lib/dedupeResults.ts +37 -0
- package/src/web/lib/diffMarks.ts +81 -0
- package/src/web/lib/fileStatus.ts +12 -0
- package/src/web/lib/filterCodeResults.ts +42 -0
- package/src/web/lib/filterPrs.ts +38 -0
- package/src/web/lib/highlight.ts +40 -0
- package/src/web/lib/identifierMenu.ts +41 -0
- package/src/web/lib/lineSelection.ts +48 -0
- package/src/web/lib/navRouting.ts +40 -0
- package/src/web/lib/navStack.ts +109 -0
- package/src/web/lib/persistedFlag.ts +43 -0
- package/src/web/lib/prPickerModel.ts +27 -0
- package/src/web/lib/railState.ts +19 -0
- package/src/web/lib/rangeCoverage.ts +27 -0
- package/src/web/lib/rangeMap.ts +42 -0
- package/src/web/lib/resultCountLabel.ts +11 -0
- package/src/web/lib/reviewProgress.ts +26 -0
- package/src/web/lib/searchInputsKey.ts +35 -0
- package/src/web/lib/searchToken.ts +25 -0
- package/src/web/lib/splitSide.ts +13 -0
- package/src/web/lib/time.ts +9 -0
- package/src/web/lib/togglePrefs.ts +81 -0
- package/src/web/lib/useEscToClose.ts +17 -0
- package/src/web/lib/useIdentifierMenu.ts +77 -0
- package/src/web/lib/useNavLookup.ts +74 -0
- package/src/web/lib/usePageTitle.ts +15 -0
- package/src/web/lib/visibleFiles.ts +52 -0
- package/src/web/main.tsx +24 -0
- package/src/web/public/favicon.svg +12 -0
- package/src/web/state/useChat.ts +181 -0
- package/src/web/state/useCodeSearch.ts +198 -0
- package/src/web/state/useReview.ts +138 -0
- package/src/web/styles.css +1020 -0
- package/src/web/trpc.ts +5 -0
- package/tsconfig.json +27 -0
- package/vite.config.ts +29 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { CharRange, DiffLine } from "@/domain/diff.ts";
|
|
2
|
+
|
|
3
|
+
/** One side of a split-view row. */
|
|
4
|
+
export interface SplitCell {
|
|
5
|
+
/** Line number on this side, or null for padding. */
|
|
6
|
+
no: number | null;
|
|
7
|
+
/** Line text. */
|
|
8
|
+
text: string;
|
|
9
|
+
/** Cell kind: unchanged, deleted, added, or empty padding. */
|
|
10
|
+
kind: "ctx" | "del" | "add" | "empty";
|
|
11
|
+
/** Intra-line changed character ranges for word highlighting, if any. */
|
|
12
|
+
changes?: CharRange[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A single row of the side-by-side split view. */
|
|
16
|
+
export interface SplitRow {
|
|
17
|
+
/** Base (left) cell. */
|
|
18
|
+
left: SplitCell;
|
|
19
|
+
/** Head (right) cell. */
|
|
20
|
+
right: SplitCell;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** An empty padding cell. */
|
|
24
|
+
const EMPTY: SplitCell = { no: null, text: "", kind: "empty" };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Convert a full-file diff's lines into aligned side-by-side rows (GitHub-style
|
|
28
|
+
* split view): context lines appear on both sides; a change block pairs its
|
|
29
|
+
* deleted lines (left) with its added lines (right), padding the shorter side.
|
|
30
|
+
*/
|
|
31
|
+
export function buildSplitRows(lines: DiffLine[]): SplitRow[] {
|
|
32
|
+
const rows: SplitRow[] = [];
|
|
33
|
+
let dels: DiffLine[] = [];
|
|
34
|
+
let adds: DiffLine[] = [];
|
|
35
|
+
|
|
36
|
+
const flush = (): void => {
|
|
37
|
+
const n: number = Math.max(dels.length, adds.length);
|
|
38
|
+
for (let i = 0; i < n; i++) {
|
|
39
|
+
const d = dels[i];
|
|
40
|
+
const a = adds[i];
|
|
41
|
+
rows.push({
|
|
42
|
+
left: d ? { no: d.oldNo ?? null, text: d.text, kind: "del", changes: d.changes } : EMPTY,
|
|
43
|
+
right: a ? { no: a.newNo ?? null, text: a.text, kind: "add", changes: a.changes } : EMPTY,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
dels = [];
|
|
47
|
+
adds = [];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
for (const line of lines) {
|
|
51
|
+
if (line.kind === "del") dels.push(line);
|
|
52
|
+
else if (line.kind === "add") adds.push(line);
|
|
53
|
+
else {
|
|
54
|
+
flush();
|
|
55
|
+
rows.push({
|
|
56
|
+
left: { no: line.oldNo ?? null, text: line.text, kind: "ctx" },
|
|
57
|
+
right: { no: line.newNo ?? null, text: line.text, kind: "ctx" },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
flush();
|
|
62
|
+
return rows;
|
|
63
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { initTRPC } from "@trpc/server";
|
|
2
|
+
import type { PrRegistry } from "./registry.ts";
|
|
3
|
+
import type { GhSearchService } from "@/services/ghSearch.ts";
|
|
4
|
+
|
|
5
|
+
/** Per-request context passed to every tRPC procedure. */
|
|
6
|
+
export interface Context {
|
|
7
|
+
/** The registry of loaded PRs. */
|
|
8
|
+
registry: PrRegistry;
|
|
9
|
+
/** GitHub search for the viewer's open PRs (home picker). */
|
|
10
|
+
search: GhSearchService;
|
|
11
|
+
/** Request daemon shutdown (called by the `stop` procedure). */
|
|
12
|
+
requestStop: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const t = initTRPC.context<Context>().create();
|
|
16
|
+
|
|
17
|
+
/** Build a tRPC router. */
|
|
18
|
+
export const router = t.router;
|
|
19
|
+
/** A procedure with no auth/middleware (this is a local single-user tool). */
|
|
20
|
+
export const publicProcedure = t.procedure;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { SCHEMA_SQL, SCHEMA_VERSION } from "./schema.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Apply the schema to a database and record the schema version. Idempotent —
|
|
6
|
+
* uses `IF NOT EXISTS` DDL so it is safe to run on every open.
|
|
7
|
+
*/
|
|
8
|
+
export function migrate(db: Database): void {
|
|
9
|
+
db.exec(SCHEMA_SQL);
|
|
10
|
+
db.query("INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)").run(
|
|
11
|
+
String(SCHEMA_VERSION),
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Open (creating if needed) a SQLite database at `path`, apply migrations, and
|
|
17
|
+
* return the ready-to-use handle. Pass `:memory:` for tests.
|
|
18
|
+
*/
|
|
19
|
+
export function openDatabase(path: string): Database {
|
|
20
|
+
const db = new Database(path, { create: true });
|
|
21
|
+
db.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;");
|
|
22
|
+
migrate(db);
|
|
23
|
+
return db;
|
|
24
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Input required to create a new AI review record. The caller supplies all
|
|
5
|
+
* timestamps so they stay deterministic in tests.
|
|
6
|
+
*/
|
|
7
|
+
export interface AiReviewInput {
|
|
8
|
+
/** The baseline (exclusive) commit SHA for this review. */
|
|
9
|
+
startSha: string;
|
|
10
|
+
/** The end (inclusive) commit SHA for this review. */
|
|
11
|
+
endSha: string;
|
|
12
|
+
/** Model identifier used to generate the review (e.g. `'claude-3-opus'`). */
|
|
13
|
+
model: string;
|
|
14
|
+
/** Optional template id from config (e.g. `'adversarial'`). */
|
|
15
|
+
template: string | null;
|
|
16
|
+
/** Optional user-supplied prompt to focus the review. */
|
|
17
|
+
prompt: string | null;
|
|
18
|
+
/** Markdown body of the review result. */
|
|
19
|
+
body: string;
|
|
20
|
+
/** Creation timestamp in milliseconds. */
|
|
21
|
+
createdAt: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A fully-hydrated AI review row as returned by the repository.
|
|
26
|
+
* Extends {@link AiReviewInput} with the auto-generated id.
|
|
27
|
+
*/
|
|
28
|
+
export interface AiReviewRow extends AiReviewInput {
|
|
29
|
+
/** Auto-increment primary key. */
|
|
30
|
+
id: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Persistence for AI-generated review results. */
|
|
34
|
+
export interface AiReviewsRepo {
|
|
35
|
+
/** Persist a new AI review and return its auto-generated id. */
|
|
36
|
+
create(input: AiReviewInput): number;
|
|
37
|
+
/** Fetch an AI review by id; returns null if not found. */
|
|
38
|
+
get(id: number): AiReviewRow | null;
|
|
39
|
+
/** All AI reviews for the given commit range. */
|
|
40
|
+
listByRange(startSha: string, endSha: string): AiReviewRow[];
|
|
41
|
+
/** Every AI review in the database. */
|
|
42
|
+
listAll(): AiReviewRow[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Raw DB row for the ai_review table. */
|
|
46
|
+
interface AiReviewDbRow {
|
|
47
|
+
id: number;
|
|
48
|
+
start_sha: string;
|
|
49
|
+
end_sha: string;
|
|
50
|
+
model: string;
|
|
51
|
+
template: string | null;
|
|
52
|
+
prompt: string | null;
|
|
53
|
+
body: string;
|
|
54
|
+
created_at: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Map a raw DB row to the public {@link AiReviewRow} shape. */
|
|
58
|
+
function toRow(r: AiReviewDbRow): AiReviewRow {
|
|
59
|
+
return {
|
|
60
|
+
id: r.id,
|
|
61
|
+
startSha: r.start_sha,
|
|
62
|
+
endSha: r.end_sha,
|
|
63
|
+
model: r.model,
|
|
64
|
+
template: r.template,
|
|
65
|
+
prompt: r.prompt,
|
|
66
|
+
body: r.body,
|
|
67
|
+
createdAt: r.created_at,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Create an {@link AiReviewsRepo} backed by the given database. */
|
|
72
|
+
export function aiReviewsRepo(db: Database): AiReviewsRepo {
|
|
73
|
+
const insert = db.query<{ id: number }, [string, string, string, string | null, string | null, string, number]>(`
|
|
74
|
+
INSERT INTO ai_review (start_sha, end_sha, model, template, prompt, body, created_at)
|
|
75
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
76
|
+
RETURNING id
|
|
77
|
+
`);
|
|
78
|
+
|
|
79
|
+
const selectOne = db.query<AiReviewDbRow, [number]>(
|
|
80
|
+
"SELECT * FROM ai_review WHERE id = ?",
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const byRange = db.query<AiReviewDbRow, [string, string]>(
|
|
84
|
+
"SELECT * FROM ai_review WHERE start_sha = ? AND end_sha = ?",
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const all = db.query<AiReviewDbRow, []>("SELECT * FROM ai_review");
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
create(input) {
|
|
91
|
+
const result = insert.get(
|
|
92
|
+
input.startSha,
|
|
93
|
+
input.endSha,
|
|
94
|
+
input.model,
|
|
95
|
+
input.template,
|
|
96
|
+
input.prompt,
|
|
97
|
+
input.body,
|
|
98
|
+
input.createdAt,
|
|
99
|
+
);
|
|
100
|
+
return result!.id;
|
|
101
|
+
},
|
|
102
|
+
get(id) {
|
|
103
|
+
const row = selectOne.get(id);
|
|
104
|
+
return row ? toRow(row) : null;
|
|
105
|
+
},
|
|
106
|
+
listByRange(startSha, endSha) {
|
|
107
|
+
return byRange.all(startSha, endSha).map(toRow);
|
|
108
|
+
},
|
|
109
|
+
listAll() {
|
|
110
|
+
return all.all().map(toRow);
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Input required to create a new artifact record. The caller supplies all
|
|
5
|
+
* timestamps so they remain deterministic in tests.
|
|
6
|
+
*/
|
|
7
|
+
export interface ArtifactInput {
|
|
8
|
+
/** The baseline commit SHA for the range this artifact was generated under. */
|
|
9
|
+
rangeStartSha: string;
|
|
10
|
+
/** The end commit SHA for the range this artifact was generated under. */
|
|
11
|
+
rangeEndSha: string;
|
|
12
|
+
/**
|
|
13
|
+
* Foreign key referencing the chat_session that produced this artifact.
|
|
14
|
+
* Null when the artifact was not generated from a specific session.
|
|
15
|
+
*/
|
|
16
|
+
sessionId: number | null;
|
|
17
|
+
/** Path to the artifact file relative to the PR artifacts directory. */
|
|
18
|
+
relPath: string;
|
|
19
|
+
/** Human-readable title shown in the artifacts browser. */
|
|
20
|
+
title: string;
|
|
21
|
+
/** Creation timestamp in milliseconds. */
|
|
22
|
+
createdAt: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A fully-hydrated artifact row as returned by the repository.
|
|
27
|
+
* Extends {@link ArtifactInput} with the auto-generated id.
|
|
28
|
+
*/
|
|
29
|
+
export interface ArtifactRow extends ArtifactInput {
|
|
30
|
+
/** Auto-increment primary key. */
|
|
31
|
+
id: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Persistence for AI-generated artifact records. */
|
|
35
|
+
export interface ArtifactsRepo {
|
|
36
|
+
/** Persist a new artifact and return its auto-generated id. */
|
|
37
|
+
create(input: ArtifactInput): number;
|
|
38
|
+
/** All artifacts generated under the given commit range. */
|
|
39
|
+
listByRange(rangeStartSha: string, rangeEndSha: string): ArtifactRow[];
|
|
40
|
+
/** Every artifact in the database. */
|
|
41
|
+
listAll(): ArtifactRow[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Raw DB row for the artifact table. */
|
|
45
|
+
interface ArtifactDbRow {
|
|
46
|
+
id: number;
|
|
47
|
+
range_start_sha: string;
|
|
48
|
+
range_end_sha: string;
|
|
49
|
+
session_id: number | null;
|
|
50
|
+
rel_path: string;
|
|
51
|
+
title: string;
|
|
52
|
+
created_at: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Map a raw DB row to the public {@link ArtifactRow} shape. */
|
|
56
|
+
function toRow(r: ArtifactDbRow): ArtifactRow {
|
|
57
|
+
return {
|
|
58
|
+
id: r.id,
|
|
59
|
+
rangeStartSha: r.range_start_sha,
|
|
60
|
+
rangeEndSha: r.range_end_sha,
|
|
61
|
+
sessionId: r.session_id,
|
|
62
|
+
relPath: r.rel_path,
|
|
63
|
+
title: r.title,
|
|
64
|
+
createdAt: r.created_at,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Create an {@link ArtifactsRepo} backed by the given database. */
|
|
69
|
+
export function artifactsRepo(db: Database): ArtifactsRepo {
|
|
70
|
+
const insert = db.query<{ id: number }, [string, string, number | null, string, string, number]>(`
|
|
71
|
+
INSERT INTO artifact (range_start_sha, range_end_sha, session_id, rel_path, title, created_at)
|
|
72
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
73
|
+
RETURNING id
|
|
74
|
+
`);
|
|
75
|
+
|
|
76
|
+
const byRange = db.query<ArtifactDbRow, [string, string]>(
|
|
77
|
+
"SELECT * FROM artifact WHERE range_start_sha = ? AND range_end_sha = ?",
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const all = db.query<ArtifactDbRow, []>("SELECT * FROM artifact");
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
create(input) {
|
|
84
|
+
const result = insert.get(
|
|
85
|
+
input.rangeStartSha,
|
|
86
|
+
input.rangeEndSha,
|
|
87
|
+
input.sessionId,
|
|
88
|
+
input.relPath,
|
|
89
|
+
input.title,
|
|
90
|
+
input.createdAt,
|
|
91
|
+
);
|
|
92
|
+
return result!.id;
|
|
93
|
+
},
|
|
94
|
+
listByRange(rangeStartSha, rangeEndSha) {
|
|
95
|
+
return byRange.all(rangeStartSha, rangeEndSha).map(toRow);
|
|
96
|
+
},
|
|
97
|
+
listAll() {
|
|
98
|
+
return all.all().map(toRow);
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The scope kind for a chat session — whether it was started from a hunk or a
|
|
5
|
+
* whole file.
|
|
6
|
+
*
|
|
7
|
+
* - `'hunk'` — session scoped to a single hunk (scopeRef is the hunk hash).
|
|
8
|
+
* - `'file'` — session scoped to a file (scopeRef is the repo-relative path).
|
|
9
|
+
*/
|
|
10
|
+
export type ChatScopeKind = "hunk" | "file";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The role of a chat participant.
|
|
14
|
+
*
|
|
15
|
+
* - `'user'` — message sent by the user.
|
|
16
|
+
* - `'assistant'` — message from the AI model.
|
|
17
|
+
*/
|
|
18
|
+
export type ChatRole = "user" | "assistant";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Input required to create a new chat session. Timestamps are supplied by the
|
|
22
|
+
* caller to remain deterministic in tests.
|
|
23
|
+
*/
|
|
24
|
+
export interface ChatSessionInput {
|
|
25
|
+
/** Whether the session was started from a hunk or a file. */
|
|
26
|
+
scopeKind: ChatScopeKind;
|
|
27
|
+
/** Hunk hash (when scopeKind='hunk') or repo-relative file path (when 'file'). */
|
|
28
|
+
scopeRef: string;
|
|
29
|
+
/** Short title summarising the session purpose. */
|
|
30
|
+
title: string;
|
|
31
|
+
/** Model identifier used in this session (e.g. `'claude-3-opus'`). */
|
|
32
|
+
model: string;
|
|
33
|
+
/** Creation timestamp in milliseconds. */
|
|
34
|
+
createdAt: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A fully-hydrated chat session row as returned by the repository.
|
|
39
|
+
* Extends {@link ChatSessionInput} with the auto-generated id.
|
|
40
|
+
*/
|
|
41
|
+
export interface ChatSessionRow extends ChatSessionInput {
|
|
42
|
+
/** Auto-increment primary key. */
|
|
43
|
+
id: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Input required to add a message to an existing chat session. Timestamps are
|
|
48
|
+
* supplied by the caller.
|
|
49
|
+
*/
|
|
50
|
+
export interface ChatMessageInput {
|
|
51
|
+
/** Foreign key referencing the parent chat_session.id. */
|
|
52
|
+
sessionId: number;
|
|
53
|
+
/** Who sent the message. */
|
|
54
|
+
role: ChatRole;
|
|
55
|
+
/** Markdown content of the message. */
|
|
56
|
+
content: string;
|
|
57
|
+
/** Creation timestamp in milliseconds. */
|
|
58
|
+
createdAt: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A fully-hydrated chat message row as returned by the repository.
|
|
63
|
+
* Extends {@link ChatMessageInput} with the auto-generated id.
|
|
64
|
+
*/
|
|
65
|
+
export interface ChatMessageRow extends ChatMessageInput {
|
|
66
|
+
/** Auto-increment primary key. */
|
|
67
|
+
id: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Persistence for AI chat sessions and their messages. */
|
|
71
|
+
export interface ChatSessionsRepo {
|
|
72
|
+
/** Persist a new chat session and return its auto-generated id. */
|
|
73
|
+
createSession(input: ChatSessionInput): number;
|
|
74
|
+
/** Fetch a chat session by id; returns null if not found. */
|
|
75
|
+
getSession(id: number): ChatSessionRow | null;
|
|
76
|
+
/** All chat sessions in the database. */
|
|
77
|
+
listSessions(): ChatSessionRow[];
|
|
78
|
+
/** All sessions with the given scopeKind and scopeRef. */
|
|
79
|
+
listSessionsByScope(scopeKind: ChatScopeKind, scopeRef: string): ChatSessionRow[];
|
|
80
|
+
/** Rename a session. No-op if the id does not exist. */
|
|
81
|
+
setTitle(id: number, title: string): void;
|
|
82
|
+
/** Append a message to an existing session and return its auto-generated id. */
|
|
83
|
+
addMessage(input: ChatMessageInput): number;
|
|
84
|
+
/** All messages for a session ordered by createdAt ascending. */
|
|
85
|
+
listMessages(sessionId: number): ChatMessageRow[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Raw DB row for the chat_session table. */
|
|
89
|
+
interface SessionDbRow {
|
|
90
|
+
id: number;
|
|
91
|
+
scope_kind: string;
|
|
92
|
+
scope_ref: string;
|
|
93
|
+
title: string;
|
|
94
|
+
model: string;
|
|
95
|
+
created_at: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Raw DB row for the chat_message table. */
|
|
99
|
+
interface MessageDbRow {
|
|
100
|
+
id: number;
|
|
101
|
+
session_id: number;
|
|
102
|
+
role: string;
|
|
103
|
+
content: string;
|
|
104
|
+
created_at: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Map a raw DB row to the public {@link ChatSessionRow} shape. */
|
|
108
|
+
function toSessionRow(r: SessionDbRow): ChatSessionRow {
|
|
109
|
+
return {
|
|
110
|
+
id: r.id,
|
|
111
|
+
scopeKind: r.scope_kind as ChatScopeKind,
|
|
112
|
+
scopeRef: r.scope_ref,
|
|
113
|
+
title: r.title,
|
|
114
|
+
model: r.model,
|
|
115
|
+
createdAt: r.created_at,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Map a raw DB row to the public {@link ChatMessageRow} shape. */
|
|
120
|
+
function toMessageRow(r: MessageDbRow): ChatMessageRow {
|
|
121
|
+
return {
|
|
122
|
+
id: r.id,
|
|
123
|
+
sessionId: r.session_id,
|
|
124
|
+
role: r.role as ChatRole,
|
|
125
|
+
content: r.content,
|
|
126
|
+
createdAt: r.created_at,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Create a {@link ChatSessionsRepo} backed by the given database. */
|
|
131
|
+
export function chatSessionsRepo(db: Database): ChatSessionsRepo {
|
|
132
|
+
const insertSession = db.query<{ id: number }, [string, string, string, string, number]>(`
|
|
133
|
+
INSERT INTO chat_session (scope_kind, scope_ref, title, model, created_at)
|
|
134
|
+
VALUES (?, ?, ?, ?, ?)
|
|
135
|
+
RETURNING id
|
|
136
|
+
`);
|
|
137
|
+
|
|
138
|
+
const selectSession = db.query<SessionDbRow, [number]>(
|
|
139
|
+
"SELECT * FROM chat_session WHERE id = ?",
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const allSessions = db.query<SessionDbRow, []>("SELECT * FROM chat_session");
|
|
143
|
+
|
|
144
|
+
const sessionsByScope = db.query<SessionDbRow, [string, string]>(
|
|
145
|
+
"SELECT * FROM chat_session WHERE scope_kind = ? AND scope_ref = ?",
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const updateTitle = db.query("UPDATE chat_session SET title = ? WHERE id = ?");
|
|
149
|
+
|
|
150
|
+
const insertMessage = db.query<{ id: number }, [number, string, string, number]>(`
|
|
151
|
+
INSERT INTO chat_message (session_id, role, content, created_at)
|
|
152
|
+
VALUES (?, ?, ?, ?)
|
|
153
|
+
RETURNING id
|
|
154
|
+
`);
|
|
155
|
+
|
|
156
|
+
const messagesBySession = db.query<MessageDbRow, [number]>(
|
|
157
|
+
"SELECT * FROM chat_message WHERE session_id = ? ORDER BY created_at ASC",
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
createSession(input) {
|
|
162
|
+
const result = insertSession.get(
|
|
163
|
+
input.scopeKind,
|
|
164
|
+
input.scopeRef,
|
|
165
|
+
input.title,
|
|
166
|
+
input.model,
|
|
167
|
+
input.createdAt,
|
|
168
|
+
);
|
|
169
|
+
return result!.id;
|
|
170
|
+
},
|
|
171
|
+
getSession(id) {
|
|
172
|
+
const row = selectSession.get(id);
|
|
173
|
+
return row ? toSessionRow(row) : null;
|
|
174
|
+
},
|
|
175
|
+
listSessions() {
|
|
176
|
+
return allSessions.all().map(toSessionRow);
|
|
177
|
+
},
|
|
178
|
+
listSessionsByScope(scopeKind, scopeRef) {
|
|
179
|
+
return sessionsByScope.all(scopeKind, scopeRef).map(toSessionRow);
|
|
180
|
+
},
|
|
181
|
+
setTitle(id, title) {
|
|
182
|
+
updateTitle.run(title, id);
|
|
183
|
+
},
|
|
184
|
+
addMessage(input) {
|
|
185
|
+
const result = insertMessage.get(
|
|
186
|
+
input.sessionId,
|
|
187
|
+
input.role,
|
|
188
|
+
input.content,
|
|
189
|
+
input.createdAt,
|
|
190
|
+
);
|
|
191
|
+
return result!.id;
|
|
192
|
+
},
|
|
193
|
+
listMessages(sessionId) {
|
|
194
|
+
return messagesBySession.all(sessionId).map(toMessageRow);
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|