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-pr-test-generator.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-pr-test-generator.tsBlame219 lines · 1 contributor
1d4ff60Claude1/**
2 * Autopilot task — `pr-test-generator`.
3 *
4 * Every tick (cadence-gated to ~5 min) pick up freshly-opened PRs that:
5 * 1. Are not themselves AI-generated (no `ai:spec-implementation`
6 * marker in the body — avoid recursion).
7 * 2. Don't already have an `ai:added-tests` marker comment.
8 * 3. Have at least one source-file change in the diff (we delegate
9 * that filter to `generateTestsForPr` which scans the diff anyway).
10 * 4. Live on a repo whose owner has `autoGenerateTests = true`.
11 *
12 * Mirrors the DI shape of `autopilot-spec-to-pr.ts` and friends:
13 * - `findCandidates` — surface fresh open PRs across opted-in repos.
14 * - `dispatcher` — runs `generateTestsForPr`. Tests inject a stub.
15 *
16 * Skip rules:
17 * - `AUTOPILOT_DISABLED=1` short-circuits.
18 * - Missing `ANTHROPIC_API_KEY` short-circuits.
19 *
20 * Never throws — per-PR failures are logged and swallowed so one broken
21 * PR can't wedge the tick.
22 */
23
24import { and, eq, gte } from "drizzle-orm";
25import { db } from "../db";
26import { pullRequests, repositories } from "../db/schema";
27import {
28 generateTestsForPr,
29 type GenerateTestsForPrResult,
30} from "./ai-test-generator";
31
32/** Window of "freshly opened" — PRs created within this many minutes. */
33export const FRESH_PR_WINDOW_MIN = 30;
34/** Hard cap on PRs processed per tick. */
35export const DEFAULT_MAX_PRS_PER_TICK = 5;
36
37export interface PrTestGenCandidate {
38 prId: string;
39 prNumber: number;
40 repositoryId: string;
41 body: string | null;
42}
43
44export interface PrTestGenDispatcher {
45 (args: { prId: string }): Promise<GenerateTestsForPrResult>;
46}
47
48export interface PrTestGenTaskDeps {
49 /** Inject candidate-finder. */
50 findCandidates?: (
51 windowMinutes: number,
52 limit: number
53 ) => Promise<PrTestGenCandidate[]>;
54 /** Inject dispatcher (real one calls `generateTestsForPr`). */
55 dispatcher?: PrTestGenDispatcher;
56 /** Override per-tick cap. */
57 maxPrsPerTick?: number;
58 /** Override the freshness window (minutes). */
59 windowMinutes?: number;
60}
61
62export interface PrTestGenTaskSummary {
63 considered: number;
64 dispatched: number;
65 skipped: number;
66 failed: number;
67}
68
69/**
70 * Default candidate finder. Open, non-draft PRs created in the last
71 * `windowMinutes` whose repo has `autoGenerateTests = true`. Body is
72 * surfaced so the runner can skip AI-generated PRs without an extra
73 * round-trip.
74 */
75async function defaultFindCandidates(
76 windowMinutes: number,
77 limit: number
78): Promise<PrTestGenCandidate[]> {
79 const cutoff = new Date(Date.now() - windowMinutes * 60 * 1000);
80 try {
81 const rows = await db
82 .select({
83 prId: pullRequests.id,
84 prNumber: pullRequests.number,
85 repositoryId: pullRequests.repositoryId,
86 body: pullRequests.body,
87 })
88 .from(pullRequests)
89 .innerJoin(repositories, eq(repositories.id, pullRequests.repositoryId))
90 .where(
91 and(
92 eq(pullRequests.state, "open"),
93 eq(pullRequests.isDraft, false),
94 eq(repositories.isArchived, false),
95 eq(repositories.autoGenerateTests, true),
96 gte(pullRequests.createdAt, cutoff)
97 )
98 )
99 .limit(limit);
100 return rows.map((r) => ({
101 prId: r.prId,
102 prNumber: r.prNumber,
103 repositoryId: r.repositoryId,
104 body: r.body,
105 }));
106 } catch (err) {
107 console.error(
108 "[autopilot] pr-test-generator: candidate query failed:",
109 err
110 );
111 return [];
112 }
113}
114
115/**
116 * Default dispatcher — just calls `generateTestsForPr` in append-commit
117 * mode. The follow-up-pr mode is reserved for the explicit-user-trigger
118 * route (`POST /:owner/:repo/pulls/:n/generate-tests`).
119 */
120async function defaultDispatcher(args: {
121 prId: string;
122}): Promise<GenerateTestsForPrResult> {
123 try {
124 return await generateTestsForPr({
125 prId: args.prId,
126 mode: "append-commit",
127 });
128 } catch (err) {
129 return {
130 ok: false,
131 error: err instanceof Error ? err.message : String(err),
132 };
133 }
134}
135
136/**
137 * One iteration of the pr-test-generator task. Returns a counts summary
138 * for the autopilot tick log. Never throws.
139 */
140export async function runPrTestGeneratorTaskOnce(
141 deps: PrTestGenTaskDeps = {}
142): Promise<PrTestGenTaskSummary> {
143 if (process.env.AUTOPILOT_DISABLED === "1") {
144 return { considered: 0, dispatched: 0, skipped: 0, failed: 0 };
145 }
146 if (!process.env.ANTHROPIC_API_KEY) {
147 return { considered: 0, dispatched: 0, skipped: 0, failed: 0 };
148 }
149
150 const findCandidates = deps.findCandidates ?? defaultFindCandidates;
151 const dispatcher = deps.dispatcher ?? defaultDispatcher;
152 const limit = deps.maxPrsPerTick ?? DEFAULT_MAX_PRS_PER_TICK;
153 const windowMinutes = deps.windowMinutes ?? FRESH_PR_WINDOW_MIN;
154
155 let candidates: PrTestGenCandidate[] = [];
156 try {
157 candidates = await findCandidates(windowMinutes, limit);
158 } catch (err) {
159 console.error(
160 "[autopilot] pr-test-generator: findCandidates threw:",
161 err
162 );
163 return { considered: 0, dispatched: 0, skipped: 0, failed: 0 };
164 }
165
166 let dispatched = 0;
167 let skipped = 0;
168 let failed = 0;
169
170 for (const cand of candidates) {
171 try {
172 const result = await dispatcher({ prId: cand.prId });
173 if (!result.ok) {
174 // Treat "PR not found" / "no candidate source files" / spec-PR
175 // refusals as skips, not failures, so the tick log is calm.
176 const err = (result.error || "").toLowerCase();
177 if (
178 err.includes("no candidate") ||
179 err.includes("ai-generated") ||
180 err.includes("not found")
181 ) {
182 skipped += 1;
183 } else {
184 failed += 1;
185 console.warn(
186 `[autopilot] pr-test-generator: PR #${cand.prNumber} failed: ${result.error}`
187 );
188 }
189 continue;
190 }
191 if (result.alreadyDone) {
192 skipped += 1;
193 continue;
194 }
195 dispatched += 1;
196 } catch (err) {
197 failed += 1;
198 console.error(
199 `[autopilot] pr-test-generator: per-PR failure for pr=${cand.prId}:`,
200 err
201 );
202 }
203 }
204
205 return {
206 considered: candidates.length,
207 dispatched,
208 skipped,
209 failed,
210 };
211}
212
213/** Test-only exports. */
214export const __test = {
215 defaultFindCandidates,
216 defaultDispatcher,
217 FRESH_PR_WINDOW_MIN,
218 DEFAULT_MAX_PRS_PER_TICK,
219};