Ctrl K
ComunidadGuías…

scripts/pr-shots

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, extname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const usage = `Usage: scripts/pr-shots [--repo OWNER/REPO] [--replace OLD_URL] PR FILE...

Upload screenshots or recordings to an existing GitHub PR and print attachment
Markdown to stdout. Diagnostics go to stderr. PR may be a number or GitHub URL.
Requires Node.js 22+ and gh 2.103.2+ with repository write access.

  scripts/pr-shots --repo multica-ai/multica 123 ./before.png ./after.png ./demo.mp4
  scripts/pr-shots --repo multica-ai/multica --replace https://example.com/old.png 123 ./after.png

--replace replaces existing standalone inline image references, keeping their
alt text and surrounding prose; it accepts exactly one file. Without it, new
attachments are appended. Identical content already marked in this PR is reused.
Run only one uploader per PR at a time; avoid editing its body during upload.
`;

function parseArgs(args) {
  const options = { files: [], repo: "", replace: "", pr: "" };
  let positional = false;
  for (let i = 0; i < args.length; i++) {
    const arg = args[i];
    if (!positional && (arg === "--help" || arg === "-h")) return { help: true };
    if (!positional && arg === "--") { positional = true; continue; }
    if (!positional && (arg === "--repo" || arg === "-R" || arg === "--replace")) {
      const value = args[++i];
      if (!value || value.startsWith("-")) throw new Error(`Missing value for ${arg}`);
      options[arg === "--replace" ? "replace" : "repo"] = value;
    } else if (!positional && arg.startsWith("-")) {
      throw new Error(`Unknown option: ${arg}`);
    } else if (!options.pr) options.pr = arg;
    else options.files.push(arg);
  }
  if (!options.pr || !options.files.length) throw new Error(usage);
  if (!/^[1-9]\d*$/.test(options.pr) && !/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/[1-9]\d*$/.test(options.pr)) {
    throw new Error("PR must be a positive number or a github.com pull request URL");
  }
  if (options.repo && !/^[\w.-]+\/[\w.-]+$/.test(options.repo)) throw new Error("--repo must be OWNER/REPO");
  if (options.replace && (options.files.length !== 1 || !/^https:\/\/[^\s<>]+$/.test(options.replace))) {
    throw new Error("--replace requires an HTTPS URL and exactly one file");
  }
  return options;
}

