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.tsx

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.tsxBlame400 lines · 1 contributor
f928118Claude1/**
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;
6fd5915Claude39 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
f928118Claude40 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 {
6fd5915Claude77 background: rgba(91,110,232,0.14);
78 border-color: rgba(91,110,232,0.45);
f928118Claude79 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;
6fd5915Claude132 background: rgba(91,110,232,0.14);
133 border: 1px solid rgba(91,110,232,0.35);
f928118Claude134 color: var(--accent);
135 font-weight: 600;
136 text-decoration: none;
137 font-size: 13.5px;
138 transition: background 120ms;
139 }
6fd5915Claude140 .ship-pr-link:hover { background: rgba(91,110,232,0.22); text-decoration: none; }
f928118Claude141`;
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} &middot; {ownerName}/{repoName} &middot;{" "}
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;