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,174 @@
|
|
|
1
|
+
import type { CharRange, DiffLine } from "./diff.ts";
|
|
2
|
+
|
|
3
|
+
export type { CharRange };
|
|
4
|
+
|
|
5
|
+
/** The intra-line changed ranges for one file, keyed by absolute line number. */
|
|
6
|
+
export interface FileWordChanges {
|
|
7
|
+
/** Base-side path (`a/…`). */
|
|
8
|
+
oldPath: string;
|
|
9
|
+
/** Head-side path (`b/…`). */
|
|
10
|
+
newPath: string;
|
|
11
|
+
/** Base-side changed ranges by 1-based old line number (only changed lines). */
|
|
12
|
+
oldByLine: Map<number, CharRange[]>;
|
|
13
|
+
/** Head-side changed ranges by 1-based new line number (only changed lines). */
|
|
14
|
+
newByLine: Map<number, CharRange[]>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Ratio of a line's characters that must have changed before we treat it as a
|
|
19
|
+
* near-total rewrite and drop word highlighting (falling back to the plain
|
|
20
|
+
* line background). Also naturally suppresses pure insertions/deletions, whose
|
|
21
|
+
* whole line reads as changed.
|
|
22
|
+
*/
|
|
23
|
+
const NEAR_TOTAL_REWRITE_RATIO = 0.6;
|
|
24
|
+
|
|
25
|
+
/** One porcelain segment within a reconstructed line. */
|
|
26
|
+
interface Segment {
|
|
27
|
+
/** Which side(s) the text belongs to. */
|
|
28
|
+
kind: "common" | "old" | "new";
|
|
29
|
+
/** The segment's literal text (marker prefix already stripped). */
|
|
30
|
+
text: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse `git diff --word-diff=porcelain` output into per-file, per-line changed
|
|
35
|
+
* character ranges. Pure — performs no git or filesystem access.
|
|
36
|
+
*
|
|
37
|
+
* Porcelain emits a token stream: lines prefixed ` ` (common), `-` (base only),
|
|
38
|
+
* `+` (head only), and a bare `~` marking a source newline. A block (the
|
|
39
|
+
* segments since the last `~`) advances the base line number if it holds any
|
|
40
|
+
* common/`-` segment, and the head line number if it holds any common/`+`
|
|
41
|
+
* segment — so pure insertions advance only the head side and pure deletions
|
|
42
|
+
* only the base side.
|
|
43
|
+
*/
|
|
44
|
+
export function parseWordDiff(text: string): FileWordChanges[] {
|
|
45
|
+
const files: FileWordChanges[] = [];
|
|
46
|
+
let current: FileWordChanges | null = null;
|
|
47
|
+
let oldNo = 0;
|
|
48
|
+
let newNo = 0;
|
|
49
|
+
let block: Segment[] = [];
|
|
50
|
+
|
|
51
|
+
const flush = (): void => {
|
|
52
|
+
if (!current) { block = []; return; }
|
|
53
|
+
applyBlock(current, block, oldNo, newNo, (o, n) => { oldNo = o; newNo = n; });
|
|
54
|
+
block = [];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
for (const line of text.split("\n")) {
|
|
58
|
+
if (line.startsWith("diff --git ")) {
|
|
59
|
+
flush();
|
|
60
|
+
current = startFile(line);
|
|
61
|
+
if (current) files.push(current);
|
|
62
|
+
} else if (line.startsWith("@@ ")) {
|
|
63
|
+
flush();
|
|
64
|
+
const header = parseAtAt(line);
|
|
65
|
+
if (header) { oldNo = header.oldStart; newNo = header.newStart; }
|
|
66
|
+
} else if (line === "~") {
|
|
67
|
+
flush();
|
|
68
|
+
} else if (line.startsWith("\\")) {
|
|
69
|
+
// "" — not part of the content.
|
|
70
|
+
} else if (current && (line.startsWith(" ") || line.startsWith("-") || line.startsWith("+"))) {
|
|
71
|
+
block.push({ kind: markerKind(line[0]), text: line.slice(1) });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
flush();
|
|
75
|
+
return files;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Start a new file accumulator from a `diff --git a/… b/…` line. */
|
|
79
|
+
function startFile(line: string): FileWordChanges | null {
|
|
80
|
+
const m = /^diff --git a\/(.+) b\/(.+)$/.exec(line);
|
|
81
|
+
if (!m) return null;
|
|
82
|
+
return { oldPath: m[1] ?? "", newPath: m[2] ?? "", oldByLine: new Map(), newByLine: new Map() };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Parse an `@@ -a,b +c,d @@` header into its 1-based start line numbers. */
|
|
86
|
+
function parseAtAt(header: string): { oldStart: number; newStart: number } | null {
|
|
87
|
+
const m = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(header);
|
|
88
|
+
if (!m) return null;
|
|
89
|
+
return { oldStart: Number(m[1]), newStart: Number(m[2]) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Map a porcelain marker character to a segment kind. */
|
|
93
|
+
function markerKind(marker: string | undefined): Segment["kind"] {
|
|
94
|
+
if (marker === "-") return "old";
|
|
95
|
+
if (marker === "+") return "new";
|
|
96
|
+
return "common";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Fold one `~`-terminated block into `current`, recording changed ranges and
|
|
101
|
+
* advancing whichever side(s) the block participates in.
|
|
102
|
+
*/
|
|
103
|
+
function applyBlock(
|
|
104
|
+
current: FileWordChanges,
|
|
105
|
+
block: Segment[],
|
|
106
|
+
oldNo: number,
|
|
107
|
+
newNo: number,
|
|
108
|
+
advance: (oldNo: number, newNo: number) => void,
|
|
109
|
+
): void {
|
|
110
|
+
const { oldRanges, newRanges, hasOld, hasNew } = reduceBlock(block);
|
|
111
|
+
if (hasOld) {
|
|
112
|
+
if (oldRanges.length > 0) current.oldByLine.set(oldNo, oldRanges);
|
|
113
|
+
oldNo += 1;
|
|
114
|
+
}
|
|
115
|
+
if (hasNew) {
|
|
116
|
+
if (newRanges.length > 0) current.newByLine.set(newNo, newRanges);
|
|
117
|
+
newNo += 1;
|
|
118
|
+
}
|
|
119
|
+
advance(oldNo, newNo);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Reconstruct a block's base/head text offsets and changed ranges. */
|
|
123
|
+
function reduceBlock(block: Segment[]): {
|
|
124
|
+
oldRanges: CharRange[];
|
|
125
|
+
newRanges: CharRange[];
|
|
126
|
+
hasOld: boolean;
|
|
127
|
+
hasNew: boolean;
|
|
128
|
+
} {
|
|
129
|
+
const oldRanges: CharRange[] = [];
|
|
130
|
+
const newRanges: CharRange[] = [];
|
|
131
|
+
let oldLen = 0;
|
|
132
|
+
let newLen = 0;
|
|
133
|
+
let hasOld = block.length === 0;
|
|
134
|
+
let hasNew = block.length === 0;
|
|
135
|
+
for (const seg of block) {
|
|
136
|
+
const len = seg.text.length;
|
|
137
|
+
if (seg.kind === "common") {
|
|
138
|
+
oldLen += len; newLen += len; hasOld = true; hasNew = true;
|
|
139
|
+
} else if (seg.kind === "old") {
|
|
140
|
+
oldRanges.push({ start: oldLen, end: oldLen + len }); oldLen += len; hasOld = true;
|
|
141
|
+
} else {
|
|
142
|
+
newRanges.push({ start: newLen, end: newLen + len }); newLen += len; hasNew = true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { oldRanges, newRanges, hasOld, hasNew };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Attach word-diff ranges to each line of a file's structural diff, keyed by
|
|
150
|
+
* side + line number, dropping ranges for near-total rewrites. Returns a new
|
|
151
|
+
* array; never mutates the input.
|
|
152
|
+
*/
|
|
153
|
+
export function withWordChanges(lines: DiffLine[], changes: FileWordChanges | undefined): DiffLine[] {
|
|
154
|
+
if (!changes) return lines;
|
|
155
|
+
return lines.map((line) => {
|
|
156
|
+
const ranges = rangesFor(line, changes);
|
|
157
|
+
if (ranges.length === 0 || isNearTotal(ranges, line.text.length)) return line;
|
|
158
|
+
return { ...line, changes: ranges };
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The changed ranges recorded for a line's side, or an empty list. */
|
|
163
|
+
function rangesFor(line: DiffLine, changes: FileWordChanges): CharRange[] {
|
|
164
|
+
if (line.kind === "del" && line.oldNo !== undefined) return changes.oldByLine.get(line.oldNo) ?? [];
|
|
165
|
+
if (line.kind === "add" && line.newNo !== undefined) return changes.newByLine.get(line.newNo) ?? [];
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Whether the changed ranges cover at least the near-total-rewrite ratio. */
|
|
170
|
+
function isNearTotal(ranges: CharRange[], length: number): boolean {
|
|
171
|
+
if (length <= 0) return true;
|
|
172
|
+
const changed = ranges.reduce((sum, r) => sum + (r.end - r.start), 0);
|
|
173
|
+
return changed / length >= NEAR_TOTAL_REWRITE_RATIO;
|
|
174
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { query as sdkQuery } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
|
|
3
|
+
/** Options for one agentic chat turn. */
|
|
4
|
+
export interface AiChatOptions {
|
|
5
|
+
/** The user's prompt for this turn. */
|
|
6
|
+
prompt: string;
|
|
7
|
+
/** Model id to run (from the config model list). */
|
|
8
|
+
model: string;
|
|
9
|
+
/** Working directory the agent operates in (the head-checkout worktree). */
|
|
10
|
+
cwd: string;
|
|
11
|
+
/** Extra readable/writable directories (e.g. the base checkout, artifacts dir). */
|
|
12
|
+
additionalDirectories?: string[];
|
|
13
|
+
/** Optional system prompt prepended to the session. */
|
|
14
|
+
systemPrompt?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A single streamed event from an agentic turn:
|
|
19
|
+
* - `delta`: an incremental token of assistant text, for live display.
|
|
20
|
+
* - `text`: a finalized assistant text block, used as the authoritative
|
|
21
|
+
* content to persist (independent of whether deltas were emitted).
|
|
22
|
+
* - `activity`: a human-readable note that the agent is doing something
|
|
23
|
+
* (using a tool), shown while it works so the wait never feels frozen.
|
|
24
|
+
*/
|
|
25
|
+
export type ChatEvent =
|
|
26
|
+
| { kind: "delta"; text: string }
|
|
27
|
+
| { kind: "text"; text: string }
|
|
28
|
+
| { kind: "activity"; text: string };
|
|
29
|
+
|
|
30
|
+
/** A stream of raw agent messages (shape narrowed internally). */
|
|
31
|
+
export type RawMessageStream = AsyncIterable<unknown>;
|
|
32
|
+
|
|
33
|
+
/** The injectable boundary: turn chat options into a raw message stream. */
|
|
34
|
+
export type QueryRunner = (opts: AiChatOptions) => RawMessageStream;
|
|
35
|
+
|
|
36
|
+
/** The AI service: run an agentic chat turn and stream back events. */
|
|
37
|
+
export interface AiService {
|
|
38
|
+
/** Stream {@link ChatEvent}s as the turn progresses. */
|
|
39
|
+
chat(opts: AiChatOptions): AsyncIterable<ChatEvent>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** True when the value is a non-null object. */
|
|
43
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
44
|
+
return typeof v === "object" && v !== null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A short human phrase describing a tool_use block (e.g. "Reading src/app.ts"). */
|
|
48
|
+
function describeTool(name: string, input: Record<string, unknown>): string {
|
|
49
|
+
const str = (v: unknown): string => (typeof v === "string" ? v : "");
|
|
50
|
+
const trunc = (s: string, n: number): string => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
|
|
51
|
+
switch (name) {
|
|
52
|
+
case "Read":
|
|
53
|
+
return `Reading ${str(input.file_path)}`;
|
|
54
|
+
case "Edit":
|
|
55
|
+
case "Write":
|
|
56
|
+
case "NotebookEdit":
|
|
57
|
+
return `Editing ${str(input.file_path ?? input.notebook_path)}`;
|
|
58
|
+
case "Bash":
|
|
59
|
+
return `Running: ${trunc(str(input.command), 60)}`;
|
|
60
|
+
case "Grep":
|
|
61
|
+
return `Searching “${trunc(str(input.pattern), 40)}”`;
|
|
62
|
+
case "Glob":
|
|
63
|
+
return `Finding files ${str(input.pattern)}`;
|
|
64
|
+
default:
|
|
65
|
+
return `${name}…`;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Extract a live text delta from a partial (`stream_event`) message, if any. */
|
|
70
|
+
function deltaOf(msg: Record<string, unknown>): ChatEvent[] {
|
|
71
|
+
if (!isRecord(msg.event)) return [];
|
|
72
|
+
const event = msg.event;
|
|
73
|
+
if (event.type !== "content_block_delta" || !isRecord(event.delta)) return [];
|
|
74
|
+
const delta = event.delta;
|
|
75
|
+
if (delta.type === "text_delta" && typeof delta.text === "string") return [{ kind: "delta", text: delta.text }];
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Extract finalized text + tool activity from a complete assistant message. */
|
|
80
|
+
function assistantEventsOf(msg: Record<string, unknown>): ChatEvent[] {
|
|
81
|
+
if (!isRecord(msg.message)) return [];
|
|
82
|
+
const content: unknown = msg.message.content;
|
|
83
|
+
if (!Array.isArray(content)) return [];
|
|
84
|
+
const out: ChatEvent[] = [];
|
|
85
|
+
for (const block of content) {
|
|
86
|
+
if (!isRecord(block)) continue;
|
|
87
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
88
|
+
out.push({ kind: "text", text: block.text });
|
|
89
|
+
} else if (block.type === "tool_use" && typeof block.name === "string") {
|
|
90
|
+
out.push({ kind: "activity", text: describeTool(block.name, isRecord(block.input) ? block.input : {}) });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Map one raw SDK message to zero or more {@link ChatEvent}s. Live text comes
|
|
98
|
+
* from streaming `delta`s; the authoritative persisted text and tool activity
|
|
99
|
+
* come from complete assistant messages. Exported for unit testing.
|
|
100
|
+
*/
|
|
101
|
+
export function eventsOf(msg: unknown): ChatEvent[] {
|
|
102
|
+
if (!isRecord(msg)) return [];
|
|
103
|
+
if (msg.type === "stream_event") return deltaOf(msg);
|
|
104
|
+
if (msg.type === "assistant") return assistantEventsOf(msg);
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The default runner: drive the Claude Agent SDK with full clone access. */
|
|
109
|
+
function defaultRunner(opts: AiChatOptions): RawMessageStream {
|
|
110
|
+
return sdkQuery({
|
|
111
|
+
prompt: opts.prompt,
|
|
112
|
+
options: {
|
|
113
|
+
model: opts.model,
|
|
114
|
+
cwd: opts.cwd,
|
|
115
|
+
additionalDirectories: opts.additionalDirectories,
|
|
116
|
+
permissionMode: "auto",
|
|
117
|
+
systemPrompt: opts.systemPrompt,
|
|
118
|
+
// Emit token-level deltas so replies stream as they are generated.
|
|
119
|
+
includePartialMessages: true,
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Create an {@link AiService}. The `runner` seam wraps the Claude Agent SDK by
|
|
126
|
+
* default (using the user's Claude Max login) and is replaced with a fake in
|
|
127
|
+
* tests. `chat` maps each raw message into {@link ChatEvent}s.
|
|
128
|
+
*/
|
|
129
|
+
export function createAiService(runner: QueryRunner = defaultRunner): AiService {
|
|
130
|
+
return {
|
|
131
|
+
async *chat(opts: AiChatOptions): AsyncIterable<ChatEvent> {
|
|
132
|
+
for await (const msg of runner(opts)) {
|
|
133
|
+
yield* eventsOf(msg);
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Result of running an external command. */
|
|
2
|
+
export interface CommandResult {
|
|
3
|
+
/** Captured standard output. */
|
|
4
|
+
stdout: string;
|
|
5
|
+
/** Captured standard error. */
|
|
6
|
+
stderr: string;
|
|
7
|
+
/** Process exit code (0 = success). */
|
|
8
|
+
exitCode: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Options for running a command. */
|
|
12
|
+
export interface RunOptions {
|
|
13
|
+
/** Working directory to run in. */
|
|
14
|
+
cwd?: string;
|
|
15
|
+
/** String written to the command's stdin. */
|
|
16
|
+
input?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Abstraction over running external commands (git, gh, sem, rg). Services
|
|
21
|
+
* depend on this interface so tests can inject a fake runner instead of
|
|
22
|
+
* spawning real processes.
|
|
23
|
+
*/
|
|
24
|
+
export interface CommandRunner {
|
|
25
|
+
run(cmd: string, args: string[], opts?: RunOptions): Promise<CommandResult>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The real runner, backed by `Bun.spawn`. */
|
|
29
|
+
export const bunRunner: CommandRunner = {
|
|
30
|
+
async run(cmd, args, opts) {
|
|
31
|
+
const proc = Bun.spawn([cmd, ...args], {
|
|
32
|
+
cwd: opts?.cwd,
|
|
33
|
+
stdin: opts?.input != null ? new TextEncoder().encode(opts.input) : undefined,
|
|
34
|
+
stdout: "pipe",
|
|
35
|
+
stderr: "pipe",
|
|
36
|
+
});
|
|
37
|
+
const [stdout, stderr] = await Promise.all([
|
|
38
|
+
new Response(proc.stdout).text(),
|
|
39
|
+
new Response(proc.stderr).text(),
|
|
40
|
+
]);
|
|
41
|
+
const exitCode: number = await proc.exited;
|
|
42
|
+
return { stdout, stderr, exitCode };
|
|
43
|
+
},
|
|
44
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { bunRunner, type CommandRunner } from "./exec.ts";
|
|
2
|
+
|
|
3
|
+
/** Minimal commit metadata for a PR, from GitHub. */
|
|
4
|
+
export interface PrCommitMeta {
|
|
5
|
+
/** Full commit SHA. */
|
|
6
|
+
sha: string;
|
|
7
|
+
/** Commit subject (message headline). */
|
|
8
|
+
subject: string;
|
|
9
|
+
/** First author's display name. */
|
|
10
|
+
authorName: string;
|
|
11
|
+
/** ISO commit date. */
|
|
12
|
+
isoDate: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** PR-level metadata from GitHub. */
|
|
16
|
+
export interface PrMeta {
|
|
17
|
+
/** PR title. */
|
|
18
|
+
title: string;
|
|
19
|
+
/** PR description (the PR body), as GitHub-flavored markdown. Normalized to
|
|
20
|
+
* LF line endings and trimmed; an empty string means "no description". */
|
|
21
|
+
body: string;
|
|
22
|
+
/** Target (base) branch name. */
|
|
23
|
+
baseRef: string;
|
|
24
|
+
/** Source (head) branch name. */
|
|
25
|
+
headRef: string;
|
|
26
|
+
/** Head commit SHA. */
|
|
27
|
+
headSha: string;
|
|
28
|
+
/** PR commits, oldest → newest (as GitHub returns them). */
|
|
29
|
+
commits: PrCommitMeta[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Identifies a PR for metadata lookup. */
|
|
33
|
+
export interface PrLookup {
|
|
34
|
+
owner: string;
|
|
35
|
+
repo: string;
|
|
36
|
+
number: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Service for fetching PR-level metadata via the `gh` CLI. */
|
|
40
|
+
export interface GhPrService {
|
|
41
|
+
fetchPr(ref: PrLookup): Promise<PrMeta>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Create a {@link GhPrService}. */
|
|
45
|
+
export function createGhPrService(runner: CommandRunner = bunRunner): GhPrService {
|
|
46
|
+
return {
|
|
47
|
+
async fetchPr(ref: PrLookup): Promise<PrMeta> {
|
|
48
|
+
const args: string[] = [
|
|
49
|
+
"pr", "view", String(ref.number), "--repo", `${ref.owner}/${ref.repo}`,
|
|
50
|
+
"--json", "title,body,baseRefName,headRefName,headRefOid,commits",
|
|
51
|
+
];
|
|
52
|
+
const res = await runner.run("gh", args);
|
|
53
|
+
if (res.exitCode !== 0) {
|
|
54
|
+
throw new Error(`gh pr view failed (${res.exitCode}): ${res.stderr.trim()}`);
|
|
55
|
+
}
|
|
56
|
+
return toPrMeta(JSON.parse(res.stdout));
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
62
|
+
return typeof v === "object" && v !== null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function str(v: unknown): string {
|
|
66
|
+
return typeof v === "string" ? v : "";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Normalize a raw PR body: coerce non-strings to "", convert CRLF → LF, and
|
|
71
|
+
* trim. A whitespace-only body collapses to "" so callers can treat empty as
|
|
72
|
+
* "no description provided".
|
|
73
|
+
*/
|
|
74
|
+
export function normalizeBody(raw: unknown): string {
|
|
75
|
+
return str(raw).replace(/\r\n/g, "\n").trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Map raw `gh pr view` JSON into a typed {@link PrMeta}. */
|
|
79
|
+
function toPrMeta(raw: unknown): PrMeta {
|
|
80
|
+
const rec: Record<string, unknown> = isRecord(raw) ? raw : {};
|
|
81
|
+
const rawCommits: unknown[] = Array.isArray(rec.commits) ? rec.commits : [];
|
|
82
|
+
return {
|
|
83
|
+
title: str(rec.title),
|
|
84
|
+
body: normalizeBody(rec.body),
|
|
85
|
+
baseRef: str(rec.baseRefName),
|
|
86
|
+
headRef: str(rec.headRefName),
|
|
87
|
+
headSha: str(rec.headRefOid),
|
|
88
|
+
commits: rawCommits.map(toCommitMeta),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function toCommitMeta(raw: unknown): PrCommitMeta {
|
|
93
|
+
const rec: Record<string, unknown> = isRecord(raw) ? raw : {};
|
|
94
|
+
const authors: unknown[] = Array.isArray(rec.authors) ? rec.authors : [];
|
|
95
|
+
const first: Record<string, unknown> = isRecord(authors[0]) ? authors[0] : {};
|
|
96
|
+
return {
|
|
97
|
+
sha: str(rec.oid),
|
|
98
|
+
subject: str(rec.messageHeadline),
|
|
99
|
+
authorName: str(first.name),
|
|
100
|
+
isoDate: str(rec.committedDate),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { bunRunner, type CommandRunner } from "./exec.ts";
|
|
2
|
+
import { parsePrUrl } from "@/domain/url.ts";
|
|
3
|
+
|
|
4
|
+
/** How the authenticated user is related to a pull request. */
|
|
5
|
+
export type PrRelationship = "authored" | "assigned" | "review-requested";
|
|
6
|
+
|
|
7
|
+
/** A pull request the authenticated user cares about, from GitHub search. */
|
|
8
|
+
export interface MyPrSummary {
|
|
9
|
+
/** Repository owner / organisation. */
|
|
10
|
+
owner: string;
|
|
11
|
+
/** Repository name. */
|
|
12
|
+
repo: string;
|
|
13
|
+
/** Pull request number. */
|
|
14
|
+
number: number;
|
|
15
|
+
/** PR title. */
|
|
16
|
+
title: string;
|
|
17
|
+
/** Canonical PR URL (used as the dedupe key and to load the PR). */
|
|
18
|
+
url: string;
|
|
19
|
+
/** GitHub login of the PR author. */
|
|
20
|
+
author: string;
|
|
21
|
+
/** Whether the PR is a draft. */
|
|
22
|
+
isDraft: boolean;
|
|
23
|
+
/** ISO-8601 last-updated timestamp (used to sort newest-first). */
|
|
24
|
+
updatedAtIso: string;
|
|
25
|
+
/** All relationships the viewer has to this PR, in canonical order. */
|
|
26
|
+
relationships: PrRelationship[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Service for listing the viewer's open PRs via the `gh` CLI. */
|
|
30
|
+
export interface GhSearchService {
|
|
31
|
+
/** Open PRs authored by, assigned to, or review-requested from the viewer. */
|
|
32
|
+
listMyPrs(): Promise<MyPrSummary[]>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The `--<flag>=@me` search filter for each relationship. */
|
|
36
|
+
const RELATION_FLAG: Record<PrRelationship, string> = {
|
|
37
|
+
authored: "--author=@me",
|
|
38
|
+
assigned: "--assignee=@me",
|
|
39
|
+
"review-requested": "--review-requested=@me",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Canonical relationship order for stable, deduped unions. */
|
|
43
|
+
const RELATION_ORDER: readonly PrRelationship[] = ["authored", "assigned", "review-requested"];
|
|
44
|
+
|
|
45
|
+
/** JSON fields requested from `gh search prs`. */
|
|
46
|
+
const JSON_FIELDS = "number,title,url,author,isDraft,updatedAt";
|
|
47
|
+
|
|
48
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
49
|
+
return typeof v === "object" && v !== null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function str(v: unknown): string {
|
|
53
|
+
return typeof v === "string" ? v : "";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Parse a `gh search prs --json` array into typed summaries, each tagged with a
|
|
58
|
+
* single relationship. Owner/repo/number are derived from the item's URL; items
|
|
59
|
+
* without a valid PR URL are skipped rather than throwing.
|
|
60
|
+
*/
|
|
61
|
+
export function toMyPrs(rawJson: string, relationship: PrRelationship): MyPrSummary[] {
|
|
62
|
+
const parsed: unknown = JSON.parse(rawJson);
|
|
63
|
+
const items: unknown[] = Array.isArray(parsed) ? parsed : [];
|
|
64
|
+
const out: MyPrSummary[] = [];
|
|
65
|
+
for (const raw of items) {
|
|
66
|
+
if (!isRecord(raw)) continue;
|
|
67
|
+
const url: string = str(raw.url);
|
|
68
|
+
const ref = tryParse(url);
|
|
69
|
+
if (!ref) continue;
|
|
70
|
+
const author: Record<string, unknown> = isRecord(raw.author) ? raw.author : {};
|
|
71
|
+
out.push({
|
|
72
|
+
owner: ref.owner,
|
|
73
|
+
repo: ref.repo,
|
|
74
|
+
number: ref.number,
|
|
75
|
+
title: str(raw.title),
|
|
76
|
+
url,
|
|
77
|
+
author: str(author.login),
|
|
78
|
+
isDraft: raw.isDraft === true,
|
|
79
|
+
updatedAtIso: str(raw.updatedAt),
|
|
80
|
+
relationships: [relationship],
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Parse a PR URL, returning null instead of throwing on a non-PR URL. */
|
|
87
|
+
function tryParse(url: string): { owner: string; repo: string; number: number } | null {
|
|
88
|
+
try {
|
|
89
|
+
return parsePrUrl(url);
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Merge per-relationship result groups into one list: deduped by URL, with each
|
|
97
|
+
* PR's relationships unioned into canonical order and the list sorted
|
|
98
|
+
* newest-updated first.
|
|
99
|
+
*/
|
|
100
|
+
export function mergePrGroups(groups: MyPrSummary[][]): MyPrSummary[] {
|
|
101
|
+
const byUrl = new Map<string, MyPrSummary>();
|
|
102
|
+
for (const group of groups) {
|
|
103
|
+
for (const pr of group) {
|
|
104
|
+
const existing = byUrl.get(pr.url);
|
|
105
|
+
if (!existing) {
|
|
106
|
+
byUrl.set(pr.url, { ...pr, relationships: [...pr.relationships] });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const rels = new Set<PrRelationship>([...existing.relationships, ...pr.relationships]);
|
|
110
|
+
existing.relationships = RELATION_ORDER.filter((r) => rels.has(r));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return [...byUrl.values()].sort((a, b) => b.updatedAtIso.localeCompare(a.updatedAtIso));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Create a {@link GhSearchService}. */
|
|
117
|
+
export function createGhSearchService(runner: CommandRunner = bunRunner): GhSearchService {
|
|
118
|
+
async function search(relationship: PrRelationship): Promise<MyPrSummary[]> {
|
|
119
|
+
const res = await runner.run("gh", [
|
|
120
|
+
"search", "prs", RELATION_FLAG[relationship],
|
|
121
|
+
"--state=open", "--json", JSON_FIELDS, "--limit", "100",
|
|
122
|
+
]);
|
|
123
|
+
if (res.exitCode !== 0) {
|
|
124
|
+
throw new Error(`gh search prs failed (${res.exitCode}): ${res.stderr.trim()}`);
|
|
125
|
+
}
|
|
126
|
+
return toMyPrs(res.stdout, relationship);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
async listMyPrs(): Promise<MyPrSummary[]> {
|
|
131
|
+
const groups = await Promise.all(RELATION_ORDER.map(search));
|
|
132
|
+
return mergePrGroups(groups);
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|