UNPKG

@phren/cli

Version:

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

230 lines (229 loc) • 13.9 kB
import { resolveHostToken } from "./git-host-auth.js"; import { apiRoot, HostApiError, hostJson, safeLink } from "./git-host-http.js"; import { git } from "./projects.js"; /** GitLab, gitlab.com or self-hosted, over its REST API (v4). The token is * GITLAB_TOKEN, the Hook's stored one, or glab's own sign-in, so glab itself * is optional. Merge requests come back in the same shape as GitHub's pull * requests; pipeline jobs are the checks, and the stage reads as the workflow. */ export const MERGE_TERMS = { short: "MR", long: "merge request", ref: "!" }; const ORDER = { failing: 0, pending: 1, passing: 2, skipped: 3, neutral: 4 }; /** A pipeline or job status as one state word. An allowed failure is neutral: * it doesn't block the merge request. */ export function gitlabState(status, allowFailure = false) { switch (String(status ?? "").toLowerCase()) { case "success": return "passing"; case "failed": return allowFailure ? "neutral" : "failing"; case "canceled": case "canceling": return allowFailure ? "neutral" : "failing"; case "skipped": return "skipped"; case "manual": return "neutral"; default: return "pending"; // created, waiting_for_resource, preparing, pending, running, scheduled } } export function gitlabRollup(states) { if (!states.length) return null; if (states.includes("failing")) return "failing"; return states.includes("pending") ? "pending" : "passing"; } /** What `detailed_merge_status` means, in GitHub's mergeStateStatus words * (which the clients already colour) plus GitLab's own sentence. */ export function gitlabMergeState(detail) { const value = String(detail ?? ""); const map = { mergeable: ["CLEAN", "Ready to merge"], conflict: ["DIRTY", "Has merge conflicts"], need_rebase: ["BEHIND", "Needs a rebase onto the target branch"], ci_must_pass: ["BLOCKED", "Waiting for the pipeline to pass"], ci_still_running: ["BLOCKED", "Pipeline still running"], not_approved: ["BLOCKED", "Needs approval"], discussions_not_resolved: ["BLOCKED", "Has unresolved threads"], draft_status: ["DRAFT", "Draft"], blocked_status: ["BLOCKED", "Blocked by another merge request"], requested_changes: ["BLOCKED", "Changes requested"], jira_association_missing: ["BLOCKED", "Needs a Jira issue"], merge_request_blocked: ["BLOCKED", "Blocked"], external_status_checks: ["BLOCKED", "Waiting for external status checks"], not_open: ["UNKNOWN", "Not open"], checking: ["UNKNOWN", "Checking mergeability"], unchecked: ["UNKNOWN", "Not checked yet"], preparing: ["UNKNOWN", "Preparing"], }; const hit = map[value]; return hit ? { mergeState: hit[0], mergeDetail: hit[1] } : value ? { mergeState: "UNKNOWN", mergeDetail: value.replace(/_/g, " ") } : {}; } function upper(state) { const value = String(state ?? "").toLowerCase(); return value === "opened" ? "OPEN" : value === "locked" ? "CLOSED" : value.toUpperCase(); } function obj(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } function projectPath(host) { if (!host.webUrl || !host.domain) return null; const path = host.webUrl.replace(/^https?:\/\/[^/]+\//, ""); return path || null; } const SIGN_IN = (domain) => `Phren has no GitLab token for ${domain}. Connect it from the desktop's Changes pane, run phren bridge git-host set ${domain} gitlab on this computer, or sign in with glab auth login.`; async function context(root, host) { const path = projectPath(host); if (!host.domain || !path) return { available: false, reason: "unknown-host", message: "This repository has no GitLab remote.", pulls: [], current: null }; const token = await resolveHostToken("gitlab", host.domain); if (!token) return { available: false, reason: "auth", message: SIGN_IN(host.domain), pulls: [], current: null }; const base = await apiRoot(root, host.domain, `https://${host.domain}/api/v4`); return { base, token: token.token, project: encodeURIComponent(path), domain: host.domain, account: { source: token.source, ...(token.user ? { user: token.user } : {}) } }; } function call(ctx, path, init = {}) { return hostJson(`${ctx.base}/projects/${ctx.project}${path}`, { token: ctx.token, scheme: "private-token", ...init }); } function listed(mr) { const author = obj(mr.author); return { number: mr.iid, title: String(mr.title ?? ""), head: String(mr.source_branch ?? ""), base: String(mr.target_branch ?? ""), author: String(author.username ?? author.name ?? ""), url: safeLink(mr.web_url) ?? "", draft: mr.draft === true || mr.work_in_progress === true, state: upper(mr.state), updated: String(mr.updated_at ?? "") }; } /** The branch's merge request with its pipeline jobs, approvals and merge state. */ async function currentRequest(ctx, branch) { if (!branch) return null; const { json: found } = await call(ctx, `/merge_requests?source_branch=${encodeURIComponent(branch)}&state=all&order_by=updated_at&per_page=5`); const first = (Array.isArray(found) ? found : []).map(obj).sort((a, b) => Number(upper(b.state) === "OPEN") - Number(upper(a.state) === "OPEN"))[0]; if (!first || typeof first.iid !== "number") return null; const [{ json: detail }, approvals] = await Promise.all([ call(ctx, `/merge_requests/${first.iid}`), call(ctx, `/merge_requests/${first.iid}/approvals`).then(r => obj(r.json)).catch(() => null), ]); const mr = obj(detail); const pipeline = obj(mr.head_pipeline ?? mr.pipeline); let checkRuns = []; if (typeof pipeline.id === "number") { const { json: jobs } = await call(ctx, `/pipelines/${pipeline.id}/jobs?per_page=100&include_retried=false`).catch(() => ({ json: [] })); checkRuns = (Array.isArray(jobs) ? jobs : []).map(obj).map(job => { const state = gitlabState(job.status, job.allow_failure === true); const url = safeLink(job.web_url); return { name: String(job.name ?? "Job").slice(0, 200), state, ...(job.stage ? { workflow: String(job.stage).slice(0, 200) } : {}), ...(url ? { url } : {}), ...(typeof job.duration === "number" ? { seconds: Math.round(job.duration) } : {}) }; }).sort((a, b) => ORDER[a.state] - ORDER[b.state]).slice(0, 100); } const given = Array.isArray(approvals?.approved_by) ? approvals.approved_by.length : 0; const left = typeof approvals?.approvals_left === "number" ? approvals.approvals_left : undefined; const required = typeof approvals?.approvals_required === "number" ? approvals.approvals_required : undefined; const reviewDecision = approvals?.approved === true && (left ?? 0) === 0 && (required ?? given) > 0 ? "APPROVED" : (left ?? 0) > 0 ? "REVIEW_REQUIRED" : undefined; const pipelineState = typeof pipeline.status === "string" ? gitlabState(pipeline.status) : null; const checks = checkRuns.length ? gitlabRollup(checkRuns.map(run => run.state)) : pipelineState === "neutral" || pipelineState === "skipped" ? "passing" : pipelineState; const pipelineUrl = safeLink(pipeline.web_url); return { ...listed(mr), checks, checkRuns, ...(reviewDecision ? { reviewDecision } : {}), ...gitlabMergeState(mr.detailed_merge_status ?? mr.merge_status), ...(typeof pipeline.id === "number" ? { pipeline: { id: pipeline.id, state: pipelineState, ...(pipelineUrl ? { url: pipelineUrl } : {}) } } : {}), ...(given || required !== undefined ? { approvals: { given, ...(required !== undefined ? { required } : {}) } } : {}), ...(typeof mr.user_notes_count === "number" ? { comments: mr.user_notes_count } : {}), ...(mr.has_conflicts === true ? { conflicts: true } : {}) }; } function failed(error, domain) { if (error instanceof HostApiError) { const message = error.reason === "auth" ? `${error.message} ${SIGN_IN(domain)}` : error.message; return { available: false, reason: error.reason, message, pulls: [], current: null }; } return { available: false, reason: "failed", message: error instanceof Error ? error.message : String(error), pulls: [], current: null }; } /** gh's --fill: one commit gives the title and body; several give a title from * the branch and a body listing them. */ export async function fillFromCommits(root, baseRef, branch) { const raw = await git(root, "log", "--reverse", "--format=%s%x1f%b%x1e", `${baseRef}..${branch}`).catch(() => ""); const commits = raw.split("\x1e").map(entry => entry.trim()).filter(Boolean).map(entry => { const [subject, body = ""] = entry.split("\x1f"); return { subject: subject.trim(), body: body.trim() }; }); if (commits.length === 1) return { title: commits[0].subject, body: commits[0].body }; const words = branch.replace(/^[^/]+\//, "").replace(/[-_]+/g, " ").trim(); const title = words ? words[0].toUpperCase() + words.slice(1) : branch; return { title, body: commits.map(commit => `- ${commit.subject}`).join("\n") }; } export const gitlab = { kind: "gitlab", name: "GitLab", terms: MERGE_TERMS, supported: true, async list(root, branch, host) { const ctx = await context(root, host); if (!("base" in ctx)) return ctx; try { const [{ json }, current] = await Promise.all([ call(ctx, "/merge_requests?state=opened&order_by=updated_at&per_page=50"), currentRequest(ctx, branch), ]); return { available: true, pulls: (Array.isArray(json) ? json : []).map(obj).map(listed), current, account: ctx.account }; } catch (error) { return failed(error, ctx.domain); } }, async open(root, branch, draft, host) { const ctx = await context(root, host); if (!("base" in ctx)) return { ok: false, reason: ctx.reason, message: ctx.message }; try { await call(ctx, `/repository/branches/${encodeURIComponent(branch)}`).catch(error => { if (error instanceof HostApiError && error.reason === "not-found") throw new HostApiError("not-found", `Push ${branch} to GitLab first, then open the merge request.`, 404); throw error; }); const { json: project } = await call(ctx, ""); const target = String(obj(project).default_branch ?? "main"); const fill = await fillFromCommits(root, `${host.remote ?? "origin"}/${target}`, branch); const title = draft && !/^draft:/i.test(fill.title) ? `Draft: ${fill.title}` : fill.title; try { const { json } = await call(ctx, "/merge_requests", { method: "POST", body: { source_branch: branch, target_branch: target, title, description: fill.body, remove_source_branch: true } }); return { ok: true, url: safeLink(obj(json).web_url) ?? "", number: obj(json).iid, branch, draft }; } catch (error) { if (error instanceof HostApiError && error.status === 409) { const { json } = await call(ctx, `/merge_requests?source_branch=${encodeURIComponent(branch)}&state=opened&per_page=1`); const existing = (Array.isArray(json) ? json : []).map(obj)[0]; if (existing) return { ok: true, url: safeLink(existing.web_url) ?? "", number: existing.iid, branch, existing: true }; } throw error; } } catch (error) { const result = failed(error, ctx.domain); return { ok: false, reason: result.reason ?? "failed", message: result.message ?? "GitLab refused the merge request." }; } }, async merge(root, request, host) { const ctx = await context(root, host); if (!("base" in ctx)) return { ok: false, reason: ctx.reason, message: ctx.message }; try { // GitLab merges, or rebases, by the project's own merge method; squash is per request. const { json } = await call(ctx, `/merge_requests/${request.number}/merge`, { method: "PUT", body: { squash: request.method === "squash", ...(request.expectedHeadSha ? { sha: request.expectedHeadSha } : {}), ...(request.deleteBranch ? { should_remove_source_branch: true } : {}) } }); const mr = obj(json); return { ok: true, number: request.number, method: request.method, state: upper(mr.state), ...(safeLink(mr.web_url) ? { url: safeLink(mr.web_url) } : {}) }; } catch (error) { if (error instanceof HostApiError && [405, 406, 409, 422].includes(error.status)) { const why = error.status === 409 ? "The merge request has new commits since you looked; refresh and check them first." : error.message.replace(/^.*? answered \d+:? ?/, "") || "GitLab says it can't be merged yet."; return { ok: false, reason: "blocked", message: why }; } const result = failed(error, ctx.domain); return { ok: false, reason: result.reason ?? "failed", message: result.message ?? "GitLab refused the merge." }; } }, async whoami(root, host, token, checkedApi) { if (!host.domain) throw new HostApiError("failed", "This repository has no GitLab remote."); const base = checkedApi ?? await apiRoot(root, host.domain, `https://${host.domain}/api/v4`); const { json } = await hostJson(`${base}/user`, { token, scheme: "private-token" }); return { user: String(obj(json).username ?? "") }; }, };