CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
ai-workspace.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.
| 77cf834 | 1 | /** |
| 2 | * AI Copilot Workspace — issue-to-PR autonomous agent. | |
| 3 | * | |
| 4 | * Pipeline: | |
| 5 | * 1. Load issue context (title + body + recent comments). | |
| 6 | * 2. Explore the codebase — ls-tree then ask Claude which files to read. | |
| 7 | * 3. Generate an implementation plan (Claude call) + post as issue comment. | |
| 8 | * 4. Implement: create branch, apply file edits via git plumbing. | |
| 9 | * 5. Open a draft PR linked to the issue. | |
| 10 | * | |
| 11 | * The hot path is fully in-memory (WorkspaceJob map). A workspace_jobs table | |
| 12 | * is written for auditability via `src/db/schema.ts` + migration 0103. | |
| 13 | * | |
| 14 | * Never throws externally. All failures set job.status = "failed". | |
| 15 | */ | |
| 16 | ||
| 17 | import { join } from "path"; | |
| 18 | import { eq, desc } from "drizzle-orm"; | |
| 19 | import { db } from "../db"; | |
| 20 | import { | |
| 21 | issues, | |
| 22 | issueComments, | |
| 23 | repositories, | |
| 24 | users, | |
| 25 | pullRequests, | |
| 26 | workspaceJobs, | |
| 27 | } from "../db/schema"; | |
| 28 | import { getAnthropic, isAiAvailable, MODEL_SONNET, extractText, parseJsonResponse } from "./ai-client"; | |
| 29 | import { applyEditsToNewBranch, type FileEdit } from "./spec-git"; | |
| 30 | import { getBlob } from "../git/repository"; | |
| 31 | import { getBotUserIdOrFallback } from "./bot-user"; | |
| 32 | import { config } from "./config"; | |
| 33 | ||
| 34 | // --------------------------------------------------------------------------- | |
| 35 | // Public types | |
| 36 | // --------------------------------------------------------------------------- | |
| 37 | ||
| 38 | export type WorkspaceStatus = | |
| 39 | | "pending" | |
| 40 | | "planning" | |
| 41 | | "implementing" | |
| 42 | | "opening_pr" | |
| 43 | | "done" | |
| 44 | | "failed"; | |
| 45 | ||
| 46 | export interface WorkspaceJob { | |
| 47 | id: string; | |
| 48 | repoId: string; | |
| 49 | issueId: string; | |
| 50 | issueNumber: number; | |
| 51 | ownerName: string; | |
| 52 | repoName: string; | |
| 53 | status: WorkspaceStatus; | |
| 54 | planComment?: string; // the plan posted as issue comment | |
| 55 | branchName?: string; // e.g. "workspace/issue-42-fix-auth" | |
| 56 | prNumber?: number; | |
| 57 | errorMessage?: string; | |
| 58 | startedAt: Date; | |
| 59 | updatedAt: Date; | |
| 60 | } | |
| 61 | ||
| 62 | // --------------------------------------------------------------------------- | |
| 63 | // In-memory job store (LRU, max 100 jobs) | |
| 64 | // --------------------------------------------------------------------------- | |
| 65 | ||
| 66 | const MAX_JOBS = 100; | |
| 67 | const jobById = new Map<string, WorkspaceJob>(); | |
| 68 | // issueId → jobId for O(1) lookup by issue | |
| 69 | const jobByIssueId = new Map<string, string>(); | |
| 70 | // repoId → jobId for "one active job per repo" guard | |
| 71 | const activeJobByRepoId = new Map<string, string>(); | |
| 72 | ||
| 73 | function evictOldestIfNeeded(): void { | |
| 74 | if (jobById.size < MAX_JOBS) return; | |
| 75 | // Evict oldest (first inserted) entry | |
| 76 | const firstKey = jobById.keys().next().value; | |
| 77 | if (firstKey) { | |
| 78 | const old = jobById.get(firstKey); | |
| 79 | if (old) { | |
| 80 | jobByIssueId.delete(old.issueId); | |
| 81 | if (activeJobByRepoId.get(old.repoId) === firstKey) { | |
| 82 | activeJobByRepoId.delete(old.repoId); | |
| 83 | } | |
| 84 | } | |
| 85 | jobById.delete(firstKey); | |
| 86 | } | |
| 87 | } | |
| 88 | ||
| 89 | function storeJob(job: WorkspaceJob): void { | |
| 90 | evictOldestIfNeeded(); | |
| 91 | jobById.set(job.id, job); | |
| 92 | jobByIssueId.set(job.issueId, job.id); | |
| 93 | if (job.status !== "done" && job.status !== "failed") { | |
| 94 | activeJobByRepoId.set(job.repoId, job.id); | |
| 95 | } | |
| 96 | } | |
| 97 | ||
| 98 | function updateJob(job: WorkspaceJob, patch: Partial<WorkspaceJob>): void { | |
| 99 | Object.assign(job, patch, { updatedAt: new Date() }); | |
| 100 | if (job.status === "done" || job.status === "failed") { | |
| 101 | if (activeJobByRepoId.get(job.repoId) === job.id) { | |
| 102 | activeJobByRepoId.delete(job.repoId); | |
| 103 | } | |
| 104 | } | |
| 105 | } | |
| 106 | ||
| 107 | // --------------------------------------------------------------------------- | |
| 108 | // Public accessors | |
| 109 | // --------------------------------------------------------------------------- | |
| 110 | ||
| 111 | export function getWorkspaceJob(jobId: string): WorkspaceJob | undefined { | |
| 112 | return jobById.get(jobId); | |
| 113 | } | |
| 114 | ||
| 115 | export function getWorkspaceJobForIssue(issueId: string): WorkspaceJob | undefined { | |
| 116 | const jobId = jobByIssueId.get(issueId); | |
| 117 | return jobId ? jobById.get(jobId) : undefined; | |
| 118 | } | |
| 119 | ||
| 120 | // --------------------------------------------------------------------------- | |
| 121 | // Entry point | |
| 122 | // --------------------------------------------------------------------------- | |
| 123 | ||
| 124 | export async function startWorkspace( | |
| 125 | issueId: string, | |
| 126 | issueNumber: number, | |
| 127 | repoId: string, | |
| 128 | ownerName: string, | |
| 129 | repoName: string, | |
| 130 | triggeredByUserId: string | |
| 131 | ): Promise<WorkspaceJob> { | |
| 132 | // Guard: AI must be available | |
| 133 | if (!isAiAvailable()) { | |
| 134 | const failed: WorkspaceJob = { | |
| 135 | id: crypto.randomUUID(), | |
| 136 | repoId, | |
| 137 | issueId, | |
| 138 | issueNumber, | |
| 139 | ownerName, | |
| 140 | repoName, | |
| 141 | status: "failed", | |
| 142 | errorMessage: "ANTHROPIC_API_KEY is not configured", | |
| 143 | startedAt: new Date(), | |
| 144 | updatedAt: new Date(), | |
| 145 | }; | |
| 146 | storeJob(failed); | |
| 147 | return failed; | |
| 148 | } | |
| 149 | ||
| 150 | // Guard: max 1 active job per repo | |
| 151 | const existingJobId = activeJobByRepoId.get(repoId); | |
| 152 | if (existingJobId) { | |
| 153 | const existing = jobById.get(existingJobId); | |
| 154 | if (existing && existing.status !== "done" && existing.status !== "failed") { | |
| 155 | return existing; | |
| 156 | } | |
| 157 | // Stale entry — clear it | |
| 158 | activeJobByRepoId.delete(repoId); | |
| 159 | } | |
| 160 | ||
| 161 | const job: WorkspaceJob = { | |
| 162 | id: crypto.randomUUID(), | |
| 163 | repoId, | |
| 164 | issueId, | |
| 165 | issueNumber, | |
| 166 | ownerName, | |
| 167 | repoName, | |
| 168 | status: "pending", | |
| 169 | startedAt: new Date(), | |
| 170 | updatedAt: new Date(), | |
| 171 | }; | |
| 172 | ||
| 173 | storeJob(job); | |
| 174 | ||
| 175 | // Persist to DB for auditability (best-effort) | |
| 176 | try { | |
| 177 | await db.insert(workspaceJobs).values({ | |
| 178 | id: job.id, | |
| 179 | repoId, | |
| 180 | issueId, | |
| 181 | triggeredBy: triggeredByUserId, | |
| 182 | status: "pending", | |
| 183 | }); | |
| 184 | } catch { | |
| 185 | /* non-fatal — in-memory store is the source of truth */ | |
| 186 | } | |
| 187 | ||
| 188 | // Fire-and-forget pipeline | |
| 189 | runWorkspacePipeline(job, triggeredByUserId).catch(() => { | |
| 190 | /* errors are captured inside the pipeline */ | |
| 191 | }); | |
| 192 | ||
| 193 | return job; | |
| 194 | } | |
| 195 | ||
| 196 | // --------------------------------------------------------------------------- | |
| 197 | // Pipeline | |
| 198 | // --------------------------------------------------------------------------- | |
| 199 | ||
| 200 | async function runWorkspacePipeline( | |
| 201 | job: WorkspaceJob, | |
| 202 | triggeredByUserId: string | |
| 203 | ): Promise<void> { | |
| 204 | try { | |
| 205 | // Step 1 — Load issue context | |
| 206 | const issueCtx = await loadIssueContext(job.issueId); | |
| 207 | if (!issueCtx) { | |
| 208 | throw new Error("Issue not found or DB read failed"); | |
| 209 | } | |
| 210 | ||
| 211 | // Step 2 — Explore codebase | |
| 212 | updateJob(job, { status: "planning" }); | |
| 213 | await persistJobStatus(job); | |
| 214 | ||
| 215 | const reposBase = config.gitReposPath; | |
| 216 | const repoDiskPath = join(reposBase, job.ownerName, `${job.repoName}.git`); | |
| 217 | ||
| 218 | const fileTree = await getFileTree(repoDiskPath); | |
| 219 | const relevantFiles = await pickRelevantFiles(issueCtx, fileTree); | |
| 220 | const fileContents = await readFileContents( | |
| 221 | job.ownerName, | |
| 222 | job.repoName, | |
| 223 | relevantFiles | |
| 224 | ); | |
| 225 | ||
| 226 | // Step 3 — Generate implementation plan | |
| 227 | const plan = await generatePlan(issueCtx, fileContents); | |
| 228 | ||
| 229 | // Post plan as issue comment | |
| 230 | const planBody = formatPlanComment(plan); | |
| 231 | const botUserId = await getBotUserIdOrFallback(triggeredByUserId); | |
| 232 | const commentId = await postIssueComment(job.issueId, botUserId, planBody); | |
| 233 | updateJob(job, { planComment: commentId ?? undefined }); | |
| 234 | ||
| 235 | // Step 4 — Implement | |
| 236 | updateJob(job, { status: "implementing" }); | |
| 237 | await persistJobStatus(job); | |
| 238 | ||
| 239 | // Resolve default branch | |
| 240 | const repoRow = await db | |
| 241 | .select({ defaultBranch: repositories.defaultBranch }) | |
| 242 | .from(repositories) | |
| 243 | .where(eq(repositories.id, job.repoId)) | |
| 244 | .limit(1); | |
| 245 | const baseBranch = repoRow[0]?.defaultBranch ?? "main"; | |
| 246 | ||
| 247 | const branchName = plan.branchName || sanitizeBranchName( | |
| 248 | `workspace/issue-${job.issueNumber}-${slugify(issueCtx.title)}` | |
| 249 | ); | |
| 250 | updateJob(job, { branchName }); | |
| 251 | ||
| 252 | const edits = buildEdits(plan); | |
| 253 | if (edits.length === 0) { | |
| 254 | throw new Error("AI plan produced no file changes"); | |
| 255 | } | |
| 256 | ||
| 257 | const commitMsg = | |
| 258 | `feat: implement #${job.issueNumber} — ${plan.summary.slice(0, 60)}\n\n` + | |
| 259 | `Closes #${job.issueNumber}\n\n` + | |
| 260 | `Generated by Gluecron AI Workspace.`; | |
| 261 | ||
| 262 | const applied = await applyEditsToNewBranch({ | |
| 263 | repoDiskPath, | |
| 264 | baseRef: baseBranch, | |
| 265 | edits, | |
| 266 | branchName, | |
| 267 | commitMessage: commitMsg, | |
| 268 | authorName: "Gluecron Workspace", | |
| 269 | authorEmail: "workspace@gluecron.com", | |
| 270 | }); | |
| 271 | ||
| 272 | if (!applied.ok) { | |
| 273 | throw new Error(`git apply failed: ${applied.error}`); | |
| 274 | } | |
| 275 | ||
| 276 | // Step 5 — Open draft PR | |
| 277 | updateJob(job, { status: "opening_pr" }); | |
| 278 | await persistJobStatus(job); | |
| 279 | ||
| 280 | const prBody = | |
| 281 | `Closes #${job.issueNumber}\n\n` + | |
| 282 | `${plan.summary}\n\n` + | |
| 283 | `**Files changed:**\n` + | |
| 284 | applied.filesChanged.map((f) => `- \`${f}\``).join("\n") + | |
| 285 | `\n\n<!-- gluecron:workspace:pr -->`; | |
| 286 | ||
| 287 | const prTitle = `feat: ${plan.summary.slice(0, 140)}`; | |
| 288 | ||
| 289 | const [pr] = await db | |
| 290 | .insert(pullRequests) | |
| 291 | .values({ | |
| 292 | repositoryId: job.repoId, | |
| 293 | authorId: botUserId, | |
| 294 | title: prTitle.slice(0, 200), | |
| 295 | body: prBody, | |
| 296 | baseBranch, | |
| 297 | headBranch: applied.branchName, | |
| 298 | isDraft: true, | |
| 299 | }) | |
| 300 | .returning({ number: pullRequests.number }); | |
| 301 | ||
| 302 | if (!pr?.number) { | |
| 303 | throw new Error("PR insert returned no number"); | |
| 304 | } | |
| 305 | ||
| 306 | updateJob(job, { status: "done", prNumber: pr.number }); | |
| 307 | await persistJobStatus(job); | |
| 308 | } catch (err: unknown) { | |
| 309 | const msg = err instanceof Error ? err.message : String(err); | |
| 310 | updateJob(job, { status: "failed", errorMessage: msg }); | |
| 311 | await persistJobStatus(job); | |
| 312 | } | |
| 313 | } | |
| 314 | ||
| 315 | // --------------------------------------------------------------------------- | |
| 316 | // Step helpers | |
| 317 | // --------------------------------------------------------------------------- | |
| 318 | ||
| 319 | interface IssueContext { | |
| 320 | title: string; | |
| 321 | body: string; | |
| 322 | comments: string[]; | |
| 323 | } | |
| 324 | ||
| 325 | async function loadIssueContext(issueId: string): Promise<IssueContext | null> { | |
| 326 | try { | |
| 327 | const issueRows = await db | |
| 328 | .select({ title: issues.title, body: issues.body }) | |
| 329 | .from(issues) | |
| 330 | .where(eq(issues.id, issueId)) | |
| 331 | .limit(1); | |
| 332 | ||
| 333 | if (!issueRows[0]) return null; | |
| 334 | const { title, body } = issueRows[0]; | |
| 335 | ||
| 336 | const commentRows = await db | |
| 337 | .select({ body: issueComments.body }) | |
| 338 | .from(issueComments) | |
| 339 | .where(eq(issueComments.issueId, issueId)) | |
| 340 | .orderBy(desc(issueComments.createdAt)) | |
| 341 | .limit(5); | |
| 342 | ||
| 343 | return { | |
| 344 | title, | |
| 345 | body: body ?? "", | |
| 346 | comments: commentRows.map((r) => r.body), | |
| 347 | }; | |
| 348 | } catch { | |
| 349 | return null; | |
| 350 | } | |
| 351 | } | |
| 352 | ||
| 353 | async function getFileTree(repoDiskPath: string): Promise<string[]> { | |
| 354 | try { | |
| 355 | const proc = Bun.spawn( | |
| 356 | ["git", "ls-tree", "-r", "--name-only", "HEAD"], | |
| 357 | { cwd: repoDiskPath, stdout: "pipe", stderr: "pipe" } | |
| 358 | ); | |
| 359 | const text = await new Response(proc.stdout).text(); | |
| 360 | await proc.exited; | |
| 361 | const lines = text | |
| 362 | .split("\n") | |
| 363 | .map((l) => l.trim()) | |
| 364 | .filter(Boolean); | |
| 365 | return lines.slice(0, 500); | |
| 366 | } catch { | |
| 367 | return []; | |
| 368 | } | |
| 369 | } | |
| 370 | ||
| 371 | async function pickRelevantFiles( | |
| 372 | ctx: IssueContext, | |
| 373 | fileTree: string[] | |
| 374 | ): Promise<string[]> { | |
| 375 | if (fileTree.length === 0) return []; | |
| 376 | ||
| 377 | const prompt = | |
| 378 | `Given this issue:\n${ctx.title}\n${ctx.body}\n\n` + | |
| 379 | `And this file tree:\n${fileTree.join("\n")}\n\n` + | |
| 380 | `List the 20 most relevant file paths to read in order to implement the issue. ` + | |
| 381 | `Return JSON: {"files": string[]}`; | |
| 382 | ||
| 383 | try { | |
| 384 | const client = getAnthropic(); | |
| 385 | const msg = await client.messages.create({ | |
| 386 | model: MODEL_SONNET, | |
| 387 | max_tokens: 512, | |
| 388 | temperature: 0, | |
| 389 | messages: [{ role: "user", content: prompt }], | |
| 390 | }); | |
| 391 | const text = extractText(msg); | |
| 392 | const parsed = parseJsonResponse<{ files: string[] }>(text); | |
| 393 | if (parsed && Array.isArray(parsed.files)) { | |
| 394 | return parsed.files.slice(0, 20).filter((f: string) => typeof f === "string"); | |
| 395 | } | |
| 396 | } catch { | |
| 397 | /* fall through — return empty */ | |
| 398 | } | |
| 399 | // Fallback: first 10 files | |
| 400 | return fileTree.slice(0, 10); | |
| 401 | } | |
| 402 | ||
| 403 | const MAX_FILE_BYTES = 8 * 1024; // 8 KB per file | |
| 404 | const MAX_TOTAL_BYTES = 80 * 1024; // 80 KB total | |
| 405 | ||
| 406 | async function readFileContents( | |
| 407 | ownerName: string, | |
| 408 | repoName: string, | |
| 409 | filePaths: string[] | |
| 410 | ): Promise<Array<{ path: string; content: string }>> { | |
| 411 | const results: Array<{ path: string; content: string }> = []; | |
| 412 | let totalBytes = 0; | |
| 413 | ||
| 414 | for (const filePath of filePaths) { | |
| 415 | if (totalBytes >= MAX_TOTAL_BYTES) break; | |
| 416 | try { | |
| 417 | const blob = await getBlob(ownerName, repoName, "HEAD", filePath); | |
| 418 | if (!blob || blob.isBinary) continue; | |
| 419 | const content = blob.content.slice(0, MAX_FILE_BYTES); | |
| 420 | totalBytes += content.length; | |
| 421 | results.push({ path: filePath, content }); | |
| 422 | } catch { | |
| 423 | /* skip unreadable files */ | |
| 424 | } | |
| 425 | } | |
| 426 | ||
| 427 | return results; | |
| 428 | } | |
| 429 | ||
| 430 | interface FilePlan { | |
| 431 | path: string; | |
| 432 | action: "create" | "modify" | "delete"; | |
| 433 | description: string; | |
| 434 | patch: string; | |
| 435 | } | |
| 436 | ||
| 437 | interface WorkspacePlan { | |
| 438 | summary: string; | |
| 439 | files: FilePlan[]; | |
| 440 | branchName: string; | |
| 441 | } | |
| 442 | ||
| 443 | async function generatePlan( | |
| 444 | ctx: IssueContext, | |
| 445 | fileContents: Array<{ path: string; content: string }> | |
| 446 | ): Promise<WorkspacePlan> { | |
| 447 | const fileSection = fileContents | |
| 448 | .map((f) => `=== ${f.path} ===\n${f.content}`) | |
| 449 | .join("\n\n"); | |
| 450 | ||
| 451 | const commentSection = ctx.comments.length > 0 | |
| 452 | ? `\n\nComments:\n${ctx.comments.map((c, i) => `[${i + 1}] ${c}`).join("\n\n")}` | |
| 453 | : ""; | |
| 454 | ||
| 455 | const userPrompt = | |
| 456 | `Issue: ${ctx.title}\n${ctx.body}${commentSection}\n\n` + | |
| 457 | `Relevant code:\n${fileSection}\n\n` + | |
| 458 | `Return JSON:\n` + | |
| 459 | `{\n` + | |
| 460 | ` "summary": string,\n` + | |
| 461 | ` "files": Array<{\n` + | |
| 462 | ` "path": string,\n` + | |
| 463 | ` "action": "create" | "modify" | "delete",\n` + | |
| 464 | ` "description": string,\n` + | |
| 465 | ` "patch": string\n` + | |
| 466 | ` }>,\n` + | |
| 467 | ` "branchName": string\n` + | |
| 468 | `}`; | |
| 469 | ||
| 470 | const systemPrompt = | |
| 471 | "You are an expert software engineer. Generate a detailed implementation plan for the given issue. " + | |
| 472 | "For each file, provide the full new content (not a diff) in the 'patch' field. " + | |
| 473 | "Branch name should follow the pattern: workspace/issue-<number>-<short-slug>. " + | |
| 474 | "Return only valid JSON — no prose, no markdown fences."; | |
| 475 | ||
| 476 | const client = getAnthropic(); | |
| 477 | const msg = await client.messages.create({ | |
| 478 | model: MODEL_SONNET, | |
| 479 | max_tokens: 4096, | |
| 480 | temperature: 0.2, | |
| 481 | system: systemPrompt, | |
| 482 | messages: [{ role: "user", content: userPrompt }], | |
| 483 | }); | |
| 484 | ||
| 485 | const text = extractText(msg); | |
| 486 | const parsed = parseJsonResponse<WorkspacePlan>(text); | |
| 487 | ||
| 488 | if (!parsed || typeof parsed.summary !== "string") { | |
| 489 | throw new Error("AI returned invalid plan JSON"); | |
| 490 | } | |
| 491 | ||
| 492 | return { | |
| 493 | summary: parsed.summary || "AI-generated implementation", | |
| 494 | files: Array.isArray(parsed.files) ? parsed.files : [], | |
| 495 | branchName: typeof parsed.branchName === "string" && parsed.branchName | |
| 496 | ? sanitizeBranchName(parsed.branchName) | |
| 497 | : "", | |
| 498 | }; | |
| 499 | } | |
| 500 | ||
| 501 | function buildEdits(plan: WorkspacePlan): FileEdit[] { | |
| 502 | const edits: FileEdit[] = []; | |
| 503 | for (const f of plan.files) { | |
| 504 | if (!f.path || typeof f.path !== "string") continue; | |
| 505 | // Reject traversal attempts | |
| 506 | if (f.path.startsWith("/") || f.path.includes("..")) continue; | |
| 507 | ||
| 508 | if (f.action === "delete") { | |
| 509 | edits.push({ action: "delete", path: f.path }); | |
| 510 | } else { | |
| 511 | // create or modify — we always write the full content | |
| 512 | const content = typeof f.patch === "string" ? f.patch : ""; | |
| 513 | edits.push({ action: f.action === "create" ? "create" : "edit", path: f.path, content }); | |
| 514 | } | |
| 515 | } | |
| 516 | return edits; | |
| 517 | } | |
| 518 | ||
| 519 | function formatPlanComment(plan: WorkspacePlan): string { | |
| 520 | const fileLines = plan.files | |
| 521 | .map((f) => `- **${f.action}** \`${f.path}\` — ${f.description}`) | |
| 522 | .join("\n"); | |
| 523 | ||
| 524 | return ( | |
| 525 | `<!-- gluecron:workspace:plan -->\n` + | |
| 526 | `## AI Workspace Plan\n\n` + | |
| 527 | `${plan.summary}\n\n` + | |
| 528 | `**Files to change:**\n` + | |
| 529 | (fileLines || "_No files identified_") + | |
| 530 | `\n\n` + | |
| 531 | `_Implementing now... a draft PR will be opened when complete._` | |
| 532 | ); | |
| 533 | } | |
| 534 | ||
| 535 | async function postIssueComment( | |
| 536 | issueId: string, | |
| 537 | authorId: string, | |
| 538 | body: string | |
| 539 | ): Promise<string | null> { | |
| 540 | try { | |
| 541 | const [row] = await db | |
| 542 | .insert(issueComments) | |
| 543 | .values({ issueId, authorId, body }) | |
| 544 | .returning({ id: issueComments.id }); | |
| 545 | return row?.id ?? null; | |
| 546 | } catch { | |
| 547 | return null; | |
| 548 | } | |
| 549 | } | |
| 550 | ||
| 551 | async function persistJobStatus(job: WorkspaceJob): Promise<void> { | |
| 552 | try { | |
| 553 | await db | |
| 554 | .update(workspaceJobs) | |
| 555 | .set({ | |
| 556 | status: job.status, | |
| 557 | planComment: job.planComment ?? null, | |
| 558 | branchName: job.branchName ?? null, | |
| 559 | prNumber: job.prNumber ?? null, | |
| 560 | errorMessage: job.errorMessage ?? null, | |
| 561 | updatedAt: job.updatedAt, | |
| 562 | }) | |
| 563 | .where(eq(workspaceJobs.id, job.id)); | |
| 564 | } catch { | |
| 565 | /* non-fatal */ | |
| 566 | } | |
| 567 | } | |
| 568 | ||
| 569 | // --------------------------------------------------------------------------- | |
| 570 | // Utilities | |
| 571 | // --------------------------------------------------------------------------- | |
| 572 | ||
| 573 | function slugify(text: string): string { | |
| 574 | return text | |
| 575 | .toLowerCase() | |
| 576 | .replace(/[^a-z0-9]+/g, "-") | |
| 577 | .replace(/^-+|-+$/g, "") | |
| 578 | .slice(0, 40) || "change"; | |
| 579 | } | |
| 580 | ||
| 581 | function sanitizeBranchName(name: string): string { | |
| 582 | return name | |
| 583 | .replace(/[^a-zA-Z0-9/_-]/g, "-") | |
| 584 | .replace(/--+/g, "-") | |
| 585 | .replace(/^-+|-+$/g, "") | |
| 586 | .slice(0, 100); | |
| 587 | } |