CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
ai-build-tasks.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.
| 2b9055e | 1 | /** |
| 2 | * Block K3 — AI-build dispatcher (issues tagged `ai:build`). | |
| 3 | * | |
| 4 | * Walks open issues whose label set includes a (case-insensitive) `ai:build` | |
| 5 | * label and, for each one, dispatches a Spec-to-PR build off the existing | |
| 6 | * `src/lib/spec-to-pr.ts` pipeline. Cheap idempotency: every dispatched | |
| 7 | * issue gets a marker comment containing `AI_BUILD_MARKER`; on subsequent | |
| 8 | * ticks that marker tells us "already handled, skip". | |
| 9 | * | |
| 10 | * Every dispatch is fire-and-forget — failures are logged and swallowed so | |
| 11 | * one bad issue can't wedge the autopilot tick. | |
| 12 | * | |
| 13 | * Inputs are dependency-injected so the autopilot test suite can exercise | |
| 14 | * the loop without touching the DB or the AI client. | |
| 15 | * | |
| 16 | * NOT in this module: | |
| 17 | * - The Spec-to-PR pipeline itself (lives in `src/lib/spec-to-pr.ts`). | |
| 18 | * - The autopilot wrapper / timing concerns (lives in `src/lib/autopilot.ts`). | |
| 19 | */ | |
| 20 | ||
| 21 | import { and, eq, ilike, sql } from "drizzle-orm"; | |
| 22 | import { db } from "../db"; | |
| 23 | import { | |
| 24 | issueComments, | |
| 25 | issueLabels, | |
| 26 | issues, | |
| 27 | labels, | |
| 28 | pullRequests, | |
| 29 | repositories, | |
| 30 | users, | |
| 31 | } from "../db/schema"; | |
| 32 | import { extractClosingRefsMulti } from "./close-keywords"; | |
| 33 | import { buildSpecFromIssue } from "../routes/specs"; | |
| 34 | ||
| 35 | /** | |
| 36 | * Stable marker baked into the issue comment so subsequent ticks can detect | |
| 37 | * "already dispatched" without race conditions. Versioned so we can bump | |
| 38 | * the contract later without re-dispatching every old issue. | |
| 39 | */ | |
| 40 | export const AI_BUILD_MARKER = "<!-- gluecron:ai-build:v1 -->"; | |
| 41 | ||
| 42 | const DEFAULT_MAX_ISSUES_PER_TICK = 20; | |
| 43 | ||
| 44 | export interface AiBuildCandidate { | |
| 45 | issueId: string; | |
| 46 | issueNumber: number; | |
| 47 | issueTitle: string; | |
| 48 | issueBody: string | null; | |
| 49 | repositoryId: string; | |
| 50 | authorUserId: string; | |
| 51 | /** Resolved repo owner username; null if the row is somehow orphaned. */ | |
| 52 | ownerUsername: string | null; | |
| 53 | /** Repo name (for logging/branch naming downstream). */ | |
| 54 | repoName: string; | |
| 55 | /** Default branch — passed to spec-to-PR as `baseRef`. */ | |
| 56 | defaultBranch: string; | |
| 57 | } | |
| 58 | ||
| 59 | export interface SpecDispatcher { | |
| 60 | /** | |
| 61 | * Same signature as `createSpecPR` in `src/lib/spec-to-pr.ts`. | |
| 62 | * Returns `ok:false` when ANTHROPIC_API_KEY is unset (gracefully). | |
| 63 | */ | |
| 64 | (args: { | |
| 65 | repoId: string; | |
| 66 | spec: string; | |
| 67 | baseRef: string; | |
| 68 | userId: string; | |
| 69 | }): Promise<{ ok: true; prNumber: number } | { ok: false; error: string }>; | |
| 70 | } | |
| 71 | ||
| 72 | export interface AiBuildTaskDeps { | |
| 73 | /** Override how candidates are sourced (DI for tests). */ | |
| 74 | findCandidates?: (limit: number) => Promise<AiBuildCandidate[]>; | |
| 75 | /** Override whether a candidate is already marker-tagged (DI for tests). */ | |
| 76 | hasDispatchMarker?: (issueId: string) => Promise<boolean>; | |
| 77 | /** | |
| 78 | * Override whether an open PR already closes this issue via close-keywords. | |
| 79 | * Returns true if some open PR's title/body references `closes #N`. | |
| 80 | */ | |
| 81 | hasOpenLinkedPr?: ( | |
| 82 | repositoryId: string, | |
| 83 | issueNumber: number | |
| 84 | ) => Promise<boolean>; | |
| 85 | /** Inject the dispatcher (real one lives in spec-to-pr.ts). */ | |
| 86 | dispatcher?: SpecDispatcher; | |
| 87 | /** Inject the marker-comment writer (DI for tests). */ | |
| 88 | postMarkerComment?: ( | |
| 89 | issueId: string, | |
| 90 | authorUserId: string, | |
| 91 | body: string | |
| 92 | ) => Promise<void>; | |
| 93 | /** Override the per-tick cap. */ | |
| 94 | maxIssuesPerTick?: number; | |
| 95 | } | |
| 96 | ||
| 97 | export interface AiBuildTaskSummary { | |
| 98 | queued: number; | |
| 99 | skipped: number; | |
| 100 | } | |
| 101 | ||
| 102 | /** | |
| 103 | * Default implementation of `findCandidates`. Joins open issues to their | |
| 104 | * label set, filters on a case-insensitive `ai:build` label name, and | |
| 105 | * resolves repo metadata + owner so the dispatcher has everything it needs. | |
| 106 | * | |
| 107 | * Skips archived repos. Cap is applied at the SQL layer. | |
| 108 | */ | |
| 109 | async function defaultFindCandidates( | |
| 110 | limit: number | |
| 111 | ): Promise<AiBuildCandidate[]> { | |
| 112 | try { | |
| 113 | const rows = await db | |
| 114 | .select({ | |
| 115 | issueId: issues.id, | |
| 116 | issueNumber: issues.number, | |
| 117 | issueTitle: issues.title, | |
| 118 | issueBody: issues.body, | |
| 119 | repositoryId: issues.repositoryId, | |
| 120 | authorUserId: issues.authorId, | |
| 121 | ownerUsername: users.username, | |
| 122 | repoName: repositories.name, | |
| 123 | defaultBranch: repositories.defaultBranch, | |
| 124 | }) | |
| 125 | .from(issues) | |
| 126 | .innerJoin(repositories, eq(repositories.id, issues.repositoryId)) | |
| 127 | .leftJoin(users, eq(users.id, repositories.ownerId)) | |
| 128 | .innerJoin(issueLabels, eq(issueLabels.issueId, issues.id)) | |
| 129 | .innerJoin(labels, eq(labels.id, issueLabels.labelId)) | |
| 130 | .where( | |
| 131 | and( | |
| 132 | eq(issues.state, "open"), | |
| 133 | eq(repositories.isArchived, false), | |
| 134 | ilike(labels.name, "ai:build") | |
| 135 | ) | |
| 136 | ) | |
| 137 | .limit(limit); | |
| 138 | return rows.map((r) => ({ | |
| 139 | issueId: r.issueId, | |
| 140 | issueNumber: r.issueNumber, | |
| 141 | issueTitle: r.issueTitle, | |
| 142 | issueBody: r.issueBody, | |
| 143 | repositoryId: r.repositoryId, | |
| 144 | authorUserId: r.authorUserId, | |
| 145 | ownerUsername: r.ownerUsername ?? null, | |
| 146 | repoName: r.repoName, | |
| 147 | defaultBranch: r.defaultBranch || "main", | |
| 148 | })); | |
| 149 | } catch (err) { | |
| 150 | console.error("[autopilot] ai-build: candidate query failed:", err); | |
| 151 | return []; | |
| 152 | } | |
| 153 | } | |
| 154 | ||
| 155 | /** | |
| 156 | * Default implementation of `hasDispatchMarker`. Looks for ANY comment on | |
| 157 | * the issue whose body contains `AI_BUILD_MARKER`. | |
| 158 | */ | |
| 159 | async function defaultHasDispatchMarker(issueId: string): Promise<boolean> { | |
| 160 | try { | |
| 161 | const rows = await db | |
| 162 | .select({ id: issueComments.id }) | |
| 163 | .from(issueComments) | |
| 164 | .where( | |
| 165 | and( | |
| 166 | eq(issueComments.issueId, issueId), | |
| 167 | sql`${issueComments.body} LIKE ${"%" + AI_BUILD_MARKER + "%"}` | |
| 168 | ) | |
| 169 | ) | |
| 170 | .limit(1); | |
| 171 | return rows.length > 0; | |
| 172 | } catch { | |
| 173 | // Be conservative: on DB error, treat as already-dispatched so we don't | |
| 174 | // spam the issue on a transient outage. | |
| 175 | return true; | |
| 176 | } | |
| 177 | } | |
| 178 | ||
| 179 | /** | |
| 180 | * Default implementation of `hasOpenLinkedPr`. Scans open PRs in the same | |
| 181 | * repo and uses `extractClosingRefsMulti` against title + body to see if | |
| 182 | * any of them references the issue with a closing keyword. | |
| 183 | */ | |
| 184 | async function defaultHasOpenLinkedPr( | |
| 185 | repositoryId: string, | |
| 186 | issueNumber: number | |
| 187 | ): Promise<boolean> { | |
| 188 | try { | |
| 189 | const rows = await db | |
| 190 | .select({ title: pullRequests.title, body: pullRequests.body }) | |
| 191 | .from(pullRequests) | |
| 192 | .where( | |
| 193 | and( | |
| 194 | eq(pullRequests.repositoryId, repositoryId), | |
| 195 | eq(pullRequests.state, "open") | |
| 196 | ) | |
| 197 | ); | |
| 198 | for (const r of rows) { | |
| 199 | const refs = extractClosingRefsMulti([r.title, r.body]); | |
| 200 | if (refs.includes(issueNumber)) return true; | |
| 201 | } | |
| 202 | return false; | |
| 203 | } catch { | |
| 204 | return false; | |
| 205 | } | |
| 206 | } | |
| 207 | ||
| 208 | /** | |
| 209 | * Default dispatcher. Dynamic-imports `createSpecPR` from `spec-to-pr.ts` | |
| 210 | * the same way `src/routes/specs.tsx` does, so we tolerate optional builds | |
| 211 | * where the module might be absent or missing the export. | |
| 212 | */ | |
| 213 | async function defaultDispatcher(args: { | |
| 214 | repoId: string; | |
| 215 | spec: string; | |
| 216 | baseRef: string; | |
| 217 | userId: string; | |
| 218 | }): Promise<{ ok: true; prNumber: number } | { ok: false; error: string }> { | |
| 219 | try { | |
| 220 | const mod: any = await import("./spec-to-pr"); | |
| 221 | const fn = mod && (mod.createSpecPR || mod.default?.createSpecPR); | |
| 222 | if (typeof fn !== "function") { | |
| 223 | return { ok: false, error: "createSpecPR not exported by spec-to-pr.ts" }; | |
| 224 | } | |
| 225 | const res = await fn(args); | |
| 226 | // Normalise — createSpecPR may return additional fields; we only need ok/prNumber. | |
| 227 | if (res && res.ok) return { ok: true, prNumber: res.prNumber }; | |
| 228 | return { | |
| 229 | ok: false, | |
| 230 | error: (res && "error" in res && res.error) || "unknown error", | |
| 231 | }; | |
| 232 | } catch (err) { | |
| 233 | return { | |
| 234 | ok: false, | |
| 235 | error: err instanceof Error ? err.message : String(err), | |
| 236 | }; | |
| 237 | } | |
| 238 | } | |
| 239 | ||
| 240 | /** | |
| 241 | * Default marker-comment writer. Posts a single issue comment authored by | |
| 242 | * the issue's own author so we don't need a separate "autopilot" user | |
| 243 | * (avoids dependency on a system user existing). | |
| 244 | */ | |
| 245 | async function defaultPostMarkerComment( | |
| 246 | issueId: string, | |
| 247 | authorUserId: string, | |
| 248 | body: string | |
| 249 | ): Promise<void> { | |
| 250 | try { | |
| 251 | await db.insert(issueComments).values({ | |
| 252 | issueId, | |
| 253 | authorId: authorUserId, | |
| 254 | body, | |
| 255 | }); | |
| 256 | } catch (err) { | |
| 257 | console.error("[autopilot] ai-build: marker insert failed:", err); | |
| 258 | } | |
| 259 | } | |
| 260 | ||
| 261 | /** | |
| 262 | * One iteration of the ai-build dispatcher. Returns a summary suitable | |
| 263 | * for the autopilot tick log. Never throws. | |
| 264 | */ | |
| 265 | export async function runAiBuildTaskOnce( | |
| 266 | deps: AiBuildTaskDeps = {} | |
| 267 | ): Promise<AiBuildTaskSummary> { | |
| 268 | const limit = deps.maxIssuesPerTick ?? DEFAULT_MAX_ISSUES_PER_TICK; | |
| 269 | const findCandidates = deps.findCandidates ?? defaultFindCandidates; | |
| 270 | const hasDispatchMarker = deps.hasDispatchMarker ?? defaultHasDispatchMarker; | |
| 271 | const hasOpenLinkedPr = deps.hasOpenLinkedPr ?? defaultHasOpenLinkedPr; | |
| 272 | const dispatcher = deps.dispatcher ?? defaultDispatcher; | |
| 273 | const postMarkerComment = deps.postMarkerComment ?? defaultPostMarkerComment; | |
| 274 | ||
| 275 | let candidates: AiBuildCandidate[] = []; | |
| 276 | try { | |
| 277 | candidates = await findCandidates(limit); | |
| 278 | } catch (err) { | |
| 279 | console.error("[autopilot] ai-build: findCandidates threw:", err); | |
| 280 | return { queued: 0, skipped: 0 }; | |
| 281 | } | |
| 282 | ||
| 283 | let queued = 0; | |
| 284 | let skipped = 0; | |
| 285 | ||
| 286 | for (const cand of candidates) { | |
| 287 | try { | |
| 288 | // Sanity: we need an owner to build the on-disk path further down. | |
| 289 | if (!cand.ownerUsername) { | |
| 290 | skipped += 1; | |
| 291 | continue; | |
| 292 | } | |
| 293 | ||
| 294 | // Skip if there's already an open PR that closes this issue via | |
| 295 | // close-keyword convention. | |
| 296 | if (await hasOpenLinkedPr(cand.repositoryId, cand.issueNumber)) { | |
| 297 | skipped += 1; | |
| 298 | continue; | |
| 299 | } | |
| 300 | ||
| 301 | // Skip if we've already dispatched (marker comment present). | |
| 302 | if (await hasDispatchMarker(cand.issueId)) { | |
| 303 | skipped += 1; | |
| 304 | continue; | |
| 305 | } | |
| 306 | ||
| 307 | const spec = buildSpecFromIssue({ | |
| 308 | number: cand.issueNumber, | |
| 309 | title: cand.issueTitle, | |
| 310 | body: cand.issueBody, | |
| 311 | }); | |
| 312 | ||
| 313 | // Post the marker BEFORE dispatching. This way, if the dispatcher is | |
| 314 | // slow or transiently fails, a subsequent tick won't double-fire. | |
| 315 | // The marker is the source of truth for idempotency. | |
| 316 | await postMarkerComment( | |
| 317 | cand.issueId, | |
| 318 | cand.authorUserId, | |
| 319 | `${AI_BUILD_MARKER}\nQueued an AI-build off this issue's spec. The PR (if any) will reference this issue via "Closes #${cand.issueNumber}".` | |
| 320 | ); | |
| 321 | ||
| 322 | // Fire the dispatcher. Errors are swallowed — the marker has already | |
| 323 | // been posted so we won't retry. (Operators can delete the marker | |
| 324 | // comment to force a re-run.) | |
| 325 | try { | |
| 326 | const res = await dispatcher({ | |
| 327 | repoId: cand.repositoryId, | |
| 328 | spec, | |
| 329 | baseRef: cand.defaultBranch, | |
| 330 | userId: cand.authorUserId, | |
| 331 | }); | |
| 332 | if (!res.ok) { | |
| 333 | console.error( | |
| 334 | `[autopilot] ai-build: dispatcher failed for issue=${cand.issueId}: ${res.error}` | |
| 335 | ); | |
| 336 | } | |
| 337 | } catch (err) { | |
| 338 | console.error( | |
| 339 | `[autopilot] ai-build: dispatcher threw for issue=${cand.issueId}:`, | |
| 340 | err | |
| 341 | ); | |
| 342 | } | |
| 343 | queued += 1; | |
| 344 | } catch (err) { | |
| 345 | console.error( | |
| 346 | `[autopilot] ai-build: per-issue failure for issue=${cand.issueId}:`, | |
| 347 | err | |
| 348 | ); | |
| 349 | skipped += 1; | |
| 350 | } | |
| 351 | } | |
| 352 | ||
| 353 | return { queued, skipped }; | |
| 354 | } | |
| 355 | ||
| 356 | /** Test-only surface. */ | |
| 357 | export const __test = { | |
| 358 | defaultFindCandidates, | |
| 359 | defaultHasDispatchMarker, | |
| 360 | defaultHasOpenLinkedPr, | |
| 361 | defaultDispatcher, | |
| 362 | defaultPostMarkerComment, | |
| 363 | DEFAULT_MAX_ISSUES_PER_TICK, | |
| 364 | }; |