Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsxBlame401 lines · 2 contributors
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";
fc623bfccantynz-alt10import { parseIdNumber } from "../lib/route-params";
f928118Claude11import { eq, and } from "drizzle-orm";
12import { db } from "../db";
13import { issues, repositories, users } from "../db/schema";
14import { Layout } from "../views/layout";
15import { softAuth, requireAuth } from "../middleware/auth";
16import { requireRepoAccess } from "../middleware/repo-access";
17import type { AuthEnv } from "../middleware/auth";
18import { startShipJob, getShipJob } from "../lib/ship-agent";
19import { isAiAvailable } from "../lib/ai-client";
20
21const shipAgentRoutes = new Hono<AuthEnv>();
22
23// ─── Styles ──────────────────────────────────────────────────────────────────
24
25const shipStyles = `
26 .ship-hero {
27 position: relative;
28 margin: 4px 0 24px;
29 padding: 28px 32px;
30 background: var(--bg-elevated);
31 border: 1px solid var(--border);
32 border-radius: 16px;
33 overflow: hidden;
34 }
35 .ship-hero::before {
36 content: '';
37 position: absolute;
38 top: 0; left: 0; right: 0;
39 height: 2px;
6fd5915Claude40 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
f928118Claude41 opacity: 0.7;
42 pointer-events: none;
43 }
44 .ship-title {
45 font-family: var(--font-display);
46 font-size: clamp(22px, 3vw, 30px);
47 font-weight: 700;
48 letter-spacing: -0.022em;
49 color: var(--text-strong);
50 margin: 0 0 8px;
51 }
52 .ship-subtitle {
53 font-size: 15px;
54 color: var(--text-muted);
55 margin: 0;
56 line-height: 1.5;
57 }
58 .ship-phases {
59 display: flex;
60 gap: 8px;
61 flex-wrap: wrap;
62 margin: 20px 0;
63 }
64 .ship-phase {
65 display: inline-flex;
66 align-items: center;
67 gap: 6px;
68 padding: 5px 12px;
69 border-radius: 9999px;
70 font-size: 12.5px;
71 font-weight: 600;
72 border: 1px solid var(--border);
73 background: var(--bg-elevated);
74 color: var(--text-muted);
75 transition: all 120ms ease;
76 }
77 .ship-phase.is-active {
6fd5915Claude78 background: rgba(91,110,232,0.14);
79 border-color: rgba(91,110,232,0.45);
f928118Claude80 color: var(--text-strong);
81 }
82 .ship-phase.is-done {
83 background: rgba(52,211,153,0.1);
84 border-color: rgba(52,211,153,0.35);
e589f77ccantynz-alt85 color: var(--green);
f928118Claude86 }
87 .ship-phase.is-failed {
88 background: rgba(248,113,113,0.1);
89 border-color: rgba(248,113,113,0.35);
e589f77ccantynz-alt90 color: var(--red);
f928118Claude91 }
92 .ship-log {
93 background: var(--bg-secondary);
94 border: 1px solid var(--border);
95 border-radius: 12px;
96 padding: 16px 18px;
97 font-family: var(--font-mono);
98 font-size: 12.5px;
99 line-height: 1.7;
100 color: var(--text-muted);
101 max-height: 400px;
102 overflow-y: auto;
103 white-space: pre-wrap;
104 word-break: break-all;
105 }
106 .ship-log-entry { display: block; }
e589f77ccantynz-alt107 .ship-log-entry.is-error { color: var(--red); }
108 .ship-log-entry.is-done { color: var(--green); }
f928118Claude109 .ship-result {
110 margin-top: 18px;
111 padding: 14px 18px;
112 border-radius: 12px;
113 font-size: 14px;
114 line-height: 1.55;
115 }
116 .ship-result.is-done {
117 background: rgba(52,211,153,0.08);
118 border: 1px solid rgba(52,211,153,0.3);
119 color: var(--text);
120 }
121 .ship-result.is-failed {
122 background: rgba(248,113,113,0.08);
123 border: 1px solid rgba(248,113,113,0.3);
124 color: var(--text);
125 }
126 .ship-pr-link {
127 display: inline-flex;
128 align-items: center;
129 gap: 6px;
130 margin-top: 10px;
131 padding: 8px 16px;
132 border-radius: 8px;
6fd5915Claude133 background: rgba(91,110,232,0.14);
134 border: 1px solid rgba(91,110,232,0.35);
f928118Claude135 color: var(--accent);
136 font-weight: 600;
137 text-decoration: none;
138 font-size: 13.5px;
139 transition: background 120ms;
140 }
6fd5915Claude141 .ship-pr-link:hover { background: rgba(91,110,232,0.22); text-decoration: none; }
f928118Claude142`;
143
144const PHASES: Array<{ key: string; label: string }> = [
145 { key: "planning", label: "Planning" },
146 { key: "reading", label: "Reading" },
147 { key: "coding", label: "Coding" },
148 { key: "committing", label: "Committing" },
149 { key: "opening-pr", label: "Opening PR" },
150 { key: "done", label: "Done" },
151];
152
153const PHASE_ORDER: Record<string, number> = {
154 planning: 0,
155 reading: 1,
156 coding: 2,
157 committing: 3,
158 "opening-pr": 4,
159 done: 5,
160 failed: 6,
161};
162
163// ─── Helper ──────────────────────────────────────────────────────────────────
164
165async function resolveIssue(ownerName: string, repoName: string, issueNum: number) {
166 const [owner] = await db
167 .select()
168 .from(users)
169 .where(eq(users.username, ownerName))
170 .limit(1);
171 if (!owner) return null;
172
173 const [repo] = await db
174 .select()
175 .from(repositories)
176 .where(and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName)))
177 .limit(1);
178 if (!repo) return null;
179
180 const [issue] = await db
181 .select()
182 .from(issues)
183 .where(and(eq(issues.repositoryId, repo.id), eq(issues.number, issueNum)))
184 .limit(1);
185 if (!issue) return null;
186
187 return { owner, repo, issue };
188}
189
190// ─── POST — start ship job ────────────────────────────────────────────────────
191
192shipAgentRoutes.post(
193 "/:owner/:repo/issues/:issueNumber/ship",
194 softAuth,
195 requireAuth,
196 requireRepoAccess("write"),
197 async (c) => {
198 if (!isAiAvailable()) {
199 return c.html(
200 <Layout title="Ship Agent unavailable" user={c.get("user")}>
201 <style dangerouslySetInnerHTML={{ __html: shipStyles }} />
202 <div class="ship-hero">
203 <h1 class="ship-title">Ship Agent unavailable</h1>
204 <p class="ship-subtitle">
205 ANTHROPIC_API_KEY is not configured. Ship Agent requires AI to function.
206 </p>
207 </div>
208 </Layout>,
209 503
210 );
211 }
212
213 const { owner: ownerName, repo: repoName } = c.req.param();
fc623bfccantynz-alt214 const issueNum = (parseIdNumber(c.req.param("issueNumber")) ?? -1);
f928118Claude215 const user = c.get("user")!;
216
217 const resolved = await resolveIssue(ownerName, repoName, issueNum);
218 if (!resolved) {
219 return c.html(
220 <Layout title="Not Found" user={user}>
221 <div style="padding:40px;text-align:center;color:var(--text-muted)">Issue not found.</div>
222 </Layout>,
223 404
224 );
225 }
226
227 let jobId: string;
228 try {
229 jobId = await startShipJob({
230 issueId: resolved.issue.id,
231 repoId: resolved.repo.id,
232 owner: ownerName,
233 repo: repoName,
234 issueNumber: issueNum,
235 issueTitle: resolved.issue.title,
236 issueBody: resolved.issue.body || "",
237 requestedByUserId: user.id,
238 });
239 } catch (err) {
240 const msg = err instanceof Error ? err.message : String(err);
241 return c.redirect(
242 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(`Ship Agent: ${msg}`)}`
243 );
244 }
245
246 return c.redirect(
247 `/${ownerName}/${repoName}/issues/${issueNum}/ship/${jobId}`
248 );
249 }
250);
251
252// ─── GET — progress page ─────────────────────────────────────────────────────
253
254shipAgentRoutes.get(
255 "/:owner/:repo/issues/:issueNumber/ship/:jobId",
256 softAuth,
257 requireRepoAccess("read"),
258 async (c) => {
259 const { owner: ownerName, repo: repoName } = c.req.param();
260 const jobId = c.req.param("jobId");
261 const user = c.get("user");
262
263 const job = getShipJob(jobId);
264 if (!job) {
265 return c.html(
266 <Layout title="Job not found" user={user}>
267 <div style="padding:40px;text-align:center;color:var(--text-muted)">
268 Ship Agent job not found. It may have been cleaned up after a server restart.
269 </div>
270 </Layout>,
271 404
272 );
273 }
274
275 const issueNum = job.issueNumber;
276 const isTerminal = job.status === "done" || job.status === "failed";
277 const currentPhaseIdx = PHASE_ORDER[job.status] ?? 0;
278
279 return c.html(
280 <Layout
281 title={`Ship Agent — ${job.issueTitle}`}
282 user={user}
283 >
284 <style dangerouslySetInnerHTML={{ __html: shipStyles }} />
285
286 <div class="ship-hero">
287 <h1 class="ship-title">
288 AI is shipping:{" "}
289 <span style="color:var(--accent)">{job.issueTitle}</span>
290 </h1>
291 <p class="ship-subtitle">
292 Issue #{issueNum} &middot; {ownerName}/{repoName} &middot;{" "}
293 <a href={`/${ownerName}/${repoName}/issues/${issueNum}`}>
294 Back to issue
295 </a>
296 </p>
297 </div>
298
299 {/* Phase progress pills */}
300 <div class="ship-phases">
301 {PHASES.map((phase) => {
302 const phaseIdx = PHASE_ORDER[phase.key];
303 const isCurrent = phase.key === job.status;
304 const isDone = phaseIdx < currentPhaseIdx && job.status !== "failed";
305 const isFailed = job.status === "failed" && phase.key === "done";
306 let cls = "ship-phase";
307 if (isDone) cls += " is-done";
308 else if (isCurrent) cls += " is-active";
309 else if (isFailed) cls += " is-failed";
310 return (
311 <span class={cls}>
312 {isDone ? "✓ " : isCurrent ? "⟳ " : ""}
313 {phase.label}
314 </span>
315 );
316 })}
317 </div>
318
319 {/* Live log */}
320 <div class="ship-log" id="ship-log">
321 {job.log.length === 0 ? (
322 <span class="ship-log-entry">Initialising…</span>
323 ) : (
324 job.log.map((entry) => (
325 <span
326 class={`ship-log-entry${entry.includes("FAILED") ? " is-error" : ""}`}
327 >
328 {entry}
329 </span>
330 ))
331 )}
332 </div>
333
334 {/* Result block */}
335 {job.status === "done" && job.prNumber && (
336 <div class="ship-result is-done">
337 <strong>Ship Agent completed!</strong> Changes are in PR #{job.prNumber} and ready for review.
338 <br />
339 <a href={`/${ownerName}/${repoName}/pulls/${job.prNumber}`} class="ship-pr-link">
340 View PR #{job.prNumber}
341 </a>
342 </div>
343 )}
344 {job.status === "failed" && (
345 <div class="ship-result is-failed">
346 <strong>Ship Agent failed.</strong>{" "}
347 {job.error}
348 <br />
349 <form method="post" action={`/${ownerName}/${repoName}/issues/${issueNum}/ship`} style="display:inline">
350 <button type="submit" class="btn btn-primary" style="margin-top:10px">
351 Try again
352 </button>
353 </form>
354 </div>
355 )}
356
357 {/* Polling script — auto-refreshes every 3s while job is running */}
358 <script
359 dangerouslySetInnerHTML={{
360 __html: `
361(function() {
362 var logEl = document.getElementById('ship-log');
363 if (logEl) logEl.scrollTop = logEl.scrollHeight;
364 ${!isTerminal ? `setTimeout(function(){ window.location.reload(); }, 3000);` : ""}
365})();
366`,
367 }}
368 />
369 </Layout>
370 );
371 }
372);
373
374// ─── GET — JSON status endpoint ────────────────────────────────────────────────
375
376shipAgentRoutes.get(
377 "/:owner/:repo/issues/:issueNumber/ship/:jobId/status",
378 softAuth,
379 requireRepoAccess("read"),
380 async (c) => {
381 const jobId = c.req.param("jobId");
382 const job = getShipJob(jobId);
383 if (!job) {
384 return c.json({ error: "Job not found" }, 404);
385 }
386 return c.json({
387 id: job.id,
388 status: job.status,
389 plan: job.plan,
390 branchName: job.branchName,
391 prNumber: job.prNumber,
392 prUrl: job.prUrl,
393 log: job.log,
394 error: job.error,
395 createdAt: job.createdAt,
396 completedAt: job.completedAt,
397 });
398 }
399);
400
401export default shipAgentRoutes;