@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
245 lines (244 loc) • 13.4 kB
JavaScript
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { git } from "./projects.js";
import { runCombined } from "./git-publish.js";
import { gitboy } from "./git-host-gitboy.js";
import { gitlab } from "./git-host-gitlab.js";
const exec = promisify(execFile);
export const HOST_KINDS = ["github", "gitlab", "gitboy"];
const PULL_TERMS = { short: "PR", long: "pull request", ref: "#" };
/** `git@host:owner/repo.git`, `ssh://git@host:2222/owner/repo.git`,
* `https://user@host/owner/repo` → the domain and the repository path. */
export function parseRemoteUrl(url) {
const text = url.trim();
const scp = /^(?:[^@/\s]+@)?([^:/\s]+):(?!\/)(.+)$/.exec(text);
if (scp && !/^[a-z][a-z0-9+.-]*:\/\//i.test(text))
return { domain: scp[1].toLowerCase(), path: clean(scp[2]) };
try {
const parsed = new URL(text);
if (!["http:", "https:", "ssh:", "git:", "git+ssh:", "ssh+git:"].includes(parsed.protocol) || !parsed.hostname)
return null;
return { domain: parsed.hostname.toLowerCase(), path: clean(parsed.pathname) };
}
catch {
return null;
}
}
function clean(path) {
return path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
}
/** The kind a domain implies on its own: the public hosts by name, a
* self-hosted one when its first label says so (`gitlab.example.com`). */
export function kindFromDomain(domain) {
if (domain === "github.com" || domain.endsWith(".github.com") || domain.endsWith(".ghe.com"))
return "github";
if (domain === "gitlab.com")
return "gitlab";
const first = domain.split(".")[0];
if (first === "gitlab")
return "gitlab";
if (first === "gitboy")
return "gitboy";
if (first === "github")
return "github";
return null;
}
function asKind(value) {
const v = value.trim().toLowerCase();
return HOST_KINDS.includes(v) ? v : null;
}
async function config(root, key) {
return (await git(root, "config", "--get", key).catch(() => "")).trim();
}
/** The remote a branch's requests belong to: its upstream's, else origin,
* else the only remote. */
async function remoteFor(root, branch) {
const remotes = (await git(root, "remote").catch(() => "")).split("\n").map(line => line.trim()).filter(Boolean);
const upstream = branch ? await config(root, `branch.${branch}.remote`) : "";
if (upstream && remotes.includes(upstream))
return upstream;
if (remotes.includes("origin"))
return "origin";
return remotes[0] ?? null;
}
/** Which host a repository's remote is on, overrides first. */
export async function detectHost(root, branch) {
const remote = await remoteFor(root, branch);
const url = remote ? await config(root, `remote.${remote}.url`) : "";
const parsed = url ? parseRemoteUrl(url) : null;
let kind = null;
let source = "none";
const remoteOverride = remote ? asKind(await config(root, `remote.${remote}.phrenHost`)) : null;
const domainOverride = parsed ? asKind(await config(root, `phren.${parsed.domain}.host`)) : null;
if (remoteOverride) {
kind = remoteOverride;
source = "remote-override";
}
else if (domainOverride) {
kind = domainOverride;
source = "domain-override";
}
else if (parsed && (kind = kindFromDomain(parsed.domain)))
source = "url";
return hostInfo(kind, { domain: parsed?.domain ?? null, webUrl: parsed ? `https://${parsed.domain}/${parsed.path}` : null, remote, source });
}
export function hostInfo(kind, where) {
const provider = kind ? PROVIDERS[kind] : null;
return { kind, name: provider?.name ?? "the git host", ...where, terms: provider?.terms ?? PULL_TERMS, supported: provider?.supported ?? false };
}
export function providerFor(host) {
return host.kind ? PROVIDERS[host.kind] : null;
}
/** Why there is no list: an unknown host says how to name it. */
export function unknownHost(host) {
const domain = host.domain ? ` ${host.domain}` : "";
return { available: false, reason: "unknown-host", pulls: [], current: null,
message: host.remote
? `Phren cannot tell which git host${domain} is. Name it with: git config remote.${host.remote}.phrenHost github|gitlab|gitboy`
: "This repository has no remote, so it has no pull or merge requests." };
}
// ---------------------------------------------------------------- GitHub
/** A check rollup as one word: any failure fails, anything unfinished is
* pending, and only finished successes (or skips) pass. No checks is null. */
export function checkRollup(items) {
if (!Array.isArray(items) || !items.length)
return null;
let pending = false;
for (const raw of items) {
const item = raw && typeof raw === "object" ? raw : {};
const conclusion = String(item.conclusion ?? "").toUpperCase(), state = String(item.state ?? "").toUpperCase();
const status = String(item.status ?? "").toUpperCase();
if (["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE"].includes(conclusion) || ["FAILURE", "ERROR"].includes(state))
return "failing";
// A commit status has a state; a check run has a status and, once
// completed, a conclusion.
if (state && !status) {
if (state !== "SUCCESS")
pending = true;
}
else if (status !== "COMPLETED")
pending = true;
}
return pending ? "pending" : "passing";
}
/** Each check as clients list it: a name, the workflow that ran it, one state
* word and its page. Check runs and commit statuses read alike; failing
* first, then pending, then the rest, at most 100. */
export function checkRuns(items) {
if (!Array.isArray(items))
return [];
const order = { failing: 0, pending: 1, passing: 2, skipped: 3, neutral: 4 };
const runs = items.map(raw => {
const item = raw && typeof raw === "object" ? raw : {};
const conclusion = String(item.conclusion ?? "").toUpperCase(), state = String(item.state ?? "").toUpperCase();
const status = String(item.status ?? "").toUpperCase();
let word;
if (["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE"].includes(conclusion) || ["FAILURE", "ERROR"].includes(state))
word = "failing";
else if (state && !status)
word = state === "SUCCESS" ? "passing" : "pending";
else if (status !== "COMPLETED")
word = "pending";
else if (conclusion === "SKIPPED")
word = "skipped";
else if (conclusion === "NEUTRAL" || conclusion === "STALE")
word = "neutral";
else
word = "passing";
const name = String(item.name ?? item.context ?? "").slice(0, 200) || "Check";
const workflow = typeof item.workflowName === "string" && item.workflowName ? item.workflowName.slice(0, 200) : undefined;
const url = String(item.detailsUrl ?? item.targetUrl ?? "");
return { name, state: word, ...(workflow ? { workflow } : {}), ...(/^https:\/\//.test(url) ? { url: url.slice(0, 2048) } : {}) };
});
return runs.sort((a, b) => order[a.state] - order[b.state]).slice(0, 100);
}
/** gh never prompts, never nags about updates, and talks to the remote's own
* domain (a GitHub Enterprise server as much as the public one). */
function ghEnv(host) {
return { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1", NO_COLOR: "1", ...(host.domain ? { GH_HOST: host.domain } : {}) };
}
const PULL_URL = /https:\/\/[^\s"'<>]+\/pull\/\d+/;
const github = {
kind: "github",
name: "GitHub",
terms: PULL_TERMS,
supported: true,
async list(root, branch, host) {
const run = (args) => exec("gh", args, { cwd: root, timeout: 15_000, maxBuffer: 4_194_304, env: ghEnv(host) });
const current = async () => {
if (!branch)
return null;
try {
const { stdout } = await run(["pr", "view", "--json", "number,title,url,state,isDraft,headRefName,headRefOid,baseRefName,statusCheckRollup,reviewDecision,mergeStateStatus,author,updatedAt"]);
const pull = JSON.parse(stdout || "null");
// gh falls back to another head's pull request; only this branch's counts.
if (!pull || typeof pull !== "object" || typeof pull.number !== "number" || pull.headRefName !== branch)
return null;
return { number: pull.number, title: String(pull.title ?? ""), url: String(pull.url ?? ""), head: branch,
base: String(pull.baseRefName ?? ""), draft: pull.isDraft === true, state: String(pull.state ?? ""), checks: checkRollup(pull.statusCheckRollup),
checkRuns: checkRuns(pull.statusCheckRollup),
...(typeof pull.reviewDecision === "string" && pull.reviewDecision ? { reviewDecision: pull.reviewDecision } : {}),
...(typeof pull.mergeStateStatus === "string" && pull.mergeStateStatus ? { mergeState: pull.mergeStateStatus } : {}),
...(typeof pull.headRefOid === "string" && pull.headRefOid ? { headSha: pull.headRefOid } : {}),
...(pull.author && typeof pull.author === "object" && typeof pull.author.login === "string" ? { author: pull.author.login } : {}),
...(typeof pull.updatedAt === "string" ? { updated: pull.updatedAt } : {}) };
}
catch {
return null;
}
};
try {
const [{ stdout }, mine] = await Promise.all([
run(["pr", "list", "--json", "number,title,headRefName,baseRefName,author,url,isDraft,state,updatedAt", "--limit", "50"]),
current(),
]);
const parsed = JSON.parse(stdout || "[]");
const pulls = (Array.isArray(parsed) ? parsed : []).map((pull) => {
const author = pull.author && typeof pull.author === "object" ? pull.author : {};
return { number: pull.number, title: pull.title, head: pull.headRefName, base: pull.baseRefName,
author: String(author.login ?? author.name ?? ""), url: pull.url, draft: pull.isDraft === true, state: pull.state, updated: pull.updatedAt };
});
return { available: true, pulls, current: mine };
}
catch {
return { available: false, reason: "auth", message: "The GitHub CLI (gh) is missing or not signed in on this computer. Install it, then run gh auth login there.", pulls: [], current: null };
}
},
async open(root, branch, draft, host) {
const env = ghEnv(host);
const run = (args, timeout) => runCombined("gh", args, root, timeout, env);
const version = await run(["--version"], 10_000);
if (version.missing || version.code !== 0) {
return { ok: false, reason: "missing", message: "The GitHub CLI (gh) is not installed on this computer. Install it, then run gh auth login there." };
}
const auth = await run(["auth", "status", ...(host.domain ? ["--hostname", host.domain] : [])], 15_000);
if (auth.code !== 0) {
return { ok: false, reason: "auth", message: "The GitHub CLI is not signed in on this computer. Run gh auth login there.", output: auth.output };
}
const result = await run(["pr", "create", "--fill", "--head", branch, ...(draft ? ["--draft"] : [])], 60_000);
const url = PULL_URL.exec(result.output)?.[0];
if (result.code === 0 && url)
return { ok: true, url, branch, draft };
if (url && /already exists/i.test(result.output))
return { ok: true, url, branch, existing: true };
return { ok: false, reason: "failed", output: result.timedOut ? `${result.output}\n[gh did not finish within 60 seconds]`.trim() : result.output || "gh pr create failed." };
},
async merge(root, request, host) {
// No --delete-branch: gh would also delete and leave the local branch,
// which may be an agent's checkout.
const args = ["pr", "merge", String(request.number), `--${request.method}`,
...(request.expectedHeadSha ? ["--match-head-commit", request.expectedHeadSha] : [])];
const result = await runCombined("gh", args, root, 60_000, ghEnv(host));
if (result.missing)
return { ok: false, reason: "missing", message: "The GitHub CLI (gh) is not installed on this computer." };
if (result.code === 0)
return { ok: true, number: request.number, method: request.method };
const output = result.output.trim();
if (/not mergeable|required status|review|protected|blocked|head branch was modified|match-head-commit|conflict/i.test(output))
return { ok: false, reason: "blocked", message: output.slice(0, 1000) };
if (/auth|login|401|403/i.test(output))
return { ok: false, reason: "auth", message: "The GitHub CLI is not signed in on this computer, or the account can't merge here. Run gh auth login there." };
return { ok: false, reason: "failed", message: output.slice(0, 1000) || "gh pr merge failed." };
},
};
export const PROVIDERS = { github, gitlab, gitboy };