framework
Version:
The (AI) Framework: turnkey, zero-config AI orchestration that wraps a coding-agent CLI (Claude Code) as a black box and takes you from an idea to a running app. Vite for AI.
326 lines • 16.3 kB
JavaScript
import { cliRunner } from '../cli-exec.js';
import { cachedRead, invalidate } from './cache.js';
import { errorMessage } from '../error-message.js';
/**
* A {@link GhRunner} for the write actions (push, open a PR). Longer timeout than a read: these
* talk to the network and to git, and the user is waiting on a button they pressed.
*/
export function nodeGhRunner() {
return cliRunner({ bin: 'gh', timeoutMs: 60_000, preferStderr: true });
}
/**
* Reads are capped short and never surface an error: every caller is a panel that renders
* whatever it got, and "gh is not installed" must cost a page load nothing.
*/
const readGh = cliRunner({ bin: 'gh', timeoutMs: 8_000 });
/**
* The GitHub token an Actions run authenticates with (#1352).
*
* `GH_TOKEN` / `GITHUB_TOKEN` win, because CI sets them and must beat whatever `gh` happens to be
* logged in as on the runner. With neither set, fall back to the `gh` CLI's own credential — the
* same one every PR this framework opens is already authenticated by. A machine that can open a PR
* could always have run an Actions session too; it just had no way to say so, and the agent failed
* with the credential sitting one `gh auth token` away.
*
* Undefined when there is no token to be had (gh missing, logged out, or refusing to hand it over),
* which the caller turns into the agent's stated reason for not starting. Deliberately quiet about
* *why* gh declined: the caller's message names both ways to fix it, and a keyring prompt's stderr
* is not something to put in front of someone who simply has not set GH_TOKEN.
*/
export async function githubToken(cwd, env = process.env, gh = readGh) {
const fromEnv = env['GH_TOKEN'] ?? env['GITHUB_TOKEN'];
if (fromEnv)
return fromEnv;
try {
return (await gh(['auth', 'token'], cwd)).trim() || undefined;
}
catch {
return undefined;
}
}
/** A forgiving `gh --json` read: resolves `empty` when gh is missing/unauthed, or its output is not JSON. */
export async function ghJson(args, cwd, empty, gh = readGh) {
try {
return JSON.parse(await gh(args, cwd));
}
catch {
return empty;
}
}
const PR_VIEW_FIELDS = 'number,url,state,title,createdAt,headRefOid';
/**
* The PR for `branch`, or for whatever branch `cwd` is on when none is named. Resolves undefined
* when gh is missing/unauthed or there is no PR.
*
* The named-branch form is what a finished session needs (#799): its worktree may be gone, so the
* checkout's current branch is the project's, not the session's. The fields are copied out rather
* than passed through, so a future `--json` addition cannot leak into what callers store.
*
* Every optional field {@link LinkedPr} declares must be both asked for and copied out here, or it
* is silently absent for every caller of this path. `createdAt` was neither, and the CI watch reads
* it to decide whether a check-less PR has outlived the window a check suite takes to attach — with
* the field missing, that age is unknowable and such a PR could never be merged at all.
*/
export async function ghPrView(cwd, branch, gh = readGh) {
const args = ['pr', 'view', ...(branch ? [branch] : []), '--json', PR_VIEW_FIELDS];
const pr = await ghJson(args, cwd, undefined, gh);
return pr
? {
number: pr.number,
url: pr.url,
state: pr.state,
title: pr.title,
...(pr.createdAt ? { createdAt: pr.createdAt } : {}),
...(pr.headRefOid ? { headRefOid: pr.headRefOid } : {}),
}
: undefined;
}
/**
* The cached form of {@link ghPrView} (#1028), and what the dashboard's panels use.
*
* A PR lookup costs about 600ms where the git reads beside it cost ten, and the answer changes
* about as often as someone opens a PR. Cached per checkout and branch, shared between the
* worktree bar and the handoff summary, and refreshed behind whoever asks. `pending` says the
* answer is not known yet rather than that there is no PR — the difference matters to a caller
* deciding whether to offer "Open PR".
*/
export async function cachedPrView(cwd, branch) {
return cachedRead(prCacheKey(cwd, branch), () => ghPrView(cwd, branch));
}
/** Forget a branch's PR, after an action that changes whether it has one. */
export function forgetPr(cwd, branch) {
invalidate(prCacheKey(cwd, branch));
}
function prCacheKey(cwd, branch) {
return `pr\u0000${cwd}\u0000${branch ?? ''}`;
}
/**
* Every PR a branch name has ever had, newest first (#1251).
*
* `gh pr view <branch>` answers with the newest PR for that head *in any state*, so a session
* whose prompt pins its branch name (`the-framework/triage-quick`) inherits a predecessor's
* merged PR as its own. The list form keeps the whole history so {@link pickAgentPr} can decide
* which entry, if any, belongs to the agent asking. Resolves `[]` when gh is missing/unauthed —
* indistinguishable from "no PRs", which is what every caller would do with a failure anyway.
*/
export async function ghPrsForBranch(cwd, branch) {
return linkedPrs(await ghJson(prListArgs(branch), cwd, []));
}
/**
* {@link ghPrsForBranch} for a caller about to *open* a PR (#1601): a listing that fails throws
* instead of reading as "no PRs", because "none" and "could not tell" must not look alike there —
* the difference is a second draft PR on a branch that already has one.
*/
export async function ghPrsForBranchOrThrow(cwd, branch) {
return linkedPrs(JSON.parse(await readGh(prListArgs(branch), cwd)));
}
function prListArgs(branch) {
return ['pr', 'list', '--head', branch, '--state', 'all', '--limit', '20', '--json', 'number,url,state,title,createdAt,headRefOid'];
}
function linkedPrs(prs) {
return prs.map(pr => ({
number: pr.number,
url: pr.url,
state: pr.state,
title: pr.title,
...(pr.createdAt ? { createdAt: pr.createdAt } : {}),
...(pr.headRefOid ? { headRefOid: pr.headRefOid } : {}),
}));
}
/**
* The refusals that mean "auto-merge is not available here, merge directly instead" (#1216).
*
* gh surfaces GitHub's GraphQL errors verbatim: `Pull request Auto merge is not allowed for this
* repository` where the repo setting is off, and `Pull request is in clean status` where nothing
* blocks the PR — auto-merge is *only* for PRs that cannot land yet, so a green PR gets the same
* refusal and the direct merge is exactly what was meant. Matched loosely (both carry the
* `enablePullRequestAutoMerge` marker) so a rephrase on GitHub's side degrades to a reported
* failure, never a wrong merge.
*/
const DIRECT_MERGE_FALLBACK = /auto[- ]?merge is not allowed|clean status|enablePullRequestAutoMerge|protected branch/i;
/**
* Merge a PR the handoff just opened (#1216): GitHub auto-merge first, so the PR lands when its
* checks pass rather than before them; where the repo does not allow auto-merge, merged directly
* or handed to the daemon's CI watch, per {@link MergePrOptions.whenUnarmed} (#1418). Squash in
* all forms — a session's branch is working history, not a story worth preserving.
*
* Never throws: the caller reports the outcome alongside the handoff's, and a merge that could
* not happen must not turn a successful handoff into a failed one.
*/
export async function ghMergePr(cwd, number, gh = nodeGhRunner(), opts = {}) {
try {
await gh(['pr', 'merge', String(number), '--squash', '--auto'], cwd);
return { outcome: 'auto-armed' };
}
catch (err) {
const refusal = errorMessage(err);
// A draft cannot be merged or auto-merged. The armed handoff opens its PR ready, but the
// already-open path can find a draft a *previous* run left behind — mark it ready and try
// once more, because an armed merge is the statement that its review already happened.
if (/draft/i.test(refusal)) {
try {
await gh(['pr', 'ready', String(number)], cwd);
await gh(['pr', 'merge', String(number), '--squash', '--auto'], cwd);
return { outcome: 'auto-armed' };
}
catch (retry) {
const readyRefusal = errorMessage(retry);
if (!DIRECT_MERGE_FALLBACK.test(readyRefusal))
return { outcome: 'failed', error: readyRefusal };
return fallbackMerge(cwd, number, gh, opts);
}
}
if (!DIRECT_MERGE_FALLBACK.test(refusal))
return { outcome: 'failed', error: refusal };
return fallbackMerge(cwd, number, gh, opts);
}
}
/**
* What happens once GitHub has refused to arm the merge: directly, or — in `watch` mode with
* checks still outstanding — deferred to the daemon's CI watch (#1418).
*
* The checks read decides, not the refusal text: `clean status` sounds like "nothing blocks this
* PR" but GitHub says it for a PR whose *non-required* checks are still running, which is the
* #1406 window. Only `passing` merges now; `none` does not, because a check suite takes a few
* seconds to attach after the push and a just-opened PR reads as check-less exactly then — the
* watch merges a genuinely check-less PR after its grace period instead.
*/
async function fallbackMerge(cwd, number, gh, opts) {
if (opts.whenUnarmed !== 'watch')
return directMerge(cwd, number, gh);
const ci = await ghPrCiStatus(cwd, number, gh);
if (ci.checks === 'passing')
return directMerge(cwd, number, gh);
return { outcome: 'watched' };
}
/** The direct-merge half of {@link ghMergePr}, shared by its two ways of getting there. */
async function directMerge(cwd, number, gh) {
try {
await gh(['pr', 'merge', String(number), '--squash'], cwd);
return { outcome: 'merged' };
}
catch (direct) {
return { outcome: 'failed', error: errorMessage(direct) };
}
}
/**
* A PR's combined check state (#1418): GitHub Actions check runs and classic commit statuses,
* both of which `statusCheckRollup` carries.
*
* Skipped and neutral conclusions count as passing, matching how GitHub's own merge box treats
* them; everything else that concluded non-successfully counts as failed — a cancelled or
* timed-out check is not evidence the work is good, and the merge this feeds exists to stop
* unverified work landing (#1406).
*/
export async function ghPrCiStatus(cwd, number, gh = readGh) {
let parsed;
try {
parsed = JSON.parse(await gh(['pr', 'view', String(number), '--json', 'statusCheckRollup,headRefOid,headRefName'], cwd));
}
catch {
return { checks: 'none', failed: [] };
}
const rollup = Array.isArray(parsed.statusCheckRollup) ? parsed.statusCheckRollup : [];
const headSha = typeof parsed.headRefOid === 'string' ? parsed.headRefOid : undefined;
const branch = typeof parsed.headRefName === 'string' ? parsed.headRefName : undefined;
const head = { ...(headSha ? { headSha } : {}), ...(branch ? { branch } : {}) };
if (rollup.length === 0)
return { checks: 'none', failed: [], ...head };
const passing = /^(SUCCESS|NEUTRAL|SKIPPED)$/;
let pending = false;
const failed = [];
for (const entry of rollup) {
// A classic status has no `status` field: its `state` is both progress and verdict.
const verdict = entry.conclusion ?? entry.state ?? '';
const done = entry.status ? entry.status === 'COMPLETED' : verdict !== 'PENDING';
if (!done)
pending = true;
else if (!passing.test(verdict))
failed.push(entry.name ?? entry.context ?? 'unnamed check');
}
const checks = failed.length > 0 ? 'failing' : pending ? 'pending' : 'passing';
return { checks, failed, ...head };
}
/**
* Whether this repo allows GitHub auto-merge (#1417).
*
* An armed merge on a repo that does not (the {@link DIRECT_MERGE_FALLBACK} half of #1216) is
* handed to the daemon's CI watch (#1418): merge on green, but only while the daemon runs — so
* the launcher notes the local fallback and names the server-side Allow auto-merge setup.
* `known: false` (gh missing, unauthenticated, not a GitHub repo) is "could not say", which
* renders nothing: no crying wolf, same stance as the trust (#1318) read.
*
* The probe is the REST endpoint, not `gh repo view --json autoMergeAllowed`: `repo view` has no
* such field (any gh version), so that spelling always errored into "could not say". REST omits
* `allow_auto_merge` for viewers without push access — absent lands in the same "could not say".
*/
export async function ghRepoAutoMerge(cwd, gh = readGh) {
try {
const parsed = JSON.parse(await gh(['api', 'repos/{owner}/{repo}'], cwd));
if (typeof parsed.allow_auto_merge !== 'boolean')
return { known: false, allowed: false };
return { known: true, allowed: parsed.allow_auto_merge };
}
catch {
return { known: false, allowed: false };
}
}
/** The cached form of {@link ghRepoAutoMerge} (#1028): the launcher polls, the setting barely changes. */
export async function cachedRepoAutoMerge(cwd) {
return cachedRead(['auto-merge-allowed', cwd].join(KEY_SEP), () => ghRepoAutoMerge(cwd), { ttlMs: 5 * 60_000 });
}
/** The cached form of {@link ghPrsForBranch}, shared through the same read-through cache (#1028). */
export async function cachedPrsForBranch(cwd, branch) {
return cachedRead(branchPrsCacheKey(cwd, branch), () => ghPrsForBranch(cwd, branch));
}
/** Forget a branch's PR history, after an action that changes it (opening one). */
export function forgetBranchPrs(cwd, branch) {
invalidate(branchPrsCacheKey(cwd, branch));
}
/** Same unprintable separator idea as `prCacheKey`, spelled so paths cannot collide with it. */
const KEY_SEP = String.fromCharCode(0);
function branchPrsCacheKey(cwd, branch) {
return ['prs', cwd, branch].join(KEY_SEP);
}
/**
* The PR that belongs to an agent, out of every PR its branch name has had (#1251/#1255).
*
* An OPEN PR always counts: GitHub allows one open PR per head branch, so whatever is open on the
* run's branch is where its pushed commits land. A closed one counts only when it was created
* after the agent started (`since`, the agent's `startedAt`) — the oldest such entry, which is the one
* this agent's handoff opened. Anything older is a previous agent's PR wearing the same branch name,
* which is exactly what showed a merged two-day-old PR as a fresh session's own. Without `since`
* only an open PR is trusted.
*
* `order` exists for the one caller asking a different question (#1512). `'first'` answers
* identity — which PR did *this agent* open, so a later agent's must not be the answer. `'latest'`
* answers the handoff decision — which PR last saw the branch, so "did the session keep working
* past it" is readable off that PR's `headRefOid`; there the oldest entry would call work that a
* second PR already landed unlanded.
*/
export function pickAgentPr(prs, since, order = 'first') {
const open = prs.find(pr => pr.state === 'OPEN');
if (open)
return open;
if (!since)
return undefined;
const closed = prs
.filter(pr => pr.createdAt && pr.createdAt >= since)
.sort((a, b) => ((a.createdAt ?? '') < (b.createdAt ?? '') ? -1 : 1));
return order === 'latest' ? closed.at(-1) : closed[0];
}
/**
* A checkout's open PRs. Unlike the other reads here it *rejects* when gh could not answer — no
* remote, not authenticated, GitHub unreachable — instead of resolving `[]`.
*
* "No PRs are open" and "I could not look" are different answers, and its caller keeps a baseline
* of what it has already announced (#1623): taking the second for the first makes the next
* successful read announce every already-open PR as new. The caller decides what a failure costs;
* it cannot decide what it never hears about.
*/
export async function ghPrList(cwd, gh = readGh) {
const fields = 'number,title,url,isDraft,headRefName,createdAt';
const args = ['pr', 'list', '--state', 'open', '--limit', '50', '--json', fields];
return JSON.parse(await gh(args, cwd));
}
//# sourceMappingURL=gh.js.map