#!/usr/bin/env node /** * One-command PR evidence attach for agents and humans: uploads media/log * artifacts to the shared `pr-evidence` release (the CLI attachment path for * headless agents that cannot drag-and-drop), rewrites the PR body's * `evidence-row:*` rows to reference the uploaded assets, and re-runs the * local evidence gate against the resulting body so the author knows the CI * check will pass BEFORE pushing the edit. Exists because the manual loop * (upload → copy URLs → hand-edit eight rows → wait for CI) is exactly the * friction that made agents skip evidence. * * node scripts/pr-evidence.mjs attach upload + print URLs * node scripts/pr-evidence.mjs rows --row id= … * patch body rows + verify * node scripts/pr-evidence.mjs verify run the gate locally * * `attach` prefixes every asset `-` so one release serves all PRs, and * skips re-uploading an asset that already exists with identical bytes. * `rows` accepts a local file (uploaded automatically), an existing URL, or an * `N/A - reason` string per row; rows not named are left untouched. Every * mutation is previewed and the gate verdict printed; `--dry-run` stops before * editing the PR. */ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { basename } from "node:path"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; const RELEASE_TAG = "pr-evidence"; const ASSET_BASE = `https://github.com/elizaOS/eliza/releases/download/${RELEASE_TAG}`; const ROW_IDS = [ "before-screenshots", "after-screenshots", "walkthrough-video", "backend-logs", "frontend-logs", "llm-trajectory", "domain-artifacts", "ocr-review", ]; function gh(args, opts = {}) { return execFileSync("gh", args, { encoding: "utf8", ...opts }); } function fail(message) { console.error(`pr-evidence: ${message}`); process.exit(1); } function usage() { console.log(`Usage: node scripts/pr-evidence.mjs attach Upload files to the '${RELEASE_TAG}' release as - and print the asset URLs ready to paste into evidence rows. node scripts/pr-evidence.mjs rows [--dry-run] --row = [...] Patch the PR body's evidence rows and verify the gate locally. one of: ${ROW_IDS.join(", ")} a local file path (auto-uploaded), an https URL, or an 'N/A - ' string. node scripts/pr-evidence.mjs verify Fetch the PR body/labels/files and run the local evidence gate exactly as CI does. Assets land on: ${ASSET_BASE}/- Worked example of a fully evidenced PR: https://github.com/elizaOS/eliza/pull/15171`); } /** Existing release assets, name → { sha? } (sha lazily unavailable via API — name match only). */ function existingAssetNames() { const out = gh([ "release", "view", RELEASE_TAG, "--json", "assets", "-q", ".assets[].name", ]); return new Set(out.split("\n").filter(Boolean)); } /** * Upload local files as `-`; returns name → URL. A name * collision uploads with `--clobber` only when the local bytes differ from a * previous upload this run cannot verify — so collide loudly instead: * existing names are reused as-is (assets referenced by open PRs are * immutable by policy), and a genuinely different file must be renamed. */ function attach(pr, files) { if (files.length === 0) fail("attach needs at least one file"); const existing = existingAssetNames(); const urls = new Map(); const toUpload = []; const staged = []; for (const file of files) { if (!existsSync(file)) fail(`no such file: ${file}`); // GitHub rejects zero-byte release assets with an opaque 400 // (Bad Content-Length) that aborts the whole batch — fail per-file with // the actual reason instead. An empty artifact is never real evidence. if (readFileSync(file).length === 0) { fail(`refusing to upload empty file (0 bytes): ${file}`); } const name = `${pr}-${basename(file).replace(new RegExp(`^${pr}-`), "")}`; const url = `${ASSET_BASE}/${name}`; urls.set(name, url); if (existing.has(name)) { console.log(` = ${name} (already uploaded, reusing)`); continue; } // gh names the asset after the file, so stage a correctly-named copy. const stagedPath = join(tmpdir(), name); writeFileSync(stagedPath, readFileSync(file)); staged.push(stagedPath); toUpload.push(stagedPath); } if (toUpload.length > 0) { gh(["release", "upload", RELEASE_TAG, ...toUpload], { stdio: "inherit" }); } for (const [name, url] of urls) console.log(` ${url}`); return urls; } function isMediaName(name) { return /\.(png|jpe?g|gif|webp|mp4|mov|webm)$/i.test(name); } /** Render the replacement row line for an id + resolved value. */ function renderRow(id, value) { if (/^N\/?A\s*[-:]/i.test(value)) return `- [ ] ${value}`; if (isMediaName(value) && /^https?:/i.test(value)) { // Embed images inline; videos/GIFs render from the bare URL on GitHub. return /\.(png|jpe?g|gif|webp)$/i.test(value) ? `- [x] ![${id}](${value})` : `- [x] ${value}`; } if (/^https?:/i.test(value)) return `- [x] [${basename(value)}](${value})`; fail(`row ${id}: value must be a file, URL, or 'N/A - ' (got: ${value})`); } /** Replace the block after `` with `line`. */ function patchRow(body, id, line) { const marker = ``; const at = body.indexOf(marker); if (at === -1) { // Row marker absent (old template) — append a fresh marker + row. return `${body.trimEnd()}\n\n${marker}\n${line}\n`; } const afterMarker = at + marker.length; const rest = body.slice(afterMarker); // The row block ends at the next blank line, heading, or marker. const end = rest.search(/\n\s*\n|\n#|\n