UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

297 lines (296 loc) • 18.6 kB
import { resolveHostToken } from "./git-host-auth.js"; import { fillFromCommits } from "./git-host-gitlab.js"; import { apiRoot, HostApiError, hostJson, safeLink } from "./git-host-http.js"; import { git } from "./projects.js"; /** gitboy, the self-hosted forge, over its REST API (/api/v1). gitboy names a * pull request by `index`, and an older gitboy has no draft state, no aggregate review decision * and no web links in its answers, so this provider derives them: web pages * are `<origin>/<owner>/<repo>/pulls/<n>` and `/pipelines/<iid>?job=<id>`, a * review decision counts approvals on the head commit, and a newer gitboy * that sends `html_url`, `draft` or `review_decision` is taken at its word. * The token is GITBOY_TOKEN (for the domain GITBOY_HOST names) or the Hook's stored one, a gbp_ personal access * token with read:user and read:repo (write:repo to open or merge); gitboy's * "Phren Hook" token preset grants exactly those. A gitboy with head_branch and * sha filters gets them; an older one ignores them and the client filters below * still pick the right rows. */ export const PULL_TERMS = { short: "PR", long: "pull request", ref: "#" }; const ORDER = { failing: 0, pending: 1, passing: 2, skipped: 3, neutral: 4 }; /** A gitboy job or pipeline status as one state word. */ export function gitboyState(status, allowFailure = false) { switch (String(status ?? "").toLowerCase()) { case "success": return "passing"; case "failed": case "failure": case "error": return allowFailure ? "neutral" : "failing"; case "canceled": case "cancelled": return allowFailure ? "neutral" : "failing"; case "skipped": return "skipped"; default: return "pending"; // created, blocked, queued, claimed, running, pending } } function obj(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } function rows(value) { const list = Array.isArray(value) ? value : Array.isArray(obj(value).items) ? obj(value).items : []; return list.map(obj); } function stateOf(pull) { if (pull.merged_at) return "MERGED"; return String(pull.state ?? "").toLowerCase() === "open" ? "OPEN" : "CLOSED"; } const SIGN_IN = (domain) => `Phren has no gitboy token for ${domain}. Connect it from the desktop's Changes pane, or run phren bridge git-host set ${domain} gitboy on this computer with a personal access token that has read:user and read:repo (write:repo to open and merge); gitboy's Settings → Access tokens has a "Phren Hook" preset.`; async function context(root, host) { const repo = host.webUrl?.replace(/^https?:\/\/[^/]+\//, "") ?? ""; if (!host.domain || repo.split("/").length !== 2) return { available: false, reason: "unknown-host", message: "This repository has no gitboy remote of the form owner/repo.", pulls: [], current: null }; const token = await resolveHostToken("gitboy", host.domain); if (!token) return { available: false, reason: "auth", message: SIGN_IN(host.domain), pulls: [], current: null }; const api = await apiRoot(root, host.domain, `https://${host.domain}/api/v1`); return { api, web: api.replace(/\/api\/v1$/, ""), repo, token: token.token, domain: host.domain, account: { source: token.source, ...(token.user ? { user: token.user } : {}) } }; } function call(ctx, path, init = {}) { return hostJson(`${ctx.api}/repos/${ctx.repo}${path}`, { token: ctx.token, scheme: "bearer", ...init }); } /** Whether a pull request's head lives in this repository rather than a fork * with a branch of the same name. gitboy names it in `head_repository` * (owner_username, name); a row without one is taken as this repository's. */ export function sameRepository(ctx, pull) { const head = obj(pull.head_repository); if (typeof head.owner_username !== "string" || typeof head.name !== "string") return true; return `${head.owner_username}/${head.name}`.toLowerCase() === ctx.repo.toLowerCase(); } /** Whether the branch was pushed: its remote-tracking ref, read locally so an * error path never waits on the network or a credential prompt. */ async function remoteHasBranch(root, remote, branch) { return git(root, "show-ref", "--verify", "--quiet", `refs/remotes/${remote}/${branch}`).then(() => true, () => false); } /** gitboy's reasons it will not merge: a 409's `details`, else the message. */ export function refusalReasons(error) { const body = obj(error.body); const details = Array.isArray(body.details) ? body.details.map(item => String(item).slice(0, 300)).filter(Boolean).slice(0, 10) : []; const message = error.message.replace(/^.*? answered \d+:? ?/, ""); if (details.length) return `${message && message !== "Pull request cannot be merged" ? `${message}: ` : ""}${details.join("; ")}`; return message || "gitboy says it can't be merged yet."; } function pullUrl(ctx, pull) { return safeLink(pull.html_url) ?? `${ctx.web}/${ctx.repo}/pulls/${pull.index ?? pull.number}`; } function listed(ctx, pull) { const number = typeof pull.index === "number" ? pull.index : pull.number; return { number, title: String(pull.title ?? ""), head: String(pull.head_branch ?? ""), base: String(pull.base_branch ?? ""), author: String(pull.author_username ?? ""), url: pullUrl(ctx, pull), draft: pull.draft === true, state: stateOf(pull), updated: String(pull.updated_at ?? "") }; } /** GitHub's reviewDecision from gitboy's reviews: each reviewer's latest * verdict on the head commit, the author's own left out. */ export function gitboyReviewDecision(pull) { if (typeof pull.review_decision === "string" && pull.review_decision) return pull.review_decision.toUpperCase(); const head = String(pull.head_sha ?? ""); const latest = new Map(); for (const review of rows(pull.reviews).sort((a, b) => String(a.created_at ?? "").localeCompare(String(b.created_at ?? "")))) { const who = String(review.reviewer_username ?? review.reviewer_id ?? ""); if (!who || who === pull.author_username) continue; const state = String(review.state ?? ""); if (state === "changes_requested") latest.set(who, state); else if (state === "approved" && (!head || review.commit_sha === head)) latest.set(who, state); } const verdicts = [...latest.values()]; if (verdicts.includes("changes_requested")) return "CHANGES_REQUESTED"; const blockers = Array.isArray(pull.merge_blockers) ? pull.merge_blockers.map(String) : []; if (blockers.some(text => /approval/i.test(text))) return "REVIEW_REQUIRED"; return verdicts.includes("approved") ? "APPROVED" : undefined; } /** gitboy's mergeability and blockers in GitHub's mergeStateStatus words. */ export function gitboyMergeState(pull) { const blockers = (Array.isArray(pull.merge_blockers) ? pull.merge_blockers : []).map(text => String(text).slice(0, 300)).slice(0, 20); const status = String(pull.status ?? ""); if (stateOf(pull) !== "OPEN") return {}; if (status === "conflict") return { mergeState: "DIRTY", mergeDetail: "Has merge conflicts", ...(blockers.length ? { blockers } : {}) }; if (status === "checking" || status === "") return { mergeState: "UNKNOWN", mergeDetail: "Checking mergeability", ...(blockers.length ? { blockers } : {}) }; if (status === "empty" || status === "ancestor") return { mergeState: "UNKNOWN", mergeDetail: "No changes to merge" }; if (status === "error") return { mergeState: "UNKNOWN", mergeDetail: "gitboy could not check mergeability" }; if (blockers.length) return { mergeState: "BLOCKED", mergeDetail: blockers[0], blockers }; return { mergeState: "CLEAN", mergeDetail: "Ready to merge" }; } /** The head commit's pipeline jobs, newest attempt of each; failing first. */ async function jobsFor(ctx, pull) { const sha = String(pull.head_sha ?? ""); const branch = String(pull.head_branch ?? ""); if (!sha || !branch) return { runs: [] }; const { json } = await call(ctx, `/pipelines?branch=${encodeURIComponent(branch)}&sha=${encodeURIComponent(sha)}&limit=20`).catch(() => ({ json: [] })); const pipeline = rows(json).filter(p => p.sha === sha).sort((a, b) => Number(b.iid ?? 0) - Number(a.iid ?? 0))[0]; if (!pipeline || pipeline.iid === undefined) return { runs: [] }; const { json: detail } = await call(ctx, `/pipelines/${pipeline.iid}`).catch(() => ({ json: {} })); const newest = new Map(); for (const job of rows(obj(detail).jobs)) { const name = String(job.name ?? "Job"); const seen = newest.get(name); if (!seen || Number(job.attempt ?? 0) >= Number(seen.attempt ?? 0)) newest.set(name, job); } const pipelineUrl = `${ctx.web}/${ctx.repo}/pipelines/${pipeline.iid}`; const runs = [...newest.values()].map(job => { const state = gitboyState(job.status, job.allow_failure === true); const seconds = job.started_at && job.finished_at ? Math.max(0, Math.round((Date.parse(String(job.finished_at)) - Date.parse(String(job.started_at))) / 1000)) : undefined; return { name: String(job.name ?? "Job").slice(0, 200), state, ...(job.stage ? { workflow: String(job.stage).slice(0, 200) } : {}), url: job.id ? `${pipelineUrl}?job=${encodeURIComponent(String(job.id))}` : pipelineUrl, ...(seconds !== undefined && Number.isFinite(seconds) ? { seconds } : {}), ...(typeof job.failure_reason === "string" && job.failure_reason ? { detail: job.failure_reason.slice(0, 300) } : {}) }; }).sort((a, b) => ORDER[a.state] - ORDER[b.state]).slice(0, 100); return { runs, pipeline: { id: pipeline.iid, state: gitboyState(obj(detail).status ?? pipeline.status), url: pipelineUrl } }; } /** Commit statuses, when there is no pipeline to read jobs from. */ function statusRuns(pull) { return rows(pull.checks).filter(check => check.context !== "gitboy/pipeline").map(check => { const url = safeLink(check.target_url); return { name: String(check.context ?? "Check").replace(/^gitboy\//, "").slice(0, 200), state: gitboyState(check.state), ...(url ? { url } : {}) }; }).sort((a, b) => ORDER[a.state] - ORDER[b.state]).slice(0, 100); } function rollup(runs) { if (!runs.length) return null; const states = runs.map(run => run.state); if (states.includes("failing")) return "failing"; return states.includes("pending") ? "pending" : "passing"; } async function currentRequest(ctx, branch) { if (!branch) return null; // gitboy can't filter by head branch: read the newest and pick this branch's. const { json } = await call(ctx, `/pulls?state=all&limit=100&head_branch=${encodeURIComponent(branch)}`); const mine = rows(json).filter(pull => pull.head_branch === branch && sameRepository(ctx, pull)) .sort((a, b) => Number(stateOf(b) === "OPEN") - Number(stateOf(a) === "OPEN") || String(b.updated_at ?? "").localeCompare(String(a.updated_at ?? "")))[0]; const index = mine?.index ?? mine?.number; if (index === undefined) return null; const { json: detail } = await call(ctx, `/pulls/${index}`); const pull = obj(detail); const { runs: jobs, pipeline } = await jobsFor(ctx, pull); const checkRuns = jobs.length ? jobs : statusRuns(pull); const reviewDecision = gitboyReviewDecision(pull); const approvals = new Set(rows(pull.reviews).filter(review => review.state === "approved" && review.reviewer_username !== pull.author_username && (!pull.head_sha || review.commit_sha === pull.head_sha)).map(review => String(review.reviewer_username ?? review.reviewer_id))).size; const conflicted = Array.isArray(pull.conflicted_files) ? pull.conflicted_files.length : 0; return { ...listed(ctx, pull), checks: rollup(checkRuns), checkRuns, ...(reviewDecision ? { reviewDecision } : {}), ...gitboyMergeState(pull), ...(pipeline ? { pipeline } : {}), ...(approvals ? { approvals: { given: approvals } } : {}), ...(typeof pull.comment_count === "number" ? { comments: pull.comment_count } : Array.isArray(pull.comments) ? { comments: pull.comments.length } : {}), ...(typeof pull.commits_behind === "number" ? { behind: pull.commits_behind } : {}), ...(typeof pull.commits_ahead === "number" ? { ahead: pull.commits_ahead } : {}), ...(conflicted ? { conflicts: true } : {}), ...(typeof pull.head_sha === "string" ? { headSha: pull.head_sha } : {}) }; } function failed(error, domain) { if (error instanceof HostApiError) return { available: false, reason: error.reason, message: error.reason === "auth" ? `${error.message} ${SIGN_IN(domain)}` : error.message, pulls: [], current: null }; return { available: false, reason: "failed", message: error instanceof Error ? error.message : String(error), pulls: [], current: null }; } /** Whether this gitboy has draft pull requests: any pull request it lists * carries a `draft` key. */ async function hasDraftState(ctx, listed) { const sample = rows(listed).length ? rows(listed) : rows((await call(ctx, "/pulls?state=all&limit=1").catch(() => ({ json: [] }))).json); return sample.some(pull => typeof pull.draft === "boolean"); } export const gitboy = { kind: "gitboy", name: "gitboy", terms: PULL_TERMS, supported: true, async list(root, branch, host) { const ctx = await context(root, host); if (!("api" in ctx)) return ctx; try { const [{ json }, current] = await Promise.all([call(ctx, "/pulls?state=open&limit=50"), currentRequest(ctx, branch)]); return { available: true, pulls: rows(json).map(pull => listed(ctx, pull)), current, account: ctx.account }; } catch (error) { return failed(error, ctx.domain); } }, async open(root, branch, draft, host) { const ctx = await context(root, host); if (!("api" in ctx)) return { ok: false, reason: ctx.reason, message: ctx.message }; try { const { json: open } = await call(ctx, `/pulls?state=open&limit=100&head_branch=${encodeURIComponent(branch)}`); const existing = rows(open).find(pull => pull.head_branch === branch); if (existing) return { ok: true, url: pullUrl(ctx, existing), number: existing.index, branch, existing: true }; const { json: repo } = await call(ctx, ""); const target = String(obj(repo).default_branch ?? "main"); const fill = await fillFromCommits(root, `${host.remote ?? "origin"}/${target}`, branch); // A gitboy with draft pull requests sends `draft` on every one; an older // one has none, and the title says it, as GitLab's convention does. const native = draft && await hasDraftState(ctx, open); const title = draft && !native && !/^draft:/i.test(fill.title) ? `Draft: ${fill.title}` : fill.title; const { json } = await call(ctx, "/pulls", { method: "POST", body: { head_branch: branch, base_branch: target, title, body: fill.body, ...(native ? { draft: true } : {}) } }); const pull = obj(json); return { ok: true, url: pullUrl(ctx, pull), number: (pull.index ?? pull.number), branch, draft }; } catch (error) { if (error instanceof HostApiError && error.status === 400 && /no changes|nothing to compare|no mergeable changes|choose different source and target/i.test(error.message)) return { ok: false, reason: "failed", message: `${branch} has no changes against the default branch.` }; // gitboy answers 500 (not 404) for a branch it doesn't have yet; ask git. if (error instanceof HostApiError && (error.status === 404 || error.status === 500) && !await remoteHasBranch(root, host.remote ?? "origin", branch)) { return { ok: false, reason: "not-found", message: `Push ${branch} to gitboy first, then open the pull request.` }; } const result = failed(error, ctx.domain); return { ok: false, reason: result.reason ?? "failed", message: result.message ?? "gitboy refused the pull request." }; } }, async merge(root, request, host) { const ctx = await context(root, host); if (!("api" in ctx)) return { ok: false, reason: ctx.reason, message: ctx.message }; try { // gitboy needs both heads for a rebase; the base guard is harmless for the others. const { json: detail } = await call(ctx, `/pulls/${request.number}`).catch(() => ({ json: {} })); const baseSha = typeof obj(detail).base_sha === "string" ? obj(detail).base_sha : undefined; const { json } = await call(ctx, `/pulls/${request.number}/merge`, { method: "POST", body: { method: request.method, ...(request.expectedHeadSha ? { expected_head_sha: request.expectedHeadSha } : {}), ...(baseSha ? { expected_base_sha: baseSha } : {}) } }); const result = obj(json); return { ok: true, number: request.number, method: request.method, ...(typeof result.merge_commit_sha === "string" ? { mergeCommit: result.merge_commit_sha } : {}) }; } catch (error) { if (error instanceof HostApiError && (error.status === 409 || error.status === 422 || error.status === 400)) { return { ok: false, reason: "blocked", message: refusalReasons(error) }; } if (error instanceof HostApiError && error.status === 403) return { ok: false, reason: "auth", message: "The gitboy token can read but not merge here; it needs write:repo, and your account needs merge rights on the branch." }; const result = failed(error, ctx.domain); return { ok: false, reason: result.reason ?? "failed", message: result.message ?? "gitboy refused the merge." }; } }, async whoami(root, host, token, checkedApi) { if (!host.domain) throw new HostApiError("failed", "This repository has no gitboy remote."); const api = checkedApi ?? await apiRoot(root, host.domain, `https://${host.domain}/api/v1`); const { json } = await hostJson(`${api}/user`, { token, scheme: "bearer" }); return { user: String(obj(json).username ?? "") }; }, };