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

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.

agent-journey.tsBlame229 lines · 1 contributor
2cecc08ccanty labs1// 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
8// 2. create repo POST /api/v2/repos (no MCP create-repo tool exists — platform gap, noted)
9// 3. git push smart-HTTP receive-pack with PAT basic auth
10// 4. branch + edit gluecron_create_branch + gluecron_write_file
11// 5. open PR gluecron_create_pr
12// 6. comment gluecron_comment_pr
13// 7. AI review poll PR comments for the ai-review marker (WARN if absent)
14// 8. merge gluecron_merge_pr → gluecron_get_pr state=merged
15// 9. events GET /api/v2/repos/:o/:r/activity
16// 10. cleanup gluecron_delete_repo (always, unless KEEP_REPO=1)
17//
18// Run: bun run scripts/agent-journey.ts
19// Env: GLUECRON_PAT REQUIRED — admin-scope PAT (merge + delete need it)
20// GLUECRON_HOST target (default https://gluecron.com)
21// KEEP_REPO=1 skip cleanup for debugging
22// Exit: 0 iff all steps pass (step 7 is WARN-only — AI review may be
23// legitimately disabled). Never touches any existing repo.
24
25import { AI_REVIEW_MARKER } from "../src/lib/ai-review";
26import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
27import { tmpdir } from "node:os";
28import { join } from "node:path";
29
30const HOST = (process.env.GLUECRON_HOST || "https://gluecron.com").replace(/\/+$/, "");
31const PAT = process.env.GLUECRON_PAT?.trim() || "";
32const KEEP = process.env.KEEP_REPO === "1";
33
34if (!PAT) {
35 console.error("GLUECRON_PAT is required (admin scope). Mint one at /settings/tokens or via scripts/emergency-pat.ts.");
36 process.exit(2);
37}
38
39// Hard safety guard: the scratch repo name is generated here and must never
40// collide with the self-host repo (its post-receive hook triggers deploys).
41const REPO = `journey-${new Date().toISOString().slice(0, 19).replace(/[:T-]/g, "")}-${Math.random().toString(36).slice(2, 6)}`;
42const FORBIDDEN = /gluecron\.com/i;
43if (FORBIDDEN.test(REPO)) throw new Error("unreachable: scratch repo name collided with self-host repo");
44
45// ─── plumbing ────────────────────────────────────────────────────────
46
47async function api(method: string, path: string, body?: unknown) {
48 const res = await fetch(HOST + path, {
49 method,
50 headers: {
51 authorization: `Bearer ${PAT}`,
52 ...(body !== undefined ? { "content-type": "application/json" } : {}),
53 },
54 ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
55 });
56 const text = await res.text();
57 let json: any = null;
58 try { json = JSON.parse(text); } catch { /* non-JSON */ }
59 return { status: res.status, json, text };
60}
61
62let rpcId = 0;
63async function mcp(tool: string, args: Record<string, unknown>) {
64 const res = await fetch(HOST + "/mcp", {
65 method: "POST",
66 headers: { authorization: `Bearer ${PAT}`, "content-type": "application/json" },
67 body: JSON.stringify({ jsonrpc: "2.0", id: ++rpcId, method: "tools/call", params: { name: tool, arguments: args } }),
68 });
69 const json: any = await res.json();
70 if (res.status !== 200 || json.error) {
71 throw new Error(`${tool} → HTTP ${res.status} ${JSON.stringify(json.error ?? json).slice(0, 300)}`);
72 }
73 if (json.result?.isError) {
74 throw new Error(`${tool} → tool error: ${JSON.stringify(json.result.content).slice(0, 300)}`);
75 }
76 // MCP text content is JSON-stringified tool output; parse when possible.
77 const textPart = (json.result?.content ?? []).find((p: any) => p.type === "text")?.text;
78 try { return JSON.parse(textPart); } catch { return textPart; }
79}
80
81function git(cwd: string, ...args: string[]): string {
82 const proc = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
83 const out = new TextDecoder().decode(proc.stdout);
84 const err = new TextDecoder().decode(proc.stderr);
85 if (proc.exitCode !== 0) throw new Error(`git ${args[0]} failed (${proc.exitCode}): ${(err || out).slice(0, 300)}`);
86 return out.trim();
87}
88
89interface StepResult { name: string; ok: boolean; warn?: boolean; ms: number; detail?: string }
90const results: StepResult[] = [];
91
92async function step<T>(name: string, fn: () => Promise<T>, opts: { warnOnly?: boolean } = {}): Promise<T | null> {
93 const t0 = Date.now();
94 try {
95 const v = await fn();
96 results.push({ name, ok: true, ms: Date.now() - t0 });
97 console.log(`PASS ${name} (${Date.now() - t0}ms)`);
98 return v;
99 } catch (err) {
100 const detail = (err as Error).message;
101 results.push({ name, ok: !!opts.warnOnly, warn: opts.warnOnly, ms: Date.now() - t0, detail });
102 console.log(`${opts.warnOnly ? "WARN" : "FAIL"} ${name} — ${detail}`);
103 if (!opts.warnOnly) throw err;
104 return null;
105 }
106}
107
108// ─── the journey ─────────────────────────────────────────────────────
109
110async function main() {
111 console.log(`# agent-journey — ${HOST} — scratch repo "${REPO}" — ${new Date().toISOString()}\n`);
112 let owner = "";
113 let repoCreated = false;
114
115 try {
116 // 1. identity
117 const user = await step("1. PAT identity (GET /api/v2/user)", async () => {
118 const r = await api("GET", "/api/v2/user");
119 if (r.status !== 200 || !r.json?.username) throw new Error(`HTTP ${r.status}: ${r.text.slice(0, 200)}`);
120 return r.json;
121 });
122 owner = user!.username;
123 if (`${owner}/${REPO}`.toLowerCase() === "ccantynz/gluecron.com") throw new Error("safety: refusing to touch the self-host repo");
124
125 // 2. create scratch repo
126 await step("2. create scratch repo (POST /api/v2/repos)", async () => {
127 const r = await api("POST", "/api/v2/repos", { name: REPO, description: "agent-journey probe — auto-deleted", private: false });
128 if (r.status !== 200 && r.status !== 201) throw new Error(`HTTP ${r.status}: ${r.text.slice(0, 200)}`);
129 repoCreated = true;
130 });
131
132 // 3. git push over smart HTTP with PAT basic auth
133 const pushUrl = `${HOST.replace("://", `://${encodeURIComponent(owner)}:${PAT}@`)}/${owner}/${REPO}.git`;
134 const mainSha = await step("3. git push main over HTTPS (PAT basic auth)", async () => {
135 const dir = mkdtempSync(join(tmpdir(), "gluecron-journey-"));
136 try {
137 git(dir, "init", "-b", "main");
138 git(dir, "config", "user.email", "journey@gluecron.com");
139 git(dir, "config", "user.name", "agent-journey");
140 writeFileSync(join(dir, "README.md"), `# ${REPO}\n\nExternal-platform journey probe.\n`);
141 git(dir, "add", ".");
142 git(dir, "commit", "-m", "chore: journey seed commit");
143 const sha = git(dir, "rev-parse", "HEAD");
144 git(dir, "push", pushUrl, "main");
145 return sha;
146 } finally {
147 rmSync(dir, { recursive: true, force: true });
148 }
149 });
150
151 // 4. branch + file via MCP write tools
152 await step("4a. gluecron_create_branch", () => mcp("gluecron_create_branch", { owner, repo: REPO, branch: "feature/journey", sha: mainSha }));
153 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` }));
154
155 // 5. open PR
156 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" }));
157 const prNumber: number = pr!.number;
158
159 // 6. comment
160 await step("6. gluecron_comment_pr", () => mcp("gluecron_comment_pr", { owner, repo: REPO, number: prNumber, body: `Journey probe comment ${new Date().toISOString()}` }));
161
162 // 7. AI review (WARN-only: may be disabled / unfunded)
163 await step("7. AI review fired (poll for marker, ≤90s)", async () => {
164 const deadline = Date.now() + 90_000;
165 while (Date.now() < deadline) {
166 const r = await api("GET", `/api/v2/repos/${owner}/${REPO}/pulls/${prNumber}`);
167 const comments: Array<{ body?: string; isAiReview?: boolean }> = r.json?.comments ?? [];
168 if (comments.some((cm) => cm.isAiReview || cm.body?.includes(AI_REVIEW_MARKER))) return true;
169 await new Promise((res) => setTimeout(res, 5000));
170 }
171 throw new Error("no AI-review comment within 90s (review disabled, or ANTHROPIC_API_KEY unset?)");
172 }, { warnOnly: true });
173
174 // 8. merge + verify
175 await step("8a. gluecron_merge_pr", async () => {
176 const m = await mcp("gluecron_merge_pr", { owner, repo: REPO, number: prNumber });
177 if (!m?.merged) throw new Error(`not merged: ${JSON.stringify(m).slice(0, 200)}`);
178 return m;
179 });
180 await step("8b. PR state is merged + main advanced", async () => {
181 const detail = await mcp("gluecron_get_pr", { owner, repo: REPO, number: prNumber });
182 const state = detail?.state ?? detail?.pr?.state;
183 if (state !== "merged") throw new Error(`PR state: ${state}`);
184 const dir = mkdtempSync(join(tmpdir(), "gluecron-lsr-"));
185 try {
186 git(dir, "init");
187 const head = git(dir, "ls-remote", pushUrl, "refs/heads/main").split(/\s/)[0];
188 if (head === mainSha) throw new Error("main did not advance past the seed commit");
189 } finally {
190 rmSync(dir, { recursive: true, force: true });
191 }
192 });
193
194 // 9. events
195 await step("9. activity feed recorded push + PR events", async () => {
196 const r = await api("GET", `/api/v2/repos/${owner}/${REPO}/activity`);
197 if (r.status !== 200) throw new Error(`HTTP ${r.status}`);
561b2a8ccanty labs198 // GET /api/v2/repos/:owner/:repo/activity returns a bare array.
199 const events: Array<{ type?: string }> = Array.isArray(r.json)
200 ? r.json
201 : r.json?.events ?? r.json?.activity ?? [];
2cecc08ccanty labs202 if (!Array.isArray(events) || events.length === 0) throw new Error("no activity events");
203 });
204 } finally {
205 // 10. cleanup — always attempt when the repo was created
206 if (repoCreated && !KEEP) {
207 try {
208 await mcp("gluecron_delete_repo", { owner, repo: REPO });
209 console.log(`PASS 10. cleanup — deleted ${owner}/${REPO}`);
210 results.push({ name: "10. cleanup (gluecron_delete_repo)", ok: true, ms: 0 });
211 } catch (err) {
212 console.error(`FAIL 10. cleanup — LEFTOVER REPO ${owner}/${REPO}: ${(err as Error).message}`);
213 results.push({ name: "10. cleanup (gluecron_delete_repo)", ok: false, ms: 0, detail: `LEFTOVER ${owner}/${REPO}` });
214 }
215 } else if (repoCreated) {
216 console.log(`KEEP 10. cleanup skipped (KEEP_REPO=1) — ${owner}/${REPO}`);
217 }
218 }
219
220 const failed = results.filter((r) => !r.ok).length;
221 const warned = results.filter((r) => r.warn).length;
222 console.log(`\n# agent-journey: ${failed === 0 ? "ALL GREEN" : `${failed} FAILURE(S)`}${warned ? ` — ${warned} warning(s)` : ""}`);
223 process.exit(failed === 0 ? 0 : 1);
224}
225
226main().catch((err) => {
227 console.error(`\n# agent-journey: FAILED — ${(err as Error).message}`);
228 process.exit(1);
229});