@adriandmitroca/relay 0.0.2

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.
@@ -0,0 +1,20 @@
1
+ export type Severity = "critical" | "high" | "medium" | "low";
2
+
3
+ export interface NormalizedIssue {
4
+ source: string;
5
+ sourceId: string;
6
+ externalUrl: string;
7
+ projectKey: string;
8
+ title: string;
9
+ body: string;
10
+ severity: Severity;
11
+ metadata: Record<string, unknown>;
12
+ }
13
+
14
+ export interface SourceAdapter {
15
+ name: string;
16
+ poll(projectKey: string): Promise<NormalizedIssue[]>;
17
+ getIssueContext(issue: NormalizedIssue): Promise<string>;
18
+ onFixAccepted?(issue: NormalizedIssue, prUrl: string): Promise<void>;
19
+ onFixDiscarded?(issue: NormalizedIssue): Promise<void>;
20
+ }
@@ -0,0 +1,23 @@
1
+ export function esc(s: string): string {
2
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
3
+ }
4
+
5
+ /** Decode HTML entities that may already be encoded in source data (e.g. Sentry titles). */
6
+ export function decodeHtmlEntities(s: string): string {
7
+ return s
8
+ .replace(/&amp;/g, "&")
9
+ .replace(/&lt;/g, "<")
10
+ .replace(/&gt;/g, ">")
11
+ .replace(/&quot;/g, '"')
12
+ .replace(/&#39;/g, "'");
13
+ }
14
+
15
+ /** Sanitize a string for use as a Telegram forum topic name (plain text, no parse mode).
16
+ * Replaces angle brackets with Unicode equivalents so they don't confuse Telegram clients. */
17
+ export function sanitizeTopicName(s: string): string {
18
+ return decodeHtmlEntities(s)
19
+ .replace(/</g, "\u2039") // ‹
20
+ .replace(/>/g, "\u203A") // ›
21
+ .replace(/\s+/g, " ")
22
+ .trim();
23
+ }
@@ -0,0 +1,49 @@
1
+ const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 } as const;
2
+ type Level = keyof typeof LEVELS;
3
+
4
+ const COLORS: Record<Level, string> = {
5
+ debug: "\x1b[90m",
6
+ info: "\x1b[36m",
7
+ warn: "\x1b[33m",
8
+ error: "\x1b[31m",
9
+ };
10
+ const RESET = "\x1b[0m";
11
+ const BOLD = "\x1b[1m";
12
+
13
+ let minLevel: Level = "info";
14
+ let fileWriter: ReturnType<ReturnType<typeof Bun.file>["writer"]> | null = null;
15
+
16
+ export function setLogLevel(level: Level) {
17
+ minLevel = level;
18
+ }
19
+
20
+ export async function setLogFile(path: string) {
21
+ fileWriter = Bun.file(path).writer();
22
+ }
23
+
24
+ function timestamp(): string {
25
+ const d = new Date();
26
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:${String(d.getSeconds()).padStart(2, "0")}`;
27
+ }
28
+
29
+ function log(level: Level, msg: string, data?: Record<string, unknown>) {
30
+ if (LEVELS[level] < LEVELS[minLevel]) return;
31
+ const ts = timestamp();
32
+ const label = level.toUpperCase().padEnd(5);
33
+ const kvs = data
34
+ ? " " + Object.entries(data).map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`).join(" ")
35
+ : "";
36
+ const colored = `${COLORS[level]}[${ts}] ${BOLD}${label}${RESET}${COLORS[level]} ${msg}${kvs}${RESET}`;
37
+ Bun.write(Bun.stderr, colored + "\n");
38
+ if (fileWriter) {
39
+ fileWriter.write(`[${ts}] ${label} ${msg}${kvs}\n`);
40
+ fileWriter.flush();
41
+ }
42
+ }
43
+
44
+ export const logger = {
45
+ debug: (msg: string, data?: Record<string, unknown>) => log("debug", msg, data),
46
+ info: (msg: string, data?: Record<string, unknown>) => log("info", msg, data),
47
+ warn: (msg: string, data?: Record<string, unknown>) => log("warn", msg, data),
48
+ error: (msg: string, data?: Record<string, unknown>) => log("error", msg, data),
49
+ };
@@ -0,0 +1,297 @@
1
+ import { appendFileSync } from "node:fs";
2
+ import { logger } from "../utils/logger.ts";
3
+
4
+ export interface ClaudeResult {
5
+ success: boolean;
6
+ output: string;
7
+ error?: string;
8
+ exitCode: number;
9
+ durationMs: number;
10
+ sessionId?: string;
11
+ inputTokens: number;
12
+ outputTokens: number;
13
+ costUsd?: number;
14
+ }
15
+
16
+ export type ClaudeStreamEvent =
17
+ | { type: "tool_use"; tool: string }
18
+ | { type: "text"; text: string }
19
+ | { type: "result"; text: string; sessionId?: string; costUsd?: number };
20
+
21
+ export interface ClaudeOptions {
22
+ cwd?: string;
23
+ timeoutMs?: number;
24
+ allowedTools?: string[];
25
+ model?: "sonnet" | "opus" | "haiku";
26
+ sessionId?: string;
27
+ resume?: string;
28
+ onEvent?: (event: ClaudeStreamEvent) => void;
29
+ logPath?: string;
30
+ }
31
+
32
+ interface ProcessResult {
33
+ stdout: string;
34
+ stderr: string;
35
+ exitCode: number;
36
+ durationMs: number;
37
+ timedOut: boolean;
38
+ }
39
+
40
+ async function spawnClaude(prompt: string, opts: ClaudeOptions): Promise<ProcessResult> {
41
+ const { cwd, timeoutMs = 300_000, allowedTools, model, resume, onEvent, logPath } = opts;
42
+ const start = performance.now();
43
+
44
+ const args = buildArgs({ prompt, allowedTools, model, resume });
45
+
46
+ logger.debug("Running Claude", { cwd, timeoutMs, model, resume: !!resume, toolCount: allowedTools?.length ?? 0 });
47
+
48
+ const env = { ...process.env };
49
+ delete env.CLAUDECODE;
50
+
51
+ const proc = Bun.spawn(["claude", ...args], { cwd, env, stdout: "pipe", stderr: "pipe" });
52
+
53
+ const result = await Promise.race([
54
+ streamProcess(proc, onEvent, logPath),
55
+ timeout(timeoutMs).then(() => {
56
+ proc.kill("SIGTERM");
57
+ return Bun.sleep(10_000).then(() => {
58
+ try { proc.kill("SIGKILL"); } catch {}
59
+ return { stdout: "", stderr: "", exitCode: -1, timedOut: true as const };
60
+ });
61
+ }),
62
+ ]);
63
+
64
+ const durationMs = Math.round(performance.now() - start);
65
+
66
+ if ("timedOut" in result && result.timedOut) {
67
+ logger.warn("Claude timed out", { durationMs, timeoutMs });
68
+ return { stdout: "", stderr: "", exitCode: -1, durationMs, timedOut: true };
69
+ }
70
+
71
+ return { ...result, durationMs, timedOut: false };
72
+ }
73
+
74
+ export async function runClaudeText(prompt: string, opts: ClaudeOptions = {}): Promise<ClaudeResult> {
75
+ const result = await spawnClaude(prompt, opts);
76
+
77
+ if (result.timedOut) {
78
+ return { success: false, output: "", error: `Timed out after ${opts.timeoutMs ?? 300_000}ms`, exitCode: -1, durationMs: result.durationMs, inputTokens: 0, outputTokens: 0 };
79
+ }
80
+
81
+ if (result.exitCode !== 0) {
82
+ logger.warn("Claude exited with error", { exitCode: result.exitCode, stderr: result.stderr.slice(0, 500) });
83
+ return { success: false, output: result.stdout, error: result.stderr || `Exit code ${result.exitCode}`, exitCode: result.exitCode, durationMs: result.durationMs, inputTokens: 0, outputTokens: 0 };
84
+ }
85
+
86
+ const parsed = parseStreamJson(result.stdout);
87
+ logger.debug("Claude completed", { durationMs: result.durationMs, outputLength: parsed.text.length, sessionId: parsed.sessionId, inputTokens: parsed.inputTokens, outputTokens: parsed.outputTokens });
88
+ return { success: true, output: parsed.text, exitCode: 0, durationMs: result.durationMs, sessionId: parsed.sessionId, inputTokens: parsed.inputTokens, outputTokens: parsed.outputTokens, costUsd: parsed.costUsd };
89
+ }
90
+
91
+ export async function runClaudeJson<T>(prompt: string, opts: ClaudeOptions = {}): Promise<{ success: boolean; data?: T; error?: string; durationMs: number; sessionId?: string; inputTokens: number; outputTokens: number; costUsd?: number }> {
92
+ const result = await spawnClaude(prompt, opts);
93
+
94
+ if (result.timedOut) {
95
+ return { success: false, error: `Timed out after ${opts.timeoutMs ?? 300_000}ms`, durationMs: result.durationMs, inputTokens: 0, outputTokens: 0 };
96
+ }
97
+
98
+ if (result.exitCode !== 0) {
99
+ return { success: false, error: result.stderr || `Exit code ${result.exitCode}`, durationMs: result.durationMs, inputTokens: 0, outputTokens: 0 };
100
+ }
101
+
102
+ try {
103
+ const parsed = parseStreamJson(result.stdout);
104
+ const data = extractJson(parsed.text) as T;
105
+ return { success: true, data, durationMs: result.durationMs, sessionId: parsed.sessionId, inputTokens: parsed.inputTokens, outputTokens: parsed.outputTokens, costUsd: parsed.costUsd };
106
+ } catch (err) {
107
+ logger.warn("Failed to parse Claude JSON output", { error: String(err), stdout: result.stdout.slice(0, 300) });
108
+ return { success: false, error: `Failed to parse JSON: ${result.stdout.slice(0, 200)}`, durationMs: result.durationMs, inputTokens: 0, outputTokens: 0 };
109
+ }
110
+ }
111
+
112
+ function buildArgs(opts: {
113
+ prompt: string;
114
+ allowedTools?: string[];
115
+ model?: string;
116
+ resume?: string;
117
+ }): string[] {
118
+ const args: string[] = [];
119
+
120
+ if (opts.resume) {
121
+ args.push("--resume", opts.resume, "-p", opts.prompt);
122
+ } else {
123
+ args.push("-p", opts.prompt);
124
+ }
125
+
126
+ args.push("--dangerously-skip-permissions", "--verbose", "--output-format", "stream-json");
127
+
128
+ if (opts.model) {
129
+ args.push("--model", opts.model);
130
+ }
131
+
132
+ if (opts.allowedTools?.length) {
133
+ for (const tool of opts.allowedTools) {
134
+ args.push("--allowedTools", tool);
135
+ }
136
+ }
137
+
138
+ return args;
139
+ }
140
+
141
+ export function parseStreamJson(stdout: string): { text: string; sessionId?: string; inputTokens: number; outputTokens: number; costUsd?: number } {
142
+ const lines = stdout.split("\n").filter((l) => l.trim());
143
+ let sessionId: string | undefined;
144
+ let resultText: string | undefined;
145
+ let costUsd: number | undefined;
146
+ let inputTokens = 0;
147
+ let outputTokens = 0;
148
+ const textParts: string[] = [];
149
+
150
+ for (const line of lines) {
151
+ let event: Record<string, unknown>;
152
+ try {
153
+ event = JSON.parse(line);
154
+ } catch {
155
+ continue;
156
+ }
157
+
158
+ if (event.type === "system" && typeof event.session_id === "string") {
159
+ sessionId = event.session_id;
160
+ }
161
+
162
+ if (event.type === "message_start") {
163
+ const msg = event.message as Record<string, unknown> | undefined;
164
+ const usage = msg?.usage as Record<string, unknown> | undefined;
165
+ if (typeof usage?.input_tokens === "number") inputTokens += usage.input_tokens;
166
+ }
167
+
168
+ if (event.type === "message_delta") {
169
+ const usage = event.usage as Record<string, unknown> | undefined;
170
+ if (typeof usage?.output_tokens === "number") outputTokens += usage.output_tokens;
171
+ }
172
+
173
+ if (event.type === "content_block_delta") {
174
+ const delta = event.delta as Record<string, unknown> | undefined;
175
+ if (delta?.type === "text_delta" && typeof delta.text === "string") {
176
+ textParts.push(delta.text);
177
+ }
178
+ }
179
+
180
+ if (event.type === "assistant" && Array.isArray(event.content)) {
181
+ for (const block of event.content) {
182
+ if ((block as Record<string, unknown>).type === "text" && typeof (block as Record<string, unknown>).text === "string") {
183
+ textParts.push((block as Record<string, unknown>).text as string);
184
+ }
185
+ }
186
+ }
187
+
188
+ if (event.type === "result") {
189
+ if (typeof event.result === "string") resultText = event.result;
190
+ if (typeof event.session_id === "string") sessionId = event.session_id;
191
+ if (typeof event.cost_usd === "number") costUsd = event.cost_usd;
192
+ }
193
+ }
194
+
195
+ const text = resultText ?? textParts.join("");
196
+ return { text, sessionId, inputTokens, outputTokens, costUsd };
197
+ }
198
+
199
+ async function streamProcess(
200
+ proc: ReturnType<typeof Bun.spawn>,
201
+ onEvent?: (event: ClaudeStreamEvent) => void,
202
+ logPath?: string,
203
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
204
+ const stderrPromise = new Response(proc.stderr).text();
205
+
206
+ const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
207
+ const decoder = new TextDecoder();
208
+ let buffer = "";
209
+ const allLines: string[] = [];
210
+
211
+ while (true) {
212
+ const { done, value } = await reader.read();
213
+ if (done) break;
214
+ buffer += decoder.decode(value, { stream: true });
215
+
216
+ while (buffer.includes("\n")) {
217
+ const idx = buffer.indexOf("\n");
218
+ const line = buffer.slice(0, idx);
219
+ buffer = buffer.slice(idx + 1);
220
+ if (line.trim()) {
221
+ allLines.push(line);
222
+ if (logPath) appendFileSync(logPath, line + "\n");
223
+ }
224
+
225
+ if (onEvent) {
226
+ const event = parseLineToEvent(line);
227
+ if (event) onEvent(event);
228
+ }
229
+ }
230
+ }
231
+
232
+ if (buffer.trim()) {
233
+ allLines.push(buffer);
234
+ if (logPath) appendFileSync(logPath, buffer + "\n");
235
+ if (onEvent) {
236
+ const event = parseLineToEvent(buffer);
237
+ if (event) onEvent(event);
238
+ }
239
+ }
240
+
241
+ const [exitCode, stderr] = await Promise.all([proc.exited, stderrPromise]);
242
+ const stdout = allLines.join("\n");
243
+ return { stdout, stderr, exitCode };
244
+ }
245
+
246
+ export function parseLineToEvent(line: string): ClaudeStreamEvent | null {
247
+ if (!line.trim()) return null;
248
+
249
+ let event: Record<string, unknown>;
250
+ try {
251
+ event = JSON.parse(line);
252
+ } catch {
253
+ return null;
254
+ }
255
+
256
+ if (event.type === "content_block_start") {
257
+ const content = event.content_block as Record<string, unknown> | undefined;
258
+ if (content?.type === "tool_use" && typeof content.name === "string") {
259
+ return { type: "tool_use", tool: content.name };
260
+ }
261
+ }
262
+
263
+ if (event.type === "content_block_delta") {
264
+ const delta = event.delta as Record<string, unknown> | undefined;
265
+ if (delta?.type === "text_delta" && typeof delta.text === "string") {
266
+ return { type: "text", text: delta.text };
267
+ }
268
+ }
269
+
270
+ if (event.type === "result" && typeof event.result === "string") {
271
+ return {
272
+ type: "result",
273
+ text: event.result,
274
+ sessionId: typeof event.session_id === "string" ? event.session_id : undefined,
275
+ costUsd: typeof event.cost_usd === "number" ? event.cost_usd : undefined,
276
+ };
277
+ }
278
+
279
+ return null;
280
+ }
281
+
282
+ function extractJson(text: string): unknown {
283
+ try {
284
+ return JSON.parse(text);
285
+ } catch {}
286
+
287
+ const match = text.match(/\{[\s\S]*\}/);
288
+ if (match) {
289
+ return JSON.parse(match[0]);
290
+ }
291
+
292
+ throw new Error("No JSON object found in response");
293
+ }
294
+
295
+ function timeout(ms: number): Promise<void> {
296
+ return new Promise((resolve) => setTimeout(resolve, ms));
297
+ }
@@ -0,0 +1,195 @@
1
+ import { runClaudeText, type ClaudeOptions, type ClaudeStreamEvent } from "./claude.ts";
2
+ import { hasChanges, autoCommit, hasNewCommits, getDiffSummary, getDiffPatch } from "./git.ts";
3
+ import { logger } from "../utils/logger.ts";
4
+
5
+ export interface FixResult {
6
+ success: boolean;
7
+ summary: string;
8
+ diffSummary: string;
9
+ diffPatch: string;
10
+ error?: string;
11
+ failureReason?: string;
12
+ durationMs: number;
13
+ inputTokens: number;
14
+ outputTokens: number;
15
+ costUsd?: number;
16
+ }
17
+
18
+ export async function fixIssue(opts: {
19
+ issueContext: string;
20
+ triagePlan: string;
21
+ worktreePath: string;
22
+ baseBranch: string;
23
+ testCommand?: string;
24
+ claudeTimeout: number;
25
+ allowedTools: string[];
26
+ projectContext?: string;
27
+ model?: "sonnet" | "opus" | "haiku";
28
+ sessionId?: string;
29
+ onEvent?: (event: ClaudeStreamEvent) => void;
30
+ logPath?: string;
31
+ }): Promise<FixResult> {
32
+ const { issueContext, triagePlan, worktreePath, baseBranch, testCommand, claudeTimeout, allowedTools, projectContext, model, sessionId, onEvent, logPath } = opts;
33
+
34
+ const testInstruction = testCommand
35
+ ? `After making your changes, run \`${testCommand}\` to verify your fix passes. Fix any test failures.`
36
+ : "If there are existing tests related to your changes, run them to verify nothing is broken.";
37
+
38
+ const contextSection = projectContext ? `\n## Project Context\n${projectContext}\n` : "";
39
+
40
+ const fullPrompt = `You are an AI coding agent tasked with implementing a task. You are working in a git worktree.
41
+ ${contextSection}
42
+ ## Task
43
+ ${issueContext}
44
+
45
+ ## Plan
46
+ ${triagePlan}
47
+
48
+ ## Instructions
49
+ 1. Implement the changes following the plan above
50
+ 2. ${testInstruction}
51
+ 3. Stage and commit your changes with a message: \`fix: <concise description>\`
52
+ 4. Make sure all changes are committed before finishing
53
+
54
+ Important:
55
+ - Only change what's necessary for this task
56
+ - Don't refactor unrelated code
57
+ - Don't add unnecessary comments or documentation
58
+ - Ensure backward compatibility — don't change public API signatures unless required
59
+ - If tests fail after your changes, debug and fix them`;
60
+
61
+ const resumePrompt = `Continue with the implementation. You already explored the codebase during triage — use that context.
62
+
63
+ ## Instructions
64
+ 1. Implement the changes following the plan from the triage phase
65
+ 2. ${testInstruction}
66
+ 3. Stage and commit your changes with a message: \`fix: <concise description>\`
67
+ 4. Make sure all changes are committed before finishing
68
+
69
+ Important:
70
+ - Only change what's necessary for this task
71
+ - Don't refactor unrelated code
72
+ - Don't add unnecessary comments or documentation
73
+ - Ensure backward compatibility — don't change public API signatures unless required
74
+ - If tests fail after your changes, debug and fix them`;
75
+
76
+ const claudeOpts: ClaudeOptions = {
77
+ cwd: worktreePath,
78
+ timeoutMs: claudeTimeout,
79
+ allowedTools,
80
+ model,
81
+ resume: sessionId,
82
+ onEvent,
83
+ logPath,
84
+ };
85
+
86
+ let result = await runClaudeText(sessionId ? resumePrompt : fullPrompt, claudeOpts);
87
+
88
+ // If resume failed, retry without it (session may be stale or cwd mismatch)
89
+ if (!result.success && sessionId) {
90
+ logger.warn("Resume failed, retrying without session", { error: result.error });
91
+ result = await runClaudeText(fullPrompt, { ...claudeOpts, resume: undefined });
92
+ }
93
+
94
+ if (!result.success) {
95
+ const failureReason = result.error?.includes("Timed out") ? "fix:timeout" : "fix:claude_failed";
96
+ return {
97
+ success: false,
98
+ summary: "",
99
+ diffSummary: "",
100
+ diffPatch: "",
101
+ error: result.error ?? "Claude failed",
102
+ failureReason,
103
+ durationMs: result.durationMs,
104
+ inputTokens: result.inputTokens,
105
+ outputTokens: result.outputTokens,
106
+ costUsd: result.costUsd,
107
+ };
108
+ }
109
+
110
+ // Check if Claude made changes
111
+ const hasCommits = await hasNewCommits(worktreePath, baseBranch);
112
+
113
+ if (!hasCommits) {
114
+ // Check if there are uncommitted changes and auto-commit
115
+ if (await hasChanges(worktreePath)) {
116
+ logger.info("Claude forgot to commit, validating and auto-committing");
117
+
118
+ // Run tests before auto-committing
119
+ if (testCommand) {
120
+ const testResult = await Bun.$`sh -c ${testCommand}`.quiet().nothrow().cwd(worktreePath);
121
+ if (testResult.exitCode !== 0) {
122
+ return {
123
+ success: false,
124
+ summary: "",
125
+ diffSummary: "",
126
+ diffPatch: "",
127
+ error: `Tests failed on uncommitted changes: ${testResult.stderr.toString().slice(0, 300)}`,
128
+ failureReason: "fix:tests_failed",
129
+ durationMs: result.durationMs,
130
+ inputTokens: result.inputTokens,
131
+ outputTokens: result.outputTokens,
132
+ costUsd: result.costUsd,
133
+ };
134
+ }
135
+ }
136
+
137
+ const committed = await autoCommit(worktreePath, "fix: automated changes");
138
+ if (!committed) {
139
+ return {
140
+ success: false,
141
+ summary: "",
142
+ diffSummary: "",
143
+ diffPatch: "",
144
+ error: "Changes were made but failed to commit",
145
+ failureReason: "fix:commit_failed",
146
+ durationMs: result.durationMs,
147
+ inputTokens: result.inputTokens,
148
+ outputTokens: result.outputTokens,
149
+ costUsd: result.costUsd,
150
+ };
151
+ }
152
+ } else {
153
+ return {
154
+ success: false,
155
+ summary: "",
156
+ diffSummary: "",
157
+ diffPatch: "",
158
+ error: "No changes were made",
159
+ failureReason: "fix:no_changes",
160
+ durationMs: result.durationMs,
161
+ inputTokens: result.inputTokens,
162
+ outputTokens: result.outputTokens,
163
+ costUsd: result.costUsd,
164
+ };
165
+ }
166
+ }
167
+
168
+ const diffSummary = await getDiffSummary(worktreePath);
169
+ const diffPatch = await getDiffPatch(worktreePath);
170
+
171
+ // Extract a summary from Claude's output (first few meaningful lines)
172
+ const summary = extractSummary(result.output);
173
+
174
+ logger.info("Fix completed", { diffSummary: diffSummary.slice(0, 200) });
175
+
176
+ return {
177
+ success: true,
178
+ summary,
179
+ diffSummary,
180
+ diffPatch,
181
+ durationMs: result.durationMs,
182
+ inputTokens: result.inputTokens,
183
+ outputTokens: result.outputTokens,
184
+ costUsd: result.costUsd,
185
+ };
186
+ }
187
+
188
+ function extractSummary(output: string): string {
189
+ // Claude usually ends with a prose summary paragraph — grab the last one
190
+ const paragraphs = output.split(/\n\n+/).map((p) => p.trim()).filter((p) => p.length > 0);
191
+ const last = paragraphs[paragraphs.length - 1] ?? "";
192
+ // Strip code fences and keep first 2 lines max
193
+ const lines = last.split("\n").filter((l) => !l.startsWith("```") && l.trim().length > 0);
194
+ return lines.slice(0, 2).join("\n").slice(0, 300).trim();
195
+ }
@@ -0,0 +1,111 @@
1
+ import { logger } from "../utils/logger.ts";
2
+ import { join, dirname } from "node:path";
3
+
4
+ export interface WorktreeInfo {
5
+ worktreePath: string;
6
+ branchName: string;
7
+ }
8
+
9
+ export async function createWorktree(
10
+ repoPath: string,
11
+ source: string,
12
+ sourceId: string,
13
+ baseBranch: string,
14
+ ): Promise<WorktreeInfo> {
15
+ const repoParent = dirname(repoPath);
16
+ const worktreeDir = join(repoParent, ".relay-worktrees");
17
+ const worktreePath = join(worktreeDir, `fix-${source}-${sourceId}`);
18
+ const branchName = `relay/fix-${source}-${sourceId}`;
19
+
20
+ // Fetch latest (60s timeout to prevent hanging on slow/broken connections)
21
+ await Promise.race([
22
+ Bun.$`git -C ${repoPath} fetch origin ${baseBranch}`.quiet().nothrow(),
23
+ new Promise<void>((_, reject) => setTimeout(() => reject(new Error("git fetch timed out after 60s")), 60_000)),
24
+ ]);
25
+
26
+ // Clean up existing worktree/branch if they exist
27
+ await Bun.$`git -C ${repoPath} worktree remove --force ${worktreePath}`.quiet().nothrow();
28
+ await Bun.$`git -C ${repoPath} branch -D ${branchName}`.quiet().nothrow();
29
+
30
+ // Ensure parent dir exists
31
+ await Bun.$`mkdir -p ${worktreeDir}`.quiet();
32
+
33
+ // Create worktree from origin/baseBranch
34
+ const result = await Bun.$`git -C ${repoPath} worktree add -b ${branchName} ${worktreePath} origin/${baseBranch}`.quiet().nothrow();
35
+ if (result.exitCode !== 0) {
36
+ const stderr = result.stderr.toString();
37
+ throw new Error(`Failed to create worktree: ${stderr}`);
38
+ }
39
+
40
+ logger.info("Created worktree", { path: worktreePath, branch: branchName });
41
+ return { worktreePath, branchName };
42
+ }
43
+
44
+ export async function removeWorktree(repoPath: string, worktreePath: string, branchName: string) {
45
+ await Bun.$`git -C ${repoPath} worktree remove --force ${worktreePath}`.quiet().nothrow();
46
+ await Bun.$`git -C ${repoPath} branch -D ${branchName}`.quiet().nothrow();
47
+ await Bun.$`git -C ${repoPath} worktree prune`.quiet().nothrow();
48
+ logger.info("Removed worktree", { path: worktreePath, branch: branchName });
49
+ }
50
+
51
+ export async function pushBranch(worktreePath: string, branchName: string): Promise<void> {
52
+ const push = await Bun.$`git -C ${worktreePath} push -u origin ${branchName}`.quiet().nothrow();
53
+ if (push.exitCode !== 0) {
54
+ throw new Error(`Failed to push: ${push.stderr.toString()}`);
55
+ }
56
+ logger.info("Pushed branch", { branch: branchName });
57
+ }
58
+
59
+ export async function buildPRUrl(
60
+ worktreePath: string,
61
+ branchName: string,
62
+ baseBranch: string,
63
+ title: string,
64
+ body: string,
65
+ ): Promise<string> {
66
+ const repo = await getRemoteRepo(worktreePath);
67
+ const params = new URLSearchParams({ expand: "1", title, body });
68
+ return `https://github.com/${repo}/compare/${baseBranch}...${branchName}?${params.toString()}`;
69
+ }
70
+
71
+ export async function getDiffSummary(worktreePath: string): Promise<string> {
72
+ const result = await Bun.$`git -C ${worktreePath} diff HEAD~1 --stat`.quiet().nothrow();
73
+ if (result.exitCode !== 0) {
74
+ return "(no diff available)";
75
+ }
76
+ return result.stdout.toString().trim();
77
+ }
78
+
79
+ export async function getDiffPatch(worktreePath: string): Promise<string> {
80
+ const result = await Bun.$`git -C ${worktreePath} diff HEAD~1`.quiet().nothrow();
81
+ if (result.exitCode !== 0) {
82
+ return "(no diff available)";
83
+ }
84
+ return result.stdout.toString().trim();
85
+ }
86
+
87
+ export async function hasChanges(worktreePath: string): Promise<boolean> {
88
+ const staged = await Bun.$`git -C ${worktreePath} diff --cached --quiet`.quiet().nothrow();
89
+ const unstaged = await Bun.$`git -C ${worktreePath} diff --quiet`.quiet().nothrow();
90
+ const untracked = await Bun.$`git -C ${worktreePath} ls-files --others --exclude-standard`.quiet().nothrow();
91
+ return staged.exitCode !== 0 || unstaged.exitCode !== 0 || untracked.stdout.toString().trim().length > 0;
92
+ }
93
+
94
+ export async function autoCommit(worktreePath: string, message: string): Promise<boolean> {
95
+ await Bun.$`git -C ${worktreePath} add -A`.quiet().nothrow();
96
+ const result = await Bun.$`git -C ${worktreePath} commit -m ${message}`.quiet().nothrow();
97
+ return result.exitCode === 0;
98
+ }
99
+
100
+ export async function hasNewCommits(worktreePath: string, baseBranch: string): Promise<boolean> {
101
+ const result = await Bun.$`git -C ${worktreePath} log origin/${baseBranch}..HEAD --oneline`.quiet().nothrow();
102
+ return result.stdout.toString().trim().length > 0;
103
+ }
104
+
105
+ async function getRemoteRepo(worktreePath: string): Promise<string> {
106
+ const result = await Bun.$`git -C ${worktreePath} remote get-url origin`.quiet();
107
+ const url = result.stdout.toString().trim();
108
+ // Extract owner/repo from git URL
109
+ const match = url.match(/github\.com[:/](.+?)(?:\.git)?$/);
110
+ return match?.[1] ?? url;
111
+ }