Commitf928118unknown_key
feat: Ship This Feature — autonomous AI agent that implements GitHub issues
feat: Ship This Feature — autonomous AI agent that implements GitHub issues Adds a one-click "Ship It" button on issue detail pages that triggers a multi-phase AI pipeline: reads the codebase, generates an implementation plan via Claude, writes code changes across relevant files, commits to a new branch, opens a PR, and posts a comment linking back to the issue. Key components: - src/lib/ship-agent.ts — core pipeline (plan → read → code → commit → PR) - src/routes/ship-agent.tsx — progress page + JSON status endpoint - src/routes/issues.tsx — "Ship It" button (guarded by ANTHROPIC_API_KEY) - src/app.tsx — route registration Rate limits: 3 jobs/user/day, 1 concurrent job/repo. Requires ANTHROPIC_API_KEY. https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
4 files changed+979−0f928118f8a8513867b00da56628f1886fda8751d
4 changed files+979−0
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
@@ -30,6 +30,7 @@ import agentsRoutes from "./routes/agents";
3030import agentPipelinesRoutes from "./routes/agent-pipelines";
3131import issueRoutes from "./routes/issues";
3232import milestonesRoutes from "./routes/milestones";
33import shipAgentRoutes from "./routes/ship-agent";
3334import commentModerationRoutes from "./routes/comment-moderation";
3435import repoSettings from "./routes/repo-settings";
3536import collaboratorRoutes from "./routes/collaborators";
@@ -504,6 +505,9 @@ app.route("/", issueRoutes);
504505// Mounted after issueRoutes so /:owner/:repo/issues/* paths win before the milestone patterns.
505506app.route("/", milestonesRoutes);
506507
508// Ship Agent — autonomous AI feature implementation
509app.route("/", shipAgentRoutes);
510
507511// Comment moderation queue — owner-only `/:owner/:repo/comments/pending`
508512// + per-row approve/reject/spam actions. Mounted before `pullRoutes` so
509513// the `/:owner/:repo/comments/*` paths resolve before the broader PR
Addedsrc/lib/ship-agent.ts+557−0View fileUnifiedSplit
@@ -0,0 +1,557 @@
1/**
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}
Modifiedsrc/routes/issues.tsx+18−0View fileUnifiedSplit
@@ -58,6 +58,7 @@ import {
5858} from "../views/ui";
5959import { getDefaultBranch, resolveRef, updateRef } from "../git/repository";
6060import { BOT_USERNAME } from "../lib/bot-user";
61import { isAiAvailable } from "../lib/ai-client";
6162
6263const issueRoutes = new Hono<AuthEnv>();
6364
@@ -1532,6 +1533,23 @@ issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("rea
15321533 Build with AI
15331534 </a>
15341535 )}
1536 {issue.state === "open" && user && isAiAvailable() && (
1537 <form
1538 method="post"
1539 action={`/${ownerName}/${repoName}/issues/${issue.number}/ship`}
1540 style="display:inline"
1541 title="Let AI implement this feature automatically"
1542 >
1543 <button
1544 type="submit"
1545 class="btn btn-primary"
1546 style="font-size:13px;padding:6px 12px;background:linear-gradient(135deg,#8c6dff,#36c5d6);border:none;cursor:pointer"
1547 onclick="return confirm('Ship Agent will read the codebase, write code, and open a PR automatically. Review the PR before merging. Continue?')"
1548 >
1549 Ship It
1550 </button>
1551 </form>
1552 )}
15351553 {issue.state === "open" && user && (
15361554 <details class="issue-branch-dropdown" style="position:relative;display:inline-block">
15371555 <summary
Addedsrc/routes/ship-agent.tsx+400−0View fileUnifiedSplit
@@ -0,0 +1,400 @@
1/**
2 * Ship Agent routes — autonomous AI feature implementation.
3 *
4 * POST /:owner/:repo/issues/:issueNumber/ship — start a job
5 * GET /:owner/:repo/issues/:issueNumber/ship/:jobId — progress page
6 * GET /:owner/:repo/issues/:issueNumber/ship/:jobId/status — JSON status
7 */
8
9import { Hono } from "hono";
10import { eq, and } from "drizzle-orm";
11import { db } from "../db";
12import { issues, repositories, users } from "../db/schema";
13import { Layout } from "../views/layout";
14import { softAuth, requireAuth } from "../middleware/auth";
15import { requireRepoAccess } from "../middleware/repo-access";
16import type { AuthEnv } from "../middleware/auth";
17import { startShipJob, getShipJob } from "../lib/ship-agent";
18import { isAiAvailable } from "../lib/ai-client";
19
20const shipAgentRoutes = new Hono<AuthEnv>();
21
22// ─── Styles ──────────────────────────────────────────────────────────────────
23
24const shipStyles = `
25 .ship-hero {
26 position: relative;
27 margin: 4px 0 24px;
28 padding: 28px 32px;
29 background: var(--bg-elevated);
30 border: 1px solid var(--border);
31 border-radius: 16px;
32 overflow: hidden;
33 }
34 .ship-hero::before {
35 content: '';
36 position: absolute;
37 top: 0; left: 0; right: 0;
38 height: 2px;
39 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
40 opacity: 0.7;
41 pointer-events: none;
42 }
43 .ship-title {
44 font-family: var(--font-display);
45 font-size: clamp(22px, 3vw, 30px);
46 font-weight: 700;
47 letter-spacing: -0.022em;
48 color: var(--text-strong);
49 margin: 0 0 8px;
50 }
51 .ship-subtitle {
52 font-size: 15px;
53 color: var(--text-muted);
54 margin: 0;
55 line-height: 1.5;
56 }
57 .ship-phases {
58 display: flex;
59 gap: 8px;
60 flex-wrap: wrap;
61 margin: 20px 0;
62 }
63 .ship-phase {
64 display: inline-flex;
65 align-items: center;
66 gap: 6px;
67 padding: 5px 12px;
68 border-radius: 9999px;
69 font-size: 12.5px;
70 font-weight: 600;
71 border: 1px solid var(--border);
72 background: var(--bg-elevated);
73 color: var(--text-muted);
74 transition: all 120ms ease;
75 }
76 .ship-phase.is-active {
77 background: rgba(140,109,255,0.14);
78 border-color: rgba(140,109,255,0.45);
79 color: var(--text-strong);
80 }
81 .ship-phase.is-done {
82 background: rgba(52,211,153,0.1);
83 border-color: rgba(52,211,153,0.35);
84 color: #34d399;
85 }
86 .ship-phase.is-failed {
87 background: rgba(248,113,113,0.1);
88 border-color: rgba(248,113,113,0.35);
89 color: #f87171;
90 }
91 .ship-log {
92 background: var(--bg-secondary);
93 border: 1px solid var(--border);
94 border-radius: 12px;
95 padding: 16px 18px;
96 font-family: var(--font-mono);
97 font-size: 12.5px;
98 line-height: 1.7;
99 color: var(--text-muted);
100 max-height: 400px;
101 overflow-y: auto;
102 white-space: pre-wrap;
103 word-break: break-all;
104 }
105 .ship-log-entry { display: block; }
106 .ship-log-entry.is-error { color: #f87171; }
107 .ship-log-entry.is-done { color: #34d399; }
108 .ship-result {
109 margin-top: 18px;
110 padding: 14px 18px;
111 border-radius: 12px;
112 font-size: 14px;
113 line-height: 1.55;
114 }
115 .ship-result.is-done {
116 background: rgba(52,211,153,0.08);
117 border: 1px solid rgba(52,211,153,0.3);
118 color: var(--text);
119 }
120 .ship-result.is-failed {
121 background: rgba(248,113,113,0.08);
122 border: 1px solid rgba(248,113,113,0.3);
123 color: var(--text);
124 }
125 .ship-pr-link {
126 display: inline-flex;
127 align-items: center;
128 gap: 6px;
129 margin-top: 10px;
130 padding: 8px 16px;
131 border-radius: 8px;
132 background: rgba(140,109,255,0.14);
133 border: 1px solid rgba(140,109,255,0.35);
134 color: var(--accent);
135 font-weight: 600;
136 text-decoration: none;
137 font-size: 13.5px;
138 transition: background 120ms;
139 }
140 .ship-pr-link:hover { background: rgba(140,109,255,0.22); text-decoration: none; }
141`;
142
143const PHASES: Array<{ key: string; label: string }> = [
144 { key: "planning", label: "Planning" },
145 { key: "reading", label: "Reading" },
146 { key: "coding", label: "Coding" },
147 { key: "committing", label: "Committing" },
148 { key: "opening-pr", label: "Opening PR" },
149 { key: "done", label: "Done" },
150];
151
152const PHASE_ORDER: Record<string, number> = {
153 planning: 0,
154 reading: 1,
155 coding: 2,
156 committing: 3,
157 "opening-pr": 4,
158 done: 5,
159 failed: 6,
160};
161
162// ─── Helper ──────────────────────────────────────────────────────────────────
163
164async function resolveIssue(ownerName: string, repoName: string, issueNum: number) {
165 const [owner] = await db
166 .select()
167 .from(users)
168 .where(eq(users.username, ownerName))
169 .limit(1);
170 if (!owner) return null;
171
172 const [repo] = await db
173 .select()
174 .from(repositories)
175 .where(and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName)))
176 .limit(1);
177 if (!repo) return null;
178
179 const [issue] = await db
180 .select()
181 .from(issues)
182 .where(and(eq(issues.repositoryId, repo.id), eq(issues.number, issueNum)))
183 .limit(1);
184 if (!issue) return null;
185
186 return { owner, repo, issue };
187}
188
189// ─── POST — start ship job ────────────────────────────────────────────────────
190
191shipAgentRoutes.post(
192 "/:owner/:repo/issues/:issueNumber/ship",
193 softAuth,
194 requireAuth,
195 requireRepoAccess("write"),
196 async (c) => {
197 if (!isAiAvailable()) {
198 return c.html(
199 <Layout title="Ship Agent unavailable" user={c.get("user")}>
200 <style dangerouslySetInnerHTML={{ __html: shipStyles }} />
201 <div class="ship-hero">
202 <h1 class="ship-title">Ship Agent unavailable</h1>
203 <p class="ship-subtitle">
204 ANTHROPIC_API_KEY is not configured. Ship Agent requires AI to function.
205 </p>
206 </div>
207 </Layout>,
208 503
209 );
210 }
211
212 const { owner: ownerName, repo: repoName } = c.req.param();
213 const issueNum = parseInt(c.req.param("issueNumber"), 10);
214 const user = c.get("user")!;
215
216 const resolved = await resolveIssue(ownerName, repoName, issueNum);
217 if (!resolved) {
218 return c.html(
219 <Layout title="Not Found" user={user}>
220 <div style="padding:40px;text-align:center;color:var(--text-muted)">Issue not found.</div>
221 </Layout>,
222 404
223 );
224 }
225
226 let jobId: string;
227 try {
228 jobId = await startShipJob({
229 issueId: resolved.issue.id,
230 repoId: resolved.repo.id,
231 owner: ownerName,
232 repo: repoName,
233 issueNumber: issueNum,
234 issueTitle: resolved.issue.title,
235 issueBody: resolved.issue.body || "",
236 requestedByUserId: user.id,
237 });
238 } catch (err) {
239 const msg = err instanceof Error ? err.message : String(err);
240 return c.redirect(
241 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(`Ship Agent: ${msg}`)}`
242 );
243 }
244
245 return c.redirect(
246 `/${ownerName}/${repoName}/issues/${issueNum}/ship/${jobId}`
247 );
248 }
249);
250
251// ─── GET — progress page ─────────────────────────────────────────────────────
252
253shipAgentRoutes.get(
254 "/:owner/:repo/issues/:issueNumber/ship/:jobId",
255 softAuth,
256 requireRepoAccess("read"),
257 async (c) => {
258 const { owner: ownerName, repo: repoName } = c.req.param();
259 const jobId = c.req.param("jobId");
260 const user = c.get("user");
261
262 const job = getShipJob(jobId);
263 if (!job) {
264 return c.html(
265 <Layout title="Job not found" user={user}>
266 <div style="padding:40px;text-align:center;color:var(--text-muted)">
267 Ship Agent job not found. It may have been cleaned up after a server restart.
268 </div>
269 </Layout>,
270 404
271 );
272 }
273
274 const issueNum = job.issueNumber;
275 const isTerminal = job.status === "done" || job.status === "failed";
276 const currentPhaseIdx = PHASE_ORDER[job.status] ?? 0;
277
278 return c.html(
279 <Layout
280 title={`Ship Agent — ${job.issueTitle}`}
281 user={user}
282 >
283 <style dangerouslySetInnerHTML={{ __html: shipStyles }} />
284
285 <div class="ship-hero">
286 <h1 class="ship-title">
287 AI is shipping:{" "}
288 <span style="color:var(--accent)">{job.issueTitle}</span>
289 </h1>
290 <p class="ship-subtitle">
291 Issue #{issueNum} · {ownerName}/{repoName} ·{" "}
292 <a href={`/${ownerName}/${repoName}/issues/${issueNum}`}>
293 Back to issue
294 </a>
295 </p>
296 </div>
297
298 {/* Phase progress pills */}
299 <div class="ship-phases">
300 {PHASES.map((phase) => {
301 const phaseIdx = PHASE_ORDER[phase.key];
302 const isCurrent = phase.key === job.status;
303 const isDone = phaseIdx < currentPhaseIdx && job.status !== "failed";
304 const isFailed = job.status === "failed" && phase.key === "done";
305 let cls = "ship-phase";
306 if (isDone) cls += " is-done";
307 else if (isCurrent) cls += " is-active";
308 else if (isFailed) cls += " is-failed";
309 return (
310 <span class={cls}>
311 {isDone ? "✓ " : isCurrent ? "⟳ " : ""}
312 {phase.label}
313 </span>
314 );
315 })}
316 </div>
317
318 {/* Live log */}
319 <div class="ship-log" id="ship-log">
320 {job.log.length === 0 ? (
321 <span class="ship-log-entry">Initialising…</span>
322 ) : (
323 job.log.map((entry) => (
324 <span
325 class={`ship-log-entry${entry.includes("FAILED") ? " is-error" : ""}`}
326 >
327 {entry}
328 </span>
329 ))
330 )}
331 </div>
332
333 {/* Result block */}
334 {job.status === "done" && job.prNumber && (
335 <div class="ship-result is-done">
336 <strong>Ship Agent completed!</strong> Changes are in PR #{job.prNumber} and ready for review.
337 <br />
338 <a href={`/${ownerName}/${repoName}/pulls/${job.prNumber}`} class="ship-pr-link">
339 View PR #{job.prNumber}
340 </a>
341 </div>
342 )}
343 {job.status === "failed" && (
344 <div class="ship-result is-failed">
345 <strong>Ship Agent failed.</strong>{" "}
346 {job.error}
347 <br />
348 <form method="post" action={`/${ownerName}/${repoName}/issues/${issueNum}/ship`} style="display:inline">
349 <button type="submit" class="btn btn-primary" style="margin-top:10px">
350 Try again
351 </button>
352 </form>
353 </div>
354 )}
355
356 {/* Polling script — auto-refreshes every 3s while job is running */}
357 <script
358 dangerouslySetInnerHTML={{
359 __html: `
360(function() {
361 var logEl = document.getElementById('ship-log');
362 if (logEl) logEl.scrollTop = logEl.scrollHeight;
363 ${!isTerminal ? `setTimeout(function(){ window.location.reload(); }, 3000);` : ""}
364})();
365`,
366 }}
367 />
368 </Layout>
369 );
370 }
371);
372
373// ─── GET — JSON status endpoint ────────────────────────────────────────────────
374
375shipAgentRoutes.get(
376 "/:owner/:repo/issues/:issueNumber/ship/:jobId/status",
377 softAuth,
378 requireRepoAccess("read"),
379 async (c) => {
380 const jobId = c.req.param("jobId");
381 const job = getShipJob(jobId);
382 if (!job) {
383 return c.json({ error: "Job not found" }, 404);
384 }
385 return c.json({
386 id: job.id,
387 status: job.status,
388 plan: job.plan,
389 branchName: job.branchName,
390 prNumber: job.prNumber,
391 prUrl: job.prUrl,
392 log: job.log,
393 error: job.error,
394 createdAt: job.createdAt,
395 completedAt: job.completedAt,
396 });
397 }
398);
399
400export default shipAgentRoutes;
0401