CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
voice-to-pr.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 45f3b73 | 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 | ||
| 36 | import { and, eq } from "drizzle-orm"; | |
| 37 | import { db } from "../db"; | |
| 38 | import { issues, repositories, users } from "../db/schema"; | |
| 39 | import { createOrUpdateFileOnBranch } from "../git/repository"; | |
| 40 | import { config } from "./config"; | |
| 41 | import { serialiseSpec } from "./spec-to-pr"; | |
| 42 | ||
| 43 | // --------------------------------------------------------------------------- | |
| 44 | // Public types | |
| 45 | // --------------------------------------------------------------------------- | |
| 46 | ||
| 47 | export type VoiceIntent = "spec" | "issue" | "unclear"; | |
| 48 | ||
| 49 | export 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 | ||
| 57 | export 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 | ||
| 65 | export type InterpretResult = | |
| 66 | | { ok: true; suggestion: VoiceInterpretation } | |
| 67 | | { ok: false; error: string }; | |
| 68 | ||
| 69 | export 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 | ||
| 79 | export type ShipSpecResult = | |
| 80 | | { | |
| 81 | ok: true; | |
| 82 | specPath: string; | |
| 83 | commitSha: string; | |
| 84 | branch: string; | |
| 85 | } | |
| 86 | | { ok: false; error: string }; | |
| 87 | ||
| 88 | export interface CreateIssueArgs { | |
| 89 | repositoryId: string; | |
| 90 | transcript: string; | |
| 91 | userId: string; | |
| 92 | interpretation?: VoiceInterpretation; | |
| 93 | } | |
| 94 | ||
| 95 | export 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 | */ | |
| 112 | export 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. */ | |
| 122 | function 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 | */ | |
| 151 | export 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 | */ | |
| 183 | export 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 | ||
| 196 | The transcript: | |
| 197 | """ | |
| 198 | ${transcript.trim().slice(0, 4000)} | |
| 199 | """${repoBlock} | |
| 200 | ||
| 201 | Decide 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 | ||
| 206 | Polish 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 | ||
| 210 | Respond 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 | */ | |
| 229 | export 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 | }); | |
| 0c3eee5 | 272 | try { |
| 273 | const { recordAiCost, extractUsage } = await import( | |
| 274 | "./ai-cost-tracker" | |
| 275 | ); | |
| 276 | const usage = extractUsage(msg); | |
| 277 | await recordAiCost({ | |
| 278 | model: MODEL_SONNET, | |
| 279 | inputTokens: usage.input, | |
| 280 | outputTokens: usage.output, | |
| 281 | category: "voice", | |
| 282 | sourceKind: "voice_transcript", | |
| 283 | }); | |
| 284 | } catch { | |
| 285 | /* swallow — best-effort */ | |
| 286 | } | |
| 45f3b73 | 287 | const text = extractText(msg); |
| 288 | const parsed = safeParseJson(text); | |
| 289 | return { | |
| 290 | ok: true, | |
| 291 | suggestion: normaliseInterpretation(parsed, transcript), | |
| 292 | }; | |
| 293 | } catch (err) { | |
| 294 | // Fall back to the heuristic so the demo doesn't dead-end on a 429. | |
| 295 | const fallback = classifyHeuristically(transcript); | |
| 296 | if (process.env.DEBUG_VOICE === "1") { | |
| 297 | console.warn( | |
| 298 | "[voice] Claude call failed, using heuristic:", | |
| 299 | err instanceof Error ? err.message : err | |
| 300 | ); | |
| 301 | } | |
| 302 | return { ok: true, suggestion: fallback }; | |
| 303 | } | |
| 304 | } | |
| 305 | ||
| 306 | function safeParseJson(text: string): unknown { | |
| 307 | if (!text) return null; | |
| 308 | // Try a fenced block first. | |
| 309 | const fenced = text.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/); | |
| 310 | const candidate = fenced ? fenced[1] : null; | |
| 311 | if (candidate) { | |
| 312 | try { | |
| 313 | return JSON.parse(candidate); | |
| 314 | } catch { | |
| 315 | /* fall through */ | |
| 316 | } | |
| 317 | } | |
| 318 | const braced = text.match(/\{[\s\S]*\}/); | |
| 319 | if (braced) { | |
| 320 | try { | |
| 321 | return JSON.parse(braced[0]); | |
| 322 | } catch { | |
| 323 | return null; | |
| 324 | } | |
| 325 | } | |
| 326 | return null; | |
| 327 | } | |
| 328 | ||
| 329 | // --------------------------------------------------------------------------- | |
| 330 | // shipAsSpec | |
| 331 | // --------------------------------------------------------------------------- | |
| 332 | ||
| 333 | /** | |
| 334 | * Resolve repo + owner + author rows for a given repository / user combo. | |
| 335 | * Returns null on any miss so callers can return a clean error. | |
| 336 | */ | |
| 337 | async function resolveRepoAndAuthor( | |
| 338 | repositoryId: string, | |
| 339 | userId: string | |
| 340 | ): Promise< | |
| 341 | | { | |
| 342 | ownerName: string; | |
| 343 | repoName: string; | |
| 344 | defaultBranch: string; | |
| 345 | authorName: string; | |
| 346 | authorEmail: string; | |
| 347 | } | |
| 348 | | null | |
| 349 | > { | |
| 350 | try { | |
| 351 | const [row] = await db | |
| 352 | .select({ | |
| 353 | repoName: repositories.name, | |
| 354 | defaultBranch: repositories.defaultBranch, | |
| 355 | ownerName: users.username, | |
| 356 | }) | |
| 357 | .from(repositories) | |
| 358 | .leftJoin(users, eq(users.id, repositories.ownerId)) | |
| 359 | .where(eq(repositories.id, repositoryId)) | |
| 360 | .limit(1); | |
| 361 | if (!row || !row.ownerName) return null; | |
| 362 | ||
| 363 | const [authorRow] = await db | |
| 364 | .select({ username: users.username, email: users.email }) | |
| 365 | .from(users) | |
| 366 | .where(eq(users.id, userId)) | |
| 367 | .limit(1); | |
| 368 | if (!authorRow) return null; | |
| 369 | ||
| 370 | return { | |
| 371 | ownerName: row.ownerName, | |
| 372 | repoName: row.repoName, | |
| 373 | defaultBranch: row.defaultBranch || "main", | |
| 374 | authorName: authorRow.username, | |
| 375 | authorEmail: | |
| 376 | authorRow.email || `${authorRow.username}@users.noreply.gluecron`, | |
| 377 | }; | |
| 378 | } catch { | |
| 379 | return null; | |
| 380 | } | |
| 381 | } | |
| 382 | ||
| 383 | /** | |
| 384 | * Commit a `.gluecron/specs/voice-<slug>.md` spec to the repo's default | |
| 385 | * branch with `status: ready`. The autopilot picks it up on the next tick. | |
| 386 | */ | |
| 387 | export async function shipAsSpec( | |
| 388 | args: ShipSpecArgs | |
| 389 | ): Promise<ShipSpecResult> { | |
| 390 | const transcript = | |
| 391 | typeof args.transcript === "string" ? args.transcript.trim() : ""; | |
| 392 | if (!transcript) return { ok: false, error: "transcript is empty" }; | |
| 393 | if (!args.repositoryId) return { ok: false, error: "repository_id required" }; | |
| 394 | if (!args.userId) return { ok: false, error: "userId required" }; | |
| 395 | ||
| 396 | const resolved = await resolveRepoAndAuthor(args.repositoryId, args.userId); | |
| 397 | if (!resolved) return { ok: false, error: "repo or user not found" }; | |
| 398 | ||
| 399 | // Use the supplied interpretation when present, otherwise fall back to | |
| 400 | // the heuristic so we always have a polished title. | |
| 401 | const interp = | |
| 402 | args.interpretation || classifyHeuristically(transcript); | |
| 403 | const slug = | |
| 404 | args.slugOverride && args.slugOverride.trim() | |
| 405 | ? voiceSlug(args.slugOverride) | |
| 406 | : voiceSlug(interp.title || transcript); | |
| 407 | // Stamp the filename with a short timestamp so back-to-back voice notes | |
| 408 | // with the same slug don't collide on the autopilot dedup. | |
| 409 | const specPath = `.gluecron/specs/voice-${slug}-${Date.now().toString(36)}.md`; | |
| 410 | ||
| 411 | const fm: Record<string, string> = { | |
| 412 | title: interp.title || "Voice spec", | |
| 413 | status: "ready", | |
| 414 | source: "voice-to-pr", | |
| 415 | }; | |
| 416 | const body = `# ${interp.title || "Voice spec"}\n\n${interp.body_markdown || transcript}\n\n---\n\n_Captured via Gluecron voice-to-PR._\n`; | |
| 417 | const content = serialiseSpec(fm, body); | |
| 418 | const bytes = new TextEncoder().encode(content); | |
| 419 | ||
| 420 | const res = await createOrUpdateFileOnBranch({ | |
| 421 | owner: resolved.ownerName, | |
| 422 | name: resolved.repoName, | |
| 423 | branch: resolved.defaultBranch, | |
| 424 | filePath: specPath, | |
| 425 | bytes, | |
| 426 | message: `voice: ${interp.title || "captured spec"}`, | |
| 427 | authorName: resolved.authorName, | |
| 428 | authorEmail: resolved.authorEmail, | |
| 429 | }); | |
| 430 | if ("error" in res) { | |
| 431 | return { ok: false, error: `git write failed: ${res.error}` }; | |
| 432 | } | |
| 433 | return { | |
| 434 | ok: true, | |
| 435 | specPath, | |
| 436 | commitSha: res.commitSha, | |
| 437 | branch: resolved.defaultBranch, | |
| 438 | }; | |
| 439 | } | |
| 440 | ||
| 441 | // --------------------------------------------------------------------------- | |
| 442 | // createIssueFromVoice | |
| 443 | // --------------------------------------------------------------------------- | |
| 444 | ||
| 445 | /** | |
| 446 | * Insert an issue row mirroring `src/routes/issues.tsx`. The route handler | |
| 447 | * wires up the AI triage trigger separately so this function stays | |
| 448 | * dependency-light + easy to test. | |
| 449 | */ | |
| 450 | export async function createIssueFromVoice( | |
| 451 | args: CreateIssueArgs | |
| 452 | ): Promise<CreateIssueResult> { | |
| 453 | const transcript = | |
| 454 | typeof args.transcript === "string" ? args.transcript.trim() : ""; | |
| 455 | if (!transcript) return { ok: false, error: "transcript is empty" }; | |
| 456 | if (!args.repositoryId) return { ok: false, error: "repository_id required" }; | |
| 457 | if (!args.userId) return { ok: false, error: "userId required" }; | |
| 458 | ||
| 459 | let repoRow: | |
| 460 | | { | |
| 461 | id: string; | |
| 462 | name: string; | |
| 463 | issueCount: number; | |
| 464 | ownerName: string | null; | |
| 465 | } | |
| 466 | | undefined; | |
| 467 | try { | |
| 468 | const [row] = await db | |
| 469 | .select({ | |
| 470 | id: repositories.id, | |
| 471 | name: repositories.name, | |
| 472 | issueCount: repositories.issueCount, | |
| 473 | ownerName: users.username, | |
| 474 | }) | |
| 475 | .from(repositories) | |
| 476 | .leftJoin(users, eq(users.id, repositories.ownerId)) | |
| 477 | .where(eq(repositories.id, args.repositoryId)) | |
| 478 | .limit(1); | |
| 479 | repoRow = row; | |
| 480 | } catch { | |
| 481 | return { ok: false, error: "db lookup failed" }; | |
| 482 | } | |
| 483 | if (!repoRow || !repoRow.ownerName) { | |
| 484 | return { ok: false, error: "repo not found" }; | |
| 485 | } | |
| 486 | ||
| 487 | const interp = args.interpretation || classifyHeuristically(transcript); | |
| 488 | const title = (interp.title || transcript.slice(0, 80)).slice(0, 200); | |
| 489 | const body = `${interp.body_markdown || transcript}\n\n---\n\n_Captured via Gluecron voice-to-PR._`; | |
| 490 | ||
| 491 | try { | |
| 492 | const [issue] = await db | |
| 493 | .insert(issues) | |
| 494 | .values({ | |
| 495 | repositoryId: repoRow.id, | |
| 496 | authorId: args.userId, | |
| 497 | title, | |
| 498 | body, | |
| 499 | }) | |
| 500 | .returning(); | |
| 501 | if (!issue) return { ok: false, error: "issue insert returned no row" }; | |
| 502 | // Best-effort counter update; mirrors src/routes/issues.tsx. | |
| 503 | try { | |
| 504 | await db | |
| 505 | .update(repositories) | |
| 506 | .set({ issueCount: (repoRow.issueCount || 0) + 1 }) | |
| 507 | .where(eq(repositories.id, repoRow.id)); | |
| 508 | } catch { | |
| 509 | /* non-fatal */ | |
| 510 | } | |
| 511 | return { | |
| 512 | ok: true, | |
| 513 | issueNumber: issue.number, | |
| 514 | ownerName: repoRow.ownerName, | |
| 515 | repoName: repoRow.name, | |
| 516 | }; | |
| 517 | } catch (err) { | |
| 518 | return { | |
| 519 | ok: false, | |
| 520 | error: `issue insert failed: ${err instanceof Error ? err.message : String(err)}`, | |
| 521 | }; | |
| 522 | } | |
| 523 | } | |
| 524 | ||
| 525 | // --------------------------------------------------------------------------- | |
| 526 | // Test-only exports | |
| 527 | // --------------------------------------------------------------------------- | |
| 528 | ||
| 529 | export const __voiceTest = { | |
| 530 | classifyHeuristically, | |
| 531 | safeParseJson, | |
| 532 | resolveRepoAndAuthor, | |
| 533 | }; | |
| 534 | ||
| 535 | /** True if access to the user's repo list should fan out (used by the route). */ | |
| 536 | export async function listUserRepos( | |
| 537 | userId: string | |
| 538 | ): Promise<Array<{ id: string; fullName: string }>> { | |
| 539 | try { | |
| 540 | const rows = await db | |
| 541 | .select({ | |
| 542 | id: repositories.id, | |
| 543 | name: repositories.name, | |
| 544 | ownerName: users.username, | |
| 545 | }) | |
| 546 | .from(repositories) | |
| 547 | .leftJoin(users, eq(users.id, repositories.ownerId)) | |
| 548 | .where( | |
| 549 | and( | |
| 550 | eq(repositories.ownerId, userId), | |
| 551 | eq(repositories.isArchived, false) | |
| 552 | ) | |
| 553 | ) | |
| 554 | .limit(100); | |
| 555 | return rows | |
| 556 | .filter((r) => r.ownerName) | |
| 557 | .map((r) => ({ id: r.id, fullName: `${r.ownerName}/${r.name}` })); | |
| 558 | } catch { | |
| 559 | return []; | |
| 560 | } | |
| 561 | } |