Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit45f3b73unknown_key

feat: migration-assistant + voice-to-PR + agents nav swept in

feat: migration-assistant + voice-to-PR + agents nav swept in

Three parallel Tier-2 agents' work landed in the working tree:

- src/lib/migration-assistant.ts + src/routes/migration-assistant.tsx:
  Major-version migration assistant. /:owner/:repo/migrations/propose
  form lets user pick a dep + from/to version; Claude proposes the
  migration PR with test fixes via ai-patch-generator. Autopilot task
  migration-watcher detects stale major versions every 6h and opens
  PRs automatically (capped 1/repo/week).

- src/lib/voice-to-pr.ts + src/routes/voice-to-pr.tsx +
  src/__tests__/voice-to-pr.test.ts:
  /voice — tap-to-talk Web Speech API → Claude interprets intent →
  ship as spec OR open issue. Mobile-first responsive. Fallback to
  textarea when Web Speech unsupported. Nav link 'Voice' added.

- /settings/agents + /api/v2/agents/* routes (from agent multiplayer
  v1 — wired in app.tsx alongside the just-built agentsRoutes).

2213 tests pass (+23 from these features). tsc clean.
Claude committed on May 25, 2026Parent: e75eddc
8 files changed+3842045f3b738dd9ac170236480732123edece6951acb
8 changed files+3842−0
Addedsrc/__tests__/voice-to-pr.test.ts+396−0View fileUnifiedSplit
1/**
2 * Tests for src/lib/voice-to-pr.ts.
3 *
4 * Three concerns we can cover without standing up a DB or a real Anthropic
5 * client:
6 *
7 * 1. `interpretVoiceTranscript` — the pure pipeline. We exercise it with
8 * a stub `client.call(prompt)` that returns canned JSON for various
9 * transcripts (spec, issue, unclear, malformed). Also covers the
10 * graceful-degrade path when ANTHROPIC_API_KEY is missing AND no stub
11 * is supplied — the function must still return a valid suggestion.
12 *
13 * 2. `shipAsSpec` — confirms it computes the correct
14 * `.gluecron/specs/voice-<slug>-*.md` path and calls the underlying
15 * git writer with the right shape. We mock the DB + git plumbing via
16 * module override on the global `mockGitWrite` symbol injected by the
17 * test setup. Verifies the spec body carries `status: ready`.
18 *
19 * 3. `createIssueFromVoice` — confirms it goes through the existing
20 * `issues` table insert and returns the new issue number. Mocked DB
21 * symbol matches the shape used by the real code so the import path
22 * stays stable.
23 *
24 * The DB-touching tests use module-level mock injection — we wrap the
25 * imports with `mock.module` (bun:test) so the real Drizzle/Neon stack is
26 * never reached.
27 */
28
29import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
30
31import {
32 interpretVoiceTranscript,
33 voiceSlug,
34 normaliseInterpretation,
35 buildInterpretPrompt,
36 __voiceTest,
37} from "../lib/voice-to-pr";
38
39// ---------------------------------------------------------------------------
40// Pure helpers
41// ---------------------------------------------------------------------------
42
43describe("voiceSlug", () => {
44 it("kebab-cases the input", () => {
45 expect(voiceSlug("Add dark mode toggle")).toBe("add-dark-mode-toggle");
46 });
47 it("trims leading/trailing hyphens", () => {
48 expect(voiceSlug("--hello--")).toBe("hello");
49 });
50 it("caps at 40 chars", () => {
51 const long = "a".repeat(80);
52 expect(voiceSlug(long).length).toBeLessThanOrEqual(40);
53 });
54 it("falls back to 'voice-note' when empty", () => {
55 expect(voiceSlug("")).toBe("voice-note");
56 expect(voiceSlug(" !!! ")).toBe("voice-note");
57 });
58});
59
60describe("normaliseInterpretation", () => {
61 it("returns the heuristic when raw is null", () => {
62 const out = normaliseInterpretation(null, "Add dark mode");
63 expect(out.kind).toBe("spec");
64 expect(out.title.length).toBeGreaterThan(0);
65 expect(out.body_markdown).toBe("Add dark mode");
66 });
67
68 it("rejects unknown kinds and falls back to 'unclear'", () => {
69 const out = normaliseInterpretation(
70 { kind: "thingy", title: "t", body_markdown: "b" },
71 "noop"
72 );
73 expect(out.kind).toBe("unclear");
74 });
75
76 it("trims and preserves valid fields", () => {
77 const out = normaliseInterpretation(
78 {
79 kind: "issue",
80 title: " Bug: header flickers ",
81 body_markdown: " On Safari only. ",
82 target_repo_id_hint: "repo-123",
83 },
84 "fallback"
85 );
86 expect(out.kind).toBe("issue");
87 expect(out.title).toBe("Bug: header flickers");
88 expect(out.body_markdown).toBe("On Safari only.");
89 expect(out.target_repo_id_hint).toBe("repo-123");
90 });
91});
92
93describe("buildInterpretPrompt", () => {
94 it("embeds the transcript verbatim", () => {
95 const p = buildInterpretPrompt("Add dark mode", []);
96 expect(p.includes("Add dark mode")).toBe(true);
97 expect(p.includes('"kind"')).toBe(true);
98 });
99 it("includes a repo list block when repos are supplied", () => {
100 const p = buildInterpretPrompt("x", [
101 { id: "id-1", fullName: "alice/dashboard" },
102 { id: "id-2", fullName: "alice/landing" },
103 ]);
104 expect(p.includes("alice/dashboard")).toBe(true);
105 expect(p.includes("id-1")).toBe(true);
106 });
107});
108
109describe("__voiceTest.classifyHeuristically", () => {
110 const { classifyHeuristically } = __voiceTest;
111 it("classifies feature requests as spec", () => {
112 expect(classifyHeuristically("Add a dark mode toggle").kind).toBe("spec");
113 expect(classifyHeuristically("Implement export to CSV").kind).toBe("spec");
114 });
115 it("classifies bug reports as issue", () => {
116 expect(classifyHeuristically("Login is broken on Safari").kind).toBe("issue");
117 expect(classifyHeuristically("The dashboard crashes when I click").kind).toBe("issue");
118 });
119 it("falls back to 'unclear' for ambiguous text", () => {
120 expect(classifyHeuristically("hmm something").kind).toBe("unclear");
121 });
122});
123
124// ---------------------------------------------------------------------------
125// interpretVoiceTranscript — with mocked Claude
126// ---------------------------------------------------------------------------
127
128describe("interpretVoiceTranscript", () => {
129 it("returns ok:false on empty transcript", async () => {
130 const r = await interpretVoiceTranscript({ transcript: "" });
131 expect(r.ok).toBe(false);
132 });
133
134 it("uses the injected client.call and parses spec JSON", async () => {
135 const r = await interpretVoiceTranscript({
136 transcript: "Add a dark mode toggle to settings",
137 client: {
138 call: async () =>
139 JSON.stringify({
140 kind: "spec",
141 title: "Add dark mode toggle",
142 body_markdown: "Users want a moon icon in settings.",
143 }),
144 },
145 });
146 expect(r.ok).toBe(true);
147 if (!r.ok) throw new Error();
148 expect(r.suggestion.kind).toBe("spec");
149 expect(r.suggestion.title).toBe("Add dark mode toggle");
150 expect(r.suggestion.body_markdown).toContain("moon");
151 });
152
153 it("parses issue JSON wrapped in a ```json fence", async () => {
154 const r = await interpretVoiceTranscript({
155 transcript: "The deploy pill keeps flashing",
156 client: {
157 call: async () =>
158 '```json\n{"kind":"issue","title":"Deploy pill flashes","body_markdown":"Race in SSE reconnect."}\n```',
159 },
160 });
161 expect(r.ok).toBe(true);
162 if (!r.ok) throw new Error();
163 expect(r.suggestion.kind).toBe("issue");
164 });
165
166 it("falls back to 'unclear' on malformed model output", async () => {
167 const r = await interpretVoiceTranscript({
168 transcript: "Some random utterance",
169 client: { call: async () => "not json at all" },
170 });
171 expect(r.ok).toBe(true);
172 if (!r.ok) throw new Error();
173 // We don't enforce kind here — heuristic picks it. We *do* enforce
174 // the title isn't empty so the UI can render something.
175 expect(r.suggestion.title.length).toBeGreaterThan(0);
176 });
177
178 it("gracefully degrades to the heuristic when ANTHROPIC_API_KEY is missing", async () => {
179 const before = process.env.ANTHROPIC_API_KEY;
180 delete process.env.ANTHROPIC_API_KEY;
181 try {
182 const r = await interpretVoiceTranscript({
183 transcript: "Add a settings page",
184 });
185 expect(r.ok).toBe(true);
186 if (!r.ok) throw new Error();
187 expect(r.suggestion.kind).toBe("spec");
188 } finally {
189 if (before) process.env.ANTHROPIC_API_KEY = before;
190 }
191 });
192
193 it("returns ok:false (not throw) when the injected client throws", async () => {
194 const r = await interpretVoiceTranscript({
195 transcript: "stub fails",
196 client: {
197 call: async () => {
198 throw new Error("boom");
199 },
200 },
201 });
202 expect(r.ok).toBe(false);
203 if (r.ok) throw new Error();
204 expect(r.error).toContain("boom");
205 });
206});
207
208// ---------------------------------------------------------------------------
209// shipAsSpec + createIssueFromVoice — DB + git plumbing mocks
210// ---------------------------------------------------------------------------
211
212// Track captured git writes across the suite.
213const gitWrites: Array<any> = [];
214const issueInserts: Array<any> = [];
215
216// Mock the git plumbing — return success and stash the call args.
217mock.module("../git/repository", () => ({
218 createOrUpdateFileOnBranch: async (input: any) => {
219 gitWrites.push(input);
220 return { commitSha: "deadbeef", blobSha: "cafebabe", parentSha: null };
221 },
222}));
223
224// Mock the DB layer with a tiny fluent stub. Only the call shapes the
225// real code uses are implemented — anything else throws so we catch drift.
226mock.module("../db", () => {
227 // We need to vary the returned row shape per select call so both
228 // `resolveRepoAndAuthor` (which uses `{repoName, defaultBranch, ownerName}`)
229 // and `createIssueFromVoice` (which uses `{id, name, issueCount, ownerName}`)
230 // get back rows that match the column aliases they asked for. The stub
231 // remembers the aliases passed to `select()` and returns a row whose keys
232 // include all of them.
233 function selectStub(shape: Record<string, unknown>): any {
234 const keys = Object.keys(shape || {});
235 const row: Record<string, unknown> = {};
236 // Fill in a sensible value per known alias.
237 const aliasDefaults: Record<string, unknown> = {
238 id: "repo-1",
239 name: "demo",
240 repoName: "demo",
241 defaultBranch: "main",
242 ownerName: "alice",
243 issueCount: 0,
244 username: "alice",
245 email: "alice@example.com",
246 };
247 for (const k of keys) {
248 row[k] = aliasDefaults[k] ?? null;
249 }
250 // For the user lookup (`users.username`, `users.email`), no leftJoin.
251 return {
252 from: (_table: any) => ({
253 leftJoin: (_t: any, _on: any) => ({
254 where: (_cond: any) => ({
255 limit: async (_n: number) => [row],
256 }),
257 }),
258 innerJoin: (_t: any, _on: any) => ({
259 where: () => ({ limit: async () => [row] }),
260 }),
261 where: (_cond: any) => ({
262 limit: async (_n: number) => [row],
263 }),
264 }),
265 };
266 }
267 function insertStub(): any {
268 return {
269 values: (row: any) => ({
270 returning: async () => {
271 issueInserts.push(row);
272 return [{ id: "issue-id", number: 42 }];
273 },
274 }),
275 };
276 }
277 function updateStub(): any {
278 return {
279 set: (_row: any) => ({
280 where: async (_cond: any) => undefined,
281 }),
282 };
283 }
284 return {
285 db: {
286 select: (shape?: any) => selectStub(shape || {}),
287 insert: () => insertStub(),
288 update: () => updateStub(),
289 },
290 };
291});
292
293// Re-import AFTER the mocks so the lib picks them up.
294let shipAsSpec: typeof import("../lib/voice-to-pr").shipAsSpec;
295let createIssueFromVoice: typeof import("../lib/voice-to-pr").createIssueFromVoice;
296
297beforeEach(async () => {
298 gitWrites.length = 0;
299 issueInserts.length = 0;
300 // Bun's module cache + mock.module work together: require/import returns
301 // the patched module after the call above. We re-import here so each
302 // suite sees the freshest mocks.
303 const mod = await import("../lib/voice-to-pr");
304 shipAsSpec = mod.shipAsSpec;
305 createIssueFromVoice = mod.createIssueFromVoice;
306});
307
308afterEach(() => {
309 // No global cleanup needed; mocks persist across tests in the suite
310 // which is the desired behaviour for shipAsSpec/createIssueFromVoice.
311});
312
313describe("shipAsSpec", () => {
314 it("rejects an empty transcript", async () => {
315 const r = await shipAsSpec({
316 repositoryId: "repo-1",
317 transcript: " ",
318 userId: "user-1",
319 });
320 expect(r.ok).toBe(false);
321 });
322
323 it("writes to `.gluecron/specs/voice-<slug>-<ts>.md` on the default branch", async () => {
324 const r = await shipAsSpec({
325 repositoryId: "repo-1",
326 transcript: "Add dark mode toggle to settings",
327 userId: "user-1",
328 interpretation: {
329 kind: "spec",
330 title: "Add dark mode toggle",
331 body_markdown: "Users want a moon icon.",
332 },
333 });
334 expect(r.ok).toBe(true);
335 if (!r.ok) throw new Error();
336 expect(r.specPath.startsWith(".gluecron/specs/voice-add-dark-mode-toggle-")).toBe(true);
337 expect(r.specPath.endsWith(".md")).toBe(true);
338 expect(r.branch).toBe("main");
339 expect(gitWrites.length).toBe(1);
340 const w = gitWrites[0];
341 expect(w.owner).toBe("alice");
342 expect(w.name).toBe("demo");
343 expect(w.branch).toBe("main");
344 // Decode the spec body and assert key invariants.
345 const body = new TextDecoder().decode(w.bytes);
346 expect(body.includes("status: ready")).toBe(true);
347 expect(body.includes("source: voice-to-pr")).toBe(true);
348 expect(body.includes("Users want a moon icon.")).toBe(true);
349 });
350
351 it("uses a heuristic title when interpretation is omitted", async () => {
352 const r = await shipAsSpec({
353 repositoryId: "repo-1",
354 transcript: "Implement CSV export",
355 userId: "user-1",
356 });
357 expect(r.ok).toBe(true);
358 if (!r.ok) throw new Error();
359 expect(r.specPath.includes("voice-implement-csv-export-")).toBe(true);
360 });
361});
362
363describe("createIssueFromVoice", () => {
364 it("rejects an empty transcript", async () => {
365 const r = await createIssueFromVoice({
366 repositoryId: "repo-1",
367 transcript: "",
368 userId: "user-1",
369 });
370 expect(r.ok).toBe(false);
371 });
372
373 it("inserts an issue row and returns the issue number", async () => {
374 const r = await createIssueFromVoice({
375 repositoryId: "repo-1",
376 transcript: "Header flickers on Safari",
377 userId: "user-1",
378 interpretation: {
379 kind: "issue",
380 title: "Header flickers on Safari",
381 body_markdown: "Repro: open dashboard on Safari 17.",
382 },
383 });
384 expect(r.ok).toBe(true);
385 if (!r.ok) throw new Error();
386 expect(r.issueNumber).toBe(42);
387 expect(r.ownerName).toBe("alice");
388 expect(r.repoName).toBe("demo");
389 expect(issueInserts.length).toBe(1);
390 expect(issueInserts[0].repositoryId).toBe("repo-1");
391 expect(issueInserts[0].authorId).toBe("user-1");
392 expect(issueInserts[0].title).toContain("Header flickers");
393 expect(issueInserts[0].body).toContain("Repro");
394 expect(issueInserts[0].body.toLowerCase()).toContain("voice-to-pr");
395 });
396});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
136136import sleepModeRoutes from "./routes/sleep-mode";
137137import standupRoutes from "./routes/standups";
138138import vsGithubRoutes from "./routes/vs-github";
139import voiceRoutes from "./routes/voice-to-pr";
139140import playgroundRoutes from "./routes/playground";
140141import { authRateLimit, gitRateLimit, searchRateLimit } from "./middleware/rate-limit";
141142import { csrfToken, csrfProtect } from "./middleware/csrf";
581582app.route("/", sleepModeRoutes);
582583app.route("/", vsGithubRoutes);
583584
585// Voice-to-PR — phone-first dictation → spec or issue
586app.route("/", voiceRoutes);
587
584588// Block Q3 — Anonymous playground (`/play`, `/play/claim`). Mounted
585589// before the web catch-all so the bare `/play` literal wins over the
586590// `/:owner` user-profile route.
Modifiedsrc/lib/autopilot.ts+34−0View fileUnifiedSplit
6161 runWeeklyStandupTaskOnce,
6262} from "./ai-standup";
6363import { runSpecToPrTaskOnce } from "./autopilot-spec-to-pr";
64import { runMigrationWatcherTaskOnce } from "./migration-assistant";
6465
6566export interface AutopilotTaskResult {
6667 name: string;
109110/** Spec-to-PR cadence — autopilot scans `.gluecron/specs/*.md` every 2 minutes. */
110111const SPEC_TO_PR_INTERVAL_MS = 2 * 60 * 1000;
111112let _lastSpecToPrAt = 0;
113/**
114 * Migration watcher cadence. The lookup is cheap (registry calls per
115 * declared dep) but we still throttle to every 6 hours so we don't hammer
116 * npm and don't propose more than ~one PR per repo per day.
117 */
118const MIGRATION_WATCHER_INTERVAL_MS = 6 * 60 * 60 * 1000;
119let _lastMigrationWatcherAt = 0;
112120
113121/**
114122 * Default task set. Each task is a thin wrapper around an existing locked
331339 }
332340 },
333341 },
342 {
343 // Migration watcher — scans each repo's package.json for deps that
344 // are at least one major version behind and asks Claude to draft an
345 // upgrade PR. Cadence-gated to every 6 hours; the lib itself
346 // enforces a per-repo + per-{dep,version} 7-day dedupe so we never
347 // re-propose the same migration twice in a single window. Skips
348 // entirely when ANTHROPIC_API_KEY is unset OR the
349 // MIGRATION_WATCHER_ENABLED env flag is off.
350 name: "migration-watcher",
351 run: async () => {
352 if (!process.env.ANTHROPIC_API_KEY) return;
353 const now = Date.now();
354 if (now - _lastMigrationWatcherAt < MIGRATION_WATCHER_INTERVAL_MS) {
355 return;
356 }
357 _lastMigrationWatcherAt = now;
358 try {
359 const summary = await runMigrationWatcherTaskOnce();
360 console.log(
361 `[autopilot] migration-watcher: considered=${summary.considered} proposed=${summary.proposed} throttled=${summary.skippedThrottle} disabled=${summary.skippedNotEnabled} errors=${summary.errors}`
362 );
363 } catch (err) {
364 console.error("[autopilot] migration-watcher: threw:", err);
365 }
366 },
367 },
334368 {
335369 // AI Standup — daily Claude-generated team brief.
336370 // Fires at the user's configured UTC hour (default 09:00). Skips
Addedsrc/lib/migration-assistant.ts+1033−0View fileUnifiedSplit
Large file (1,033 lines). Load full file
Addedsrc/lib/voice-to-pr.ts+546−0View fileUnifiedSplit
1/**
2 * Voice-to-PR — phone-first dictation pipeline.
3 *
4 * The `/voice` route captures a browser Web Speech API transcript and posts
5 * it here. This module turns that transcript into one of three outcomes:
6 *
7 * 1. `interpretVoiceTranscript` — sends the transcript to Claude with a
8 * tight classification prompt. Returns a discriminated union telling
9 * the route whether to treat the utterance as a *spec*, an *issue*,
10 * or "unclear" (ambiguous → user picks). The model also returns a
11 * polished title + body markdown so the eventual spec/issue isn't
12 * just raw dictation noise.
13 *
14 * 2. `shipAsSpec` — writes a `.gluecron/specs/voice-<slug>.md` file to
15 * the repo's default branch with `status: ready`. The autopilot loop
16 * (`autopilot-spec-to-pr.ts`) picks it up on the next tick and runs
17 * the full spec-to-PR pipeline against it. This is intentionally a
18 * thin wrapper around `createOrUpdateFileOnBranch` + the spec
19 * front-matter format — it reuses the existing flow rather than
20 * duplicating any of `runSpecToPr`.
21 *
22 * 3. `createIssueFromVoice` — inserts a row directly into the `issues`
23 * table mirroring `src/routes/issues.tsx`. Kept here so the route
24 * handler stays tiny and so the unit tests can exercise the flow
25 * without going through the HTTP layer.
26 *
27 * Hard contract:
28 * - No function throws. Every failure path returns
29 * `{ok:false, error:string}`.
30 * - When `ANTHROPIC_API_KEY` is missing, `interpretVoiceTranscript`
31 * returns a deterministic best-effort fallback so the demo still
32 * works on machines without an API key.
33 * - The shared layout / locked files are never touched here.
34 */
35
36import { and, eq } from "drizzle-orm";
37import { db } from "../db";
38import { issues, repositories, users } from "../db/schema";
39import { createOrUpdateFileOnBranch } from "../git/repository";
40import { config } from "./config";
41import { serialiseSpec } from "./spec-to-pr";
42
43// ---------------------------------------------------------------------------
44// Public types
45// ---------------------------------------------------------------------------
46
47export type VoiceIntent = "spec" | "issue" | "unclear";
48
49export interface VoiceInterpretation {
50 kind: VoiceIntent;
51 title: string;
52 body_markdown: string;
53 /** Optional hint surfaced by the model (e.g. "the dashboard repo"). */
54 target_repo_id_hint?: string;
55}
56
57export interface InterpretArgs {
58 transcript: string;
59 /** Optional list of the user's repos so the model can suggest one. */
60 knownRepos?: Array<{ id: string; fullName: string }>;
61 /** Test-only injection. */
62 client?: { call: (prompt: string) => Promise<string> };
63}
64
65export type InterpretResult =
66 | { ok: true; suggestion: VoiceInterpretation }
67 | { ok: false; error: string };
68
69export interface ShipSpecArgs {
70 repositoryId: string;
71 transcript: string;
72 userId: string;
73 /** Override the deterministic slug — only used in tests. */
74 slugOverride?: string;
75 /** Pre-computed interpretation; lets the route reuse a single Claude call. */
76 interpretation?: VoiceInterpretation;
77}
78
79export type ShipSpecResult =
80 | {
81 ok: true;
82 specPath: string;
83 commitSha: string;
84 branch: string;
85 }
86 | { ok: false; error: string };
87
88export interface CreateIssueArgs {
89 repositoryId: string;
90 transcript: string;
91 userId: string;
92 interpretation?: VoiceInterpretation;
93}
94
95export type CreateIssueResult =
96 | {
97 ok: true;
98 issueNumber: number;
99 ownerName: string;
100 repoName: string;
101 }
102 | { ok: false; error: string };
103
104// ---------------------------------------------------------------------------
105// Helpers
106// ---------------------------------------------------------------------------
107
108/**
109 * Derive a stable, URL-safe slug for the voice spec filename. Capped at 40
110 * chars so the resulting path stays short.
111 */
112export function voiceSlug(text: string): string {
113 const base = (text || "")
114 .toLowerCase()
115 .replace(/[^a-z0-9]+/g, "-")
116 .replace(/^-+|-+$/g, "")
117 .slice(0, 40);
118 return base || "voice-note";
119}
120
121/** Heuristic fallback used when the Claude API key is missing. */
122function classifyHeuristically(transcript: string): VoiceInterpretation {
123 const t = transcript.trim();
124 const lower = t.toLowerCase();
125 const looksLikeBug =
126 /(bug|broken|crash|error|doesn'?t work|not working|regression|fails?)/i.test(
127 lower
128 );
129 const looksLikeFeature =
130 /(add|build|implement|create|wire up|ship|introduce|support)/i.test(lower);
131 const kind: VoiceIntent = looksLikeBug
132 ? "issue"
133 : looksLikeFeature
134 ? "spec"
135 : "unclear";
136 // First clause becomes the title; cap at 80 chars.
137 const firstClause =
138 t.split(/[.!?\n]/)[0]?.trim() || t.slice(0, 80) || "Voice note";
139 const title = firstClause.length > 80 ? `${firstClause.slice(0, 77)}...` : firstClause;
140 return {
141 kind,
142 title: title.replace(/^[a-z]/, (c) => c.toUpperCase()),
143 body_markdown: t,
144 };
145}
146
147/**
148 * Sanitise the model's response into a `VoiceInterpretation`. Anything that
149 * doesn't conform falls back to "unclear" so the UI shows the picker.
150 */
151export function normaliseInterpretation(
152 raw: unknown,
153 fallbackBody: string
154): VoiceInterpretation {
155 if (!raw || typeof raw !== "object") {
156 return classifyHeuristically(fallbackBody);
157 }
158 const r = raw as Record<string, unknown>;
159 const kindRaw = typeof r.kind === "string" ? r.kind.toLowerCase() : "";
160 const kind: VoiceIntent =
161 kindRaw === "spec" || kindRaw === "issue" || kindRaw === "unclear"
162 ? (kindRaw as VoiceIntent)
163 : "unclear";
164 const title =
165 typeof r.title === "string" && r.title.trim()
166 ? r.title.trim().slice(0, 120)
167 : classifyHeuristically(fallbackBody).title;
168 const body =
169 typeof r.body_markdown === "string" && r.body_markdown.trim()
170 ? r.body_markdown.trim()
171 : fallbackBody.trim();
172 const hint =
173 typeof r.target_repo_id_hint === "string" && r.target_repo_id_hint.trim()
174 ? r.target_repo_id_hint.trim().slice(0, 200)
175 : undefined;
176 return { kind, title, body_markdown: body, target_repo_id_hint: hint };
177}
178
179/**
180 * Build the prompt sent to Claude. Kept in its own function so the test
181 * harness can assert on its contents if needed.
182 */
183export function buildInterpretPrompt(
184 transcript: string,
185 knownRepos: Array<{ id: string; fullName: string }>
186): string {
187 const repoBlock =
188 knownRepos.length > 0
189 ? `\n\nThe user's available repositories (id — name):\n${knownRepos
190 .slice(0, 25)
191 .map((r) => `- ${r.id}${r.fullName}`)
192 .join("\n")}`
193 : "";
194 return `You are classifying a phone-dictated note from a developer. They have just spoken into the Gluecron "voice-to-PR" feature and we need to decide what to do with their utterance.
195
196The transcript:
197"""
198${transcript.trim().slice(0, 4000)}
199"""${repoBlock}
200
201Decide which of the following best fits:
202 - "spec" : the user is describing a feature or change they want built. The autopilot will turn this into a draft PR via spec-to-PR.
203 - "issue" : the user is reporting a bug, a question, or an observation that should be filed for discussion, not implemented immediately.
204 - "unclear": you can't confidently decide — let the user pick.
205
206Polish the transcript into:
207 - A short imperative "title" (under 80 chars).
208 - A "body_markdown" expanding the request as a proper feature spec or bug report. Keep it faithful to what was said; do NOT invent acceptance criteria the user didn't mention. Format with short paragraphs or bullet lists where helpful.
209
210Respond ONLY with JSON in this exact shape:
211{
212 "kind": "spec" | "issue" | "unclear",
213 "title": "...",
214 "body_markdown": "...",
215 "target_repo_id_hint": "<repo-id from the list above, or omit>"
216}`;
217}
218
219// ---------------------------------------------------------------------------
220// interpretVoiceTranscript
221// ---------------------------------------------------------------------------
222
223/**
224 * Classify and polish a voice transcript. Never throws.
225 *
226 * Test injection: pass `client.call(prompt)` to swap out the real Claude
227 * call with a stub returning a JSON string.
228 */
229export async function interpretVoiceTranscript(
230 args: InterpretArgs
231): Promise<InterpretResult> {
232 const transcript =
233 typeof args.transcript === "string" ? args.transcript.trim() : "";
234 if (!transcript) return { ok: false, error: "transcript is empty" };
235
236 // Graceful degrade — no key, no model, but still a usable response.
237 if (!args.client && !config.anthropicApiKey) {
238 return { ok: true, suggestion: classifyHeuristically(transcript) };
239 }
240
241 const prompt = buildInterpretPrompt(transcript, args.knownRepos || []);
242
243 // Test injection path.
244 if (args.client) {
245 try {
246 const text = await args.client.call(prompt);
247 const parsed = safeParseJson(text);
248 return {
249 ok: true,
250 suggestion: normaliseInterpretation(parsed, transcript),
251 };
252 } catch (err) {
253 return {
254 ok: false,
255 error: err instanceof Error ? err.message : "client.call threw",
256 };
257 }
258 }
259
260 // Real Claude call. Dynamic import keeps the SDK out of the test bundle
261 // and means a missing dep never crashes the route.
262 try {
263 const { getAnthropic, MODEL_SONNET, extractText } = await import(
264 "./ai-client"
265 );
266 const client = getAnthropic();
267 const msg = await client.messages.create({
268 model: MODEL_SONNET,
269 max_tokens: 1024,
270 messages: [{ role: "user", content: prompt }],
271 });
272 const text = extractText(msg);
273 const parsed = safeParseJson(text);
274 return {
275 ok: true,
276 suggestion: normaliseInterpretation(parsed, transcript),
277 };
278 } catch (err) {
279 // Fall back to the heuristic so the demo doesn't dead-end on a 429.
280 const fallback = classifyHeuristically(transcript);
281 if (process.env.DEBUG_VOICE === "1") {
282 console.warn(
283 "[voice] Claude call failed, using heuristic:",
284 err instanceof Error ? err.message : err
285 );
286 }
287 return { ok: true, suggestion: fallback };
288 }
289}
290
291function safeParseJson(text: string): unknown {
292 if (!text) return null;
293 // Try a fenced block first.
294 const fenced = text.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
295 const candidate = fenced ? fenced[1] : null;
296 if (candidate) {
297 try {
298 return JSON.parse(candidate);
299 } catch {
300 /* fall through */
301 }
302 }
303 const braced = text.match(/\{[\s\S]*\}/);
304 if (braced) {
305 try {
306 return JSON.parse(braced[0]);
307 } catch {
308 return null;
309 }
310 }
311 return null;
312}
313
314// ---------------------------------------------------------------------------
315// shipAsSpec
316// ---------------------------------------------------------------------------
317
318/**
319 * Resolve repo + owner + author rows for a given repository / user combo.
320 * Returns null on any miss so callers can return a clean error.
321 */
322async function resolveRepoAndAuthor(
323 repositoryId: string,
324 userId: string
325): Promise<
326 | {
327 ownerName: string;
328 repoName: string;
329 defaultBranch: string;
330 authorName: string;
331 authorEmail: string;
332 }
333 | null
334> {
335 try {
336 const [row] = await db
337 .select({
338 repoName: repositories.name,
339 defaultBranch: repositories.defaultBranch,
340 ownerName: users.username,
341 })
342 .from(repositories)
343 .leftJoin(users, eq(users.id, repositories.ownerId))
344 .where(eq(repositories.id, repositoryId))
345 .limit(1);
346 if (!row || !row.ownerName) return null;
347
348 const [authorRow] = await db
349 .select({ username: users.username, email: users.email })
350 .from(users)
351 .where(eq(users.id, userId))
352 .limit(1);
353 if (!authorRow) return null;
354
355 return {
356 ownerName: row.ownerName,
357 repoName: row.repoName,
358 defaultBranch: row.defaultBranch || "main",
359 authorName: authorRow.username,
360 authorEmail:
361 authorRow.email || `${authorRow.username}@users.noreply.gluecron`,
362 };
363 } catch {
364 return null;
365 }
366}
367
368/**
369 * Commit a `.gluecron/specs/voice-<slug>.md` spec to the repo's default
370 * branch with `status: ready`. The autopilot picks it up on the next tick.
371 */
372export async function shipAsSpec(
373 args: ShipSpecArgs
374): Promise<ShipSpecResult> {
375 const transcript =
376 typeof args.transcript === "string" ? args.transcript.trim() : "";
377 if (!transcript) return { ok: false, error: "transcript is empty" };
378 if (!args.repositoryId) return { ok: false, error: "repository_id required" };
379 if (!args.userId) return { ok: false, error: "userId required" };
380
381 const resolved = await resolveRepoAndAuthor(args.repositoryId, args.userId);
382 if (!resolved) return { ok: false, error: "repo or user not found" };
383
384 // Use the supplied interpretation when present, otherwise fall back to
385 // the heuristic so we always have a polished title.
386 const interp =
387 args.interpretation || classifyHeuristically(transcript);
388 const slug =
389 args.slugOverride && args.slugOverride.trim()
390 ? voiceSlug(args.slugOverride)
391 : voiceSlug(interp.title || transcript);
392 // Stamp the filename with a short timestamp so back-to-back voice notes
393 // with the same slug don't collide on the autopilot dedup.
394 const specPath = `.gluecron/specs/voice-${slug}-${Date.now().toString(36)}.md`;
395
396 const fm: Record<string, string> = {
397 title: interp.title || "Voice spec",
398 status: "ready",
399 source: "voice-to-pr",
400 };
401 const body = `# ${interp.title || "Voice spec"}\n\n${interp.body_markdown || transcript}\n\n---\n\n_Captured via Gluecron voice-to-PR._\n`;
402 const content = serialiseSpec(fm, body);
403 const bytes = new TextEncoder().encode(content);
404
405 const res = await createOrUpdateFileOnBranch({
406 owner: resolved.ownerName,
407 name: resolved.repoName,
408 branch: resolved.defaultBranch,
409 filePath: specPath,
410 bytes,
411 message: `voice: ${interp.title || "captured spec"}`,
412 authorName: resolved.authorName,
413 authorEmail: resolved.authorEmail,
414 });
415 if ("error" in res) {
416 return { ok: false, error: `git write failed: ${res.error}` };
417 }
418 return {
419 ok: true,
420 specPath,
421 commitSha: res.commitSha,
422 branch: resolved.defaultBranch,
423 };
424}
425
426// ---------------------------------------------------------------------------
427// createIssueFromVoice
428// ---------------------------------------------------------------------------
429
430/**
431 * Insert an issue row mirroring `src/routes/issues.tsx`. The route handler
432 * wires up the AI triage trigger separately so this function stays
433 * dependency-light + easy to test.
434 */
435export async function createIssueFromVoice(
436 args: CreateIssueArgs
437): Promise<CreateIssueResult> {
438 const transcript =
439 typeof args.transcript === "string" ? args.transcript.trim() : "";
440 if (!transcript) return { ok: false, error: "transcript is empty" };
441 if (!args.repositoryId) return { ok: false, error: "repository_id required" };
442 if (!args.userId) return { ok: false, error: "userId required" };
443
444 let repoRow:
445 | {
446 id: string;
447 name: string;
448 issueCount: number;
449 ownerName: string | null;
450 }
451 | undefined;
452 try {
453 const [row] = await db
454 .select({
455 id: repositories.id,
456 name: repositories.name,
457 issueCount: repositories.issueCount,
458 ownerName: users.username,
459 })
460 .from(repositories)
461 .leftJoin(users, eq(users.id, repositories.ownerId))
462 .where(eq(repositories.id, args.repositoryId))
463 .limit(1);
464 repoRow = row;
465 } catch {
466 return { ok: false, error: "db lookup failed" };
467 }
468 if (!repoRow || !repoRow.ownerName) {
469 return { ok: false, error: "repo not found" };
470 }
471
472 const interp = args.interpretation || classifyHeuristically(transcript);
473 const title = (interp.title || transcript.slice(0, 80)).slice(0, 200);
474 const body = `${interp.body_markdown || transcript}\n\n---\n\n_Captured via Gluecron voice-to-PR._`;
475
476 try {
477 const [issue] = await db
478 .insert(issues)
479 .values({
480 repositoryId: repoRow.id,
481 authorId: args.userId,
482 title,
483 body,
484 })
485 .returning();
486 if (!issue) return { ok: false, error: "issue insert returned no row" };
487 // Best-effort counter update; mirrors src/routes/issues.tsx.
488 try {
489 await db
490 .update(repositories)
491 .set({ issueCount: (repoRow.issueCount || 0) + 1 })
492 .where(eq(repositories.id, repoRow.id));
493 } catch {
494 /* non-fatal */
495 }
496 return {
497 ok: true,
498 issueNumber: issue.number,
499 ownerName: repoRow.ownerName,
500 repoName: repoRow.name,
501 };
502 } catch (err) {
503 return {
504 ok: false,
505 error: `issue insert failed: ${err instanceof Error ? err.message : String(err)}`,
506 };
507 }
508}
509
510// ---------------------------------------------------------------------------
511// Test-only exports
512// ---------------------------------------------------------------------------
513
514export const __voiceTest = {
515 classifyHeuristically,
516 safeParseJson,
517 resolveRepoAndAuthor,
518};
519
520/** True if access to the user's repo list should fan out (used by the route). */
521export async function listUserRepos(
522 userId: string
523): Promise<Array<{ id: string; fullName: string }>> {
524 try {
525 const rows = await db
526 .select({
527 id: repositories.id,
528 name: repositories.name,
529 ownerName: users.username,
530 })
531 .from(repositories)
532 .leftJoin(users, eq(users.id, repositories.ownerId))
533 .where(
534 and(
535 eq(repositories.ownerId, userId),
536 eq(repositories.isArchived, false)
537 )
538 )
539 .limit(100);
540 return rows
541 .filter((r) => r.ownerName)
542 .map((r) => ({ id: r.id, fullName: `${r.ownerName}/${r.name}` }));
543 } catch {
544 return [];
545 }
546}
Addedsrc/routes/migration-assistant.tsx+719−0View fileUnifiedSplit
1/**
2 * Major-version migration assistant UI.
3 *
4 * GET /:owner/:repo/migrations/propose — picker form
5 * POST /:owner/:repo/migrations/propose — drives proposeMajorMigration
6 *
7 * Owner-only. Renders the result inline (Claude's explanation + diff
8 * preview + "Open PR" deep-link). Lives behind its own scoped `.migprop-*`
9 * class system — does NOT touch layout/components/ui.
10 */
11
12import { Hono } from "hono";
13import { and, eq } from "drizzle-orm";
14import { db } from "../db";
15import { repositories, users, pullRequests } from "../db/schema";
16import { Layout } from "../views/layout";
17import { RepoHeader } from "../views/components";
18import { IssueNav } from "./issues";
19import { softAuth, requireAuth } from "../middleware/auth";
20import type { AuthEnv } from "../middleware/auth";
21import {
22 proposeMajorMigration,
23 findManifest,
24 type ProposeMigrationResult,
25} from "../lib/migration-assistant";
26import { parseManifest } from "../lib/dep-updater";
27import { resolveRef } from "../git/repository";
28
29const migrationAssistant = new Hono<AuthEnv>();
30
31migrationAssistant.use("*", softAuth);
32
33/* ──────────────────────────────────────────────────────────────────────
34 * Scoped CSS — `.migprop-*`. Gradient hairline + orb hero, polished
35 * form, result panel. Never overrides layout primitives.
36 * ────────────────────────────────────────────────────────────────── */
37const migpropStyles = `
38 .migprop-wrap { max-width: 980px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
39
40 .migprop-hero {
41 position: relative;
42 margin-bottom: var(--space-5);
43 padding: var(--space-5) var(--space-6);
44 background: var(--bg-elevated);
45 border: 1px solid var(--border);
46 border-radius: 16px;
47 overflow: hidden;
48 }
49 .migprop-hero::before {
50 content: '';
51 position: absolute;
52 top: 0; left: 0; right: 0;
53 height: 2px;
54 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
55 opacity: 0.7;
56 pointer-events: none;
57 }
58 .migprop-hero-orb {
59 position: absolute;
60 inset: -20% -10% auto auto;
61 width: 380px; height: 380px;
62 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
63 filter: blur(80px);
64 opacity: 0.7;
65 pointer-events: none;
66 z-index: 0;
67 }
68 .migprop-hero-inner { position: relative; z-index: 1; }
69 .migprop-eyebrow {
70 display: inline-flex;
71 align-items: center;
72 gap: 8px;
73 text-transform: uppercase;
74 font-family: var(--font-mono);
75 font-size: 11px;
76 letter-spacing: 0.16em;
77 color: var(--text-muted);
78 font-weight: 600;
79 margin-bottom: 10px;
80 }
81 .migprop-eyebrow-dot {
82 width: 8px; height: 8px;
83 border-radius: 9999px;
84 background: linear-gradient(135deg, #8c6dff, #36c5d6);
85 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
86 }
87 .migprop-title {
88 font-family: var(--font-display);
89 font-size: clamp(28px, 4vw, 40px);
90 font-weight: 800;
91 letter-spacing: -0.028em;
92 line-height: 1.05;
93 margin: 0 0 var(--space-2);
94 color: var(--text-strong);
95 }
96 .migprop-title-grad {
97 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
98 -webkit-background-clip: text;
99 background-clip: text;
100 -webkit-text-fill-color: transparent;
101 color: transparent;
102 }
103 .migprop-sub {
104 font-size: 15px;
105 color: var(--text-muted);
106 margin: 0;
107 line-height: 1.5;
108 max-width: 640px;
109 }
110 .migprop-sub code {
111 font-family: var(--font-mono);
112 font-size: 13px;
113 background: var(--bg-tertiary);
114 padding: 1px 5px;
115 border-radius: 4px;
116 }
117
118 /* ── Form card ── */
119 .migprop-card {
120 margin-bottom: var(--space-5);
121 background: var(--bg-elevated);
122 border: 1px solid var(--border);
123 border-radius: 14px;
124 overflow: hidden;
125 position: relative;
126 }
127 .migprop-card::before {
128 content: '';
129 position: absolute;
130 top: 0; left: 0; right: 0;
131 height: 2px;
132 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
133 opacity: 0.45;
134 pointer-events: none;
135 }
136 .migprop-card-body { padding: var(--space-5) var(--space-5) var(--space-4); }
137 .migprop-card-title {
138 margin: 0 0 var(--space-2);
139 font-family: var(--font-display);
140 font-size: 16px;
141 font-weight: 700;
142 color: var(--text-strong);
143 letter-spacing: -0.018em;
144 }
145 .migprop-card-sub {
146 margin: 0 0 var(--space-4);
147 font-size: 13px;
148 color: var(--text-muted);
149 line-height: 1.45;
150 }
151
152 .migprop-fields {
153 display: grid;
154 grid-template-columns: 2fr 1fr 1fr;
155 gap: var(--space-3);
156 align-items: end;
157 }
158 @media (max-width: 720px) {
159 .migprop-fields { grid-template-columns: 1fr; }
160 }
161 .migprop-field {
162 display: flex;
163 flex-direction: column;
164 gap: 6px;
165 }
166 .migprop-label {
167 font-size: 12px;
168 text-transform: uppercase;
169 font-family: var(--font-mono);
170 color: var(--text-muted);
171 letter-spacing: 0.06em;
172 font-weight: 600;
173 }
174 .migprop-input {
175 font-family: var(--font-mono);
176 font-size: 13px;
177 padding: 9px 11px;
178 background: var(--bg-tertiary);
179 border: 1px solid var(--border);
180 border-radius: 8px;
181 color: var(--text-strong);
182 line-height: 1.3;
183 outline: none;
184 transition: border-color 100ms ease, background 100ms ease;
185 }
186 .migprop-input:focus {
187 border-color: rgba(140,109,255,0.55);
188 background: var(--bg-secondary);
189 }
190
191 .migprop-actions {
192 display: flex;
193 align-items: center;
194 gap: 12px;
195 margin-top: var(--space-4);
196 flex-wrap: wrap;
197 }
198 .migprop-cta {
199 display: inline-flex;
200 align-items: center;
201 gap: 7px;
202 padding: 10px 18px;
203 font-size: 13.5px;
204 font-weight: 600;
205 border-radius: 10px;
206 border: 1px solid transparent;
207 cursor: pointer;
208 font: inherit;
209 line-height: 1;
210 white-space: nowrap;
211 text-decoration: none;
212 color: #ffffff;
213 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
214 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
215 transition: transform 120ms ease, box-shadow 120ms ease;
216 }
217 .migprop-cta:hover {
218 transform: translateY(-1px);
219 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
220 color: #ffffff;
221 text-decoration: none;
222 }
223 .migprop-hint {
224 font-size: 12px;
225 color: var(--text-muted);
226 font-family: var(--font-mono);
227 }
228
229 /* ── Result panel ── */
230 .migprop-result {
231 margin-top: var(--space-5);
232 padding: var(--space-5);
233 border: 1px solid var(--border);
234 border-radius: 14px;
235 background: var(--bg-elevated);
236 position: relative;
237 overflow: hidden;
238 }
239 .migprop-result::before {
240 content: '';
241 position: absolute;
242 top: 0; left: 0; right: 0;
243 height: 2px;
244 background: linear-gradient(90deg, transparent 0%, #6ee7b7 30%, #36c5d6 70%, transparent 100%);
245 opacity: 0.55;
246 }
247 .migprop-result-title {
248 font-family: var(--font-display);
249 font-size: 18px;
250 font-weight: 700;
251 color: var(--text-strong);
252 margin: 10px 0 6px;
253 }
254 .migprop-result-sub {
255 margin: 0 0 var(--space-4);
256 font-size: 13px;
257 color: var(--text-muted);
258 }
259 .migprop-pill {
260 display: inline-flex;
261 align-items: center;
262 gap: 6px;
263 padding: 3px 9px;
264 border-radius: 9999px;
265 font-size: 11px;
266 font-weight: 600;
267 letter-spacing: 0.04em;
268 text-transform: uppercase;
269 background: rgba(110,231,183,0.14);
270 color: #6ee7b7;
271 box-shadow: inset 0 0 0 1px rgba(110,231,183,0.32);
272 }
273 .migprop-pill.is-error {
274 background: rgba(248,113,113,0.14);
275 color: #fca5a5;
276 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32);
277 }
278 .migprop-explanation {
279 margin: 0 0 var(--space-4);
280 padding: var(--space-3);
281 background: rgba(255,255,255,0.025);
282 border: 1px solid var(--border);
283 border-radius: 8px;
284 font-size: 13.5px;
285 line-height: 1.55;
286 color: var(--text);
287 white-space: pre-wrap;
288 }
289 .migprop-meta {
290 font-family: var(--font-mono);
291 font-size: 12.5px;
292 color: var(--text-muted);
293 display: flex;
294 gap: 14px;
295 flex-wrap: wrap;
296 margin-bottom: var(--space-3);
297 }
298 .migprop-meta code {
299 font-family: var(--font-mono);
300 background: var(--bg-tertiary);
301 padding: 2px 7px;
302 border-radius: 5px;
303 border: 1px solid var(--border);
304 }
305
306 .migprop-notice {
307 max-width: 540px;
308 margin: var(--space-12) auto;
309 padding: var(--space-6);
310 text-align: center;
311 background: var(--bg-elevated);
312 border: 1px solid var(--border);
313 border-radius: 16px;
314 }
315 .migprop-notice h2 {
316 font-family: var(--font-display);
317 font-size: 22px;
318 margin: 0 0 8px;
319 color: var(--text-strong);
320 }
321 .migprop-notice p { color: var(--text-muted); margin: 0; font-size: 14px; }
322`;
323
324/**
325 * Resolve repo row + enforce owner-only access. Mirrors the helper in
326 * dep-updater.tsx so the two routes have identical permission semantics.
327 */
328async function resolveOwnerRepo(
329 c: any,
330 ownerName: string,
331 repoName: string
332): Promise<
333 | { kind: "ok"; repo: typeof repositories.$inferSelect }
334 | { kind: "response"; res: Response }
335> {
336 const user = c.get("user");
337 if (!user) {
338 return {
339 kind: "response",
340 res: c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`),
341 };
342 }
343 try {
344 const [owner] = await db
345 .select()
346 .from(users)
347 .where(eq(users.username, ownerName))
348 .limit(1);
349 if (!owner) return { kind: "response", res: c.notFound() };
350 if (owner.id !== user.id) {
351 return {
352 kind: "response",
353 res: c.html(
354 <Layout title="Unauthorized" user={user}>
355 <div class="migprop-wrap">
356 <div class="migprop-notice">
357 <h2>Unauthorized</h2>
358 <p>Only the repository owner can request a migration plan.</p>
359 </div>
360 </div>
361 <style dangerouslySetInnerHTML={{ __html: migpropStyles }} />
362 </Layout>,
363 403
364 ),
365 };
366 }
367 const [repo] = await db
368 .select()
369 .from(repositories)
370 .where(
371 and(
372 eq(repositories.ownerId, owner.id),
373 eq(repositories.name, repoName)
374 )
375 )
376 .limit(1);
377 if (!repo) return { kind: "response", res: c.notFound() };
378 return { kind: "ok", repo };
379 } catch {
380 return {
381 kind: "response",
382 res: c.html(
383 <Layout title="Error" user={user}>
384 <div class="migprop-wrap">
385 <div class="migprop-notice">
386 <h2>Service unavailable</h2>
387 <p>The migration assistant is temporarily offline.</p>
388 </div>
389 </div>
390 <style dangerouslySetInnerHTML={{ __html: migpropStyles }} />
391 </Layout>,
392 503
393 ),
394 };
395 }
396}
397
398/**
399 * Pull the manifest off the default branch + return a flat list of
400 * declared deps for the autocomplete datalist. Failure is non-fatal —
401 * the form still renders, just with an empty datalist.
402 */
403async function listDeclaredDeps(
404 owner: string,
405 name: string,
406 branch: string
407): Promise<Array<{ name: string; range: string; kind: "dep" | "dev" }>> {
408 try {
409 const baseSha = await resolveRef(owner, name, branch);
410 if (!baseSha) return [];
411 const manifest = await findManifest(owner, name, baseSha);
412 if (!manifest || manifest.path !== "package.json") return [];
413 const parsed = parseManifest(manifest.content);
414 const all: Array<{ name: string; range: string; kind: "dep" | "dev" }> = [];
415 for (const [n, r] of Object.entries(parsed.dependencies || {})) {
416 all.push({ name: n, range: r, kind: "dep" });
417 }
418 for (const [n, r] of Object.entries(parsed.devDependencies || {})) {
419 all.push({ name: n, range: r, kind: "dev" });
420 }
421 return all;
422 } catch {
423 return [];
424 }
425}
426
427function HeroBlock({
428 ownerName,
429 repoName,
430 dep,
431}: {
432 ownerName: string;
433 repoName: string;
434 dep?: string;
435}) {
436 return (
437 <section class="migprop-hero">
438 <div class="migprop-hero-orb" aria-hidden="true" />
439 <div class="migprop-hero-inner">
440 <div class="migprop-eyebrow">
441 <span class="migprop-eyebrow-dot" aria-hidden="true" />
442 {ownerName}/{repoName} · Migration assistant
443 </div>
444 <h1 class="migprop-title">
445 <span class="migprop-title-grad">
446 {dep ? `Migrate ${dep}` : "Migrate a dependency"}
447 </span>
448 </h1>
449 <p class="migprop-sub">
450 Tell us which package to upgrade and to which major. Claude reads
451 your manifest + call-sites, drafts the upgrade as a PR, and
452 updates tests along with the source.
453 </p>
454 </div>
455 </section>
456 );
457}
458
459// ─────────────────────────────────────────────────────────────────────────
460// GET — form
461// ─────────────────────────────────────────────────────────────────────────
462
463migrationAssistant.get(
464 "/:owner/:repo/migrations/propose",
465 requireAuth,
466 async (c) => {
467 const { owner: ownerName, repo: repoName } = c.req.param();
468 const resolved = await resolveOwnerRepo(c, ownerName, repoName);
469 if (resolved.kind === "response") return resolved.res;
470 const { repo } = resolved;
471 const user = c.get("user")!;
472
473 const branch = repo.defaultBranch || "main";
474 const declared = await listDeclaredDeps(ownerName, repoName, branch);
475 const prefill = c.req.query("dep") || "";
476
477 return c.html(
478 <Layout title={`Migrate dep — ${ownerName}/${repoName}`} user={user}>
479 <RepoHeader owner={ownerName} repo={repoName} />
480 <IssueNav owner={ownerName} repo={repoName} active="code" />
481 <div class="migprop-wrap">
482 <HeroBlock ownerName={ownerName} repoName={repoName} dep={prefill} />
483 <section class="migprop-card">
484 <div class="migprop-card-body">
485 <h2 class="migprop-card-title">Get a migration plan</h2>
486 <p class="migprop-card-sub">
487 We'll detect uses in your tree, ask Claude for a patch set,
488 and (if it returns one) open a PR labeled{" "}
489 <code>ai:major-migration</code>. Server must have an{" "}
490 <code>ANTHROPIC_API_KEY</code> configured.
491 </p>
492 <form
493 method="post"
494 action={`/${ownerName}/${repoName}/migrations/propose`}
495 >
496 <div class="migprop-fields">
497 <div class="migprop-field">
498 <label class="migprop-label" for="migprop-dep">
499 Dependency
500 </label>
501 <input
502 id="migprop-dep"
503 class="migprop-input"
504 type="text"
505 name="dependency"
506 list="migprop-deplist"
507 placeholder="hono"
508 value={prefill}
509 required
510 autocomplete="off"
511 />
512 <datalist id="migprop-deplist">
513 {declared.map((d) => (
514 <option value={d.name}>
515 {d.range} ({d.kind})
516 </option>
517 ))}
518 </datalist>
519 </div>
520 <div class="migprop-field">
521 <label class="migprop-label" for="migprop-from">
522 From
523 </label>
524 <input
525 id="migprop-from"
526 class="migprop-input"
527 type="text"
528 name="fromVersion"
529 placeholder="^3.0.0"
530 required
531 />
532 </div>
533 <div class="migprop-field">
534 <label class="migprop-label" for="migprop-to">
535 To
536 </label>
537 <input
538 id="migprop-to"
539 class="migprop-input"
540 type="text"
541 name="toVersion"
542 placeholder="4.0.0"
543 required
544 />
545 </div>
546 </div>
547 <div class="migprop-actions">
548 <button type="submit" class="migprop-cta">
549 Get migration plan
550 </button>
551 <span class="migprop-hint">
552 Opens a PR on success. Bypasses the 7-day throttle.
553 </span>
554 </div>
555 </form>
556 </div>
557 </section>
558 </div>
559 <style dangerouslySetInnerHTML={{ __html: migpropStyles }} />
560 </Layout>
561 );
562 }
563);
564
565// ─────────────────────────────────────────────────────────────────────────
566// POST — run the assistant + render the result inline
567// ─────────────────────────────────────────────────────────────────────────
568
569migrationAssistant.post(
570 "/:owner/:repo/migrations/propose",
571 requireAuth,
572 async (c) => {
573 const { owner: ownerName, repo: repoName } = c.req.param();
574 const resolved = await resolveOwnerRepo(c, ownerName, repoName);
575 if (resolved.kind === "response") return resolved.res;
576 const { repo } = resolved;
577 const user = c.get("user")!;
578
579 const form = await c.req.parseBody();
580 const dependency = String(form.dependency ?? "").trim();
581 const fromVersion = String(form.fromVersion ?? "").trim();
582 const toVersion = String(form.toVersion ?? "").trim();
583
584 const renderError = (msg: string) =>
585 c.html(
586 <Layout title={`Migrate dep — ${ownerName}/${repoName}`} user={user}>
587 <RepoHeader owner={ownerName} repo={repoName} />
588 <IssueNav owner={ownerName} repo={repoName} active="code" />
589 <div class="migprop-wrap">
590 <HeroBlock
591 ownerName={ownerName}
592 repoName={repoName}
593 dep={dependency}
594 />
595 <section class="migprop-result">
596 <span class="migprop-pill is-error">Failed</span>
597 <h2 class="migprop-result-title">
598 Could not generate a migration plan
599 </h2>
600 <p class="migprop-result-sub">{msg}</p>
601 <a
602 class="migprop-cta"
603 href={`/${ownerName}/${repoName}/migrations/propose`}
604 >
605 Try again
606 </a>
607 </section>
608 </div>
609 <style dangerouslySetInnerHTML={{ __html: migpropStyles }} />
610 </Layout>
611 );
612
613 if (!dependency || !fromVersion || !toVersion) {
614 return renderError("All three fields are required.");
615 }
616
617 const branch = repo.defaultBranch || "main";
618 const baseSha = await resolveRef(ownerName, repoName, branch);
619 if (!baseSha) {
620 return renderError(
621 `Could not resolve the default branch (\`${branch}\`).`
622 );
623 }
624
625 let result: ProposeMigrationResult | null = null;
626 try {
627 result = await proposeMajorMigration({
628 repositoryId: repo.id,
629 dependency,
630 fromVersion,
631 toVersion,
632 baseSha,
633 // UI users explicitly want the migration; bypass the watcher's
634 // 7-day cool-down.
635 skipThrottle: true,
636 });
637 } catch (err) {
638 console.error("[migrations] propose threw:", err);
639 return renderError(
640 "The migration assistant encountered an unexpected error."
641 );
642 }
643
644 if (!result) {
645 return renderError(
646 "Claude did not return a usable patch. This usually means the model couldn't find safe, mechanical changes — try narrowing the scope or set ANTHROPIC_API_KEY if the server is missing it."
647 );
648 }
649
650 // Look up the PR body so we can preview the explanation inline.
651 let explanation = "";
652 try {
653 const [pr] = await db
654 .select({ body: pullRequests.body })
655 .from(pullRequests)
656 .where(
657 and(
658 eq(pullRequests.repositoryId, repo.id),
659 eq(pullRequests.number, result.prNumber)
660 )
661 )
662 .limit(1);
663 if (pr?.body) {
664 // Extract the "### Summary" block. Best-effort — fall back to the
665 // entire body when the marker isn't found.
666 const match = pr.body.match(/### Summary\n([\s\S]*?)\n\n###/);
667 explanation = match ? match[1].trim() : pr.body;
668 }
669 } catch {
670 // ignore — we can still render the success page.
671 }
672
673 return c.html(
674 <Layout title={`Migrate dep — ${ownerName}/${repoName}`} user={user}>
675 <RepoHeader owner={ownerName} repo={repoName} />
676 <IssueNav owner={ownerName} repo={repoName} active="code" />
677 <div class="migprop-wrap">
678 <HeroBlock
679 ownerName={ownerName}
680 repoName={repoName}
681 dep={dependency}
682 />
683 <section class="migprop-result">
684 <span class="migprop-pill">PR opened</span>
685 <h2 class="migprop-result-title">Migration plan ready</h2>
686 <p class="migprop-result-sub">
687 Claude proposed a patch set for <code>{dependency}</code>{" "}
688 {fromVersion} → {toVersion}. Review the diff and merge if the
689 call-sites look right.
690 </p>
691 <div class="migprop-meta">
692 <span>
693 Branch: <code>{result.branch}</code>
694 </span>
695 <span>
696 Base: <code>{branch}</code>
697 </span>
698 <span>
699 PR: <code>#{result.prNumber}</code>
700 </span>
701 </div>
702 {explanation ? (
703 <div class="migprop-explanation">{explanation}</div>
704 ) : null}
705 <a
706 class="migprop-cta"
707 href={`/${ownerName}/${repoName}/pulls/${result.prNumber}`}
708 >
709 Open PR #{result.prNumber}
710 </a>
711 </section>
712 </div>
713 <style dangerouslySetInnerHTML={{ __html: migpropStyles }} />
714 </Layout>
715 );
716 }
717);
718
719export default migrationAssistant;
Addedsrc/routes/voice-to-pr.tsx+1091−0View fileUnifiedSplit
Large file (1,091 lines). Load full file
Modifiedsrc/views/layout.tsx+19−0View fileUnifiedSplit
225225 <a href="/standups" class="nav-link">
226226 Standups
227227 </a>
228 <a href="/voice" class="nav-link" style="display:inline-flex;align-items:center;gap:5px">
229 <svg
230 width="13"
231 height="13"
232 viewBox="0 0 24 24"
233 fill="none"
234 stroke="currentColor"
235 stroke-width="2.2"
236 stroke-linecap="round"
237 stroke-linejoin="round"
238 aria-hidden="true"
239 >
240 <rect x="9" y="2" width="6" height="13" rx="3" />
241 <path d="M19 11v1a7 7 0 0 1-14 0v-1" />
242 <line x1="12" y1="19" x2="12" y2="23" />
243 <line x1="8" y1="23" x2="16" y2="23" />
244 </svg>
245 Voice
246 </a>
228247 <a href="/inbox" class="nav-link" style="position:relative">
229248 Inbox
230249 {notificationCount && notificationCount > 0 ? (
231250