@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
297 lines (296 loc) • 18.6 kB
JavaScript
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 ?? "") };
},
};