Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

ai-build-tasks.tsBlame364 lines · 1 contributor
2b9055eClaude1/**
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
21import { and, eq, ilike, sql } from "drizzle-orm";
22import { db } from "../db";
23import {
24 issueComments,
25 issueLabels,
26 issues,
27 labels,
28 pullRequests,
29 repositories,
30 users,
31} from "../db/schema";
32import { extractClosingRefsMulti } from "./close-keywords";
33import { 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 */
40export const AI_BUILD_MARKER = "<!-- gluecron:ai-build:v1 -->";
41
42const DEFAULT_MAX_ISSUES_PER_TICK = 20;
43
44export 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
59export 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
72export 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
97export 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 */
109async 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 */
159async 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 */
184async 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 */
213async 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 */
245async 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 */
265export 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. */
357export const __test = {
358 defaultFindCandidates,
359 defaultHasDispatchMarker,
360 defaultHasOpenLinkedPr,
361 defaultDispatcher,
362 defaultPostMarkerComment,
363 DEFAULT_MAX_ISSUES_PER_TICK,
364};