CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
agent-journey.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.
| 2cecc08 | 1 | // Agent journey — simulate an EXTERNAL AI platform driving a full PR |
| 2 | // lifecycle against a live Gluecron instance, using exactly the surface | |
| 3 | // such a platform would use: a PAT bearer on MCP tools + git-over-HTTPS | |
| 4 | // with token basic-auth. This is the go/no-go certification for "other | |
| 5 | // platforms can open and merge PRs here". | |
| 6 | // | |
| 7 | // 1. identity GET /api/v2/user | |
| f8f6bd0 | 8 | // 1b. sweep delete any journey-* repos >1h old left behind by a |
| a74f4ed | 9 | // prior run that never reached its own step 11 (e.g. |
| f8f6bd0 | 10 | // the process was killed mid-run) — WARN-only |
| 2cecc08 | 11 | // 2. create repo POST /api/v2/repos (no MCP create-repo tool exists — platform gap, noted) |
| 12 | // 3. git push smart-HTTP receive-pack with PAT basic auth | |
| 13 | // 4. branch + edit gluecron_create_branch + gluecron_write_file | |
| a74f4ed | 14 | // 4c. webhook register a probe webhook (pr events) via the same |
| 15 | // PAT bearer, so step 10 can prove fireWebhooks() is | |
| 16 | // wired into the real create/merge call sites | |
| 2cecc08 | 17 | // 5. open PR gluecron_create_pr |
| 18 | // 6. comment gluecron_comment_pr | |
| 19 | // 7. AI review poll PR comments for the ai-review marker (WARN if absent) | |
| 20 | // 8. merge gluecron_merge_pr → gluecron_get_pr state=merged | |
| 21 | // 9. events GET /api/v2/repos/:o/:r/activity | |
| a74f4ed | 22 | // 10. webhook delivery poll the probe webhook's settings page for a |
| 23 | // recorded delivery attempt | |
| 24 | // 11. cleanup gluecron_delete_repo (always, unless KEEP_REPO=1) | |
| 2cecc08 | 25 | // |
| 26 | // Run: bun run scripts/agent-journey.ts | |
| 27 | // Env: GLUECRON_PAT REQUIRED — admin-scope PAT (merge + delete need it) | |
| 28 | // GLUECRON_HOST target (default https://gluecron.com) | |
| 29 | // KEEP_REPO=1 skip cleanup for debugging | |
| 30 | // Exit: 0 iff all steps pass (step 7 is WARN-only — AI review may be | |
| 31 | // legitimately disabled). Never touches any existing repo. | |
| 32 | ||
| 33 | import { AI_REVIEW_MARKER } from "../src/lib/ai-review"; | |
| 34 | import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; | |
| 35 | import { tmpdir } from "node:os"; | |
| 36 | import { join } from "node:path"; | |
| 37 | ||
| 38 | const HOST = (process.env.GLUECRON_HOST || "https://gluecron.com").replace(/\/+$/, ""); | |
| 39 | const PAT = process.env.GLUECRON_PAT?.trim() || ""; | |
| 40 | const KEEP = process.env.KEEP_REPO === "1"; | |
| 41 | ||
| 42 | if (!PAT) { | |
| 43 | console.error("GLUECRON_PAT is required (admin scope). Mint one at /settings/tokens or via scripts/emergency-pat.ts."); | |
| 44 | process.exit(2); | |
| 45 | } | |
| 46 | ||
| 47 | // Hard safety guard: the scratch repo name is generated here and must never | |
| 48 | // collide with the self-host repo (its post-receive hook triggers deploys). | |
| 49 | const REPO = `journey-${new Date().toISOString().slice(0, 19).replace(/[:T-]/g, "")}-${Math.random().toString(36).slice(2, 6)}`; | |
| 50 | const FORBIDDEN = /gluecron\.com/i; | |
| 51 | if (FORBIDDEN.test(REPO)) throw new Error("unreachable: scratch repo name collided with self-host repo"); | |
| 52 | ||
| 53 | // ─── plumbing ──────────────────────────────────────────────────────── | |
| 54 | ||
| 55 | async function api(method: string, path: string, body?: unknown) { | |
| 56 | const res = await fetch(HOST + path, { | |
| 57 | method, | |
| 58 | headers: { | |
| 59 | authorization: `Bearer ${PAT}`, | |
| 60 | ...(body !== undefined ? { "content-type": "application/json" } : {}), | |
| 61 | }, | |
| 62 | ...(body !== undefined ? { body: JSON.stringify(body) } : {}), | |
| 63 | }); | |
| 64 | const text = await res.text(); | |
| 65 | let json: any = null; | |
| 66 | try { json = JSON.parse(text); } catch { /* non-JSON */ } | |
| 67 | return { status: res.status, json, text }; | |
| 68 | } | |
| 69 | ||
| 70 | let rpcId = 0; | |
| 71 | async function mcp(tool: string, args: Record<string, unknown>) { | |
| 72 | const res = await fetch(HOST + "/mcp", { | |
| 73 | method: "POST", | |
| 74 | headers: { authorization: `Bearer ${PAT}`, "content-type": "application/json" }, | |
| 75 | body: JSON.stringify({ jsonrpc: "2.0", id: ++rpcId, method: "tools/call", params: { name: tool, arguments: args } }), | |
| 76 | }); | |
| 77 | const json: any = await res.json(); | |
| 78 | if (res.status !== 200 || json.error) { | |
| 79 | throw new Error(`${tool} → HTTP ${res.status} ${JSON.stringify(json.error ?? json).slice(0, 300)}`); | |
| 80 | } | |
| 81 | if (json.result?.isError) { | |
| 82 | throw new Error(`${tool} → tool error: ${JSON.stringify(json.result.content).slice(0, 300)}`); | |
| 83 | } | |
| 84 | // MCP text content is JSON-stringified tool output; parse when possible. | |
| 85 | const textPart = (json.result?.content ?? []).find((p: any) => p.type === "text")?.text; | |
| 86 | try { return JSON.parse(textPart); } catch { return textPart; } | |
| 87 | } | |
| 88 | ||
| 89 | function git(cwd: string, ...args: string[]): string { | |
| 90 | const proc = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" }); | |
| 91 | const out = new TextDecoder().decode(proc.stdout); | |
| 92 | const err = new TextDecoder().decode(proc.stderr); | |
| 93 | if (proc.exitCode !== 0) throw new Error(`git ${args[0]} failed (${proc.exitCode}): ${(err || out).slice(0, 300)}`); | |
| 94 | return out.trim(); | |
| 95 | } | |
| 96 | ||
| 97 | interface StepResult { name: string; ok: boolean; warn?: boolean; ms: number; detail?: string } | |
| 98 | const results: StepResult[] = []; | |
| 99 | ||
| 100 | async function step<T>(name: string, fn: () => Promise<T>, opts: { warnOnly?: boolean } = {}): Promise<T | null> { | |
| 101 | const t0 = Date.now(); | |
| 102 | try { | |
| 103 | const v = await fn(); | |
| 104 | results.push({ name, ok: true, ms: Date.now() - t0 }); | |
| 105 | console.log(`PASS ${name} (${Date.now() - t0}ms)`); | |
| 106 | return v; | |
| 107 | } catch (err) { | |
| 108 | const detail = (err as Error).message; | |
| 109 | results.push({ name, ok: !!opts.warnOnly, warn: opts.warnOnly, ms: Date.now() - t0, detail }); | |
| 110 | console.log(`${opts.warnOnly ? "WARN" : "FAIL"} ${name} — ${detail}`); | |
| 111 | if (!opts.warnOnly) throw err; | |
| 112 | return null; | |
| 113 | } | |
| 114 | } | |
| 115 | ||
| 116 | // ─── the journey ───────────────────────────────────────────────────── | |
| 117 | ||
| 118 | async function main() { | |
| 119 | console.log(`# agent-journey — ${HOST} — scratch repo "${REPO}" — ${new Date().toISOString()}\n`); | |
| 120 | let owner = ""; | |
| 121 | let repoCreated = false; | |
| 122 | ||
| 123 | try { | |
| 124 | // 1. identity | |
| 125 | const user = await step("1. PAT identity (GET /api/v2/user)", async () => { | |
| 126 | const r = await api("GET", "/api/v2/user"); | |
| 127 | if (r.status !== 200 || !r.json?.username) throw new Error(`HTTP ${r.status}: ${r.text.slice(0, 200)}`); | |
| 128 | return r.json; | |
| 129 | }); | |
| 130 | owner = user!.username; | |
| 131 | if (`${owner}/${REPO}`.toLowerCase() === "ccantynz/gluecron.com") throw new Error("safety: refusing to touch the self-host repo"); | |
| 132 | ||
| f8f6bd0 | 133 | // 1b. sweep leftover probes from earlier runs — WARN-only, best-effort. |
| 134 | // The step-10 cleanup below only runs inside this process's try/finally, | |
| 135 | // which can't protect against the process itself dying mid-run (killed | |
| 136 | // terminal, crashed script, host restart) — a hard kill skips `finally` | |
| 137 | // entirely, same as any JS runtime. Rather than chase every possible | |
| 138 | // cause of a mid-run death, sweep for orphaned `journey-*` repos here so | |
| 139 | // cleanup is self-healing across runs instead of depending on any single | |
| 140 | // run completing cleanly. 1-hour grace period avoids racing a | |
| 141 | // concurrently-running journey. | |
| 142 | await step("1b. sweep leftover journey-* probes", async () => { | |
| 143 | const r = await api("GET", `/api/v2/users/${encodeURIComponent(owner)}/repos`); | |
| 144 | if (r.status !== 200 || !Array.isArray(r.json)) throw new Error(`HTTP ${r.status}: ${r.text.slice(0, 200)}`); | |
| 145 | const cutoff = Date.now() - 60 * 60 * 1000; | |
| 146 | const orphans = r.json.filter((repo: any) => | |
| 147 | /^journey-\d{14}-[a-z0-9]{4}$/.test(repo.name) && | |
| 148 | repo.name !== REPO && | |
| 149 | new Date(repo.createdAt).getTime() < cutoff | |
| 150 | ); | |
| 151 | if (orphans.length === 0) return; | |
| 152 | let swept = 0; | |
| 153 | const failures: string[] = []; | |
| 154 | for (const repo of orphans) { | |
| 155 | try { | |
| 156 | await mcp("gluecron_delete_repo", { owner, repo: repo.name }); | |
| 157 | swept++; | |
| 158 | } catch (err) { | |
| 159 | failures.push(`${repo.name}: ${(err as Error).message}`); | |
| 160 | } | |
| 161 | } | |
| 162 | console.log(` swept ${swept}/${orphans.length} orphaned probe repo(s)`); | |
| 163 | if (failures.length > 0) throw new Error(`${failures.length} orphan(s) failed to delete: ${failures.join("; ").slice(0, 300)}`); | |
| 164 | }, { warnOnly: true }); | |
| 165 | ||
| 2cecc08 | 166 | // 2. create scratch repo |
| 167 | await step("2. create scratch repo (POST /api/v2/repos)", async () => { | |
| 168 | const r = await api("POST", "/api/v2/repos", { name: REPO, description: "agent-journey probe — auto-deleted", private: false }); | |
| 169 | if (r.status !== 200 && r.status !== 201) throw new Error(`HTTP ${r.status}: ${r.text.slice(0, 200)}`); | |
| 170 | repoCreated = true; | |
| 171 | }); | |
| 172 | ||
| 173 | // 3. git push over smart HTTP with PAT basic auth | |
| 174 | const pushUrl = `${HOST.replace("://", `://${encodeURIComponent(owner)}:${PAT}@`)}/${owner}/${REPO}.git`; | |
| 175 | const mainSha = await step("3. git push main over HTTPS (PAT basic auth)", async () => { | |
| 176 | const dir = mkdtempSync(join(tmpdir(), "gluecron-journey-")); | |
| 177 | try { | |
| 178 | git(dir, "init", "-b", "main"); | |
| 179 | git(dir, "config", "user.email", "journey@gluecron.com"); | |
| 180 | git(dir, "config", "user.name", "agent-journey"); | |
| 181 | writeFileSync(join(dir, "README.md"), `# ${REPO}\n\nExternal-platform journey probe.\n`); | |
| 182 | git(dir, "add", "."); | |
| 183 | git(dir, "commit", "-m", "chore: journey seed commit"); | |
| 184 | const sha = git(dir, "rev-parse", "HEAD"); | |
| 185 | git(dir, "push", pushUrl, "main"); | |
| 186 | return sha; | |
| 187 | } finally { | |
| 188 | rmSync(dir, { recursive: true, force: true }); | |
| 189 | } | |
| 190 | }); | |
| 191 | ||
| 192 | // 4. branch + file via MCP write tools | |
| 193 | await step("4a. gluecron_create_branch", () => mcp("gluecron_create_branch", { owner, repo: REPO, branch: "feature/journey", sha: mainSha })); | |
| 194 | await step("4b. gluecron_write_file on branch", () => mcp("gluecron_write_file", { owner, repo: REPO, path: "probe.md", branch: "feature/journey", message: "feat: journey probe file", content: `probe ${new Date().toISOString()}\n` })); | |
| 195 | ||
| a74f4ed | 196 | // 4c. register a probe webhook (pr events) so step 11 below can prove |
| 197 | // fireWebhooks() is actually wired into the real PR create/merge call | |
| 198 | // sites, not just present in the codebase. Points at this instance's | |
| 199 | // own /healthz — fast, always answers with a real HTTP status, no | |
| 200 | // external network dependency. requireAuth on this form route accepts | |
| 201 | // the same PAT bearer as everything else (softAuth: "Bearer token takes | |
| 202 | // precedence over cookie for API calls"). | |
| 203 | await step("4c. register probe webhook", async () => { | |
| 204 | const res = await fetch(`${HOST}/${owner}/${REPO}/settings/webhooks`, { | |
| 205 | method: "POST", | |
| 206 | headers: { | |
| 207 | authorization: `Bearer ${PAT}`, | |
| 208 | "content-type": "application/x-www-form-urlencoded", | |
| 209 | }, | |
| 210 | body: new URLSearchParams({ url: `${HOST}/healthz`, events: "pr" }), | |
| 211 | redirect: "manual", | |
| 212 | }); | |
| 213 | // The handler always redirects (3xx) on both success and validation | |
| 214 | // error; anything else means something is actually broken. | |
| 215 | if (res.status < 300 || res.status >= 400) { | |
| 216 | throw new Error(`webhook registration: HTTP ${res.status}`); | |
| 217 | } | |
| 218 | }); | |
| 219 | ||
| 2cecc08 | 220 | // 5. open PR |
| 221 | const pr = await step("5. gluecron_create_pr", () => mcp("gluecron_create_pr", { owner, repo: REPO, title: "Journey probe PR", body: "Opened by scripts/agent-journey.ts — will be merged and the repo deleted.", head_branch: "feature/journey", base_branch: "main" })); | |
| 222 | const prNumber: number = pr!.number; | |
| 223 | ||
| 224 | // 6. comment | |
| 225 | await step("6. gluecron_comment_pr", () => mcp("gluecron_comment_pr", { owner, repo: REPO, number: prNumber, body: `Journey probe comment ${new Date().toISOString()}` })); | |
| 226 | ||
| 227 | // 7. AI review (WARN-only: may be disabled / unfunded) | |
| 228 | await step("7. AI review fired (poll for marker, ≤90s)", async () => { | |
| 229 | const deadline = Date.now() + 90_000; | |
| 230 | while (Date.now() < deadline) { | |
| 231 | const r = await api("GET", `/api/v2/repos/${owner}/${REPO}/pulls/${prNumber}`); | |
| 232 | const comments: Array<{ body?: string; isAiReview?: boolean }> = r.json?.comments ?? []; | |
| 233 | if (comments.some((cm) => cm.isAiReview || cm.body?.includes(AI_REVIEW_MARKER))) return true; | |
| 234 | await new Promise((res) => setTimeout(res, 5000)); | |
| 235 | } | |
| 236 | throw new Error("no AI-review comment within 90s (review disabled, or ANTHROPIC_API_KEY unset?)"); | |
| 237 | }, { warnOnly: true }); | |
| 238 | ||
| 239 | // 8. merge + verify | |
| 240 | await step("8a. gluecron_merge_pr", async () => { | |
| 241 | const m = await mcp("gluecron_merge_pr", { owner, repo: REPO, number: prNumber }); | |
| 242 | if (!m?.merged) throw new Error(`not merged: ${JSON.stringify(m).slice(0, 200)}`); | |
| 243 | return m; | |
| 244 | }); | |
| 245 | await step("8b. PR state is merged + main advanced", async () => { | |
| 246 | const detail = await mcp("gluecron_get_pr", { owner, repo: REPO, number: prNumber }); | |
| 247 | const state = detail?.state ?? detail?.pr?.state; | |
| 248 | if (state !== "merged") throw new Error(`PR state: ${state}`); | |
| 249 | const dir = mkdtempSync(join(tmpdir(), "gluecron-lsr-")); | |
| 250 | try { | |
| 251 | git(dir, "init"); | |
| 252 | const head = git(dir, "ls-remote", pushUrl, "refs/heads/main").split(/\s/)[0]; | |
| 253 | if (head === mainSha) throw new Error("main did not advance past the seed commit"); | |
| 254 | } finally { | |
| 255 | rmSync(dir, { recursive: true, force: true }); | |
| 256 | } | |
| 257 | }); | |
| 258 | ||
| a74f4ed | 259 | // 9. events — activityFeed wiring gap (found 2026-07-15) was fixed same |
| 260 | // day (commit b7b5f75): logActivity() is now called from git push, PR | |
| 261 | // create/comment/merge, and the MCP write tools. Promoted from | |
| 262 | // warnOnly to a real assertion now that the underlying gap is closed — | |
| 263 | // this is exactly the check that caught the gap in the first place, so | |
| 264 | // it stays a hard requirement going forward rather than reverting to | |
| 265 | // silently-optional once fixed. | |
| 2cecc08 | 266 | await step("9. activity feed recorded push + PR events", async () => { |
| 267 | const r = await api("GET", `/api/v2/repos/${owner}/${REPO}/activity`); | |
| 268 | if (r.status !== 200) throw new Error(`HTTP ${r.status}`); | |
| 561b2a8 | 269 | // GET /api/v2/repos/:owner/:repo/activity returns a bare array. |
| 270 | const events: Array<{ type?: string }> = Array.isArray(r.json) | |
| 271 | ? r.json | |
| 272 | : r.json?.events ?? r.json?.activity ?? []; | |
| a74f4ed | 273 | if (!Array.isArray(events) || events.length === 0) throw new Error("no activity events recorded — activity_feed wiring regressed"); |
| 274 | }); | |
| 275 | ||
| 276 | // 10. webhook delivery recorded — proves fireWebhooks() is actually | |
| 277 | // called from the real PR create/merge handlers (found 2026-07-16: | |
| 278 | // fireWebhooks() had zero callers anywhere despite a full config UI and | |
| 279 | // "last delivery" status pill; fixed by wiring it into post-receive.ts, | |
| 280 | // issues.tsx, pulls.tsx, pr-merge.ts, and web.tsx). The probe webhook | |
| 281 | // registered in step 4c points at this instance's own /healthz — any | |
| 282 | // delivery attempt, success or failure, sets webhooks.lastStatus to a | |
| 283 | // non-null value (see src/lib/webhook-delivery.ts's `lastStatus: | |
| 284 | // statusCode ?? 0` on every branch), which flips the settings page's | |
| 285 | // "no deliveries yet" text. Polls because fireWebhooks() enqueues | |
| 286 | // fire-and-forget — the delivery worker runs asynchronously after the | |
| 287 | // PR-open/merge response already returned. | |
| 288 | await step("10. webhook delivery recorded (poll for lastStatus, ≤30s)", async () => { | |
| 289 | const deadline = Date.now() + 30_000; | |
| 290 | while (Date.now() < deadline) { | |
| 291 | const res = await fetch(`${HOST}/${owner}/${REPO}/settings/webhooks`, { | |
| 292 | headers: { authorization: `Bearer ${PAT}` }, | |
| 293 | }); | |
| 294 | const html = await res.text(); | |
| 295 | if (res.status === 200 && !html.includes("no deliveries yet")) return true; | |
| 296 | await new Promise((r) => setTimeout(r, 3000)); | |
| 297 | } | |
| 298 | throw new Error("no webhook delivery recorded within 30s — fireWebhooks() call sites may have regressed"); | |
| 299 | }); | |
| 2cecc08 | 300 | } finally { |
| a74f4ed | 301 | // 11. cleanup — always attempt when the repo was created |
| 2cecc08 | 302 | if (repoCreated && !KEEP) { |
| 303 | try { | |
| 304 | await mcp("gluecron_delete_repo", { owner, repo: REPO }); | |
| a74f4ed | 305 | console.log(`PASS 11. cleanup — deleted ${owner}/${REPO}`); |
| 306 | results.push({ name: "11. cleanup (gluecron_delete_repo)", ok: true, ms: 0 }); | |
| 2cecc08 | 307 | } catch (err) { |
| a74f4ed | 308 | console.error(`FAIL 11. cleanup — LEFTOVER REPO ${owner}/${REPO}: ${(err as Error).message}`); |
| 309 | results.push({ name: "11. cleanup (gluecron_delete_repo)", ok: false, ms: 0, detail: `LEFTOVER ${owner}/${REPO}` }); | |
| 2cecc08 | 310 | } |
| 311 | } else if (repoCreated) { | |
| a74f4ed | 312 | console.log(`KEEP 11. cleanup skipped (KEEP_REPO=1) — ${owner}/${REPO}`); |
| 2cecc08 | 313 | } |
| 314 | } | |
| 315 | ||
| 316 | const failed = results.filter((r) => !r.ok).length; | |
| 317 | const warned = results.filter((r) => r.warn).length; | |
| 318 | console.log(`\n# agent-journey: ${failed === 0 ? "ALL GREEN" : `${failed} FAILURE(S)`}${warned ? ` — ${warned} warning(s)` : ""}`); | |
| 319 | process.exit(failed === 0 ? 0 : 1); | |
| 320 | } | |
| 321 | ||
| 322 | main().catch((err) => { | |
| 323 | console.error(`\n# agent-journey: FAILED — ${(err as Error).message}`); | |
| 324 | process.exit(1); | |
| 325 | }); |