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 |
| 9 | // prior run that never reached its own step 10 (e.g. | |
| 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 | |
| 14 | // 5. open PR gluecron_create_pr | |
| 15 | // 6. comment gluecron_comment_pr | |
| 16 | // 7. AI review poll PR comments for the ai-review marker (WARN if absent) | |
| 17 | // 8. merge gluecron_merge_pr → gluecron_get_pr state=merged | |
| 18 | // 9. events GET /api/v2/repos/:o/:r/activity | |
| 19 | // 10. cleanup gluecron_delete_repo (always, unless KEEP_REPO=1) | |
| 20 | // | |
| 21 | // Run: bun run scripts/agent-journey.ts | |
| 22 | // Env: GLUECRON_PAT REQUIRED — admin-scope PAT (merge + delete need it) | |
| 23 | // GLUECRON_HOST target (default https://gluecron.com) | |
| 24 | // KEEP_REPO=1 skip cleanup for debugging | |
| 25 | // Exit: 0 iff all steps pass (step 7 is WARN-only — AI review may be | |
| 26 | // legitimately disabled). Never touches any existing repo. | |
| 27 | ||
| 28 | import { AI_REVIEW_MARKER } from "../src/lib/ai-review"; | |
| 29 | import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; | |
| 30 | import { tmpdir } from "node:os"; | |
| 31 | import { join } from "node:path"; | |
| 32 | ||
| 33 | const HOST = (process.env.GLUECRON_HOST || "https://gluecron.com").replace(/\/+$/, ""); | |
| 34 | const PAT = process.env.GLUECRON_PAT?.trim() || ""; | |
| 35 | const KEEP = process.env.KEEP_REPO === "1"; | |
| 36 | ||
| 37 | if (!PAT) { | |
| 38 | console.error("GLUECRON_PAT is required (admin scope). Mint one at /settings/tokens or via scripts/emergency-pat.ts."); | |
| 39 | process.exit(2); | |
| 40 | } | |
| 41 | ||
| 42 | // Hard safety guard: the scratch repo name is generated here and must never | |
| 43 | // collide with the self-host repo (its post-receive hook triggers deploys). | |
| 44 | const REPO = `journey-${new Date().toISOString().slice(0, 19).replace(/[:T-]/g, "")}-${Math.random().toString(36).slice(2, 6)}`; | |
| 45 | const FORBIDDEN = /gluecron\.com/i; | |
| 46 | if (FORBIDDEN.test(REPO)) throw new Error("unreachable: scratch repo name collided with self-host repo"); | |
| 47 | ||
| 48 | // ─── plumbing ──────────────────────────────────────────────────────── | |
| 49 | ||
| 50 | async function api(method: string, path: string, body?: unknown) { | |
| 51 | const res = await fetch(HOST + path, { | |
| 52 | method, | |
| 53 | headers: { | |
| 54 | authorization: `Bearer ${PAT}`, | |
| 55 | ...(body !== undefined ? { "content-type": "application/json" } : {}), | |
| 56 | }, | |
| 57 | ...(body !== undefined ? { body: JSON.stringify(body) } : {}), | |
| 58 | }); | |
| 59 | const text = await res.text(); | |
| 60 | let json: any = null; | |
| 61 | try { json = JSON.parse(text); } catch { /* non-JSON */ } | |
| 62 | return { status: res.status, json, text }; | |
| 63 | } | |
| 64 | ||
| 65 | let rpcId = 0; | |
| 66 | async function mcp(tool: string, args: Record<string, unknown>) { | |
| 67 | const res = await fetch(HOST + "/mcp", { | |
| 68 | method: "POST", | |
| 69 | headers: { authorization: `Bearer ${PAT}`, "content-type": "application/json" }, | |
| 70 | body: JSON.stringify({ jsonrpc: "2.0", id: ++rpcId, method: "tools/call", params: { name: tool, arguments: args } }), | |
| 71 | }); | |
| 72 | const json: any = await res.json(); | |
| 73 | if (res.status !== 200 || json.error) { | |
| 74 | throw new Error(`${tool} → HTTP ${res.status} ${JSON.stringify(json.error ?? json).slice(0, 300)}`); | |
| 75 | } | |
| 76 | if (json.result?.isError) { | |
| 77 | throw new Error(`${tool} → tool error: ${JSON.stringify(json.result.content).slice(0, 300)}`); | |
| 78 | } | |
| 79 | // MCP text content is JSON-stringified tool output; parse when possible. | |
| 80 | const textPart = (json.result?.content ?? []).find((p: any) => p.type === "text")?.text; | |
| 81 | try { return JSON.parse(textPart); } catch { return textPart; } | |
| 82 | } | |
| 83 | ||
| 84 | function git(cwd: string, ...args: string[]): string { | |
| 85 | const proc = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" }); | |
| 86 | const out = new TextDecoder().decode(proc.stdout); | |
| 87 | const err = new TextDecoder().decode(proc.stderr); | |
| 88 | if (proc.exitCode !== 0) throw new Error(`git ${args[0]} failed (${proc.exitCode}): ${(err || out).slice(0, 300)}`); | |
| 89 | return out.trim(); | |
| 90 | } | |
| 91 | ||
| 92 | interface StepResult { name: string; ok: boolean; warn?: boolean; ms: number; detail?: string } | |
| 93 | const results: StepResult[] = []; | |
| 94 | ||
| 95 | async function step<T>(name: string, fn: () => Promise<T>, opts: { warnOnly?: boolean } = {}): Promise<T | null> { | |
| 96 | const t0 = Date.now(); | |
| 97 | try { | |
| 98 | const v = await fn(); | |
| 99 | results.push({ name, ok: true, ms: Date.now() - t0 }); | |
| 100 | console.log(`PASS ${name} (${Date.now() - t0}ms)`); | |
| 101 | return v; | |
| 102 | } catch (err) { | |
| 103 | const detail = (err as Error).message; | |
| 104 | results.push({ name, ok: !!opts.warnOnly, warn: opts.warnOnly, ms: Date.now() - t0, detail }); | |
| 105 | console.log(`${opts.warnOnly ? "WARN" : "FAIL"} ${name} — ${detail}`); | |
| 106 | if (!opts.warnOnly) throw err; | |
| 107 | return null; | |
| 108 | } | |
| 109 | } | |
| 110 | ||
| 111 | // ─── the journey ───────────────────────────────────────────────────── | |
| 112 | ||
| 113 | async function main() { | |
| 114 | console.log(`# agent-journey — ${HOST} — scratch repo "${REPO}" — ${new Date().toISOString()}\n`); | |
| 115 | let owner = ""; | |
| 116 | let repoCreated = false; | |
| 117 | ||
| 118 | try { | |
| 119 | // 1. identity | |
| 120 | const user = await step("1. PAT identity (GET /api/v2/user)", async () => { | |
| 121 | const r = await api("GET", "/api/v2/user"); | |
| 122 | if (r.status !== 200 || !r.json?.username) throw new Error(`HTTP ${r.status}: ${r.text.slice(0, 200)}`); | |
| 123 | return r.json; | |
| 124 | }); | |
| 125 | owner = user!.username; | |
| 126 | if (`${owner}/${REPO}`.toLowerCase() === "ccantynz/gluecron.com") throw new Error("safety: refusing to touch the self-host repo"); | |
| 127 | ||
| f8f6bd0 | 128 | // 1b. sweep leftover probes from earlier runs — WARN-only, best-effort. |
| 129 | // The step-10 cleanup below only runs inside this process's try/finally, | |
| 130 | // which can't protect against the process itself dying mid-run (killed | |
| 131 | // terminal, crashed script, host restart) — a hard kill skips `finally` | |
| 132 | // entirely, same as any JS runtime. Rather than chase every possible | |
| 133 | // cause of a mid-run death, sweep for orphaned `journey-*` repos here so | |
| 134 | // cleanup is self-healing across runs instead of depending on any single | |
| 135 | // run completing cleanly. 1-hour grace period avoids racing a | |
| 136 | // concurrently-running journey. | |
| 137 | await step("1b. sweep leftover journey-* probes", async () => { | |
| 138 | const r = await api("GET", `/api/v2/users/${encodeURIComponent(owner)}/repos`); | |
| 139 | if (r.status !== 200 || !Array.isArray(r.json)) throw new Error(`HTTP ${r.status}: ${r.text.slice(0, 200)}`); | |
| 140 | const cutoff = Date.now() - 60 * 60 * 1000; | |
| 141 | const orphans = r.json.filter((repo: any) => | |
| 142 | /^journey-\d{14}-[a-z0-9]{4}$/.test(repo.name) && | |
| 143 | repo.name !== REPO && | |
| 144 | new Date(repo.createdAt).getTime() < cutoff | |
| 145 | ); | |
| 146 | if (orphans.length === 0) return; | |
| 147 | let swept = 0; | |
| 148 | const failures: string[] = []; | |
| 149 | for (const repo of orphans) { | |
| 150 | try { | |
| 151 | await mcp("gluecron_delete_repo", { owner, repo: repo.name }); | |
| 152 | swept++; | |
| 153 | } catch (err) { | |
| 154 | failures.push(`${repo.name}: ${(err as Error).message}`); | |
| 155 | } | |
| 156 | } | |
| 157 | console.log(` swept ${swept}/${orphans.length} orphaned probe repo(s)`); | |
| 158 | if (failures.length > 0) throw new Error(`${failures.length} orphan(s) failed to delete: ${failures.join("; ").slice(0, 300)}`); | |
| 159 | }, { warnOnly: true }); | |
| 160 | ||
| 2cecc08 | 161 | // 2. create scratch repo |
| 162 | await step("2. create scratch repo (POST /api/v2/repos)", async () => { | |
| 163 | const r = await api("POST", "/api/v2/repos", { name: REPO, description: "agent-journey probe — auto-deleted", private: false }); | |
| 164 | if (r.status !== 200 && r.status !== 201) throw new Error(`HTTP ${r.status}: ${r.text.slice(0, 200)}`); | |
| 165 | repoCreated = true; | |
| 166 | }); | |
| 167 | ||
| 168 | // 3. git push over smart HTTP with PAT basic auth | |
| 169 | const pushUrl = `${HOST.replace("://", `://${encodeURIComponent(owner)}:${PAT}@`)}/${owner}/${REPO}.git`; | |
| 170 | const mainSha = await step("3. git push main over HTTPS (PAT basic auth)", async () => { | |
| 171 | const dir = mkdtempSync(join(tmpdir(), "gluecron-journey-")); | |
| 172 | try { | |
| 173 | git(dir, "init", "-b", "main"); | |
| 174 | git(dir, "config", "user.email", "journey@gluecron.com"); | |
| 175 | git(dir, "config", "user.name", "agent-journey"); | |
| 176 | writeFileSync(join(dir, "README.md"), `# ${REPO}\n\nExternal-platform journey probe.\n`); | |
| 177 | git(dir, "add", "."); | |
| 178 | git(dir, "commit", "-m", "chore: journey seed commit"); | |
| 179 | const sha = git(dir, "rev-parse", "HEAD"); | |
| 180 | git(dir, "push", pushUrl, "main"); | |
| 181 | return sha; | |
| 182 | } finally { | |
| 183 | rmSync(dir, { recursive: true, force: true }); | |
| 184 | } | |
| 185 | }); | |
| 186 | ||
| 187 | // 4. branch + file via MCP write tools | |
| 188 | await step("4a. gluecron_create_branch", () => mcp("gluecron_create_branch", { owner, repo: REPO, branch: "feature/journey", sha: mainSha })); | |
| 189 | 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` })); | |
| 190 | ||
| 191 | // 5. open PR | |
| 192 | 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" })); | |
| 193 | const prNumber: number = pr!.number; | |
| 194 | ||
| 195 | // 6. comment | |
| 196 | await step("6. gluecron_comment_pr", () => mcp("gluecron_comment_pr", { owner, repo: REPO, number: prNumber, body: `Journey probe comment ${new Date().toISOString()}` })); | |
| 197 | ||
| 198 | // 7. AI review (WARN-only: may be disabled / unfunded) | |
| 199 | await step("7. AI review fired (poll for marker, ≤90s)", async () => { | |
| 200 | const deadline = Date.now() + 90_000; | |
| 201 | while (Date.now() < deadline) { | |
| 202 | const r = await api("GET", `/api/v2/repos/${owner}/${REPO}/pulls/${prNumber}`); | |
| 203 | const comments: Array<{ body?: string; isAiReview?: boolean }> = r.json?.comments ?? []; | |
| 204 | if (comments.some((cm) => cm.isAiReview || cm.body?.includes(AI_REVIEW_MARKER))) return true; | |
| 205 | await new Promise((res) => setTimeout(res, 5000)); | |
| 206 | } | |
| 207 | throw new Error("no AI-review comment within 90s (review disabled, or ANTHROPIC_API_KEY unset?)"); | |
| 208 | }, { warnOnly: true }); | |
| 209 | ||
| 210 | // 8. merge + verify | |
| 211 | await step("8a. gluecron_merge_pr", async () => { | |
| 212 | const m = await mcp("gluecron_merge_pr", { owner, repo: REPO, number: prNumber }); | |
| 213 | if (!m?.merged) throw new Error(`not merged: ${JSON.stringify(m).slice(0, 200)}`); | |
| 214 | return m; | |
| 215 | }); | |
| 216 | await step("8b. PR state is merged + main advanced", async () => { | |
| 217 | const detail = await mcp("gluecron_get_pr", { owner, repo: REPO, number: prNumber }); | |
| 218 | const state = detail?.state ?? detail?.pr?.state; | |
| 219 | if (state !== "merged") throw new Error(`PR state: ${state}`); | |
| 220 | const dir = mkdtempSync(join(tmpdir(), "gluecron-lsr-")); | |
| 221 | try { | |
| 222 | git(dir, "init"); | |
| 223 | const head = git(dir, "ls-remote", pushUrl, "refs/heads/main").split(/\s/)[0]; | |
| 224 | if (head === mainSha) throw new Error("main did not advance past the seed commit"); | |
| 225 | } finally { | |
| 226 | rmSync(dir, { recursive: true, force: true }); | |
| 227 | } | |
| 228 | }); | |
| 229 | ||
| 48c26de | 230 | // 9. events — WARN-only (known platform gap, 2026-07-15): activityFeed is |
| 231 | // only written by fork/use-template/claude-connect/reviewer-suggest. | |
| 232 | // None of git push, MCP branch/PR/comment/merge, or POST /api/v2/repos | |
| 233 | // insert an activityFeed row, so dashboards rolling it up are blind to | |
| 234 | // everything this journey just did. Real gap, not a test bug — tracked | |
| 235 | // separately; not a reason to fail the merge-lifecycle certification. | |
| 2cecc08 | 236 | await step("9. activity feed recorded push + PR events", async () => { |
| 237 | const r = await api("GET", `/api/v2/repos/${owner}/${REPO}/activity`); | |
| 238 | if (r.status !== 200) throw new Error(`HTTP ${r.status}`); | |
| 561b2a8 | 239 | // GET /api/v2/repos/:owner/:repo/activity returns a bare array. |
| 240 | const events: Array<{ type?: string }> = Array.isArray(r.json) | |
| 241 | ? r.json | |
| 242 | : r.json?.events ?? r.json?.activity ?? []; | |
| 48c26de | 243 | if (!Array.isArray(events) || events.length === 0) throw new Error("no activity events (known gap — see comment above)"); |
| 244 | }, { warnOnly: true }); | |
| 2cecc08 | 245 | } finally { |
| 246 | // 10. cleanup — always attempt when the repo was created | |
| 247 | if (repoCreated && !KEEP) { | |
| 248 | try { | |
| 249 | await mcp("gluecron_delete_repo", { owner, repo: REPO }); | |
| 250 | console.log(`PASS 10. cleanup — deleted ${owner}/${REPO}`); | |
| 251 | results.push({ name: "10. cleanup (gluecron_delete_repo)", ok: true, ms: 0 }); | |
| 252 | } catch (err) { | |
| 253 | console.error(`FAIL 10. cleanup — LEFTOVER REPO ${owner}/${REPO}: ${(err as Error).message}`); | |
| 254 | results.push({ name: "10. cleanup (gluecron_delete_repo)", ok: false, ms: 0, detail: `LEFTOVER ${owner}/${REPO}` }); | |
| 255 | } | |
| 256 | } else if (repoCreated) { | |
| 257 | console.log(`KEEP 10. cleanup skipped (KEEP_REPO=1) — ${owner}/${REPO}`); | |
| 258 | } | |
| 259 | } | |
| 260 | ||
| 261 | const failed = results.filter((r) => !r.ok).length; | |
| 262 | const warned = results.filter((r) => r.warn).length; | |
| 263 | console.log(`\n# agent-journey: ${failed === 0 ? "ALL GREEN" : `${failed} FAILURE(S)`}${warned ? ` — ${warned} warning(s)` : ""}`); | |
| 264 | process.exit(failed === 0 ? 0 : 1); | |
| 265 | } | |
| 266 | ||
| 267 | main().catch((err) => { | |
| 268 | console.error(`\n# agent-journey: FAILED — ${(err as Error).message}`); | |
| 269 | process.exit(1); | |
| 270 | }); |