function runGh(args) {
  const result = spawnSync("gh", args, { encoding: "utf8", timeout: 120_000, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
  return { status: result.status ?? 1, stdout: result.stdout ?? "", stderr: result.error?.message ?? result.stderr ?? "" };
}

function checkedGh(gh, args) {
  const result = gh(args);
  if (result.status !== 0) throw new Error(result.stderr.trim() || `gh ${args.slice(0, 2).join(" ")} failed`);
  return result.stdout;
}

function checkVersion(gh) {
  const match = checkedGh(gh, ["--version"]).match(/^gh version (\d+)\.(\d+)\.(\d+)/);
  const [major, minor, patch] = match ? match.slice(1).map(Number) : [];
  if (!(major > 2 || (major === 2 && (minor > 103 || (minor === 103 && patch >= 2))))) {
    throw new Error("Upgrade GitHub CLI to gh 2.103.2 or newer");
  }
}

function snapshot(file) {
  const path = resolve(file);
  const extension = extname(path).slice(1).toLowerCase();
  const video = ["mp4", "mov", "webm"].includes(extension);
  if (!video && !["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(extension)) {
    throw new Error(`${file}: unsupported image/video extension`);
  }
  const info = statSync(path);
  const limit = (video ? 100 : 10) * 1024 * 1024;
  if (!info.isFile() || info.size === 0 || info.size > limit) throw new Error(`${file}: expected a nonempty file up to ${video ? 100 : 10} MB`);
  const bytes = readFileSync(path);
  return { path, extension, video, bytes, hash: createHash("sha256").update(bytes).digest("hex") };
}

function marker(hash) { return `<!-- pr-shots:sha256=${hash} -->`; }
function escapeRegex(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
function escapeAlt(value) { return value.replace(/\\/g, "\\\\").replace(/\[/g, "\\[").replace(/\]/g, "\\]").replace(/[\r\n]/g, " "); }

function nativeUrl(value) {
  try {
    const url = new URL(value);
    return url.protocol === "https:" && url.hostname === "github.com" && /^\/user-attachments\/assets\/[\w-]+$/.test(url.pathname);
  } catch { return false; }
}

function savedAttachment(body, hash) {
  const pattern = new RegExp(`${escapeRegex(marker(hash))}[ \\t]*\\r?\\n(?:[ \\t]*\\r?\\n)*([^\\r\\n]+)`, "g");
  for (const match of body.matchAll(pattern)) {
    const markdown = match[1].trim();
    const image = markdown.match(/^!\[(?:\\.|[^\]\\])*\]\((https:\/\/[^\s)]+)\)$/);
    const url = image ? image[1] : markdown;
    if (nativeUrl(url)) return { markdown, url };
  }
  return null;
}

function replaceImage(body, oldUrl, newUrl, hash) {
  const pattern = new RegExp(`^(!\\[(?:\\\\.|[^\\]\\\\])*\\]\\()${escapeRegex(oldUrl)}(\\))[ \\t]*$`, "gm");
  let count = 0;
  const next = body.replace(pattern, (_match, before, after) => {
    count++;
    return `${marker(hash)}\n\n${before}${newUrl}${after}`;
  });
  if (!count) throw new Error("--replace URL must match an existing image on its own line; the PR was not changed");
  return next;
}

export function runPrShots(args, { gh = runGh, out = (text) => process.stdout.write(text), log = (text) => process.stderr.write(`${text}\n`) } = {}) {
  let directory;
  try {
    const options = parseArgs(args);
    if (options.help) { out(usage); return 0; }
    // Snapshot every input before the first remote mutation, including paths with #.
    const files = [...new Map(options.files.map((file) => {
      const asset = snapshot(file);
      return [asset.hash, asset];
    })).values()];
    if (options.replace && files[0].video) throw new Error("--replace supports images; append recordings instead");
    checkVersion(gh);
    const readPr = (target, repo = "") => {
      const flags = repo ? ["--repo", repo] : [];
      const pr = JSON.parse(checkedGh(gh, ["pr", "view", target, ...flags, "--json", "body,url"]));
      if (typeof pr.body !== "string" || !/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/[1-9]\d*$/.test(pr.url)) {
        throw new Error("Expected a GitHub.com PR body and canonical URL");
      }
      return pr;
    };
    const target = readPr(options.pr, options.repo).url;
    directory = mkdtempSync(join(tmpdir(), "pr-shots-"));
    for (let i = 0; i < files.length; i++) {
      const file = files[i];
      try {
        const pr = readPr(target);
        const existing = savedAttachment(pr.body, file.hash);
        if (existing && (!options.replace || !pr.body.includes(options.replace))) {
          log(`Reused ${basename(file.path)}`);
          out(`${existing.markdown}\n\n`);
          continue;
        }
        const staged = join(directory, `${file.hash}.${file.extension}`);
        writeFileSync(staged, file.bytes, { mode: 0o600 });
        const destination = existing?.url ?? staged;
        const markdown = file.video ? `![](${destination})` : `![${escapeAlt(basename(file.path))}](${destination})`;
        const body = options.replace
          ? replaceImage(pr.body, options.replace, destination, file.hash)
          : `${pr.body}${pr.body ? "\n\n" : ""}${marker(file.hash)}\n\n${markdown}\n`;
        const bodyFile = join(directory, "body.md");
        writeFileSync(bodyFile, body, { mode: 0o600 });
        const attach = existing ? [] : ["--attach", staged];
        const result = gh(["pr", "edit", target, "--body-file", bodyFile, ...attach]);
        // A nonzero exit can follow a successful write: read back before retrying.
        const saved = savedAttachment(readPr(target).body, file.hash);
        if (!saved) throw new Error(result.stderr.trim() || "Upload was not confirmed in the PR body; inspect the PR before retrying");
        out(`${saved.markdown}\n\n`);
        log(`Saved ${basename(file.path)}`);
        if (result.status !== 0) throw new Error(`Attachment saved, but gh exited ${result.status}: ${result.stderr.trim()}`);
      } catch (error) {
        log(`Failed ${basename(file.path)}: ${error.message}`);
        if (i + 1 < files.length) log(`Not attempted: ${files.slice(i + 1).map((file) => basename(file.path)).join(", ")}`);
        log("Earlier confirmed attachments remain saved. Re-run the same command to reuse them.");
        return 1;
      }
    }
    return 0;
  } catch (error) {
    log(error.message);
    return 1;
  } finally {
    if (directory) rmSync(directory, { recursive: true, force: true });
  }
}

if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
  process.exitCode = runPrShots(process.argv.slice(2));
}