scripts/lib.mjs
scripts/lib.mjsBrowse 6 files
1,787 tokens
7,156 bytes
Token encoding: o200k_base
Snapshot 5b913e7
← Back to SKILL.md
1import { spawnSync } from "node:child_process";2import { readFileSync, writeFileSync } from "node:fs";3 4export const GREEN_CHECK_CONCLUSIONS = new Set(["SUCCESS", "NEUTRAL", "SKIPPED"]);5export const GREEN_STATUS_STATES = new Set(["SUCCESS"]);6export const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]);7const GH_JSON_MAX_BUFFER_BYTES = 50 * 1024 * 1024;8 9export function parseArgs(argv, defaults = {}) {10 const args = { ...defaults };11 for (let index = 0; index < argv.length; index += 1) {12 const token = argv[index];13 if (!token.startsWith("--")) throw new Error(`Unexpected argument: ${token}`);14 const key = token.slice(2).replaceAll("-", "_");15 const next = argv[index + 1];16 if (!next || next.startsWith("--")) {17 args[key] = true;18 continue;19 }20 args[key] = next;21 index += 1;22 }23 return args;24}25 26export function readJson(path) {27 return JSON.parse(readFileSync(path, "utf8"));28}29 30export function writeJson(path, value) {31 const body = `${JSON.stringify(value, null, 2)}\n`;32 if (path === "-") process.stdout.write(body);33 else writeFileSync(path, body);34}35 36export function ghJson(args) {37 const result = spawnSync("gh", args, {38 encoding: "utf8",39 maxBuffer: GH_JSON_MAX_BUFFER_BYTES,40 stdio: ["ignore", "pipe", "pipe"],41 });42 // Surface gh diagnostics (warnings, deprecation/auth notices, error output)43 // on both success and failure — spawnSync captures stderr in every case.44 if (result.stderr) process.stderr.write(result.stderr);45 if (result.error) throw result.error;46 if (result.status !== 0) {47 const error = new Error(`gh ${args.join(" ")} exited with status ${result.status}`);48 error.stderr = result.stderr;49 error.status = result.status;50 throw error;51 }52 return JSON.parse(result.stdout);53}54 55export function isMissingPullRequestError(error) {56 const detail = `${error?.message ?? ""}\n${error?.stderr ?? ""}`;57 // Scope to the exact signals gh emits for a deleted/nonexistent PR: the GraphQL58 // "Could not resolve to a PullRequest" message and REST "Not Found (HTTP 404)".59 // A bare "Not Found" would over-match unrelated failures (e.g. "repository not60 // found"), so we require the HTTP 404 marker for the REST case.61 return /Could not resolve to a PullRequest|HTTP 404/i.test(detail);62}63 64export function normalizeRepository(value) {65 const match = String(value).match(/(?:github\.com[/:])?([^/\s]+)\/([^/\s]+?)(?:\.git)?$/i);66 if (!match) throw new Error(`Invalid GitHub repository: ${value}`);67 return `${match[1]}/${match[2]}`;68}69 70export function repositoryFromGh() {71 return normalizeRepository(ghJson(["repo", "view", "--json", "nameWithOwner"]).nameWithOwner);72}73 74export function prUrl(repository, number) {75 return `https://github.com/${repository}/pull/${number}`;76}77 78export function resolveAuthorAllowlist(options, getGhJson) {79 if (options.include_community) return null;80 if (options.authors === true) throw new Error("--authors requires a comma-separated list of GitHub logins");81 // Default to the authenticated gh identity: every PR this Paperclip instance82 // opens is authored by that login, so it is the scope boundary that excludes83 // community contributions without maintaining a separate roster.84 const raw = options.authors ?? getGhJson(["api", "user"]).login;85 const authors = String(raw)86 .split(",")87 .map((login) => login.trim().toLowerCase())88 .filter(Boolean);89 if (authors.length === 0) throw new Error("--authors requires at least one GitHub login");90 return authors;91}92 93export function summarizePullRequestBody(body) {94 const text = String(body ?? "")95 .replace(/<!--[\s\S]*?-->/g, "")96 .replaceAll("\r", "");97 for (const block of text.split(/\n\s*\n/)) {98 const line = block99 .split("\n")100 .map((entry) => entry.replace(/^[\s>]*(?:[-*]\s+)?/, "").trim())101 .filter((entry) => entry && !entry.startsWith("#"))102 .join(" ");103 if (!line) continue;104 return line.length > 280 ? `${line.slice(0, 277)}…` : line;105 }106 return null;107}108 109export function pullRequestIdentity(value) {110 const match = String(value).match(/(?:https?:\/\/)?github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)/i);111 if (!match) return null;112 return `${match[1].toLowerCase()}/${match[2].toLowerCase()}#${Number(match[3])}`;113}114 115export function extractPullRequestNumber(value, repository) {116 const identity = pullRequestIdentity(value);117 const prefix = `${repository.toLowerCase()}#`;118 return identity?.startsWith(prefix) ? Number(identity.slice(prefix.length)) : null;119}120 121export async function paperclipGet(path, { apiUrl, apiKey }) {122 const response = await fetch(`${apiUrl.replace(/\/$/, "")}/api${path}`, {123 headers: { Authorization: `Bearer ${apiKey}` },124 });125 if (!response.ok) {126 const body = await response.text();127 throw new Error(`Paperclip GET ${path} failed (${response.status}): ${body}`);128 }129 return response.json();130}131 132export function issueSummary(issue) {133 return {134 issueId: issue.issueId,135 identifier: issue.identifier,136 title: issue.title,137 status: issue.status,138 assigneeAgentId: issue.assigneeAgentId,139 updatedAt: issue.updatedAt,140 };141}142 143export function chooseOriginatingIssue(sourceIssues, pullRequestUrl) {144 const targetIdentity = pullRequestIdentity(pullRequestUrl);145 const workProductIssue = sourceIssues.find((issue) =>146 issue.workProducts?.some(147 (product) => product.type === "pull_request" && pullRequestIdentity(product.url) === targetIdentity,148 ),149 );150 if (workProductIssue) return { ...issueSummary(workProductIssue), selectionBasis: "pull_request_work_product" };151 152 const commentIssues = sourceIssues153 .filter((issue) => issue.mentions.some((mention) => mention.field === "comment"))154 .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));155 if (commentIssues[0]) return { ...issueSummary(commentIssues[0]), selectionBasis: "comment_mention" };156 157 const recentIssue = [...sourceIssues].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0];158 return recentIssue ? { ...issueSummary(recentIssue), selectionBasis: "most_recent_mention" } : null;159}160 161export function normalizeCheck(context) {162 if (context.__typename === "CheckRun") {163 return {164 type: "check_run",165 name: context.name,166 status: context.status,167 conclusion: context.conclusion,168 detailsUrl: context.detailsUrl ?? null,169 workflowName: context.workflowName ?? null,170 green: context.status === "COMPLETED" && GREEN_CHECK_CONCLUSIONS.has(context.conclusion),171 pending: context.status !== "COMPLETED",172 };173 }174 return {175 type: "status_context",176 name: context.context,177 status: context.state,178 conclusion: context.state,179 detailsUrl: context.targetUrl ?? null,180 workflowName: null,181 green: GREEN_STATUS_STATES.has(context.state),182 pending: context.state === "PENDING" || context.state === "EXPECTED",183 };184}185 186export function reason(code, message, severity = "blocking", details = {}) {187 return { code, severity, message, ...details };188}189 190export function isTerminalIssue(status) {191 return TERMINAL_ISSUE_STATUSES.has(status);192}193