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

autopilot-spec-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.

autopilot-spec-to-pr.tsBlame305 lines · 1 contributor
950ef90Claude1/**
2 * Autopilot task — spec-to-PR loop.
3 *
4 * Every tick, walk every repo the owner-user maintains and look at the
5 * files under `.gluecron/specs/*.md`. For each spec whose front-matter is
6 * `status: ready` AND that does not yet have an open PR pointing at the
7 * same spec path, dispatch `runSpecToPr`.
8 *
9 * Mirrors the dependency-injection shape of `ai-build-tasks.ts`:
10 * - `findCandidates` — surface ready specs across enabled repos.
11 * - `hasOpenLinkedPr` — short-circuit if a previous tick already opened a PR.
12 * - `dispatcher` — runs `runSpecToPr`. Tests inject a stub.
13 *
14 * Skip rules (graceful no-ops):
15 * - `AUTOPILOT_DISABLED=1` short-circuits the task entirely.
16 * - Missing `ANTHROPIC_API_KEY` short-circuits (the dispatcher would also
17 * refuse, but skipping here saves a DB round-trip per tick).
18 * - Repos flagged as archived are filtered out at the SQL layer.
19 *
20 * Never throws. Per-repo failures are logged and swallowed so one broken
21 * spec can't wedge the autopilot tick.
22 */
23
24import { and, eq, like } from "drizzle-orm";
25import { join } from "path";
26import { db } from "../db";
27import { pullRequests, repositories, users } from "../db/schema";
28import { getBlob, getTreeRecursive } from "../git/repository";
29import {
30 AI_SPEC_PR_MARKER,
31 parseFrontMatter,
32 runSpecToPr,
33 type RunSpecToPrResult,
34} from "./spec-to-pr";
35
36/** Hard cap to bound work per tick on big multi-tenant deployments. */
37const DEFAULT_MAX_SPECS_PER_TICK = 10;
38/** Per-repo cap so one busy repo can't starve the rest. */
39const DEFAULT_MAX_SPECS_PER_REPO = 3;
40
41export interface SpecToPrCandidate {
42 repositoryId: string;
43 ownerName: string;
44 repoName: string;
45 defaultBranch: string;
46 /** Path inside the repo, e.g. `.gluecron/specs/foo.md`. */
47 specPath: string;
48}
49
50export interface SpecToPrDispatcher {
51 (args: {
52 repositoryId: string;
53 specPath: string;
54 baseSha?: string;
55 }): Promise<RunSpecToPrResult>;
56}
57
58export interface SpecToPrTaskDeps {
59 /** Inject candidate finder (defaults walk every enabled repo). */
60 findCandidates?: (
61 limit: number,
62 perRepoLimit: number
63 ) => Promise<SpecToPrCandidate[]>;
64 /** Inject the dedup check (looks for any PR body referencing the spec path). */
65 hasOpenLinkedPr?: (
66 repositoryId: string,
67 specPath: string
68 ) => Promise<boolean>;
69 /** Inject the dispatcher (real one calls `runSpecToPr`). */
70 dispatcher?: SpecToPrDispatcher;
71 /** Override the per-tick cap. */
72 maxSpecsPerTick?: number;
73 /** Override the per-repo cap. */
74 maxSpecsPerRepo?: number;
75}
76
77export interface SpecToPrTaskSummary {
78 considered: number;
79 dispatched: number;
80 skipped: number;
81 failed: number;
82}
83
84/**
85 * Default candidate finder. For every non-archived repo (capped via
86 * `limit`), list `.gluecron/specs/*.md` on the default branch, parse each
87 * file's front-matter, and surface specs whose status is `ready`.
88 *
89 * We intentionally cap the per-repo result at `perRepoLimit` so one repo
90 * with hundreds of ready specs can't monopolise the tick.
91 */
92async function defaultFindCandidates(
93 limit: number,
94 perRepoLimit: number
95): Promise<SpecToPrCandidate[]> {
96 let repoRows: Array<{
97 id: string;
98 name: string;
99 defaultBranch: string;
100 ownerName: string | null;
101 }>;
102 try {
103 repoRows = await db
104 .select({
105 id: repositories.id,
106 name: repositories.name,
107 defaultBranch: repositories.defaultBranch,
108 ownerName: users.username,
109 })
110 .from(repositories)
111 .innerJoin(users, eq(users.id, repositories.ownerId))
112 .where(eq(repositories.isArchived, false))
113 .limit(200);
114 } catch (err) {
115 console.error("[autopilot] spec-to-pr: repo query failed:", err);
116 return [];
117 }
118
119 const out: SpecToPrCandidate[] = [];
120 for (const repo of repoRows) {
121 if (out.length >= limit) break;
122 if (!repo.ownerName) continue;
123 const defaultBranch = repo.defaultBranch || "main";
124 let specPaths: string[] = [];
125 try {
126 const tree = await getTreeRecursive(repo.ownerName, repo.name, defaultBranch, 5000);
127 if (!tree) continue;
128 specPaths = tree.tree
129 .filter(
130 (e) =>
131 e.type === "blob" &&
132 e.path.startsWith(".gluecron/specs/") &&
133 e.path.toLowerCase().endsWith(".md")
134 )
135 .map((e) => e.path)
136 .slice(0, perRepoLimit * 5); // headroom — we'll filter by status next
137 } catch (err) {
138 console.warn(
139 `[autopilot] spec-to-pr: tree scan failed for ${repo.ownerName}/${repo.name}:`,
140 err instanceof Error ? err.message : err
141 );
142 continue;
143 }
144
145 let perRepo = 0;
146 for (const path of specPaths) {
147 if (perRepo >= perRepoLimit) break;
148 if (out.length >= limit) break;
149 try {
150 const blob = await getBlob(
151 repo.ownerName,
152 repo.name,
153 defaultBranch,
154 path
155 );
156 if (!blob || blob.isBinary) continue;
157 const parsed = parseFrontMatter(blob.content);
158 const status = (parsed.frontMatter.status || "").toLowerCase();
159 if (status !== "ready") continue;
160 out.push({
161 repositoryId: repo.id,
162 ownerName: repo.ownerName,
163 repoName: repo.name,
164 defaultBranch,
165 specPath: path,
166 });
167 perRepo += 1;
168 } catch (err) {
169 console.warn(
170 `[autopilot] spec-to-pr: blob read failed for ${repo.ownerName}/${repo.name}:${path}:`,
171 err instanceof Error ? err.message : err
172 );
173 }
174 }
175 }
176
177 return out;
178}
179
180/**
181 * Default dedup check. We look for any PR (open or otherwise) whose body
182 * embeds the spec path; the autopilot writes the spec path into the PR
183 * body so this is a reliable signal.
184 */
185async function defaultHasOpenLinkedPr(
186 repositoryId: string,
187 specPath: string
188): Promise<boolean> {
189 try {
190 const rows = await db
191 .select({ id: pullRequests.id })
192 .from(pullRequests)
193 .where(
194 and(
195 eq(pullRequests.repositoryId, repositoryId),
196 like(pullRequests.body, `%${AI_SPEC_PR_MARKER}%`),
197 like(pullRequests.body, `%${specPath}%`)
198 )
199 )
200 .limit(1);
201 return rows.length > 0;
202 } catch {
203 return false;
204 }
205}
206
207/** Default dispatcher just calls `runSpecToPr`. */
208async function defaultDispatcher(args: {
209 repositoryId: string;
210 specPath: string;
211 baseSha?: string;
212}): Promise<RunSpecToPrResult> {
213 try {
214 return await runSpecToPr(args);
215 } catch (err) {
216 return {
217 ok: false,
218 error: err instanceof Error ? err.message : String(err),
219 };
220 }
221}
222
223/**
224 * One iteration of the spec-to-pr loop. Returns a counts summary suitable
225 * for the autopilot tick log. Never throws.
226 */
227export async function runSpecToPrTaskOnce(
228 deps: SpecToPrTaskDeps = {}
229): Promise<SpecToPrTaskSummary> {
230 // Graceful gates — keep the work-skip cheap.
231 if (process.env.AUTOPILOT_DISABLED === "1") {
232 return { considered: 0, dispatched: 0, skipped: 0, failed: 0 };
233 }
234 if (!process.env.ANTHROPIC_API_KEY) {
235 return { considered: 0, dispatched: 0, skipped: 0, failed: 0 };
236 }
237
238 const limit = deps.maxSpecsPerTick ?? DEFAULT_MAX_SPECS_PER_TICK;
239 const perRepoLimit = deps.maxSpecsPerRepo ?? DEFAULT_MAX_SPECS_PER_REPO;
240 const findCandidates = deps.findCandidates ?? defaultFindCandidates;
241 const hasOpenLinkedPr = deps.hasOpenLinkedPr ?? defaultHasOpenLinkedPr;
242 const dispatcher = deps.dispatcher ?? defaultDispatcher;
243
244 let candidates: SpecToPrCandidate[] = [];
245 try {
246 candidates = await findCandidates(limit, perRepoLimit);
247 } catch (err) {
248 console.error("[autopilot] spec-to-pr: findCandidates threw:", err);
249 return { considered: 0, dispatched: 0, skipped: 0, failed: 0 };
250 }
251
252 let dispatched = 0;
253 let skipped = 0;
254 let failed = 0;
255
256 for (const cand of candidates) {
257 try {
258 if (await hasOpenLinkedPr(cand.repositoryId, cand.specPath)) {
259 skipped += 1;
260 continue;
261 }
262 const result = await dispatcher({
263 repositoryId: cand.repositoryId,
264 specPath: cand.specPath,
265 });
266 if (result.ok) {
267 dispatched += 1;
268 } else {
269 failed += 1;
270 console.warn(
271 `[autopilot] spec-to-pr: dispatch failed for ${cand.ownerName}/${cand.repoName}:${cand.specPath}: ${result.error}`
272 );
273 }
274 } catch (err) {
275 failed += 1;
276 console.error(
277 `[autopilot] spec-to-pr: per-spec failure for ${cand.specPath}:`,
278 err
279 );
280 }
281 }
282
283 return {
284 considered: candidates.length,
285 dispatched,
286 skipped,
287 failed,
288 };
289}
290
291// Re-export the disk-path helper so callers can build the same paths the
292// task computes internally — useful in admin tooling / observability.
293export function repoDiskPath(ownerName: string, repoName: string): string {
294 const base = process.env.GIT_REPOS_PATH || "./repos";
295 return join(base, ownerName, `${repoName}.git`);
296}
297
298/** Test-only exports. */
299export const __test = {
300 defaultFindCandidates,
301 defaultHasOpenLinkedPr,
302 defaultDispatcher,
303 DEFAULT_MAX_SPECS_PER_TICK,
304 DEFAULT_MAX_SPECS_PER_REPO,
305};