Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

ship-agent.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

ship-agent.tsBlame557 lines · 1 contributor
f928118Claude1/**
2 * Ship Agent — autonomous AI feature implementation pipeline.
3 *
4 * Given a GitHub issue, the agent:
5 * 1. Plans the implementation by reading the file tree and key files.
6 * 2. Reads all files the plan references.
7 * 3. Creates a branch and rewrites each file via Claude.
8 * 4. Commits the changes.
9 * 5. Opens a PR and posts a comment on the original issue.
10 *
11 * Jobs run fire-and-forget (async, no await at call-site).
12 * Progress is stored in-memory in `shipJobs` and polled by the UI.
13 */
14
15import { randomUUID } from "crypto";
16import { join } from "path";
17import { writeFile, mkdir } from "fs/promises";
18import { config } from "./config";
19import { getAnthropic, extractText, parseJsonResponse, MODEL_SONNET } from "./ai-client";
20import { getRepoPath, getDefaultBranch, resolveRef } from "../git/repository";
21import { db } from "../db";
22import { pullRequests, issues, issueComments, users } from "../db/schema";
23import { and, eq, desc } from "drizzle-orm";
24
25// ─── Types ─────────────────────────────────────────────────────────────────
26
27export type ShipStatus =
28 | "planning"
29 | "reading"
30 | "coding"
31 | "committing"
32 | "opening-pr"
33 | "done"
34 | "failed";
35
36export interface ShipJob {
37 id: string;
38 issueId: string;
39 repoId: string;
40 owner: string;
41 repo: string;
42 issueNumber: number;
43 issueTitle: string;
44 issueBody: string;
45 requestedByUserId: string;
46 status: ShipStatus;
47 plan?: string;
48 branchName?: string;
49 prNumber?: number;
50 prUrl?: string;
51 log: string[];
52 error?: string;
53 createdAt: Date;
54 completedAt?: Date;
55}
56
57interface PlanResponse {
58 plan: string;
59 files_to_modify: Array<{ path: string; change_description: string }>;
60 new_files: Array<{ path: string; purpose: string }>;
61 branch_name: string;
62}
63
64// ─── In-memory store ────────────────────────────────────────────────────────
65
66export const shipJobs = new Map<string, ShipJob>();
67
68// ─── Rate-limiting state ────────────────────────────────────────────────────
69
70// Track jobs started per user per day (UTC).
71const userDayJobCount = new Map<string, { date: string; count: number }>();
72// Track active jobs per repo.
73const repoActiveJobs = new Map<string, string>(); // repoId -> jobId
74
75function todayUtc(): string {
76 return new Date().toISOString().slice(0, 10);
77}
78
79function checkRateLimits(userId: string, repoId: string): string | null {
80 const today = todayUtc();
81 const userKey = `${userId}:${today}`;
82 const entry = userDayJobCount.get(userId);
83 if (entry && entry.date === today && entry.count >= 3) {
84 return "Rate limit: max 3 ship jobs per user per day.";
85 }
86 const activeJobId = repoActiveJobs.get(repoId);
87 if (activeJobId && shipJobs.get(activeJobId)?.status !== "done" && shipJobs.get(activeJobId)?.status !== "failed") {
88 return "Rate limit: only 1 concurrent ship job per repo.";
89 }
90 return null;
91}
92
93function incrementUserCount(userId: string) {
94 const today = todayUtc();
95 const entry = userDayJobCount.get(userId);
96 if (!entry || entry.date !== today) {
97 userDayJobCount.set(userId, { date: today, count: 1 });
98 } else {
99 entry.count++;
100 }
101}
102
103// ─── Helpers ────────────────────────────────────────────────────────────────
104
105function addLog(job: ShipJob, msg: string) {
106 job.log.push(`[${new Date().toISOString()}] ${msg}`);
107}
108
109async function execGit(
110 args: string[],
111 cwd: string,
112 env?: Record<string, string>
113): Promise<{ stdout: string; stderr: string; exitCode: number }> {
114 const proc = Bun.spawn(args, {
115 cwd,
116 env: { ...process.env, ...env },
117 stdout: "pipe",
118 stderr: "pipe",
119 });
120 const [stdout, stderr] = await Promise.all([
121 new Response(proc.stdout).text(),
122 new Response(proc.stderr).text(),
123 ]);
124 const exitCode = await proc.exited;
125 return { stdout, stderr, exitCode };
126}
127
128/**
129 * Get a flat list of all files (blob paths) in the repo, up to maxCount.
130 */
131async function listAllFiles(
132 repoPath: string,
133 ref: string,
134 maxCount = 200
135): Promise<string[]> {
136 const { stdout, exitCode } = await execGit(
137 ["git", "ls-tree", "-r", "--name-only", ref],
138 repoPath
139 );
140 if (exitCode !== 0) return [];
141 return stdout
142 .trim()
143 .split("\n")
144 .filter(Boolean)
145 .slice(0, maxCount);
146}
147
148/**
149 * Read a file's content from the bare git repo.
150 */
151async function readFileFromRepo(repoPath: string, ref: string, filePath: string): Promise<string> {
152 const { stdout, exitCode } = await execGit(
153 ["git", "show", `${ref}:${filePath}`],
154 repoPath
155 );
156 if (exitCode !== 0) return "";
157 return stdout;
158}
159
160/**
161 * Get a bot user for authoring the PR.
162 * Falls back to the requesting user if no bot user exists.
163 */
164async function getBotUserId(fallbackUserId: string): Promise<string> {
165 try {
166 const [bot] = await db
167 .select({ id: users.id })
168 .from(users)
169 .where(eq(users.username, "gluecron-bot"))
170 .limit(1);
171 if (bot) return bot.id;
172 } catch {
173 // fall through
174 }
175 return fallbackUserId;
176}
177
178/**
179 * Post a comment on the issue from the requesting user.
180 */
181async function postIssueComment(
182 issueId: string,
183 authorId: string,
184 body: string
185): Promise<void> {
186 try {
187 await db.insert(issueComments).values({
188 issueId,
189 authorId,
190 body,
191 moderationStatus: "approved",
192 });
193 } catch (err) {
194 console.warn("[ship-agent] failed to post issue comment:", err);
195 }
196}
197
198// ─── Public API ─────────────────────────────────────────────────────────────
199
200export async function startShipJob(params: {
201 issueId: string;
202 repoId: string;
203 owner: string;
204 repo: string;
205 issueNumber: number;
206 issueTitle: string;
207 issueBody: string;
208 requestedByUserId: string;
209}): Promise<string> {
210 const rateLimitErr = checkRateLimits(params.requestedByUserId, params.repoId);
211 if (rateLimitErr) throw new Error(rateLimitErr);
212
213 const jobId = randomUUID();
214 const job: ShipJob = {
215 id: jobId,
216 ...params,
217 status: "planning",
218 log: [],
219 createdAt: new Date(),
220 };
221
222 shipJobs.set(jobId, job);
223 incrementUserCount(params.requestedByUserId);
224 repoActiveJobs.set(params.repoId, jobId);
225
226 // Fire-and-forget
227 runShipJob(job).catch((err) => {
228 console.error("[ship-agent] unhandled error in runShipJob:", err);
229 });
230
231 return jobId;
232}
233
234export function getShipJob(jobId: string): ShipJob | undefined {
235 return shipJobs.get(jobId);
236}
237
238// ─── Main pipeline ───────────────────────────────────────────────────────────
239
240async function runShipJob(job: ShipJob): Promise<void> {
241 try {
242 await phasePlan(job);
243 await phaseRead(job);
244 await phaseCode(job);
245 await phaseCommit(job);
246 await phaseOpenPr(job);
247 job.status = "done";
248 job.completedAt = new Date();
249 addLog(job, "Ship agent completed successfully.");
250 await postIssueComment(
251 job.issueId,
252 job.requestedByUserId,
253 `Ship Agent completed! Changes are ready for review in PR #${job.prNumber}. If the GateTest passes, this is ready to merge.`
254 );
255 } catch (err) {
256 const msg = err instanceof Error ? err.message : String(err);
257 job.status = "failed";
258 job.error = msg;
259 job.completedAt = new Date();
260 addLog(job, `FAILED: ${msg}`);
261 await postIssueComment(
262 job.issueId,
263 job.requestedByUserId,
264 `Ship Agent failed during the **${job.status}** phase.\n\nError: \`${msg}\`\n\nPlease review the issue and try again or implement manually.`
265 ).catch(() => {});
266 }
267}
268
269// ─── Phase 1: Planning ───────────────────────────────────────────────────────
270
271async function phasePlan(job: ShipJob): Promise<void> {
272 job.status = "planning";
273 addLog(job, "Starting planning phase...");
274
275 const repoDiskPath = getRepoPath(job.owner, job.repo);
276 const defaultBranch = (await getDefaultBranch(job.owner, job.repo)) ?? "main";
277 const ref = await resolveRef(job.owner, job.repo, defaultBranch);
278 if (!ref) throw new Error(`Cannot resolve ref for branch '${defaultBranch}'. Does the repo have commits?`);
279
280 // File tree (up to 200 files)
281 const allFiles = await listAllFiles(repoDiskPath, ref, 200);
282 const treeStr = allFiles.join("\n");
283 addLog(job, `Read file tree: ${allFiles.length} files.`);
284
285 // Read a few key files for context
286 const keyFiles = ["README.md", "package.json", "bun.lockb", "src/app.tsx", "CLAUDE.md"];
287 const keyFileContents: string[] = [];
288 for (const f of keyFiles) {
289 if (allFiles.includes(f)) {
290 const content = await readFileFromRepo(repoDiskPath, ref, f);
291 if (content && !content.includes("\0")) {
292 keyFileContents.push(`--- ${f} ---\n${content.slice(0, 2000)}`);
293 }
294 }
295 }
296
297 const client = getAnthropic();
298
299 const userPrompt = `Issue: ${job.issueTitle}\n\n${job.issueBody}\n\nFile tree:\n${treeStr}\n\nKey files:\n${keyFileContents.join("\n\n").slice(0, 6000)}\n\nReturn JSON with this exact shape:\n{"plan": "string describing what will be done", "files_to_modify": [{"path": "src/...", "change_description": "..."}], "new_files": [{"path": "src/...", "purpose": "..."}], "branch_name": "feat/short-slug"}`;
300
301 let planRaw: PlanResponse | null = null;
302 for (let attempt = 0; attempt < 2; attempt++) {
303 const msg = await client.messages.create({
304 model: MODEL_SONNET,
305 max_tokens: 4000,
306 system:
307 "You are an expert developer. Given a GitHub issue and codebase, create a precise implementation plan. Return ONLY valid JSON, no explanations.",
308 messages: [{ role: "user", content: attempt === 0 ? userPrompt : `Return ONLY valid JSON, no explanation:\n${userPrompt}` }],
309 });
310 const text = extractText(msg);
311 planRaw = parseJsonResponse<PlanResponse>(text);
312 if (planRaw) break;
313 }
314
315 if (!planRaw) {
316 throw new Error("AI returned invalid JSON for the implementation plan. Aborting.");
317 }
318
319 job.plan = planRaw.plan;
320 job.branchName = sanitizeBranchName(planRaw.branch_name || `ship-agent/issue-${job.issueNumber}`);
321 // Store the plan details for later phases
322 (job as any)._planDetails = planRaw;
323 (job as any)._defaultBranch = defaultBranch;
324 (job as any)._ref = ref;
325
326 addLog(job, `Plan: ${job.plan}`);
327 addLog(job, `Branch: ${job.branchName}`);
328 addLog(
329 job,
330 `Files to modify: ${planRaw.files_to_modify.length}, new files: ${planRaw.new_files.length}`
331 );
332}
333
334// ─── Phase 2: Reading ────────────────────────────────────────────────────────
335
336async function phaseRead(job: ShipJob): Promise<void> {
337 job.status = "reading";
338 addLog(job, "Reading relevant files...");
339
340 const plan: PlanResponse = (job as any)._planDetails;
341 const ref: string = (job as any)._ref;
342 const repoDiskPath = getRepoPath(job.owner, job.repo);
343
344 const fileContents = new Map<string, string>();
345
346 for (const fm of plan.files_to_modify) {
347 const content = await readFileFromRepo(repoDiskPath, ref, fm.path);
348 fileContents.set(fm.path, content);
349 }
350
351 (job as any)._fileContents = fileContents;
352
353 addLog(job, `Read ${fileContents.size} files.`);
354}
355
356// ─── Phase 3: Coding ─────────────────────────────────────────────────────────
357
358async function phaseCode(job: ShipJob): Promise<void> {
359 job.status = "coding";
360 addLog(job, "Creating branch and writing code...");
361
362 const plan: PlanResponse = (job as any)._planDetails;
363 const defaultBranch: string = (job as any)._defaultBranch;
364 const fileContents: Map<string, string> = (job as any)._fileContents;
365 const repoDiskPath = getRepoPath(job.owner, job.repo);
366
367 // We work in a temporary clone of the bare repo so we can read/write files
368 // without polluting the bare repo. Use a worktree instead.
369 const worktreeBase = join(config.gitReposPath, ".ship-agent-worktrees");
370 const worktreePath = join(worktreeBase, job.id);
371
372 await mkdir(worktreeBase, { recursive: true });
373
374 // Create a worktree at the default branch
375 const addResult = await execGit(
376 ["git", "worktree", "add", "--no-checkout", worktreePath, defaultBranch],
377 repoDiskPath
378 );
379 if (addResult.exitCode !== 0) {
380 throw new Error(`Failed to create worktree: ${addResult.stderr}`);
381 }
382
383 // Configure identity for commits inside worktree
384 await execGit(["git", "config", "user.email", "ship-agent@gluecron.com"], worktreePath);
385 await execGit(["git", "config", "user.name", "Gluecron Ship Agent"], worktreePath);
386
387 // Checkout the default branch
388 await execGit(["git", "checkout", defaultBranch], worktreePath);
389
390 // Create the feature branch
391 const branchResult = await execGit(
392 ["git", "checkout", "-b", job.branchName!],
393 worktreePath
394 );
395 if (branchResult.exitCode !== 0) {
396 throw new Error(`Failed to create branch '${job.branchName}': ${branchResult.stderr}`);
397 }
398 addLog(job, `Created branch: ${job.branchName}`);
399
400 const client = getAnthropic();
401
402 // Modify existing files
403 for (const fm of plan.files_to_modify) {
404 const currentContent = fileContents.get(fm.path) ?? "";
405
406 const msg = await client.messages.create({
407 model: MODEL_SONNET,
408 max_tokens: 8000,
409 system:
410 "You are implementing a feature. Return the COMPLETE updated file content. No explanations, no markdown code blocks — just the raw file content.",
411 messages: [
412 {
413 role: "user",
414 content: `File: ${fm.path}\nCurrent content:\n${currentContent}\n\nChange needed: ${fm.change_description}\nFull issue context: ${job.issueTitle}\n${job.issueBody}\nImplementation plan: ${job.plan}`,
415 },
416 ],
417 });
418
419 let newContent = extractText(msg);
420 // Strip potential markdown code fences if Claude added them
421 newContent = stripCodeFences(newContent);
422
423 const targetPath = join(worktreePath, fm.path);
424 await mkdir(join(targetPath, ".."), { recursive: true }).catch(() => {});
425 await writeFile(targetPath, newContent, "utf8");
426 await execGit(["git", "add", fm.path], worktreePath);
427 addLog(job, `Modified: ${fm.path}`);
428 }
429
430 // Create new files
431 for (const nf of plan.new_files) {
432 const msg = await client.messages.create({
433 model: MODEL_SONNET,
434 max_tokens: 8000,
435 system:
436 "You are implementing a feature. Return the COMPLETE file content for the new file. No explanations, no markdown code blocks — just the raw file content.",
437 messages: [
438 {
439 role: "user",
440 content: `New file: ${nf.path}\nPurpose: ${nf.purpose}\nFull issue context: ${job.issueTitle}\n${job.issueBody}\nImplementation plan: ${job.plan}`,
441 },
442 ],
443 });
444
445 let newContent = extractText(msg);
446 newContent = stripCodeFences(newContent);
447
448 const targetPath = join(worktreePath, nf.path);
449 await mkdir(join(targetPath, ".."), { recursive: true }).catch(() => {});
450 await writeFile(targetPath, newContent, "utf8");
451 await execGit(["git", "add", nf.path], worktreePath);
452 addLog(job, `Created: ${nf.path}`);
453 }
454
455 (job as any)._worktreePath = worktreePath;
456}
457
458// ─── Phase 4: Committing ─────────────────────────────────────────────────────
459
460async function phaseCommit(job: ShipJob): Promise<void> {
461 job.status = "committing";
462 addLog(job, "Committing changes...");
463
464 const worktreePath: string = (job as any)._worktreePath;
465 const repoDiskPath = getRepoPath(job.owner, job.repo);
466
467 const commitMsg = `feat: ${job.issueTitle}\n\nCloses #${job.issueNumber}\n\nAI-implemented via Gluecron Ship Agent`;
468
469 const commitResult = await execGit(
470 ["git", "commit", "-m", commitMsg],
471 worktreePath
472 );
473 if (commitResult.exitCode !== 0) {
474 throw new Error(`git commit failed: ${commitResult.stderr}`);
475 }
476 addLog(job, "Committed changes.");
477
478 // Push the branch to origin (the bare repo itself)
479 const pushResult = await execGit(
480 ["git", "push", repoDiskPath, job.branchName!],
481 worktreePath
482 );
483 if (pushResult.exitCode !== 0) {
484 throw new Error(`git push failed: ${pushResult.stderr}`);
485 }
486 addLog(job, `Pushed branch '${job.branchName}' to origin.`);
487
488 // Clean up worktree
489 await execGit(
490 ["git", "worktree", "remove", "--force", worktreePath],
491 repoDiskPath
492 ).catch(() => {});
493}
494
495// ─── Phase 5: Opening PR ─────────────────────────────────────────────────────
496
497async function phaseOpenPr(job: ShipJob): Promise<void> {
498 job.status = "opening-pr";
499 addLog(job, "Opening pull request...");
500
501 const plan: PlanResponse = (job as any)._planDetails;
502 const defaultBranch: string = (job as any)._defaultBranch;
503 const authorId = await getBotUserId(job.requestedByUserId);
504
505 const fileList = [
506 ...plan.files_to_modify.map((f) => `- Modified: \`${f.path}\``),
507 ...plan.new_files.map((f) => `- Created: \`${f.path}\``),
508 ].join("\n");
509
510 const prBody = `Closes #${job.issueNumber}\n\n## What was done\n\n${job.plan}\n\n## Changes\n\n${fileList}\n\n*AI-implemented via Gluecron Ship Agent*`;
511
512 const [pr] = await db
513 .insert(pullRequests)
514 .values({
515 repositoryId: job.repoId,
516 authorId,
517 title: `feat: ${job.issueTitle}`,
518 body: prBody,
519 baseBranch: defaultBranch,
520 headBranch: job.branchName!,
521 state: "open",
522 })
523 .returning();
524
525 job.prNumber = pr.number;
526 job.prUrl = `/${job.owner}/${job.repo}/pulls/${pr.number}`;
527
528 addLog(job, `PR #${pr.number} opened.`);
529
530 // Post comment on the issue linking to the PR
531 await postIssueComment(
532 job.issueId,
533 job.requestedByUserId,
534 `**Ship Agent started work!** PR opened: #${pr.number} — [View PR](/${job.owner}/${job.repo}/pulls/${pr.number})`
535 );
536}
537
538// ─── Utilities ───────────────────────────────────────────────────────────────
539
540function sanitizeBranchName(name: string): string {
541 return name
542 .toLowerCase()
543 .replace(/[^a-z0-9._\-/]/g, "-")
544 .replace(/^[-./]+|[-./]+$/g, "")
545 .replace(/\/+/g, "/")
546 .slice(0, 80) || `ship-agent/issue`;
547}
548
549function stripCodeFences(text: string): string {
550 // Remove ```lang ... ``` wrapper if present
551 const match = text.match(/^```(?:\w+)?\n([\s\S]*)\n```$/);
552 if (match) return match[1];
553 // Also handle without trailing newline
554 const match2 = text.match(/^```(?:\w+)?\n([\s\S]*)```$/);
555 if (match2) return match2[1];
556 return text;
557}