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.
| 950ef90 | 1 | /** |
| 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 | ||
| 24 | import { and, eq, like } from "drizzle-orm"; | |
| 25 | import { join } from "path"; | |
| 26 | import { db } from "../db"; | |
| 27 | import { pullRequests, repositories, users } from "../db/schema"; | |
| 28 | import { getBlob, getTreeRecursive } from "../git/repository"; | |
| 29 | import { | |
| 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. */ | |
| 37 | const DEFAULT_MAX_SPECS_PER_TICK = 10; | |
| 38 | /** Per-repo cap so one busy repo can't starve the rest. */ | |
| 39 | const DEFAULT_MAX_SPECS_PER_REPO = 3; | |
| 40 | ||
| 41 | export 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 | ||
| 50 | export interface SpecToPrDispatcher { | |
| 51 | (args: { | |
| 52 | repositoryId: string; | |
| 53 | specPath: string; | |
| 54 | baseSha?: string; | |
| 55 | }): Promise<RunSpecToPrResult>; | |
| 56 | } | |
| 57 | ||
| 58 | export 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 | ||
| 77 | export 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 | */ | |
| 92 | async 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 | */ | |
| 185 | async 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`. */ | |
| 208 | async 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 | */ | |
| 227 | export 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. | |
| 293 | export 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. */ | |
| 299 | export const __test = { | |
| 300 | defaultFindCandidates, | |
| 301 | defaultHasOpenLinkedPr, | |
| 302 | defaultDispatcher, | |
| 303 | DEFAULT_MAX_SPECS_PER_TICK, | |
| 304 | DEFAULT_MAX_SPECS_PER_REPO, | |
| 305 | }; |