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