UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

231 lines (230 loc) 7.96 kB
import { t as truncateUtf8Prefix } from "./utf8-truncate-CCgJrv8A.js"; import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; //#region src/infra/github-issue.ts /** Prepares and submits bounded issue content to openclaw/openclaw. */ const GITHUB_REPOSITORY = "github.com/openclaw/openclaw"; const GITHUB_REPOSITORY_ISSUES_API = "repos/openclaw/openclaw/issues"; const GITHUB_ISSUE_CREATE_TIMEOUT_MS = 3e4; const GITHUB_OUTPUT_MAX_BYTES = 1048576; const GITHUB_ISSUE_BODY_MAX_BYTES = 2e4; const GITHUB_ISSUE_TITLE_MAX_BYTES = 512; const GITHUB_PREFILL_URL_MAX_BYTES = 8e3; const GITHUB_BODY_TRUNCATED_SUFFIX = "\n\n...<truncated>"; const GITHUB_MARKER_RE = /^openclaw-report:[a-f0-9]{64}$/u; const GITHUB_AUTH_ARGS = [ "auth", "status", "--active", "--hostname", "github.com" ]; const inflightSubmissions = /* @__PURE__ */ new Map(); function boundUtf8(value, maxBytes, suffix) { if (Buffer.byteLength(value, "utf8") <= maxBytes) return value; const suffixBytes = Buffer.byteLength(suffix, "utf8"); return `${truncateUtf8Prefix(value, Math.max(0, maxBytes - suffixBytes))}${suffix}`; } function buildPrefilledUrl(title, body) { return `https://github.com/openclaw/openclaw/issues/new?${new URLSearchParams({ body, title }).toString()}`; } /** Builds an exact browser fallback when its encoded request stays within a safe bound. */ function prepareGithubIssueBrowserFallback(title, body) { const url = buildPrefilledUrl(boundUtf8(title, GITHUB_ISSUE_TITLE_MAX_BYTES, GITHUB_BODY_TRUNCATED_SUFFIX), body); if (Buffer.byteLength(url, "utf8") > GITHUB_PREFILL_URL_MAX_BYTES) return { reason: "url-too-long", status: "unavailable" }; return { status: "available", url }; } /** Bounds sanitized content and adds the stable marker used for reconciliation. */ function prepareGithubIssue(input) { const title = boundUtf8(input.title, GITHUB_ISSUE_TITLE_MAX_BYTES, GITHUB_BODY_TRUNCATED_SUFFIX); const boundedBody = boundUtf8(input.body, GITHUB_ISSUE_BODY_MAX_BYTES, GITHUB_BODY_TRUNCATED_SUFFIX); const marker = `openclaw-report:${createHash("sha256").update(title).update("\0").update(boundedBody).digest("hex")}`; const markerComment = `\n\n<!-- ${marker} -->\n`; const body = `${boundUtf8(boundedBody.trimEnd(), GITHUB_ISSUE_BODY_MAX_BYTES - Buffer.byteLength(markerComment, "utf8"), GITHUB_BODY_TRUNCATED_SUFFIX)}${markerComment}`; return { body, browserFallback: prepareGithubIssueBrowserFallback(title, body), marker, title }; } function browserFallbackResult(issue, reason) { return issue.browserFallback.status === "available" ? { reason, status: "browser-fallback", url: issue.browserFallback.url } : { cause: reason, reason: "fallback-url-too-long", status: "fallback-unavailable" }; } function issueCreateArgs() { return [ "api", "--hostname", "github.com", "--include", "--method", "POST", GITHUB_REPOSITORY_ISSUES_API, "--input", "-", "--jq", ".html_url" ]; } function issueLookupArgs(marker) { return [ "issue", "list", "--repo", GITHUB_REPOSITORY, "--state", "all", "--search", `"${marker}" in:body`, "--limit", "100", "--json", "url,title,body" ]; } function createdIssueUrl(value) { if (typeof value !== "string") return; try { const url = new URL(value); if (url.origin === "https://github.com" && !url.search && !url.hash && /^\/openclaw\/openclaw\/issues\/\d+$/u.test(url.pathname)) return url.toString(); } catch {} } function issueUrlFromOutput(result) { return createdIssueUrl(result.stdout.toString("utf8").trim().split(/\r?\n/u).at(-1)); } function finalApiHttpStatus(result) { const matches = [...result.stdout.toString("utf8").matchAll(/^HTTP\/\S+\s+(\d{3})(?:\s|$)/gmu)]; const status = Number(matches.at(-1)?.[1]); return Number.isInteger(status) ? status : void 0; } function browserFallbackReason(result) { return result.errorCode === "ENOENT" || result.errorCode === "EACCES" || result.errorCode === "EPERM" ? "cli-unavailable" : "authentication-unavailable"; } /** Looks up one exact prepared issue without creating or mutating remote state. */ async function reconcileGithubIssue(issue, runGh = runGithubCli) { if (!GITHUB_MARKER_RE.test(issue.marker)) return { status: "unavailable" }; const lookup = await runGh(issueLookupArgs(issue.marker), { input: "" }); if (lookup.errorCode || lookup.status !== 0) return { status: "unavailable" }; let rows; try { rows = JSON.parse(lookup.stdout.toString("utf8")); } catch { return { status: "unavailable" }; } if (!Array.isArray(rows)) return { status: "unavailable" }; for (const row of rows) { if (typeof row !== "object" || row === null || !("body" in row) || row.body !== issue.body || !("title" in row) || row.title !== issue.title || !("url" in row)) continue; const url = createdIssueUrl(row.url); if (url) return { status: "created", url }; } return { status: "not-found" }; } async function submitGithubIssueOnce(issue, runGh) { const auth = await runGh(GITHUB_AUTH_ARGS, { input: "" }); if (auth.errorCode || auth.status !== 0) return browserFallbackResult(issue, browserFallbackReason(auth)); const created = await runGh(issueCreateArgs(), { input: JSON.stringify({ body: issue.body, title: issue.title }) }); const httpStatus = finalApiHttpStatus(created); const directUrl = httpStatus === void 0 || httpStatus === 201 ? issueUrlFromOutput(created) : void 0; if (directUrl) return { status: "created", url: directUrl }; if (!created.started && created.errorCode) return browserFallbackResult(issue, "transport-unavailable"); if (httpStatus !== void 0 && httpStatus >= 400 && httpStatus < 500 && httpStatus !== 408 && httpStatus !== 499) return browserFallbackResult(issue, httpStatus === 401 ? "authentication-unavailable" : "transport-unavailable"); const reconciled = await reconcileGithubIssue(issue, runGh); return reconciled.status === "created" ? reconciled : { reason: "creation-outcome-unknown", status: "outcome-unknown" }; } /** Submits once per marker in this process and reconciles uncertain create outcomes. */ function submitGithubIssue(issue, runGh = runGithubCli) { const current = inflightSubmissions.get(issue.marker); if (current) return current; const submission = submitGithubIssueOnce(issue, runGh).finally(() => { if (inflightSubmissions.get(issue.marker) === submission) inflightSubmissions.delete(issue.marker); }); inflightSubmissions.set(issue.marker, submission); return submission; } async function runGithubCli(args, options) { if (process.env.VITEST || false) return { errorCode: "EPERM", started: false, status: null, stdout: Buffer.alloc(0) }; return await new Promise((resolve) => { const child = spawn("gh", [...args], { stdio: [ "pipe", "pipe", "ignore" ] }); const stdout = []; let stdoutBytes = 0; let errorCode; let started = false; let settled = false; const settle = (status) => { if (settled) return; settled = true; clearTimeout(timeout); resolve({ ...errorCode ? { errorCode } : {}, started, status, stdout: Buffer.concat(stdout) }); }; child.stdout.on("data", (chunk) => { const remaining = GITHUB_OUTPUT_MAX_BYTES - stdoutBytes; if (remaining > 0) { stdout.push(chunk.subarray(0, remaining)); stdoutBytes += Math.min(chunk.byteLength, remaining); } }); child.on("spawn", () => { started = true; }); child.on("error", (error) => { errorCode = error.code ?? "SPAWN_FAILED"; }); child.on("close", settle); child.stdin.on("error", () => {}); const timeout = setTimeout(() => { errorCode = "ETIMEDOUT"; child.kill("SIGKILL"); child.stdin.destroy(); child.stdout.destroy(); child.unref(); settle(null); }, GITHUB_ISSUE_CREATE_TIMEOUT_MS); timeout.unref?.(); child.stdin.end(options.input); }); } //#endregion export { reconcileGithubIssue as n, submitGithubIssue as r, prepareGithubIssue as t };