Blame · Line-by-line history
gatetest-client.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.
| a6d8fd5 | 1 | /** |
| 2 | * Block K — Gatetest HTTP client. | |
| 3 | * | |
| 4 | * Typed tool primitives that K-agents call to drive Gatetest (external | |
| 5 | * testing / test-repair platform). Each primitive hits the documented | |
| 6 | * endpoint when `GATETEST_API_KEY` is set and falls back to a deterministic | |
| 7 | * offline mode otherwise. No method throws — callers get a well-formed | |
| 8 | * result with `offline: true` on any failure. | |
| 9 | * | |
| 10 | * Env vars (read lazily via getters so tests can flip them per-case): | |
| 11 | * GATETEST_API_KEY — bearer token for the Gatetest API (required) | |
| 12 | * GATETEST_BASE_URL — override base URL (default `https://gatetest.ai`) | |
| 13 | * | |
| 14 | * Endpoint shapes assumed (documented for the Gatetest team): | |
| 15 | * POST {base}/api/v2/run-and-repair | |
| 16 | * body: { repo, ref, targetGlob? } | |
| 17 | * 200 -> { | |
| 18 | * passed: boolean, | |
| 19 | * totalTests: number, | |
| 20 | * failedBefore: number, | |
| 21 | * failedAfter: number, | |
| 22 | * repairs: [{ file, before, after, reason }], | |
| 23 | * unfixable: [{ file, reason }], | |
| 24 | * durationMs: number | |
| 25 | * } | |
| 26 | * POST {base}/api/v2/stack-to-test | |
| 27 | * body: { repo, stackTrace, language? } | |
| 28 | * 200 -> { testCode, framework, suggestedPath } | |
| 29 | * POST {base}/api/v2/heal-suite | |
| 30 | * body: { repo } | |
| 31 | * 200 -> { | |
| 32 | * flakyFound: number, | |
| 33 | * deadFound: number, | |
| 34 | * coverageGapsFound: number, | |
| 35 | * prDraftBranch: string | null | |
| 36 | * } | |
| 37 | */ | |
| 38 | ||
| 39 | // --------------------------------------------------------------------------- | |
| 40 | // Env getters — read process.env at access time so tests can mutate freely. | |
| 41 | // --------------------------------------------------------------------------- | |
| 42 | ||
| 43 | export const gatetestEnv = { | |
| 44 | get apiKey(): string { | |
| 45 | return process.env.GATETEST_API_KEY || ""; | |
| 46 | }, | |
| 47 | get baseUrl(): string { | |
| 48 | return (process.env.GATETEST_BASE_URL || "https://gatetest.ai").replace( | |
| 49 | /\/+$/, | |
| 50 | "" | |
| 51 | ); | |
| 52 | }, | |
| 53 | }; | |
| 54 | ||
| 55 | export function isConfigured(): boolean { | |
| 56 | return !!gatetestEnv.apiKey; | |
| 57 | } | |
| 58 | ||
| 59 | export function buildAuthHeaders(): Record<string, string> { | |
| 60 | const headers: Record<string, string> = { | |
| 61 | "Content-Type": "application/json", | |
| 62 | }; | |
| 63 | const key = gatetestEnv.apiKey; | |
| 64 | if (key) headers["Authorization"] = `Bearer ${key}`; | |
| 65 | return headers; | |
| 66 | } | |
| 67 | ||
| 68 | // --------------------------------------------------------------------------- | |
| 69 | // Types | |
| 70 | // --------------------------------------------------------------------------- | |
| 71 | ||
| 72 | export type GatetestRepair = { | |
| 73 | file: string; | |
| 74 | before: string; | |
| 75 | after: string; | |
| 76 | reason: string; | |
| 77 | }; | |
| 78 | ||
| 79 | export type GatetestUnfixable = { | |
| 80 | file: string; | |
| 81 | reason: string; | |
| 82 | }; | |
| 83 | ||
| 84 | export type RunAndRepairResult = { | |
| 85 | passed: boolean; | |
| 86 | totalTests: number; | |
| 87 | failedBefore: number; | |
| 88 | failedAfter: number; | |
| 89 | repairs: GatetestRepair[]; | |
| 90 | unfixable: GatetestUnfixable[]; | |
| 91 | durationMs: number; | |
| 92 | offline: boolean; | |
| 93 | }; | |
| 94 | ||
| 95 | export type StackTraceToTestResult = { | |
| 96 | testCode: string; | |
| 97 | framework: string; | |
| 98 | suggestedPath: string; | |
| 99 | offline: boolean; | |
| 100 | }; | |
| 101 | ||
| 102 | export type HealSuiteResult = { | |
| 103 | flakyFound: number; | |
| 104 | deadFound: number; | |
| 105 | coverageGapsFound: number; | |
| 106 | prDraftBranch: string | null; | |
| 107 | offline: boolean; | |
| 108 | }; | |
| 109 | ||
| 110 | // --------------------------------------------------------------------------- | |
| 111 | // Offline defaults | |
| 112 | // --------------------------------------------------------------------------- | |
| 113 | ||
| 114 | function offlineRunAndRepair(): RunAndRepairResult { | |
| 115 | return { | |
| 116 | passed: false, | |
| 117 | totalTests: 0, | |
| 118 | failedBefore: 0, | |
| 119 | failedAfter: 0, | |
| 120 | repairs: [], | |
| 121 | unfixable: [], | |
| 122 | durationMs: 0, | |
| 123 | offline: true, | |
| 124 | }; | |
| 125 | } | |
| 126 | ||
| 127 | function offlineHealSuite(): HealSuiteResult { | |
| 128 | return { | |
| 129 | flakyFound: 0, | |
| 130 | deadFound: 0, | |
| 131 | coverageGapsFound: 0, | |
| 132 | prDraftBranch: null, | |
| 133 | offline: true, | |
| 134 | }; | |
| 135 | } | |
| 136 | ||
| 137 | function offlineStackToTest( | |
| 138 | stackTrace: string, | |
| 139 | language?: string | |
| 140 | ): StackTraceToTestResult { | |
| 141 | // Deterministic stub test. Intentionally decoupled from ai-tests.ts — | |
| 142 | // we're an offline fallback, not an AI-driven generator. | |
| 143 | const firstLine = (stackTrace || "").split("\n")[0]?.trim() || "error"; | |
| 144 | const escaped = firstLine.replace(/[`\\]/g, "").slice(0, 200); | |
| 145 | const lang = (language || "typescript").toLowerCase(); | |
| 146 | let suggestedPath = "tests/reproduce.test.ts"; | |
| 147 | let testCode = ""; | |
| 148 | if (lang === "python") { | |
| 149 | suggestedPath = "tests/test_reproduce.py"; | |
| 150 | testCode = | |
| 151 | `# TODO: Gatetest is offline — replace this stub with a real reproducer.\n` + | |
| 152 | `# Seed stack-trace: ${escaped}\n` + | |
| 153 | `def test_reproduce():\n` + | |
| 154 | ` assert False, "offline stub: paste stack trace + repro here"\n`; | |
| 155 | } else if (lang === "go") { | |
| 156 | suggestedPath = "reproduce_test.go"; | |
| 157 | testCode = | |
| 158 | `// TODO: Gatetest is offline — replace this stub with a real reproducer.\n` + | |
| 159 | `// Seed stack-trace: ${escaped}\n` + | |
| 160 | `package main\n\nimport "testing"\n\n` + | |
| 161 | `func TestReproduce(t *testing.T) {\n` + | |
| 162 | `\tt.Fatal("offline stub: paste stack trace + repro here")\n` + | |
| 163 | `}\n`; | |
| 164 | } else { | |
| 165 | testCode = | |
| 166 | `// TODO: Gatetest is offline — replace this stub with a real reproducer.\n` + | |
| 167 | `// Seed stack-trace: ${escaped}\n` + | |
| 168 | `import { test, expect } from "bun:test";\n\n` + | |
| 169 | `test("reproduce", () => {\n` + | |
| 170 | ` expect.unreachable("offline stub: paste stack trace + repro here");\n` + | |
| 171 | `});\n`; | |
| 172 | } | |
| 173 | return { | |
| 174 | testCode, | |
| 175 | framework: "fallback", | |
| 176 | suggestedPath, | |
| 177 | offline: true, | |
| 178 | }; | |
| 179 | } | |
| 180 | ||
| 181 | // --------------------------------------------------------------------------- | |
| 182 | // Shared fetch-with-timeout helper. Never throws — returns null on any | |
| 183 | // failure so callers can flip to the offline branch. | |
| 184 | // --------------------------------------------------------------------------- | |
| 185 | ||
| 186 | async function postJson( | |
| 187 | url: string, | |
| 188 | body: unknown, | |
| 189 | timeoutMs: number | |
| 190 | ): Promise<unknown | null> { | |
| 191 | const controller = new AbortController(); | |
| 192 | const timer = setTimeout(() => controller.abort(), timeoutMs); | |
| 193 | try { | |
| 194 | const res = await fetch(url, { | |
| 195 | method: "POST", | |
| 196 | headers: buildAuthHeaders(), | |
| 197 | body: JSON.stringify(body ?? {}), | |
| 198 | signal: controller.signal, | |
| 199 | }); | |
| 200 | if (!res.ok) return null; | |
| 201 | return await res.json().catch(() => null); | |
| 202 | } catch { | |
| 203 | return null; | |
| 204 | } finally { | |
| 205 | clearTimeout(timer); | |
| 206 | } | |
| 207 | } | |
| 208 | ||
| 209 | // --------------------------------------------------------------------------- | |
| 210 | // Public primitives | |
| 211 | // --------------------------------------------------------------------------- | |
| 212 | ||
| 213 | export async function runAndRepair(params: { | |
| 214 | repo: string; | |
| 215 | ref: string; | |
| 216 | targetGlob?: string; | |
| 217 | }): Promise<RunAndRepairResult> { | |
| 218 | if (!isConfigured()) return offlineRunAndRepair(); | |
| 219 | const url = `${gatetestEnv.baseUrl}/api/v2/run-and-repair`; | |
| 220 | const data = (await postJson(url, params, 5 * 60 * 1000)) as | |
| 221 | | Partial<RunAndRepairResult> | |
| 222 | | null; | |
| 223 | if (!data || typeof data !== "object") return offlineRunAndRepair(); | |
| 224 | return { | |
| 225 | passed: !!data.passed, | |
| 226 | totalTests: Number(data.totalTests || 0), | |
| 227 | failedBefore: Number(data.failedBefore || 0), | |
| 228 | failedAfter: Number(data.failedAfter || 0), | |
| 229 | repairs: Array.isArray(data.repairs) ? (data.repairs as GatetestRepair[]) : [], | |
| 230 | unfixable: Array.isArray(data.unfixable) | |
| 231 | ? (data.unfixable as GatetestUnfixable[]) | |
| 232 | : [], | |
| 233 | durationMs: Number(data.durationMs || 0), | |
| 234 | offline: false, | |
| 235 | }; | |
| 236 | } | |
| 237 | ||
| 238 | export async function stackTraceToTest(params: { | |
| 239 | repo: string; | |
| 240 | stackTrace: string; | |
| 241 | language?: string; | |
| 242 | }): Promise<StackTraceToTestResult> { | |
| 243 | if (!isConfigured()) { | |
| 244 | return offlineStackToTest(params.stackTrace, params.language); | |
| 245 | } | |
| 246 | const url = `${gatetestEnv.baseUrl}/api/v2/stack-to-test`; | |
| 247 | const data = (await postJson(url, params, 60 * 1000)) as | |
| 248 | | Partial<StackTraceToTestResult> | |
| 249 | | null; | |
| 250 | if (!data || typeof data !== "object" || typeof data.testCode !== "string") { | |
| 251 | return offlineStackToTest(params.stackTrace, params.language); | |
| 252 | } | |
| 253 | return { | |
| 254 | testCode: data.testCode, | |
| 255 | framework: typeof data.framework === "string" ? data.framework : "unknown", | |
| 256 | suggestedPath: | |
| 257 | typeof data.suggestedPath === "string" | |
| 258 | ? data.suggestedPath | |
| 259 | : "tests/reproduce.test.ts", | |
| 260 | offline: false, | |
| 261 | }; | |
| 262 | } | |
| 263 | ||
| 264 | export async function healSuite(params: { | |
| 265 | repo: string; | |
| 266 | }): Promise<HealSuiteResult> { | |
| 267 | if (!isConfigured()) return offlineHealSuite(); | |
| 268 | const url = `${gatetestEnv.baseUrl}/api/v2/heal-suite`; | |
| 269 | const data = (await postJson(url, params, 10 * 60 * 1000)) as | |
| 270 | | Partial<HealSuiteResult> | |
| 271 | | null; | |
| 272 | if (!data || typeof data !== "object") return offlineHealSuite(); | |
| 273 | return { | |
| 274 | flakyFound: Number(data.flakyFound || 0), | |
| 275 | deadFound: Number(data.deadFound || 0), | |
| 276 | coverageGapsFound: Number(data.coverageGapsFound || 0), | |
| 277 | prDraftBranch: | |
| 278 | typeof data.prDraftBranch === "string" ? data.prDraftBranch : null, | |
| 279 | offline: false, | |
| 280 | }; | |
| 281 | } | |
| 282 | ||
| 283 | export const __internal = { | |
| 284 | offlineRunAndRepair, | |
| 285 | offlineHealSuite, | |
| 286 | offlineStackToTest, | |
| 287 | postJson, | |
| 288 | }; |