UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

570 lines (569 loc) • 22.1 kB
import { m as readNonBlankString } from "./string-coerce-6kR-86XC.mjs"; import { s as asFiniteNumber } from "./number-coercion-Dq1l5muL.mjs"; import { c as isRecord } from "./record-coerce-TIsquoVR.mjs"; import { n as runGitReadOperation, r as createRetainedCache, t as releaseGitReadCache } from "./git-read-cache-DiYjgftZ.mjs"; import { n as gitHubPublicApi } from "./github-public-api-D6N-IT-v.mjs"; import { t as resolveGitHubForkParent } from "./github-repository-target-CLDMTfb7.mjs"; //#region src/gateway/control-ui-session-prs-checks.ts const FAILING_CHECK_CONCLUSIONS = /* @__PURE__ */ new Set([ "failure", "timed_out", "cancelled", "action_required", "startup_failure" ]); const PAGE_SIZE = 100; const MAX_PAGES = 10; const PAGE_BYTES = 1048576; const MAX_ACTIONS_SUITES = 20; const MAX_STEPS = 200; function sessionPullRequestCheckState(status, conclusion) { if (typeof conclusion === "string" && FAILING_CHECK_CONCLUSIONS.has(conclusion)) return "failed"; if (status !== "completed" || conclusion === "stale") return "running"; return conclusion === "skipped" ? "skipped" : "passed"; } function sessionPullRequestRepositoryApiUrl(target) { return `${gitHubPublicApi.GITHUB_API_ORIGIN}/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}`; } function incomplete() { return new gitHubPublicApi.ControlUiGitHubError(502, "GitHub CI details were incomplete or changed while loading"); } /** One bounded pagination owner serves both the compact rollup and on-demand detail. */ async function fetchSessionPullRequestCheckRuns(target, request) { return fetchPages(`${sessionPullRequestRepositoryApiUrl(target)}/commits/${target.headSha}/check-runs?filter=latest`, "check_runs", request); } async function fetchPages(url, field, request) { const rows = []; let expected; for (let page = 1; page <= MAX_PAGES; page += 1) { const value = await request(`${url}&per_page=${PAGE_SIZE}&page=${page}`, PAGE_BYTES); if (!isRecord(value)) throw incomplete(); const items = value[field]; const total = value.total_count; if (!Array.isArray(items) || items.length > PAGE_SIZE || typeof total !== "number" || !Number.isSafeInteger(total) || total < 0 || total > 1e3 || expected !== void 0 && expected !== total) throw incomplete(); expected = total; for (const item of items) { if (!isRecord(item)) throw incomplete(); rows.push(item); } if (rows.length === total) return rows; if (rows.length > total || items.length < PAGE_SIZE) throw incomplete(); } throw incomplete(); } async function fetchSessionPullRequestCheckRollup(item, fetchImpl, token) { if (!item.headSha || !/^[0-9a-f]{40}$/i.test(item.headSha)) return; const runs = await fetchSessionPullRequestCheckRuns({ ...item, headSha: item.headSha }, (url, maxBytes) => gitHubPublicApi.fetchGitHubJson(url, fetchImpl, token, maxBytes)); if (runs.length === 0) return; const counts = { passed: 0, failed: 0, skipped: 0, running: 0 }; for (const run of runs) counts[sessionPullRequestCheckState(run.status, run.conclusion)] += 1; return { state: counts.failed > 0 ? "failing" : counts.running > 0 ? "pending" : "passing", ...counts }; } function positiveId(value) { if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) throw incomplete(); return value; } function text(value, max = 512) { const result = readNonBlankString(value); if (!result || result.length > max) throw incomplete(); return result; } function timing(value) { const timestamp = (raw) => { if (raw === null || raw === void 0) return; const date = text(raw, 40); if (!Number.isFinite(Date.parse(date))) throw incomplete(); return date; }; return { startedAt: timestamp(value.started_at), completedAt: timestamp(value.completed_at) }; } function readConclusion(value) { return value === null || value === void 0 ? void 0 : text(value, 64); } function safeCheckLink(value) { if (typeof value !== "string" || value.length > 2048) return; try { const url = new URL(value); return url.protocol === "https:" && !url.username && !url.password ? url.href : void 0; } catch { return; } } function parseChecks(rows, target) { const ids = /* @__PURE__ */ new Set(); return rows.map((row) => { const id = positiveId(row.id); if (ids.has(id) || row.head_sha !== target.headSha) throw incomplete(); ids.add(id); const source = isRecord(row.app) && row.app.slug === "github-actions" ? "actions" : "check"; const status = text(row.status, 64); const result = readConclusion(row.conclusion); return { check: { id, name: text(row.name), status, conclusion: result, state: sessionPullRequestCheckState(status, result), source, detailsUrl: safeCheckLink(row.details_url ?? row.html_url), ...timing(row) }, ...source === "actions" && isRecord(row.check_suite) ? { suiteId: positiveId(row.check_suite.id) } : {} }; }); } function parseSteps(value) { if (!Array.isArray(value) || value.length > MAX_STEPS) throw incomplete(); const numbers = /* @__PURE__ */ new Set(); return value.map((step) => { if (!isRecord(step)) throw incomplete(); const number = positiveId(step.number); if (numbers.has(number)) throw incomplete(); numbers.add(number); return { number, name: text(step.name), status: text(step.status, 64), conclusion: readConclusion(step.conclusion), ...timing(step) }; }).toSorted((left, right) => left.number - right.number); } async function loadSuiteJobs(target, suiteId, checks, request) { const base = sessionPullRequestRepositoryApiUrl(target); const runs = await fetchPages(`${base}/actions/runs?head_sha=${target.headSha}&check_suite_id=${suiteId}`, "workflow_runs", request); if (runs.length !== 1 || runs[0]?.check_suite_id !== suiteId || runs[0]?.head_sha !== target.headSha) throw incomplete(); const runId = positiveId(runs[0].id); const attempt = runs[0].run_attempt === void 0 ? void 0 : positiveId(runs[0].run_attempt); const jobs = await fetchPages(`${base}/actions/runs/${runId}/jobs?filter=all`, "jobs", request); const wanted = new Map(checks.filter((row) => row.suiteId === suiteId).map(({ check }) => [`${base}/check-runs/${check.id}`.toLowerCase(), check])); const result = /* @__PURE__ */ new Map(); for (const job of jobs) { if (job.run_id !== runId || job.head_sha !== target.headSha || job.run_attempt !== void 0 && attempt !== void 0 && positiveId(job.run_attempt) > attempt) throw incomplete(); const check = typeof job.check_run_url === "string" ? wanted.get(job.check_run_url.toLowerCase()) : void 0; if (!check) continue; if (result.has(check.id)) throw incomplete(); const jobId = positiveId(job.id); const status = text(job.status, 64); const verdict = readConclusion(job.conclusion); result.set(check.id, { ...check, status, conclusion: verdict, state: sessionPullRequestCheckState(status, verdict), ...timing(job), detailsUrl: `https://github.com/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/actions/runs/${runId}/job/${jobId}`, steps: parseSteps(job.steps ?? []) }); } if ([...wanted.values()].some((check) => check.state !== "skipped" && !result.has(check.id))) throw incomplete(); return result; } /** Checks remain useful when Actions permissions, history, or the bounded detail budget fail. */ async function fetchSessionPullRequestCheckDetails(target, request) { const rows = parseChecks(await fetchSessionPullRequestCheckRuns(target, request), target); const priority = { failed: 0, running: 1, passed: 2, skipped: 3 }; const compareChecks = (a, b) => priority[a.state] - priority[b.state] || a.name.localeCompare(b.name) || a.id - b.id; const orderedRows = rows.toSorted((a, b) => compareChecks(a.check, b.check)); const suites = [...new Set(orderedRows.filter(({ check }) => check.source === "actions" && check.state !== "skipped").map(({ suiteId }) => suiteId))]; let error; const details = /* @__PURE__ */ new Map(); for (const suite of suites.slice(0, MAX_ACTIONS_SUITES)) try { if (suite === void 0) throw incomplete(); const jobs = await loadSuiteJobs(target, suite, rows, request); for (const [id, check] of jobs) details.set(id, check); } catch (failure) { error = failure; if (failure instanceof gitHubPublicApi.ControlUiGitHubError && failure.statusCode === 429) break; } if (suites.length > MAX_ACTIONS_SUITES && !error) error = new gitHubPublicApi.ControlUiGitHubError(502, "Some Actions steps exceeded the CI detail limit; open the job on GitHub"); if (suites.length > 0 && !error) { const latest = parseChecks(await fetchSessionPullRequestCheckRuns(target, request), target); const identity = (values) => values.map(({ check, suiteId }) => `${check.id}:${suiteId ?? ""}`).toSorted().join(","); if (identity(latest) !== identity(rows)) throw new gitHubPublicApi.ControlUiGitHubError(409, "CI jobs were rerun while loading; reopen CI details"); } return { checks: orderedRows.map(({ check }) => details.get(check.id) ?? check).toSorted(compareChecks), error }; } //#endregion //#region src/gateway/control-ui-session-prs.ts const SUCCESS_CACHE_MS = 9e4; const RATE_LIMIT_CACHE_MS = 3e5; const FAILURE_CACHE_MS = 3e4; const MAX_PULL_REQUESTS = 3; const branchCache = createRetainedCache(); function releaseSessionPullRequestLocalGitCache(signal) { releaseGitReadCache("checkout.context", signal); releaseGitReadCache("pull-request.branch-facts", signal); } /** * Resolves the GitHub repo + branch, caching detached/default/non-GitHub * outcomes too so repeated sidebar requests do not respawn the same probes. */ async function resolveSessionPullRequestGitContext(params, deps, capturedSource) { const source = deps.resolveGitRoot ? await deps.resolveGitRoot(params) : capturedSource; if (typeof source !== "string") { releaseSessionPullRequestLocalGitCache(deps.cacheSignal); return source; } return runGitReadOperation({ type: "checkout.context", input: { root: source } }, { refresh: params.refresh ? "unversioned" : false, cacheSignal: deps.cacheSignal }); } function branchCreateUrl(context, branchName) { return `https://github.com/${encodeURIComponent(context.owner)}/${encodeURIComponent(context.repo)}/pull/new/${branchName.split("/").map(encodeURIComponent).join("/")}`; } async function resolveSessionBranch(context, mergedHeads, deps, refresh) { if (!context.branch || context.branch === context.defaultBranch) return; const root = context.root; if (!root) return { owner: context.owner, repo: context.repo, branch: context.branch, createUrl: branchCreateUrl(context, context.branch) }; const facts = await runGitReadOperation({ type: "pull-request.branch-facts", input: { root, branch: context.branch, defaultBranch: context.defaultBranch, mergedHeads } }, { refresh, cacheSignal: deps.cacheSignal }); if (!facts) return; return { owner: context.owner, repo: context.repo, branch: context.branch, ...facts.creatable ? { createUrl: branchCreateUrl(context, context.branch) } : {}, ...facts.stats ? { additions: facts.stats.additions, deletions: facts.stats.deletions, changedFiles: facts.stats.changedFiles } : {} }; } function derivePullState(value) { if (readNonBlankString(value.merged_at)) return "merged"; if (value.state !== "open") return "closed"; return value.draft === true ? "draft" : "open"; } function parsePullListItem(value) { if (!isRecord(value)) return null; const number = asFiniteNumber(value.number); const title = readNonBlankString(value.title); const url = readNonBlankString(value.html_url); const base = isRecord(value.base) ? value.base : {}; const baseRepo = isRecord(base.repo) ? base.repo : {}; const baseOwner = isRecord(baseRepo.owner) ? baseRepo.owner : {}; const owner = readNonBlankString(baseOwner.login); const repo = readNonBlankString(baseRepo.name); const head = isRecord(value.head) ? value.head : {}; if (!number || !Number.isSafeInteger(number) || number < 1 || !title || !url || !owner || !repo) return null; const user = isRecord(value.user) ? value.user : {}; const authorLogin = readNonBlankString(user.login); return { number, title, url, owner, repo, state: derivePullState(value), ...authorLogin ? { author: { login: authorLogin } } : {}, branch: readNonBlankString(head.ref), headSha: readNonBlankString(head.sha), baseRef: readNonBlankString(base.ref), mergeCommitSha: readNonBlankString(value.merge_commit_sha) }; } function parsePullList(value) { if (!Array.isArray(value)) return []; return value.map(parsePullListItem).filter((item) => item !== null); } function pullsByHeadUrl(owner, repo, head) { const encHead = encodeURIComponent(head); return `${sessionPullRequestRepositoryApiUrl({ owner, repo })}/pulls?head=${encHead}&state=all&sort=updated&direction=desc&per_page=5`; } async function fetchParentRepo(owner, repo, fetchImpl, token) { const value = await gitHubPublicApi.fetchGitHubJson(sessionPullRequestRepositoryApiUrl({ owner, repo }), fetchImpl, token); return resolveGitHubForkParent(value) ?? null; } function rethrowRateLimit(error) { if (error instanceof gitHubPublicApi.ControlUiGitHubError && error.statusCode === 429) throw error; } /** * The facts a chip carries without spending quota on per-PR detail calls. The * rate-limited path renders exactly this, so both callers share one shape. */ function stateOnlyPullRequestChip(item, branch) { return { number: item.number, owner: item.owner, repo: item.repo, branch, title: item.title, url: item.url, state: item.state, ...item.headSha && /^[0-9a-f]{40}$/i.test(item.headSha) ? { headSha: item.headSha.toLowerCase() } : {}, ...item.author ? { author: item.author } : {} }; } async function finishPullRequest(item, branch, fetchImpl, token) { const chip = stateOnlyPullRequestChip(item, branch); if (item.state !== "open" && item.state !== "draft") return chip; const detailUrl = `${sessionPullRequestRepositoryApiUrl(item)}/pulls/${item.number}`; const [details, checks] = await Promise.all([gitHubPublicApi.fetchGitHubJson(detailUrl, fetchImpl, token).catch(rethrowRateLimit), fetchSessionPullRequestCheckRollup(item, fetchImpl, token).catch(rethrowRateLimit)]); return { ...chip, ...isRecord(details) ? { additions: asFiniteNumber(details.additions), deletions: asFiniteNumber(details.deletions), changedFiles: asFiniteNumber(details.changed_files) } : {}, ...checks ? { checks, checksUrl: `${item.url}/checks` } : {} }; } function mergedHeadsOf(items) { const heads = []; for (const item of items) if (item.state === "merged" && item.headSha) heads.push({ sha: item.headSha.toLowerCase(), ...item.baseRef ? { baseRef: item.baseRef } : {}, ...item.mergeCommitSha ? { mergeCommitSha: item.mergeCommitSha.toLowerCase() } : {} }); return heads; } async function fetchBranchPullRequests(context, fetchImpl, token) { const head = `${context.owner}:${context.branch}`; const hasWorkingBranch = Boolean(context.branch && context.branch !== context.defaultBranch); let items = hasWorkingBranch ? parsePullList(await gitHubPublicApi.fetchGitHubJson(pullsByHeadUrl(context.owner, context.repo, head), fetchImpl, token)) : []; if (hasWorkingBranch && items.length === 0) { const parent = await fetchParentRepo(context.owner, context.repo, fetchImpl, token); if (parent) items = parsePullList(await gitHubPublicApi.fetchGitHubJson(pullsByHeadUrl(parent.owner, parent.repo, head), fetchImpl, token)); } const mergedHeads = mergedHeadsOf(items); const workingBranchHasLivePullRequest = items.some((item) => item.state === "open" || item.state === "draft"); const isActive = (item) => item.state === "open" || item.state === "draft"; const capped = items.toSorted((left, right) => Number(isActive(right)) - Number(isActive(left))).slice(0, MAX_PULL_REQUESTS); const branchOf = (item) => item.branch ?? context.branch ?? ""; const stateOnlySnapshot = () => ({ pullRequests: capped.map((item) => stateOnlyPullRequestChip(item, branchOf(item))), rateLimited: true, mergedHeads, workingBranchHasLivePullRequest }); try { return { pullRequests: await Promise.all(capped.map((item) => finishPullRequest(item, branchOf(item), fetchImpl, token))), rateLimited: false, mergedHeads, workingBranchHasLivePullRequest }; } catch (error) { if (!(error instanceof gitHubPublicApi.ControlUiGitHubError && error.statusCode === 429)) throw error; return stateOnlySnapshot(); } } async function refreshBranchPullRequests(context, fetchImpl, entry, token) { const repository = { owner: context.owner, repo: context.repo }; try { const result = { ...await fetchBranchPullRequests(context, fetchImpl, token), repository }; if (result.rateLimited) { entry.expiresAt = Date.now() + RATE_LIMIT_CACHE_MS; if (entry.lastGood) { const identity = (item) => `${item.owner}/${item.repo}#${item.number}`.toLowerCase(); const retained = new Map(entry.lastGood.pullRequests.map((item) => [identity(item), item])); result.pullRequests = [...result.pullRequests.map((item) => { const previous = retained.get(identity(item)); retained.delete(identity(item)); return previous && item.state === previous.state && item.headSha === previous.headSha && (item.state === "open" || item.state === "draft") ? { ...previous, ...item } : item; }), ...retained.values()].slice(0, MAX_PULL_REQUESTS); } } entry.lastGood = { pullRequests: result.pullRequests, mergedHeads: result.mergedHeads, workingBranchHasLivePullRequest: result.workingBranchHasLivePullRequest, repository }; return result; } catch (error) { const rateLimited = error instanceof gitHubPublicApi.ControlUiGitHubError && error.statusCode === 429; entry.expiresAt = Date.now() + (rateLimited ? RATE_LIMIT_CACHE_MS : FAILURE_CACHE_MS); if (rateLimited) return { pullRequests: [], mergedHeads: [], workingBranchHasLivePullRequest: false, ...entry.lastGood, repository, rateLimited: true }; if (entry.lastGood) return { ...entry.lastGood, rateLimited: false, status: "unavailable" }; throw error; } } async function loadControlUiSessionPullRequests(params, deps) { const { target, assertCurrent } = deps.read; try { assertCurrent(); const request = { ...params, ...target.params }; const context = deps.resolveGitContext ? await deps.resolveGitContext(request) : await resolveSessionPullRequestGitContext(request, deps, target.source); assertCurrent(); if (!context) { releaseGitReadCache("pull-request.branch-facts", deps.cacheSignal); branchCache.release(deps.cacheSignal); return { pullRequests: [], rateLimited: false }; } if (!context.branch || context.branch === context.defaultBranch) { releaseGitReadCache("pull-request.branch-facts", deps.cacheSignal); branchCache.release(deps.cacheSignal); return { pullRequests: [], repository: { owner: context.owner, repo: context.repo }, rateLimited: false }; } const result = await cachedBranchPullRequests(context, deps, request.refresh === true, JSON.stringify([target.identity, deps.read.sourceIdentity])).catch(() => { releaseGitReadCache("pull-request.branch-facts", deps.cacheSignal); return null; }); assertCurrent(); if (!result) return { pullRequests: [], repository: { owner: context.owner, repo: context.repo }, rateLimited: false, status: "unavailable" }; const { mergedHeads, workingBranchHasLivePullRequest, ...snapshot } = result; const branch = workingBranchHasLivePullRequest ? void 0 : await resolveSessionBranch(context, mergedHeads, deps, request.refresh === true); assertCurrent(); return { ...snapshot, ...branch ? { branch } : {} }; } catch (error) { releaseSessionPullRequestLocalGitCache(deps.cacheSignal); branchCache.release(deps.cacheSignal); throw error; } } function trackBranchRefresh(entry, mode, load) { entry.expiresAt = Date.now() + SUCCESS_CACHE_MS; entry.refreshMode = mode; const trackedPromise = load().finally(() => { if (entry.promise === trackedPromise) entry.refreshMode = null; }); entry.promise = trackedPromise; return trackedPromise; } async function cachedBranchPullRequests(context, deps, refresh, sessionIdentity) { let identity; try { identity = gitHubPublicApi.resolveGitHubApiCredentialScope(); } catch (error) { branchCache.release(deps.cacheSignal); throw error; } const { token, cacheScope } = identity; const key = JSON.stringify([ context.owner.toLowerCase(), context.repo.toLowerCase(), context.branch, sessionIdentity, cacheScope ]); const cached = branchCache.get(key, deps.cacheSignal); if (cached && cached.expiresAt > Date.now()) { branchCache.set(key, cached, deps.cacheSignal); if (!refresh || cached.refreshMode === "forced") return cached.promise; const pendingSnapshot = cached.promise; const pendingRefreshMode = cached.refreshMode; const pendingExpiresAt = cached.expiresAt; return trackBranchRefresh(cached, "forced", async () => { const snapshot = await pendingSnapshot; if (snapshot.rateLimited) { if (pendingRefreshMode === null) cached.expiresAt = pendingExpiresAt; return snapshot; } return refreshBranchPullRequests(context, deps.fetchImpl ?? fetch, cached, token); }); } const entry = cached ?? { expiresAt: 0, promise: Promise.resolve({ pullRequests: [], rateLimited: false, mergedHeads: [], workingBranchHasLivePullRequest: false }), refreshMode: null }; const promise = trackBranchRefresh(entry, refresh ? "forced" : "normal", () => refreshBranchPullRequests(context, deps.fetchImpl ?? fetch, entry, token)); branchCache.set(key, entry, deps.cacheSignal); return promise; } //#endregion export { sessionPullRequestRepositoryApiUrl as i, parsePullListItem as n, fetchSessionPullRequestCheckDetails as r, loadControlUiSessionPullRequests as t };