From 942d322f8675784bc9e814e3e7db93a573350c54 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 11:44:16 -0700 Subject: [PATCH 1/7] quest: claim quest/m0/helpers/start.md claim-nonce: 30d93de94810d0ee Co-Authored-By: Claude Opus 5.5 From 44e23fd774443e4d56c8964998aaace2727cb4f8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 11:57:40 -0700 Subject: [PATCH 2/7] feat(cli): claim quests with quest start and filter unclaimed work Co-Authored-By: Claude Opus 5.5 --- scripts/lifecycle-check.mjs | 8 +- scripts/lifecycle-check.sh | 2 +- src/cli/bin.ts | 2 +- src/cli/claims.ts | 192 +++++++++++++++++ src/cli/github.ts | 192 +++++++++++++++++ src/cli/main.ts | 212 ++++++++++++++----- src/cli/start.ts | 253 ++++++++++++++++++++++ src/cli/theme.ts | 9 +- src/cli/wait.ts | 151 +++---------- test/cli.test.ts | 36 ++-- test/start.test.ts | 410 ++++++++++++++++++++++++++++++++++++ test/theme.test.ts | 64 ++---- test/tree.test.ts | 4 +- 13 files changed, 1282 insertions(+), 253 deletions(-) create mode 100644 src/cli/claims.ts create mode 100644 src/cli/github.ts create mode 100644 src/cli/start.ts create mode 100644 test/start.test.ts diff --git a/scripts/lifecycle-check.mjs b/scripts/lifecycle-check.mjs index 18e7d66f..e8e39aa6 100644 --- a/scripts/lifecycle-check.mjs +++ b/scripts/lifecycle-check.mjs @@ -98,10 +98,10 @@ try { "# [S] Two\r\n\r\n## Goal\r\n\r\nSecond.\r\n\r\n## Required\r\n\r\n- [One](/quest/m0/one.md)\r\n", ); run(root, "check"); - assert.equal(run(root, "ready"), "quest/m0/one.md\n"); - assert.equal(run(root, "ready", join(root, "quest", "m0", "one.md")), ""); - assert.equal(run(root, "ready", join("quest", "m0", "one.md")), ""); - assert.match(run(root, "ready", "quest/m0/two.md"), /quest\/m0\/one.md/); + assert.equal(run(root, "unblocked"), "1\tquest/m0/one.md\n"); + assert.equal(run(root, "unblocked", join(root, "quest", "m0", "one.md")), ""); + assert.equal(run(root, "unblocked", join("quest", "m0", "one.md")), ""); + assert.match(run(root, "unblocked", "quest/m0/two.md"), /quest\/m0\/one.md/); const plan = read(root, "quest/m0/one.md"); const edited = `${read(root, ".claude/skills/quest-plan/SKILL.md")}\nUser customization.\n`; write(root, ".claude/skills/quest-plan/SKILL.md", edited); diff --git a/scripts/lifecycle-check.sh b/scripts/lifecycle-check.sh index e939b6de..a984ea8e 100644 --- a/scripts/lifecycle-check.sh +++ b/scripts/lifecycle-check.sh @@ -17,7 +17,7 @@ trap 'rm -rf "$work"' EXIT # The shared lifecycle is also run directly by native Windows CI. bun "$(dirname -- "${BASH_SOURCE[0]}")/lifecycle-check.mjs" "$binary" -# A reader that closes stdout early, like `quest ready | head -1`, ends the +# A reader that closes stdout early, like `quest unblocked | head -1`, ends the # command quietly. The writer waits on a FIFO until the reader has closed, so # every write fails with EPIPE rather than racing into the pipe buffer. mkfifo "$work/reader-closed" diff --git a/src/cli/bin.ts b/src/cli/bin.ts index e6969b67..a4575cd8 100644 --- a/src/cli/bin.ts +++ b/src/cli/bin.ts @@ -3,7 +3,7 @@ import { main } from "./main"; import { detect } from "./theme"; -// A reader that stops early, as in `quest ready | head -1`, closes the pipe. +// A reader that stops early, as in `quest unblocked | head -1`, closes the pipe. // The output is unwanted, so stop writing and keep the command's exit status; // any other write error still crashes. process.stdout.on("error", (error: NodeJS.ErrnoException) => { diff --git a/src/cli/claims.ts b/src/cli/claims.ts new file mode 100644 index 00000000..c89b37ca --- /dev/null +++ b/src/cli/claims.ts @@ -0,0 +1,192 @@ +// Who holds a quest: its claim branch upstream, or an open PR from a branch of +// that name in any repository. Commands that must not disturb held work look +// here first, and `quest start` records a claim the same way. +import { execFileSync } from "node:child_process"; + +import { + type GitHubDependencies, + type Repository, + GitHub, + githubRepository, + object, + remoteUrl, + string, +} from "./github"; + +/** The heading that hands a draft back to the maintainer; only its presence matters. */ +const HAND_BACK = /^#{2}[ \t]+Decisions needed[ \t]*#*[ \t]*$/im; + +/** A quest's branch: its path without `.md`. */ +export function questBranch(path: string): string { + return path.replace(/\.md$/, ""); +} + +/** An open PR, from any head repository, as the claim checks read it. */ +export interface OpenPull { + number: number; + url: string; + branch: string; + /** The head repository as `owner/repo`, or `null` once a fork is deleted. */ + head: string | null; + draft: boolean; + /** Epoch milliseconds. The earliest open PR on a branch holds the claim. */ + created: number; + /** Epoch milliseconds of the last push, comment, or edit. */ + updated: number; + /** Its body has a `## Decisions needed` section, so it waits on the maintainer. */ + handedBack: boolean; +} + +/** A held quest branch, and the open PR from it if there is one. */ +export interface Claim { + branch: string; + /** The PR holding the claim: the earliest open one. */ + url: string | null; + pull: OpenPull | null; + /** The branch tip upstream; `null` when only a PR, such as one from a fork, holds it. */ + sha: string | null; +} + +export interface ClaimDependencies { + /** Called only when the remote is on GitHub, so other forges need no token. */ + github(): GitHubDependencies; + warn(message: string): void; +} + +/** Where claims live. For a GitHub fork, that is its parent rather than the remote itself. */ +export interface Upstream { + /** The remote's name, or the parent's URL for a fork; git reads upstream branches from it. */ + git: string; + github: { + client: GitHub; + /** Where PRs open and claims are checked. */ + repository: Repository; + /** The repository the remote pushes to: `repository` itself, or the fork. */ + head: Repository; + defaultBranch: string; + } | null; +} + +/** + * The upstream of `remote`. A GitHub repository that is a fork resolves to its + * parent, reached with the same URL scheme as the remote; anything else is + * its own upstream with no GitHub side. + */ +export async function upstream(root: string, remote: string, deps: ClaimDependencies): Promise { + // The configured URL first, so a URL rewrite (`insteadOf`) still names the GitHub repository. + const configured = gitOutput(root, ["config", "--get", `remote.${remote}.url`]); + const url = remoteUrl(root, remote); + const named = githubRepository(configured) ?? githubRepository(url); + if (named === null) return { git: remote, github: null }; + + const client = new GitHub(deps.github()); + const info = object(await (await client.request(repositoryPath(named))).json()); + const self = { owner: string(object(info.owner).login), repo: string(info.name) }; + if (info.fork === true && info.parent !== undefined) { + const parent = object(info.parent); + return { + git: string(/^https:/.test(configured || url) ? parent.clone_url : parent.ssh_url), + github: { + client, + repository: { owner: string(object(parent.owner).login), repo: string(parent.name) }, + head: self, + defaultBranch: string(parent.default_branch), + }, + }; + } + return { + git: remote, + github: { client, repository: self, head: self, defaultBranch: string(info.default_branch) }, + }; +} + +/** The REST path of a repository. */ +export function repositoryPath(repository: Repository): string { + return `/repos/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repo)}`; +} + +/** The `quest/*` branches on `remote` (a name or URL), with their tips. Plain git, so it works with any forge. */ +export function remoteBranches(root: string, remote: string): Map { + const branches = new Map(); + for (const line of gitOutput(root, ["ls-remote", remote, "refs/heads/quest/*"]).split("\n")) { + if (line === "") continue; + const [sha, ref] = line.split("\t"); + branches.set(ref.replace(/^refs\/heads\//, ""), sha); + } + return branches; +} + +/** Every open PR in `repository`, from any head repository. */ +export async function openPullRequests(github: GitHub, repository: Repository): Promise { + const rows = await github.all(`${repositoryPath(repository)}/pulls?state=open&per_page=100`); + return rows.map((row) => { + const head = object(row.head); + const repo = head.repo === null ? null : object(head.repo); + return { + number: Number(row.number), + url: string(row.html_url), + branch: string(head.ref), + head: repo === null ? null : string(repo.full_name), + draft: row.draft === true, + created: Date.parse(string(row.created_at)), + updated: Date.parse(string(row.updated_at)), + handedBack: typeof row.body === "string" && HAND_BACK.test(row.body), + }; + }); +} + +/** The open PRs from `branch`, earliest first: the first one holds the claim. */ +export function pullsFrom(pulls: OpenPull[], branch: string): OpenPull[] { + return pulls.filter((pull) => pull.branch === branch).sort((a, b) => a.created - b.created || a.number - b.number); +} + +/** + * Which of `branches` someone holds: pushed upstream, or the head of an open + * PR there. Without the remote it warns and checks nothing; off GitHub it + * checks branches only. + */ +export async function claims( + root: string, + remote: string, + branches: string[], + deps: ClaimDependencies, +): Promise { + try { + remoteUrl(root, remote); + } catch { + deps.warn(`no ${remote} remote, so claims were not checked`); + return []; + } + const up = await upstream(root, remote, deps); + if (up.github === null) deps.warn(`${remote} is not on GitHub, so only its branches were checked for claims`); + return held(root, up, branches); +} + +/** Which of `branches` someone holds upstream, sorted by branch. */ +export async function held(root: string, up: Upstream, branches: string[]): Promise { + const wanted = new Set(branches); + const tips = remoteBranches(root, up.git); + const pulls = up.github ? await openPullRequests(up.github.client, up.github.repository) : []; + const result: Claim[] = []; + for (const branch of [...wanted].sort()) { + const pull = pullsFrom(pulls, branch)[0] ?? null; + const sha = tips.get(branch) ?? null; + if (pull !== null || sha !== null) result.push({ branch, url: pull?.url ?? null, pull, sha }); + } + return result; +} + +/** Run git in `root` and return its trimmed stdout; an unset config key is empty rather than an error. */ +export function gitOutput(root: string, args: string[]): string { + try { + return execFileSync("git", ["-C", root, ...args], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, + }).trim(); + } catch (error) { + if (args[0] === "config" && (error as { status?: number }).status === 1) return ""; + const stderr = String((error as { stderr?: unknown }).stderr ?? "").trim(); + throw new Error(`git ${args[0]} failed${stderr ? `: ${stderr}` : ""}`); + } +} diff --git a/src/cli/github.ts b/src/cli/github.ts new file mode 100644 index 00000000..56aa4b2a --- /dev/null +++ b/src/cli/github.ts @@ -0,0 +1,192 @@ +// GitHub requests for the commands that talk to GitHub, outside the +// forge-agnostic core: authentication, rate-limit retry, and same-origin +// pagination. +import { execFileSync } from "node:child_process"; +import { setTimeout as sleep } from "node:timers/promises"; + +const API = "https://api.github.com"; + +export type ObjectValue = Record; + +/** Network, time, and credentials, injected so tests replay GitHub. */ +export interface GitHubDependencies { + fetch(url: URL, init: RequestInit): Promise; + now(): number; + sleep(milliseconds: number): Promise; + token: string; + warn(message: string): void; +} + +/** A repository on github.com. */ +export interface Repository { + owner: string; + repo: string; +} + +/** The request outlived the caller's deadline. */ +export class GitHubDeadline extends Error { + constructor() { + super("deadline passed during a GitHub request"); + } +} + +/** Authenticate with GH_TOKEN, then GITHUB_TOKEN, then `gh auth token`. */ +export function githubDependencies(): GitHubDependencies { + let token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; + if (!token) { + try { + token = execFileSync("gh", ["auth", "token", "--hostname", "github.com"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + } catch { + throw new Error("set GH_TOKEN or GITHUB_TOKEN, or sign in with gh auth login"); + } + } + if (!token) throw new Error("GitHub authentication returned an empty token"); + return { fetch, now: Date.now, sleep, token, warn: (message) => process.stderr.write(`quest: ${message}\n`) }; +} + +/** The URL a git remote fetches from. */ +export function remoteUrl(root: string, remote: string): string { + return execFileSync("git", ["-C", root, "remote", "get-url", remote], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +/** The github.com repository a remote URL names, or `null` for any other host. */ +export function githubRepository(url: string): Repository | null { + const match = + /^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([^/]+)\/([^/]+?)(?:\.git)?$/.exec(url); + return match ? { owner: match[1], repo: match[2] } : null; +} + +/** REST and GraphQL requests that retry rate limits and give up at `deadline` (epoch milliseconds). */ +export class GitHub { + constructor( + private readonly deps: GitHubDependencies, + private readonly deadline = Infinity, + ) {} + + /** One request, returning a success or 304; `body` makes it a POST unless `method` says otherwise. */ + async request( + path: string, + options: { etag?: string; body?: unknown; method?: "POST" | "PATCH" } = {}, + ): Promise { + const { deps, deadline } = this; + // Follow only same-origin pagination links; never send a token to another host. + const url = new URL(path, API); + if (url.origin !== API || url.username || url.password) throw new Error("invalid GitHub pagination URL"); + let attempts = 0; + for (;;) { + const remaining = deadline - deps.now(); + if (remaining <= 0) throw new GitHubDeadline(); + let response: Response; + try { + response = await deps.fetch(url, { + ...(options.body === undefined + ? {} + : { method: options.method ?? "POST", body: JSON.stringify(options.body) }), + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${deps.token}`, + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "quest", + ...(options.etag ? { "If-None-Match": options.etag } : {}), + }, + ...(Number.isFinite(remaining) ? { signal: AbortSignal.timeout(Math.ceil(remaining)) } : {}), + }); + } catch (error) { + if (deps.now() >= deadline) throw new GitHubDeadline(); + throw error; + } + if (response.ok || response.status === 304) return response; + const body = await response.text(); + const limited = + response.status === 429 || + (response.status === 403 && + (response.headers.has("retry-after") || + response.headers.get("x-ratelimit-remaining") === "0" || + /rate limit/i.test(body))); + if (!limited || attempts >= 6) + throw new Error( + `GitHub HTTP ${response.status} for ${url.pathname}; ${limited ? "rate limit persisted" : (explanation(body) ?? "check repository access and token permissions")}`, + ); + const retry = seconds(response.headers.get("retry-after")); + const reset = + response.headers.get("x-ratelimit-remaining") === "0" + ? seconds(response.headers.get("x-ratelimit-reset")) + : undefined; + const delay = retry ?? (reset === undefined ? 60_000 * 2 ** attempts : Math.max(1000, reset - deps.now())); + attempts++; + deps.warn(`GitHub rate limited this request; waiting ${Math.ceil(delay / 1000)} seconds`); + const left = deadline - deps.now(); + if (left <= 0) throw new GitHubDeadline(); + await deps.sleep(Math.min(delay, left, 2_147_483_647)); + if (deps.now() >= deadline) throw new GitHubDeadline(); + } + } + + /** One page of a REST listing, and its response for the next link. */ + async list(path: string): Promise<{ rows: ObjectValue[]; response: Response }> { + const response = await this.request(path); + if (response.status !== 200) throw new Error("unexpected GitHub 304 without a cached response"); + return { rows: array(await response.json()), response }; + } + + /** Every page of a REST listing. */ + async all(path: string): Promise { + const rows: ObjectValue[] = []; + for (let next: string | undefined = path; next;) { + const page = await this.list(next); + rows.push(...page.rows); + next = link(page.response, "next"); + } + return rows; + } +} + +/** The URL of a `Link` header relation, such as the next page. */ +export function link(response: Response, relation: string): string | undefined { + return response.headers + .get("link") + ?.split(",") + .map((entry) => /<([^>]+)>;\s*rel="([^"]+)"/.exec(entry.trim())) + .find((match) => match?.[2] === relation)?.[1]; +} + +/** GitHub's reason in an error body, such as why it refused to create a pull request. */ +function explanation(body: string): string | undefined { + try { + const parsed = object(JSON.parse(body)); + const errors = Array.isArray(parsed.errors) ? (parsed.errors as unknown[]) : []; + const parts = [parsed.message, ...errors.map((error) => (error as ObjectValue | null)?.message)]; + return parts.filter((part): part is string => typeof part === "string" && part !== "").join(": ") || undefined; + } catch { + return undefined; + } +} + +/** Milliseconds from a header holding seconds. */ +export function seconds(value: string | null): number | undefined { + if (value === null || !/^\d+(?:\.\d+)?$/.test(value)) return undefined; + const result = Number(value) * 1000; + return Number.isFinite(result) ? result : undefined; +} + +export function object(value: unknown): ObjectValue { + if (typeof value !== "object" || value === null || Array.isArray(value)) + throw new Error("invalid GitHub response: expected an object"); + return value as ObjectValue; +} + +export function array(value: unknown): ObjectValue[] { + if (!Array.isArray(value)) throw new Error("invalid GitHub response: expected an array"); + return value.map(object); +} + +export function string(value: unknown): string { + if (typeof value !== "string" || !value) throw new Error("invalid GitHub response: expected a nonempty string"); + return value; +} diff --git a/src/cli/main.ts b/src/cli/main.ts index e75c0d52..f02d5f5b 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -6,12 +6,25 @@ import { relative, resolve, sep } from "node:path"; import { Command, CommanderError, InvalidArgumentError, Option } from "commander"; import { description, version } from "../../package.json"; -import { blockers, check, formatFinding, label, lookup, ready, renderBlocker, unblocked } from "../core"; +import { + type Doc, + type RankedQuest, + blockers, + check, + formatFinding, + label, + lookup, + renderBlocker, + unblocked, +} from "../core"; +import { claims, questBranch } from "./claims"; +import { githubDependencies } from "./github"; import { init, uninstall } from "./setup"; +import { Refusal, describe, releaseQuest, startQuest } from "./start"; import { GUIDE, SKILLS, skill } from "./skills"; import { type Terminal, Theme } from "./theme"; import { collect, exists, load, under } from "./tree"; -import { pullRequest, waitDependencies, waitForActivity, WaitInputError, WaitTimeout } from "./wait"; +import { pullRequest, waitForActivity, WaitInputError, WaitTimeout } from "./wait"; /** Where the command writes; the process streams, or a buffer under test. */ export interface Output { @@ -66,73 +79,160 @@ export function main(argv: string[], out: Output): number | Promise { code = 1; }); - program - .command("ready") - .summary("Print what blocks a quest, or list every ready quest") - .description( - "Print what blocks a quest, or list every ready quest.\n\n" + - "Exits 0 either way; a non-zero exit means the command itself failed. Piped, the blocker list on " + - "stdout is the result, so no output means ready and a caller tests that rather than parsing prose.\n\n" + - "On a terminal, ready prints a quest log instead: a yellow ! for a ready quest, a grey ! for a " + - "blocked one, its size in its size colour, its title, and its path. Set NO_COLOR to drop the colours.", - ) - .argument("[path]", "Quest to explain. Omit to list every ready quest in tree order") - .action((path?: string) => { - const docs = load(root()); - const byPath = new Map(docs.map((doc) => [doc.path, doc])); - if (path === undefined) { - const quests = ready(docs); - for (const line of theme ? theme.ready(byPath, quests) : quests) print(line); - return; - } - const inside = withinRoot(root(), path); - const doc = lookup(docs, path) ?? (inside === null ? null : lookup(docs, inside)); - if (doc === null) throw new Error(`${path} is not a quest document under ${under(root(), "quest")}`); - const found = blockers(docs, doc); - if (theme) { - // The themed log already names every blocker; stderr keeps only the advice. - for (const line of theme.blockers(byPath, doc, found)) print(line); - } else { - for (const blocker of found) { - for (const line of renderBlocker(blocker)) print(line); - warn(`blocked by ${label(blocker)}`); - } - } - if (found.length > 0) { - // Blocked is not a verdict on the whole plan: the piece of it that - // does not need the blocker is split into its own quest. - warn( - `${path} is blocked; split any independently landable piece into its own quest (\`quest guide\`, Creation) rather than starting this one as it stands`, - ); + /** The quest document `path` names, as the tree writes it, as the shell completes it, or as a filesystem path. */ + const find = (docs: Doc[], path: string): Doc => { + const inside = withinRoot(root(), path); + const doc = lookup(docs, path) ?? (inside === null ? null : lookup(docs, inside)); + if (doc === null) throw new Error(`${path} is not a quest document under ${under(root(), "quest")}`); + return doc; + }; + + /** Print what blocks `doc`: the themed log on a terminal, else the blocker list with advice on stderr. */ + const explain = (docs: Doc[], doc: Doc, path: string) => { + const found = blockers(docs, doc); + if (theme) { + // The themed log already names every blocker; stderr keeps only the advice. + for (const line of theme.blockers(new Map(docs.map((d) => [d.path, d])), doc, found)) print(line); + } else { + for (const blocker of found) { + for (const line of renderBlocker(blocker)) print(line); + warn(`blocked by ${label(blocker)}`); } - }); + } + if (found.length > 0) { + // Blocked is not a verdict on the whole plan: the piece of it that + // does not need the blocker is split into its own quest. + warn( + `${path} is blocked; split any independently landable piece into its own quest (\`quest guide\`, Creation) rather than starting this one as it stands`, + ); + } + }; + + /** A claim command's outcome: 0 when done, 3 when refused, and 1 for any other failure. */ + const settle = (work: Promise, done: (result: T) => void) => { + pending = work.then( + (result) => { + done(result); + return 0; + }, + (error: unknown) => { + warn(error instanceof Error ? error.message : String(error)); + return error instanceof Refusal ? 3 : 1; + }, + ); + }; program .command("unblocked") - .summary("Rank ready quests by milestone and downstream impact") + .summary("Rank ready quests, or print what blocks one quest") .description( - "Rank ready quests by milestone priority, distinct transitive dependents, then tree order.\n\n" + + "Rank ready quests by milestone priority, distinct transitive dependents, then tree order. " + + "Given a path, print what blocks that quest instead.\n\n" + "Counts exclude structural epic membership. Other prerequisites may remain for dependents. " + - "Does not check claims or outside conditions. Invalid trees fail before printing a ranking.\n\n" + - "Prints a count and path per line; --json prints an ordered array of {path, dependents} objects.", + "Invalid trees fail before printing a ranking. Prints a count and path per line; --json prints an " + + "ordered array of {path, dependents} objects.\n\n" + + "--unclaimed drops quests someone holds: a quest branch upstream, or an open PR from that branch in " + + "any repository. A GitHub fork's upstream is its parent. Each skipped quest is listed on stderr with " + + "its PR and how long ago that PR was last active, so a stale claim can be offered for takeover. Off " + + "GitHub, only branches are checked. Outside conditions are never checked.\n\n" + + "Given a path, piped output lists its blockers, one per line with nesting indented, with an " + + "explanation on stderr; no output means it is ready. On a terminal it prints a quest log instead: a " + + "yellow ! for a ready quest, a grey ! for a blocked one, and each size label in its colour. Set " + + "NO_COLOR to drop the colours. Exits 0 whether or not the quest is blocked; non-zero means the " + + "command failed.", ) + .argument("[path]", "Quest to explain instead of ranking every ready quest") .option("--json", "Print a deterministic JSON array") .option("--limit ", "Maximum number of ready quests to print", positiveInteger) - .action((options: { json?: boolean; limit?: number }) => { + .option("--unclaimed", "Skip quests with a claim branch or open PR, listing them on stderr") + .option("--remote ", "Remote whose upstream holds the claims", "origin") + .action( + ( + path: string | undefined, + options: { json?: boolean; limit?: number; unclaimed?: boolean; remote: string }, + ) => { + const docs = load(root()); + if (path !== undefined) { + if (options.json || options.limit !== undefined || options.unclaimed) { + throw new UsageError("--json, --limit, and --unclaimed apply to the ranking; omit the path"); + } + explain(docs, find(docs, path), path); + return; + } + const findings = check(docs, (path) => exists(root(), path)); + if (findings.length > 0) { + for (const finding of findings) warn(formatFinding(finding)); + code = 1; + return; + } + const ranked = unblocked(docs); + const show = (quests: RankedQuest[]) => { + const shown = quests.slice(0, options.limit); + if (options.json) { + print(JSON.stringify(shown)); + return; + } + if (theme) + print("Ready quests by milestone and downstream impact (other prerequisites may remain):"); + for (const quest of shown) print(`${quest.dependents}\t${quest.path}`); + }; + if (!options.unclaimed) { + show(ranked); + return; + } + const branches = ranked.map((quest) => questBranch(quest.path)); + settle(claims(root(), options.remote, branches, { github: githubDependencies, warn }), (held) => { + const byBranch = new Map(held.map((claim) => [claim.branch, claim])); + const now = Date.now(); + for (const quest of ranked) { + const claim = byBranch.get(questBranch(quest.path)); + if (claim) warn(`skipped ${quest.path}: held by ${describe(claim.branch, claim.pull, now)}`); + } + show(ranked.filter((quest) => !byBranch.has(questBranch(quest.path)))); + }); + }, + ); + + program + .command("start") + .summary("Claim a ready quest with a draft PR, or release that claim") + .description( + "Claim a ready quest: push an empty claim commit, built on the upstream default branch, to the quest's " + + "branch (its path without .md) without forcing, then open a draft PR from it right away. Creates the " + + "local branch when that name is free, and prints the PR URL, or the branch off GitHub.\n\n" + + "Refuses a blocked quest, one missing from the upstream default branch, and one already held: a " + + "branch upstream or an open PR from that branch in any repository. A rejected push means someone " + + "else pushed the branch first. When two PRs race, as from forks, the earliest open PR keeps the " + + "claim; a later one is closed and its branch deleted.\n\n" + + "The remote's upstream holds claims: a GitHub fork pushes the branch to itself and opens the PR on its " + + "parent. Off GitHub, only the branch is pushed, with a warning.\n\n" + + "--release deletes the quest's branch on the remote, with a lease on its tip, and closes its PR, only " + + "while that tip is still an empty commit.\n\n" + + "Uses GH_TOKEN, GITHUB_TOKEN, or gh auth token on GitHub. Exits 0 when done, 3 when refused " + + "(blocked, held, or work on the branch), 2 for invalid arguments, and 1 for other failures.", + ) + .argument("", "Quest to claim, as /quest/..., quest/..., or a filesystem path") + .option("--release", "Release the claim instead: delete the empty claim branch and close its PR") + .option("--remote ", "Remote to push the claim to", "origin") + .action((path: string, options: { release?: boolean; remote: string }) => { const docs = load(root()); - const findings = check(docs, (path) => exists(root(), path)); - if (findings.length > 0) { - for (const finding of findings) warn(formatFinding(finding)); - code = 1; + const doc = find(docs, path); + const deps = { github: githubDependencies, warn }; + if (options.release) { + settle(releaseQuest(root(), doc.path, options.remote, deps), (released) => { + print(`deleted ${released.branch}`); + for (const url of released.closed) print(`closed ${url}`); + }); return; } - const ranked = unblocked(docs).slice(0, options.limit); - if (options.json) { - print(JSON.stringify(ranked)); + if (blockers(docs, doc).length > 0) { + explain(docs, doc, path); + code = 3; return; } - if (theme) print("Ready quests by milestone and downstream impact (other prerequisites may remain):"); - for (const quest of ranked) print(`${quest.dependents}\t${quest.path}`); + settle(startQuest(root(), doc.path, doc.title?.text ?? doc.path, options.remote, deps), (started) => + print(started.url ?? started.branch), + ); }); program @@ -154,7 +254,7 @@ export function main(argv: string[], out: Output): number | Promise { .option("--timeout ", "Maximum wait in seconds", positiveSeconds) .action((value: string, options: { since?: string; timeout?: number }) => { const pr = pullRequest(value, root()); - const deps = waitDependencies(); + const deps = githubDependencies(); deps.warn = warn; pending = waitForActivity(pr, options, deps).then( (lines) => { diff --git a/src/cli/start.ts b/src/cli/start.ts new file mode 100644 index 00000000..deea0cb8 --- /dev/null +++ b/src/cli/start.ts @@ -0,0 +1,253 @@ +// `quest start`: claim a ready quest by pushing an empty claim commit to its +// branch and opening a draft PR from it right away, and release that claim +// while nothing but the claim commit is on the branch. +import { execFileSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; + +import { + type ClaimDependencies, + type OpenPull, + type Upstream, + gitOutput, + held, + openPullRequests, + pullsFrom, + questBranch, + repositoryPath, + upstream, +} from "./claims"; +import { object, string } from "./github"; + +/** Someone else holds the quest, or the claim cannot be released; distinct from a failure. */ +export class Refusal extends Error {} + +export interface Started { + branch: string; + /** The claim commit, also the local branch's tip when that branch was free. */ + sha: string; + /** The draft PR; `null` off GitHub. */ + url: string | null; +} + +/** Claim `path`, a ready quest, on `remote`. Throws `Refusal` when someone else holds it. */ +export async function startQuest( + root: string, + path: string, + title: string, + remote: string, + deps: ClaimDependencies, +): Promise { + const branch = questBranch(path); + const up = await upstream(root, remote, deps); + const [claim] = await held(root, up, [branch]); + if (claim) throw new Refusal(`${path} is held by ${describe(claim.branch, claim.pull, Date.now())}`); + + // Build the claim on the upstream default branch, which must still have the quest. + const base = remoteHead(root, up.git); + if (!succeeds(root, ["cat-file", "-e", `${base}:${path}`])) { + throw new Refusal(`${path} is not on the upstream default branch; update your checkout`); + } + // A nonce keeps two claimers on the same base, identity, and second from building the same commit. + const nonce = randomBytes(8).toString("hex"); + const sha = gitOutput(root, [ + "commit-tree", + `${base}^{tree}`, + "-p", + base, + "-m", + `chore(quest): claim ${path}`, + "-m", + `Quest-Claim: ${nonce}`, + ]); + push(root, remote, [`${sha}:refs/heads/${branch}`], `someone pushed ${branch} first`); + + let url: string | null = null; + if (up.github === null) { + deps.warn(`${remote} is not on GitHub, so the claim branch was pushed without a PR`); + } else { + url = await openDraft(root, up, remote, path, title, branch, sha); + } + + // A local branch to build on, unless one already exists. + if (!succeeds(root, ["update-ref", `refs/heads/${branch}`, sha, ""])) { + deps.warn(`local branch ${branch} already exists; the claim commit is ${sha}`); + } + return { branch, sha, url }; +} + +/** Open the draft PR, then keep it only if it is the earliest open PR on the branch. */ +async function openDraft( + root: string, + up: Upstream, + remote: string, + path: string, + title: string, + branch: string, + sha: string, +): Promise { + const github = up.github!; + const { owner, repo } = github.repository; + const fork = github.head.owner !== owner || github.head.repo !== repo; + let created: OpenPull; + try { + const response = await github.client.request(`${repositoryPath(github.repository)}/pulls`, { + body: { + title: title.replace(/^\[(XS|S|M|L|XL)\] /, ""), + head: fork ? `${github.head.owner}:${branch}` : branch, + base: github.defaultBranch, + draft: true, + body: `Claims [${path}](https://github.com/${owner}/${repo}/blob/${github.defaultBranch}/${path}).`, + }, + }); + const row = object(await response.json()); + created = { + number: Number(row.number), + url: string(row.html_url), + branch, + head: `${github.head.owner}/${github.head.repo}`, + draft: true, + created: Date.parse(string(row.created_at)), + updated: Date.parse(string(row.created_at)), + handedBack: false, + }; + } catch (error) { + // Without its PR the claim is invisible on GitHub, so take the branch back. + deleteBranch(root, remote, branch, sha); + throw error; + } + + // Two claimers on different repositories, such as forks, both push; the earliest PR wins. + const open = pullsFrom(await openPullRequests(github.client, github.repository), branch); + const first = pullsFrom([...open.filter((pull) => pull.number !== created.number), created], branch)[0]; + if (first.number !== created.number) { + deleteBranch(root, remote, branch, sha); + await close(up, created.number); + throw new Refusal(`${first.url} claimed ${path} first; closed ${created.url}`); + } + return created.url; +} + +export interface Released { + branch: string; + /** The PRs closed with it. */ + closed: string[]; +} + +/** + * Release the claim on `path`: delete its branch on `remote` with a lease on + * the claim commit, then close its open PR. Refuses once the branch has work on it. + */ +export async function releaseQuest( + root: string, + path: string, + remote: string, + deps: ClaimDependencies, +): Promise { + const branch = questBranch(path); + const up = await upstream(root, remote, deps); + const tip = gitOutput(root, ["ls-remote", remote, `refs/heads/${branch}`]).split("\t")[0]; + if (!tip) throw new Error(`${remote} has no branch ${branch} to release`); + ensure(root, remote, tip); + const parents = gitOutput(root, ["rev-list", "--parents", "-n", "1", tip]).split(" ").slice(1); + const empty = + parents.length === 1 && + gitOutput(root, ["rev-parse", `${tip}^{tree}`]) === gitOutput(root, ["rev-parse", `${parents[0]}^{tree}`]); + if (!empty) { + throw new Refusal( + `${branch} on ${remote} has work beyond its empty claim commit; close its PR yourself to abandon that work`, + ); + } + + const pulls: OpenPull[] = []; + if (up.github) { + const head = `${up.github.head.owner}/${up.github.head.repo}`.toLowerCase(); + const open = await openPullRequests(up.github.client, up.github.repository); + pulls.push(...pullsFrom(open, branch).filter((pull) => pull.head?.toLowerCase() === head)); + } + // Deleting first means the lease decides: a push since the check keeps both the branch and its PR. + deleteBranch(root, remote, branch, tip); + for (const pull of pulls) await close(up, pull.number); + return { branch, closed: pulls.map((pull) => pull.url) }; +} + +/** How long ago `time` was, roughly: minutes, hours, then days. */ +export function ago(now: number, time: number): string { + const minutes = Math.max(0, Math.round((now - time) / 60_000)); + const [count, unit] = + minutes < 60 + ? [minutes, "minute"] + : minutes < 48 * 60 + ? [Math.round(minutes / 60), "hour"] + : [Math.round(minutes / 1440), "day"]; + return `${count} ${unit}${count === 1 ? "" : "s"} ago`; +} + +/** A claim for a person deciding whether to wait, take it over, or pick other work. */ +export function describe(branch: string, pull: OpenPull | null, now: number): string { + if (pull === null) return `branch ${branch}, with no open PR`; + // A draft without the hand-back heading is still its starter's; the others wait on the maintainer. + const state = !pull.draft ? "ready for review" : pull.handedBack ? "draft, decisions needed" : "draft"; + return `${pull.url} (${state}, last active ${ago(now, pull.updated)})`; +} + +/** Close a PR on the upstream repository. */ +async function close(up: Upstream, number: number): Promise { + await up.github!.client.request(`${repositoryPath(up.github!.repository)}/pulls/${number}`, { + method: "PATCH", + body: { state: "closed" }, + }); +} + +/** The upstream default branch's tip, fetched so a commit can build on it. */ +function remoteHead(root: string, remote: string): string { + const sha = gitOutput(root, ["ls-remote", remote, "HEAD"]).split("\t")[0]; + if (!sha) throw new Error(`${remote} has no default branch to build a claim on`); + ensure(root, remote, sha); + return sha; +} + +/** Make `sha` from `remote` available locally. */ +function ensure(root: string, remote: string, sha: string): void { + if (!succeeds(root, ["cat-file", "-e", `${sha}^{commit}`])) { + gitOutput(root, ["fetch", "--quiet", "--no-tags", "--no-write-fetch-head", remote, sha]); + } +} + +/** Delete `branch` on `remote` only while its tip is still `sha`. */ +function deleteBranch(root: string, remote: string, branch: string, sha: string): void { + push( + root, + remote, + [`--force-with-lease=refs/heads/${branch}:${sha}`, `:refs/heads/${branch}`], + `${branch} moved on ${remote}, so it was kept`, + ); +} + +/** + * Push without forcing. A rejection, which means the remote ref is not what + * this push expects, is a `Refusal` with `rejected`; anything else fails. + */ +function push(root: string, remote: string, args: string[], rejected: string): void { + try { + execFileSync("git", ["-C", root, "push", "--porcelain", remote, ...args], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, + }); + } catch (error) { + const { stdout, stderr } = error as { stdout?: string; stderr?: string }; + // --porcelain reports each ref on stdout: `!` and `[rejected]` for a ref that is not as expected. + if (/^!\t.*\t\[rejected\]/m.test(stdout ?? "")) throw new Refusal(rejected); + throw new Error(`git push to ${remote} failed: ${(stderr || stdout || "").trim()}`); + } +} + +/** Whether a git command exits zero. */ +function succeeds(root: string, args: string[]): boolean { + try { + gitOutput(root, args); + return true; + } catch { + return false; + } +} diff --git a/src/cli/theme.ts b/src/cli/theme.ts index 5109a85e..f24a455f 100644 --- a/src/cli/theme.ts +++ b/src/cli/theme.ts @@ -57,15 +57,8 @@ interface Row { export class Theme { constructor(private readonly terminal: Terminal) {} - /** `quest ready`: every ready quest, under a count. */ - ready(docs: Map, paths: string[]): string[] { - if (paths.length === 0) return ["No quests ready."]; - const count = `${paths.length} ${paths.length === 1 ? "quest" : "quests"} ready`; - return [count, ...layout(paths.map((path) => ({ indent: 0, label: this.quest(docs, path, "ready"), path })))]; - } - /** - * `quest ready `: the quest with its marker, then its status: Ready + * `quest unblocked `: the quest with its marker, then its status: Ready * or the `Required` chain, with each required epic expanded * into the quests it still holds. */ diff --git a/src/cli/wait.ts b/src/cli/wait.ts index cb2fe082..00b5d37f 100644 --- a/src/cli/wait.ts +++ b/src/cli/wait.ts @@ -1,8 +1,17 @@ // GitHub-specific PR activity waiting stays outside the repository-independent core. -import { execFileSync } from "node:child_process"; -import { setTimeout as sleep } from "node:timers/promises"; +import { + type GitHubDependencies, + type ObjectValue, + GitHub, + array, + githubRepository, + link, + object, + remoteUrl, + seconds, + string, +} from "./github"; -const API = "https://api.github.com"; const POLL = 5 * 60_000; const SHA = /^[a-f\d]{7,40}$/i; /** The timeline records a force push a few seconds after repository activity (1-2s observed). */ @@ -34,13 +43,7 @@ export interface WaitOptions { timeout?: number; } -export interface WaitDependencies { - fetch: typeof fetch; - now(): number; - sleep(milliseconds: number): Promise; - token: string; - warn(message: string): void; -} +export type WaitDependencies = GitHubDependencies; export class WaitInputError extends Error {} @@ -51,7 +54,6 @@ export class WaitTimeout extends Error { } } -type ObjectValue = Record; interface Activity { point: string; time: number; @@ -82,38 +84,15 @@ interface Cursor { /** Accept a GitHub PR URL, or a number in the origin repository. */ export function pullRequest(value: string, root: string): PullRequest { if (/^[1-9]\d*$/.test(value)) { - const origin = execFileSync("git", ["-C", root, "remote", "get-url", "origin"], { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }).trim(); - const match = - /^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([^/]+)\/([^/]+?)(?:\.git)?$/.exec( - origin, - ); - if (!match) throw new Error("origin must name a github.com repository; pass a GitHub PR URL instead"); - return { owner: match[1], repo: match[2], number: Number(value) }; + const repository = githubRepository(remoteUrl(root, "origin")); + if (!repository) throw new Error("origin must name a github.com repository; pass a GitHub PR URL instead"); + return { ...repository, number: Number(value) }; } const match = /^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/([1-9]\d*)\/?(?:[?#].*)?$/.exec(value); if (!match) throw new WaitInputError("expected a GitHub pull request URL or a PR number in origin"); return { owner: match[1], repo: match[2], number: Number(match[3]) }; } -export function waitDependencies(): WaitDependencies { - let token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; - if (!token) { - try { - token = execFileSync("gh", ["auth", "token", "--hostname", "github.com"], { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }).trim(); - } catch { - throw new Error("set GH_TOKEN or GITHUB_TOKEN, or sign in with gh auth login"); - } - } - if (!token) throw new Error("GitHub authentication returned an empty token"); - return { fetch, now: Date.now, sleep, token, warn: (message) => process.stderr.write(`quest: ${message}\n`) }; -} - /** Wait until activity, returning plain output lines. Network and time are injected. */ export async function waitForActivity( pr: PullRequest, @@ -157,60 +136,20 @@ async function watch( if (deps.now() >= deadline) throw new WaitTimeout(); }; let pollInterval = POLL; - const request = async (path: string, etag?: string, payload?: unknown): Promise => { - // Follow only same-origin pagination links; never send a token to another host. - const url = new URL(path, API); - if (url.origin !== API || url.username || url.password) throw new Error("invalid GitHub pagination URL"); - let attempts = 0; - for (;;) { - const remaining = deadline - deps.now(); - if (remaining <= 0) throw new WaitTimeout(); - let response: Response; - try { - response = await deps.fetch(url, { - ...(payload === undefined ? {} : { method: "POST", body: JSON.stringify(payload) }), - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${deps.token}`, - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "quest", - ...(etag ? { "If-None-Match": etag } : {}), - }, - ...(Number.isFinite(remaining) ? { signal: AbortSignal.timeout(Math.ceil(remaining)) } : {}), - }); - } catch (error) { - if (deps.now() >= deadline) throw new WaitTimeout(); - throw error; - } - pollInterval = Math.max(pollInterval, seconds(response.headers.get("x-poll-interval")) ?? 0); - if (response.status === 200 || response.status === 304) return response; - const body = await response.text(); - const limited = - response.status === 429 || - (response.status === 403 && - (response.headers.has("retry-after") || - response.headers.get("x-ratelimit-remaining") === "0" || - /rate limit/i.test(body))); - if (!limited || attempts >= 6) - throw new Error( - `GitHub HTTP ${response.status} for ${url.pathname}; ${limited ? "rate limit persisted" : "check repository access and token permissions"}`, - ); - const retry = seconds(response.headers.get("retry-after")); - const reset = - response.headers.get("x-ratelimit-remaining") === "0" - ? seconds(response.headers.get("x-ratelimit-reset")) - : undefined; - const delay = retry ?? (reset === undefined ? 60_000 * 2 ** attempts : Math.max(1000, reset - deps.now())); - attempts++; - deps.warn(`GitHub rate limited this request; waiting ${Math.ceil(delay / 1000)} seconds`); - await pause(delay); - } - }; - const list = async (path: string): Promise<{ rows: ObjectValue[]; response: Response }> => { - const response = await request(path); - if (response.status !== 200) throw new Error("unexpected GitHub 304 without a cached response"); - return { rows: array(await response.json()), response }; - }; + // Any failure past the deadline, the client's included, becomes a WaitTimeout in waitForActivity. + const github = new GitHub( + { + ...deps, + fetch: async (url, init) => { + const response = await deps.fetch(url, init); + pollInterval = Math.max(pollInterval, seconds(response.headers.get("x-poll-interval")) ?? 0); + return response; + }, + }, + deadline, + ); + const request = (path: string, etag?: string, body?: unknown) => github.request(path, { etag, body }); + const list = (path: string) => github.list(path); let cursor: Cursor; let historical: Activity[] = []; @@ -512,22 +451,6 @@ function actor(value: unknown): string { : "-"; } -function object(value: unknown): ObjectValue { - if (typeof value !== "object" || value === null || Array.isArray(value)) - throw new Error("invalid GitHub response: expected an object"); - return value as ObjectValue; -} - -function array(value: unknown): ObjectValue[] { - if (!Array.isArray(value)) throw new Error("invalid GitHub response: expected an array"); - return value.map(object); -} - -function string(value: unknown): string { - if (typeof value !== "string" || !value) throw new Error("invalid GitHub response: expected a nonempty string"); - return value; -} - function timestamp(value: string): number { const result = Date.parse(value); if (!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/.test(value) || !Number.isFinite(result)) @@ -535,20 +458,6 @@ function timestamp(value: string): number { return result; } -function seconds(value: string | null): number | undefined { - if (value === null || !/^\d+(?:\.\d+)?$/.test(value)) return undefined; - const result = Number(value) * 1000; - return Number.isFinite(result) ? result : undefined; -} - -function link(response: Response, relation: string): string | undefined { - return response.headers - .get("link") - ?.split(",") - .map((entry) => /<([^>]+)>;\s*rel="([^"]+)"/.exec(entry.trim())) - .find((match) => match?.[2] === relation)?.[1]; -} - function fullSha(value: unknown): boolean { return typeof value === "string" && /^[a-f\d]{40}$/i.test(value); } diff --git a/test/cli.test.ts b/test/cli.test.ts index 3f5c6030..dd281ce0 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -19,33 +19,43 @@ test("check prints findings on stderr and fails", () => { expect(out.stderr).toContain("quest: quest/m0/epic/one.md:7: unknown '## Requires'"); }); -test("ready lists ready quests", () => { - expect(Tree.baseline().run("ready")).toEqual({ code: 0, stdout: "quest/m0/epic/one.md\n", stderr: "" }); -}); - -// Blocked or not, ready exits 0: the blocker list on stdout is the result. -test("ready prints blockers on stdout and explains on stderr", () => { - const out = Tree.baseline().run("ready", "/quest/m0/epic/two.md"); +// Blocked or not, unblocked exits 0: the blocker list on stdout is the result. +test("unblocked with a path prints blockers on stdout and explains on stderr", () => { + const out = Tree.baseline().run("unblocked", "/quest/m0/epic/two.md"); expect(out.code).toBe(0); expect(out.stdout).toBe("quest/m0/epic/one.md\n"); expect(out.stderr).toContain("quest: blocked by quest/m0/epic/one.md\n"); expect(out.stderr).toContain("quest: /quest/m0/epic/two.md is blocked;"); }); -test("ready accepts a filesystem path", () => { +test("unblocked with a ready quest prints nothing", () => { + expect(Tree.baseline().run("unblocked", "quest/m0/epic/one.md")).toEqual({ code: 0, stdout: "", stderr: "" }); +}); + +test("unblocked accepts a filesystem path", () => { const tree = Tree.baseline(); - expect(tree.run("ready", `${tree.path}/quest/m0/epic/two.md`).stdout).toBe("quest/m0/epic/one.md\n"); + expect(tree.run("unblocked", `${tree.path}/quest/m0/epic/two.md`).stdout).toBe("quest/m0/epic/one.md\n"); }); -test("ready rejects a path that is not a quest", () => { - const out = Tree.baseline().run("ready", "nope.md"); +test("unblocked rejects a path that is not a quest", () => { + const out = Tree.baseline().run("unblocked", "nope.md"); expect(out.code).toBe(1); expect(out.stderr).toMatch(/^quest: nope.md is not a quest document under .*\/quest\n$/); }); +test("unblocked rejects ranking options with a path", () => { + for (const option of ["--json", "--unclaimed", "--limit=1"]) { + expect(Tree.baseline().run("unblocked", "quest/m0/epic/one.md", option).code).toBe(2); + } +}); + +test("the ready command is gone", () => { + expect(Tree.baseline().run("ready").code).toBe(2); +}); + test("the root option is accepted after the command", () => { const tree = Tree.baseline(); - expect(run("ready", "--root", tree.path).stdout).toBe("quest/m0/epic/one.md\n"); + expect(run("unblocked", "--root", tree.path).stdout).toBe("1\tquest/m0/epic/one.md\n"); }); test("init and uninstall print each changed path and nothing else", () => { @@ -78,7 +88,7 @@ test("skill rejects an unknown name", () => { test("usage errors exit 2", () => { expect(run("bogus").code).toBe(2); expect(run("skill", "--stub").code).toBe(2); - expect(run("ready", "a", "b").code).toBe(2); + expect(run("unblocked", "a", "b").code).toBe(2); expect(run().code).toBe(2); }); diff --git a/test/start.test.ts b/test/start.test.ts new file mode 100644 index 00000000..a707dfcd --- /dev/null +++ b/test/start.test.ts @@ -0,0 +1,410 @@ +// `quest start` and `quest unblocked --unclaimed`: claims on local bare +// remotes, with GitHub replayed in memory. URL rewrites (`insteadOf`) let a +// github.com remote name reach a local bare repository. + +import { execFileSync } from "node:child_process"; +import { appendFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { claims } from "../src/cli/claims"; +import { type GitHubDependencies, type ObjectValue } from "../src/cli/github"; +import { type Output, main } from "../src/cli/main"; +import { Refusal, ago, releaseQuest, startQuest } from "../src/cli/start"; +import { Tree, tempDir } from "./fixture"; + +const ONE = "quest/m0/epic/one.md"; +const BRANCH = "quest/m0/epic/one"; +const UPSTREAM = "https://github.com/example/project.git"; +const FORK = "https://github.com/me/project.git"; + +function git(cwd: string, ...args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); +} + +/** A bare repository whose default branch holds the baseline tree. */ +function upstream(): string { + const bare = tempDir(); + git(bare, "init", "-q", "--bare", "-b", "main"); + const seed = Tree.baseline().path; + setup(seed); + git(seed, "add", "."); + git(seed, "commit", "-q", "-m", "seed"); + git(seed, "push", "-q", bare, "HEAD:refs/heads/main"); + return bare; +} + +/** Identity and no signing, so commits work under any user configuration. */ +function setup(dir: string): void { + git(dir, "init", "-q", "-b", "main"); + git(dir, "config", "user.name", "Test"); + git(dir, "config", "user.email", "test@example.com"); + git(dir, "config", "commit.gpgsign", "false"); +} + +/** A clone whose origin is `url`, rewritten to the bare repositories in `routes`. */ +function clone(url: string, routes: Record): string { + const dir = Tree.baseline().path; + setup(dir); + git(dir, "remote", "add", "origin", url); + for (const [from, to] of Object.entries(routes)) git(dir, "config", `url.${to}.insteadOf`, from); + return dir; +} + +interface Pull { + number: number; + base: string; + head: { ref: string; repo: { full_name: string } | null }; + draft: boolean; + state: string; + body: string; + created_at: string; + updated_at: string; + html_url: string; +} + +/** GitHub's repositories and pull requests, in memory. */ +class FakeGitHub { + readonly repos = new Map(); + readonly pulls: Pull[] = []; + /** Runs after each PR is created, as when another claimer opens one at the same moment. */ + onCreate?: () => void; + private clock = Date.now(); + + constructor() { + this.repo("example/project", { fork: false, default_branch: "main" }); + } + + repo(name: string, extra: ObjectValue): void { + const [owner, repo] = name.split("/"); + this.repos.set(name, { owner: { login: owner }, name: repo, full_name: name, ...extra }); + } + + /** Open a PR as another contributor, `minutes` before now. */ + open(base: string, ref: string, headRepo: string, options: Partial = {}): Pull { + const number = this.pulls.length + 1; + const pull: Pull = { + number, + base, + head: { ref, repo: { full_name: headRepo } }, + draft: true, + state: "open", + body: "", + created_at: new Date(this.clock++).toISOString(), + updated_at: new Date(this.clock).toISOString(), + html_url: `https://github.com/${base}/pull/${number}`, + ...options, + }; + this.pulls.push(pull); + return pull; + } + + open_(base: string): Pull[] { + return this.pulls.filter((pull) => pull.base === base && pull.state === "open"); + } + + deps(): GitHubDependencies { + return { + fetch: async (url, init) => this.handle(url, init), + now: () => this.clock, + sleep: async () => undefined, + token: "token", + warn: () => undefined, + }; + } + + private handle(url: URL, init: RequestInit): Response { + const method = init.method ?? "GET"; + const body = init.body ? (JSON.parse(String(init.body)) as ObjectValue) : {}; + const json = (status: number, value: unknown) => new Response(JSON.stringify(value), { status }); + let match = /^\/repos\/([^/]+\/[^/]+)$/.exec(url.pathname); + if (match && method === "GET") { + const repo = this.repos.get(match[1]); + return repo ? json(200, repo) : json(404, { message: "Not Found" }); + } + match = /^\/repos\/([^/]+\/[^/]+)\/pulls$/.exec(url.pathname); + if (match && method === "GET") return json(200, this.open_(match[1])); + if (match && method === "POST") { + const [owner, ref] = String(body.head).includes(":") ? String(body.head).split(":") : [null, body.head]; + const headRepo = owner ? `${owner}/${match[1].split("/")[1]}` : match[1]; + const pull = this.open(match[1], String(ref), headRepo, { + draft: body.draft === true, + body: String(body.body), + }); + this.onCreate?.(); + return json(201, { ...pull, title: body.title, baseRef: body.base }); + } + match = /^\/repos\/([^/]+\/[^/]+)\/pulls\/(\d+)$/.exec(url.pathname); + if (match && method === "PATCH") { + const pull = this.pulls.find((p) => p.base === match![1] && p.number === Number(match![2]))!; + Object.assign(pull, body); + return json(200, pull); + } + return json(404, { message: `no route for ${method} ${url.pathname}` }); + } +} + +function deps(github: FakeGitHub, warnings: string[] = []) { + return { github: () => github.deps(), warn: (message: string) => warnings.push(message) }; +} + +/** The branch tip on a bare repository, or "" when it is missing. */ +function tip(bare: string, branch = BRANCH): string { + return git(bare, "for-each-ref", "--format=%(objectname)", `refs/heads/${branch}`); +} + +/** Run the CLI and wait for it. */ +async function cli(...args: string[]): Promise<{ code: number; stdout: string; stderr: string }> { + let stdout = ""; + let stderr = ""; + const out: Output = { stdout: (text) => void (stdout += text), stderr: (text) => void (stderr += text) }; + const code = await main(args, out); + return { code, stdout, stderr }; +} + +describe("start on GitHub", () => { + test("claims a free quest with an empty commit and a draft PR", async () => { + const bare = upstream(); + const github = new FakeGitHub(); + const work = clone(UPSTREAM, { [UPSTREAM]: bare }); + const started = await startQuest(work, ONE, "[S] One", "origin", deps(github)); + + expect(started).toEqual({ branch: BRANCH, sha: tip(bare), url: "https://github.com/example/project/pull/1" }); + const base = git(bare, "rev-parse", "main"); + expect(git(bare, "rev-parse", `${started.sha}^`)).toBe(base); + expect(git(bare, "rev-parse", `${started.sha}^{tree}`)).toBe(git(bare, "rev-parse", `${base}^{tree}`)); + expect(git(bare, "log", "-1", "--format=%B", started.sha)).toMatch( + /^chore\(quest\): claim quest\/m0\/epic\/one\.md\n\nQuest-Claim: [0-9a-f]{16}$/, + ); + expect(git(work, "rev-parse", `refs/heads/${BRANCH}`)).toBe(started.sha); + expect(github.pulls).toMatchObject([ + { base: "example/project", head: { ref: BRANCH, repo: { full_name: "example/project" } }, draft: true }, + ]); + expect(github.pulls[0].body).toContain(ONE); + }); + + test("refuses a quest whose branch is already upstream", async () => { + const bare = upstream(); + git(bare, "branch", BRANCH, "main"); + const work = clone(UPSTREAM, { [UPSTREAM]: bare }); + await expect(startQuest(work, ONE, "[S] One", "origin", deps(new FakeGitHub()))).rejects.toThrow( + new Refusal(`${ONE} is held by branch ${BRANCH}, with no open PR`), + ); + }); + + test("refuses a quest a fork's PR claims, and says whether it was handed back", async () => { + const bare = upstream(); + const work = clone(UPSTREAM, { [UPSTREAM]: bare }); + const github = new FakeGitHub(); + const pull = github.open("example/project", BRANCH, "someone/project"); + const start = () => startQuest(work, ONE, "[S] One", "origin", deps(github)); + + await expect(start()).rejects.toThrow(`${ONE} is held by ${pull.html_url} (draft, last active 0 minutes ago)`); + pull.body = "Work so far.\n\n## Decisions needed\n\n1. Which name?\n"; + await expect(start()).rejects.toThrow(`held by ${pull.html_url} (draft, decisions needed,`); + pull.draft = false; + await expect(start()).rejects.toThrow(`held by ${pull.html_url} (ready for review,`); + expect(tip(bare)).toBe(""); + }); + + test("two claimers race: one push wins and the other is refused", async () => { + const bare = upstream(); + const github = new FakeGitHub(); + const [a, b] = [clone(UPSTREAM, { [UPSTREAM]: bare }), clone(UPSTREAM, { [UPSTREAM]: bare })]; + const results = await Promise.allSettled([ + startQuest(a, ONE, "[S] One", "origin", deps(github)), + startQuest(b, ONE, "[S] One", "origin", deps(github)), + ]); + + const won = results.filter((result) => result.status === "fulfilled"); + const lost = results.filter((result) => result.status === "rejected"); + expect(won).toHaveLength(1); + expect(lost).toHaveLength(1); + expect((lost[0] as PromiseRejectedResult).reason).toBeInstanceOf(Refusal); + expect(tip(bare)).toBe((won[0] as PromiseFulfilledResult<{ sha: string }>).value.sha); + expect(github.open_("example/project")).toHaveLength(1); + }); + + test("a fork PR opened first keeps the claim; the later PR closes and its branch goes", async () => { + const bare = upstream(); + const github = new FakeGitHub(); + const work = clone(UPSTREAM, { [UPSTREAM]: bare }); + let fork: Pull | undefined; + github.onCreate = () => { + github.onCreate = undefined; + fork = github.open("example/project", BRANCH, "someone/project", { + created_at: new Date(Date.now() - 3_600_000).toISOString(), + }); + }; + + await expect(startQuest(work, ONE, "[S] One", "origin", deps(github))).rejects.toThrow( + `${fork?.html_url ?? "https://github.com/example/project/pull/2"} claimed ${ONE} first; closed https://github.com/example/project/pull/1`, + ); + expect(github.open_("example/project").map((pull) => pull.number)).toEqual([2]); + expect(tip(bare)).toBe(""); + }); + + test("a fork pushes to itself and opens the PR on its parent", async () => { + const bare = upstream(); + const forkBare = tempDir(); + git(forkBare, "init", "-q", "--bare", "-b", "main"); + const github = new FakeGitHub(); + github.repo("me/project", { + fork: true, + default_branch: "main", + parent: { + owner: { login: "example" }, + name: "project", + clone_url: UPSTREAM, + ssh_url: "git@github.com:example/project.git", + default_branch: "main", + }, + }); + const work = clone(FORK, { [FORK]: forkBare, [UPSTREAM]: bare }); + + const started = await startQuest(work, ONE, "[S] One", "origin", deps(github)); + expect(tip(forkBare)).toBe(started.sha); + expect(tip(bare)).toBe(""); + expect(github.pulls).toMatchObject([ + { base: "example/project", head: { ref: BRANCH, repo: { full_name: "me/project" } } }, + ]); + + // Another clone of the upstream sees the fork's claim. + const other = clone(UPSTREAM, { [UPSTREAM]: bare }); + await expect(startQuest(other, ONE, "[S] One", "origin", deps(github))).rejects.toThrow(Refusal); + + const released = await releaseQuest(work, ONE, "origin", deps(github)); + expect(released).toEqual({ branch: BRANCH, closed: [started.url] }); + expect(tip(forkBare)).toBe(""); + }); + + test("refuses a quest missing from the upstream default branch", async () => { + const bare = upstream(); + const work = clone(UPSTREAM, { [UPSTREAM]: bare }); + const extra = "quest/m0/epic/three.md"; + writeFileSync(join(work, extra), "# [S] Three\n\n## Goal\n\nOnly local.\n"); + await expect(startQuest(work, extra, "[S] Three", "origin", deps(new FakeGitHub()))).rejects.toThrow( + new Refusal(`${extra} is not on the upstream default branch; update your checkout`), + ); + expect(tip(bare, "quest/m0/epic/three")).toBe(""); + }); + + test("release deletes the empty claim branch and closes its PR", async () => { + const bare = upstream(); + const github = new FakeGitHub(); + const work = clone(UPSTREAM, { [UPSTREAM]: bare }); + const started = await startQuest(work, ONE, "[S] One", "origin", deps(github)); + + expect(await releaseQuest(work, ONE, "origin", deps(github))).toEqual({ + branch: BRANCH, + closed: [started.url], + }); + expect(tip(bare)).toBe(""); + expect(github.pulls[0].state).toBe("closed"); + }); + + test("release refuses a branch with work on it", async () => { + const bare = upstream(); + const github = new FakeGitHub(); + const work = clone(UPSTREAM, { [UPSTREAM]: bare }); + await startQuest(work, ONE, "[S] One", "origin", deps(github)); + git(work, "checkout", "-q", "-f", BRANCH); + appendFileSync(join(work, ONE), "\nWork in progress.\n"); + git(work, "commit", "-q", "-m", "work", "--", ONE); + git(work, "push", "-q", "origin", `refs/heads/${BRANCH}:refs/heads/${BRANCH}`); + + await expect(releaseQuest(work, ONE, "origin", deps(github))).rejects.toThrow( + new Refusal( + `${BRANCH} on origin has work beyond its empty claim commit; close its PR yourself to abandon that work`, + ), + ); + expect(tip(bare)).toBe(git(work, "rev-parse", "HEAD")); + expect(github.pulls[0].state).toBe("open"); + }); +}); + +describe("start on another forge", () => { + /** A clone whose origin is a plain bare repository path. */ + function plain(): { bare: string; work: string } { + const bare = upstream(); + const work = Tree.baseline().path; + setup(work); + git(work, "remote", "add", "origin", bare); + return { bare, work }; + } + + test("pushes the claim branch without a PR, then refuses a second claim", async () => { + const { bare, work } = plain(); + const first = await cli("--root", work, "start", ONE); + expect(first).toEqual({ + code: 0, + stdout: `${BRANCH}\n`, + stderr: "quest: origin is not on GitHub, so the claim branch was pushed without a PR\n", + }); + expect(tip(bare)).not.toBe(""); + + const second = await cli("--root", work, "start", `/${ONE}`); + expect(second.code).toBe(3); + expect(second.stderr).toContain(`quest: ${ONE} is held by branch ${BRANCH}, with no open PR\n`); + }); + + test("refuses a blocked quest with exit 3", async () => { + const { bare, work } = plain(); + const out = await cli("--root", work, "start", "quest/m0/epic/two.md"); + expect(out.code).toBe(3); + expect(out.stderr).toContain("quest: quest/m0/epic/two.md is blocked;"); + expect(tip(bare, "quest/m0/epic/two")).toBe(""); + }); + + test("releases the claim", async () => { + const { bare, work } = plain(); + await cli("--root", work, "start", ONE); + expect(await cli("--root", work, "start", "--release", ONE)).toEqual({ + code: 0, + stdout: `deleted ${BRANCH}\n`, + stderr: "", + }); + expect(tip(bare)).toBe(""); + expect((await cli("--root", work, "start", "--release", ONE)).code).toBe(1); + }); + + test("unblocked --unclaimed drops held quests and lists them on stderr", async () => { + const { bare, work } = plain(); + // Two ready quests, so the filter leaves one. + appendFileSync(join(work, "quest/m0/epic/README.md"), "- [Three](/quest/m0/epic/three.md)\n"); + writeFileSync(join(work, "quest/m0/epic/three.md"), "# [S] Three\n\n## Goal\n\nAlso ready.\n"); + git(bare, "branch", BRANCH, "main"); + + expect(await cli("--root", work, "unblocked", "--unclaimed")).toEqual({ + code: 0, + stdout: "0\tquest/m0/epic/three.md\n", + stderr: + "quest: origin is not on GitHub, so only its branches were checked for claims\n" + + `quest: skipped ${ONE}: held by branch ${BRANCH}, with no open PR\n`, + }); + expect((await cli("--root", work, "unblocked", "--unclaimed", "--remote", "nope")).stderr).toBe( + "quest: no nope remote, so claims were not checked\n", + ); + }); +}); + +test("claims reports the earliest open PR on each branch", async () => { + const bare = upstream(); + const github = new FakeGitHub(); + const work = clone(UPSTREAM, { [UPSTREAM]: bare }); + const first = github.open("example/project", BRANCH, "a/project"); + github.open("example/project", BRANCH, "b/project"); + github.open("example/project", "feature", "example/project"); + const held = await claims(work, "origin", [BRANCH, "quest/m0/epic/two"], deps(github)); + expect(held).toEqual([ + { branch: BRANCH, url: first.html_url, pull: expect.objectContaining({ number: 1 }), sha: null }, + ]); +}); + +test("ago rounds to minutes, hours, then days", () => { + const now = Date.parse("2026-10-21T12:00:00Z"); + expect(ago(now, now - 60_000)).toBe("1 minute ago"); + expect(ago(now, now - 3 * 3_600_000)).toBe("3 hours ago"); + expect(ago(now, now - 3 * 86_400_000)).toBe("3 days ago"); +}); diff --git a/test/theme.test.ts b/test/theme.test.ts index 0a556188..a5e27782 100644 --- a/test/theme.test.ts +++ b/test/theme.test.ts @@ -42,39 +42,17 @@ describe("detect", () => { }); }); -describe("ready", () => { - test("lists ready quests with a yellow marker and size colours", () => { - const bang = sgr("33", "!"); - expect(sizes().runOn(COLOUR, "ready")).toEqual({ - code: 0, - stdout: - "5 quests ready\n" + - `${bang} ${sgr("90", "[XS]")} Tiny quest/m0/epic/xs.md\n` + - `${bang} ${sgr("32", "[S]")} Small quest/m0/epic/s.md\n` + - `${bang} ${sgr("34", "[M]")} Medium quest/m0/epic/m.md\n` + - `${bang} ${sgr("35", "[L]")} Large quest/m0/epic/l.md\n` + - `${bang} ${sgr("33", "[XL]")} Huge quest/m0/epic/xl.md\n`, - stderr: "", - }); - }); - - test("NO_COLOR keeps the layout and drops the escape codes", () => { - expect(sizes().runOn(NO_COLOUR, "ready").stdout).toBe( - "5 quests ready\n" + - "! [XS] Tiny quest/m0/epic/xs.md\n" + - "! [S] Small quest/m0/epic/s.md\n" + - "! [M] Medium quest/m0/epic/m.md\n" + - "! [L] Large quest/m0/epic/l.md\n" + - "! [XL] Huge quest/m0/epic/xl.md\n", - ); - }); - - test("says so when nothing is ready", () => { - const tree = Tree.baseline().append( - "quest/m0/epic/one.md", - "\n## Required\n\n- [Two](/quest/m0/epic/two.md)\n", +describe("unblocked ", () => { + test.each([ + ["xs", "90", "[XS]"], + ["s", "32", "[S]"], + ["m", "34", "[M]"], + ["l", "35", "[L]"], + ["xl", "33", "[XL]"], + ])("size %s takes its colour", (name, code, label) => { + expect(sizes().runOn(COLOUR, "unblocked", `quest/m0/epic/${name}.md`).stdout).toMatch( + new RegExp(`^${sgr("33", "!").replace(/\[/g, "\\[")} ${sgr(code, label).replace(/\[/g, "\\[")} `), ); - expect(tree.runOn(NO_COLOUR, "ready").stdout).toBe("No quests ready.\n"); }); test("a title cannot send escape sequences to the terminal", () => { @@ -82,21 +60,13 @@ describe("ready", () => { "quest/m0/epic/one.md", "# [S] One \x1b]0;pwned\x07 \x1b[2J\n\n## Goal\n\nA quest.\n", ); - expect(tree.runOn(NO_COLOUR, "ready").stdout).toBe( - "1 quest ready\n! [S] One \uFFFD]0;pwned\uFFFD \uFFFD[2J quest/m0/epic/one.md\n", + expect(tree.runOn(NO_COLOUR, "unblocked", "quest/m0/epic/one.md").stdout).toBe( + "! [S] One �]0;pwned� �[2J quest/m0/epic/one.md\n Ready\n", ); }); - test("one quest reads in the singular", () => { - expect(Tree.baseline().runOn(NO_COLOUR, "ready").stdout).toBe( - "1 quest ready\n! [S] One quest/m0/epic/one.md\n", - ); - }); -}); - -describe("ready ", () => { test("a ready quest", () => { - expect(Tree.baseline().runOn(COLOUR, "ready", "quest/m0/epic/one.md")).toEqual({ + expect(Tree.baseline().runOn(COLOUR, "unblocked", "quest/m0/epic/one.md")).toEqual({ code: 0, stdout: `${sgr("33", "!")} ${sgr("32", "[S]")} One quest/m0/epic/one.md\n Ready\n`, stderr: "", @@ -104,7 +74,7 @@ describe("ready ", () => { }); test("a blocked quest shows a grey marker and what it requires", () => { - const out = Tree.baseline().runOn(COLOUR, "ready", "/quest/m0/epic/two.md"); + const out = Tree.baseline().runOn(COLOUR, "unblocked", "/quest/m0/epic/two.md"); expect(out.code).toBe(0); expect(out.stdout).toBe( `${sgr("90", "!")} ${sgr("32", "[S]")} Two quest/m0/epic/two.md\n` + @@ -117,7 +87,7 @@ describe("ready ", () => { }); test("a required epic expands into the quests it still holds", () => { - const out = Tree.baseline().runOn(NO_COLOUR, "ready", "quest/m0/README.md"); + const out = Tree.baseline().runOn(NO_COLOUR, "unblocked", "quest/m0/README.md"); expect(out.stdout).toBe( "! M0 quest/m0/README.md\n" + " Required:\n" + @@ -132,14 +102,14 @@ describe("ready ", () => { "quest/m0/epic/one.md", "\n## Claim\n\n- Jane Doe (github:jdoe) on jdoe/quest since 2026-10-02\n", ); - expect(tree.runOn(NO_COLOUR, "ready", "quest/m0/epic/one.md").stdout).toBe( + expect(tree.runOn(NO_COLOUR, "unblocked", "quest/m0/epic/one.md").stdout).toBe( "! [S] One quest/m0/epic/one.md\n Ready\n", ); }); test("an entry that is not a quest prints as written", () => { const tree = Tree.baseline().append("quest/m0/epic/one.md", "\n## Required\n\n- the design review\n"); - expect(tree.runOn(NO_COLOUR, "ready", "quest/m0/epic/one.md").stdout).toBe( + expect(tree.runOn(NO_COLOUR, "unblocked", "quest/m0/epic/one.md").stdout).toBe( "! [S] One quest/m0/epic/one.md\n Required:\n the design review\n", ); }); diff --git a/test/tree.test.ts b/test/tree.test.ts index a0d7195d..2e6b4707 100644 --- a/test/tree.test.ts +++ b/test/tree.test.ts @@ -42,7 +42,7 @@ describe("retired claims", () => { t.rejects("unknown '## Claim'"); expect(t.ready()).toEqual(["quest/m0/epic/one.md"]); expect(t.blockers("quest/m0/epic/one.md")).toEqual([]); - expect(t.run("ready", "quest/m0/epic/one.md")).toEqual({ code: 0, stdout: "", stderr: "" }); + expect(t.run("unblocked", "quest/m0/epic/one.md")).toEqual({ code: 0, stdout: "", stderr: "" }); }, ); @@ -555,7 +555,7 @@ describe("Required", () => { }); }); -// Readiness: what `quest ready` reports about the same fixture. A blocker list +// Readiness: what `quest unblocked ` reports about the same fixture. A blocker list // is the machine-readable result, so the cases that must come back EMPTY carry // as much weight as the ones that must not. describe("ready", () => { From 8e93aef05e58d0a7cd833d19c9a7bf3754753384 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 12:02:12 -0700 Subject: [PATCH 3/7] docs: claim with quest start, hand back drafts, and run CI on ready PRs Replace quest ready with quest unblocked [path] across the guide, skills, README, SETUP, and the kix Worker, and delete the finished quest. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/check.yml | 5 +++ README.md | 70 ++++++++++++++++++++++-------- SETUP.md | 9 ++++ assets/AGENTS.md | 31 ++++++++------ assets/skills/spawn.md | 5 ++- assets/skills/start.md | 7 ++- cloudflare/README.md | 2 +- cloudflare/src/board/model.ts | 2 +- cloudflare/src/home.tsx | 2 +- docs/getting-started.md | 12 +++--- quest/m0/helpers/README.md | 1 - quest/m0/helpers/pr-states.md | 8 +--- quest/m0/helpers/start.md | 81 ----------------------------------- quest/m0/kix/README.md | 2 +- quest/m0/kix/execution.md | 2 +- quest/m0/kix/merge-job.md | 2 +- quest/m0/kix/planning.md | 2 +- src/cli/start.ts | 6 ++- test/start.test.ts | 23 +++++++++- 19 files changed, 128 insertions(+), 144 deletions(-) delete mode 100644 quest/m0/helpers/start.md diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index c0e16df7..d8eeabf2 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -3,7 +3,9 @@ name: Check on: push: branches: [main] + # Drafts are claims with local checks; CI starts once a PR is ready for review. pull_request: + types: [opened, synchronize, reopened, ready_for_review] workflow_dispatch: permissions: @@ -15,6 +17,7 @@ concurrency: jobs: check: + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -35,6 +38,7 @@ jobs: lifecycle: name: lifecycle (${{ matrix.runner }}) + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false strategy: fail-fast: false matrix: @@ -56,6 +60,7 @@ jobs: windows: name: native Windows (${{ matrix.runner }}) + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false strategy: fail-fast: false matrix: diff --git a/README.md b/README.md index 577dd266..397c52ad 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,8 @@ finished plan stays in Git history, beside the code that completed it. Work is taken through a live runner lock or an open change on the forge. kix.quest records each active job in its database and reassigns a job whose runner goes offline. On GitHub, a draft PR -opened from the quest's branch, including from a fork, claims the work. These -states stay out of the plan file. +opened from the quest's branch, including from a fork, claims the work; +`quest start` opens one. These states stay out of the plan file. ## Try it @@ -89,17 +89,16 @@ quest tree. From this checkout: $ quest --root examples/export check The quest log is in order: 4 documents checked. -$ quest --root examples/export ready -1 quest ready -! [S] Add a CSV export endpoint quest/m0/export.md +$ quest --root examples/export unblocked +1 quest/m0/export.md -$ quest --root examples/export ready quest/m0/download.md +$ quest --root examples/export unblocked quest/m0/download.md ! [S] Add a download button quest/m0/download.md Required: [S] Add a CSV export endpoint quest/m0/export.md ``` -With no path, `ready` lists every ready quest. Given a path, it shows what +With no path, `unblocked` ranks every ready quest. Given a path, it shows what blocks that quest. On a terminal, a yellow `!` marks a ready quest and a grey `!` a blocked one, and each size label takes its colour, like loot rarity: XS grey, S green, M blue, L purple, and XL orange (your terminal's yellow). Set @@ -108,19 +107,13 @@ grey, S green, M blue, L purple, and XL orange (your terminal's yellow). Set Scripts and agents read the piped form, which is plain text: ```console -$ quest --root examples/export ready | cat -quest/m0/export.md - -$ quest --root examples/export ready quest/m0/download.md | cat +$ quest --root examples/export unblocked quest/m0/download.md | cat quest/m0/export.md ``` -Piped, `ready` prints one path per line. Given a path, it prints that quest's -blockers, with an explanation on stderr; no blockers means no stdout. -It exits zero for both ready and blocked quests; a nonzero exit means the -command failed. Run `check` first to catch malformed plans. `ready` reads -dependencies in the quest tree; it does not inspect live runner locks, open changes, or GitHub branch -claims. Check those before you start work someone else may already be on. +Piped, a path prints that quest's blockers, with an explanation on stderr; no +blockers means no stdout. It exits zero for both ready and blocked quests; a +nonzero exit means the command failed. ## Prioritize ready work @@ -142,10 +135,49 @@ $ quest --root examples/export unblocked --json --limit 1 Each plain output line contains the count, a tab, and the path. On a terminal, a heading explains the count. `--json` prints an ordered array, including `[]` when no quests are ready; `--limit ` requires a positive integer. -Invalid trees fail with diagnostics and no ranking. This command uses the same -local readiness as `ready`; verify claims and outside conditions before starting. +Invalid trees fail with diagnostics and no ranking. `quest-spawn` uses the ranking unless you explicitly select work in another order. +`--unclaimed` drops quests someone already holds: the quest's branch on the +upstream repository, or an open PR from that branch in any repository, forks +included. It lists each one it skips on stderr with its PR and how long ago +that PR was last active, so you can decide whether a stale claim should be +taken over. In a fork's clone, the upstream is the fork's parent; `--remote` +picks a remote other than `origin`. Off GitHub, only branches are checked. +Neither form inspects kix.quest runner locks or outside conditions. + +## Claim a quest + +`quest start` claims a ready quest so every claim is visible on GitHub: + +```sh +quest start quest/m0/export.md +quest start --release quest/m0/export.md +``` + +It pushes an empty claim commit, built on the upstream default branch, to the +quest's branch (its path without `.md`) without forcing, then opens a draft PR +from it right away and prints the PR's URL. It also creates the local branch +when that name is free. A fork pushes to itself and opens the PR on its parent. +It refuses a blocked quest and one someone else holds; a rejected push means +another claimer got there first, and when two PRs race, the earliest open one +keeps the claim and the later one is closed. Off GitHub, it pushes the branch +without a PR. + +`--release` deletes the branch, with a lease on its tip, and closes its PR, but +only while the tip is still an empty commit. Once work is on the branch, hand +the draft back instead: a `## Decisions needed` section in its body marks it as +waiting on the maintainer. + +`quest start` exits 0 when done, 3 when it refuses (blocked, held, or work on +the branch), 2 for invalid arguments, and 1 for other failures. On GitHub it +uses `GH_TOKEN`, `GITHUB_TOKEN`, or `gh auth token`. + +Draft claims are placeholders, so CI can skip them. This repository runs its +checks only on PRs ready for review: the `pull_request` trigger lists +`ready_for_review`, and each job has the condition +`github.event_name != 'pull_request' || github.event.pull_request.draft == false`. + ## Wait for pull request activity `quest wait` blocks until a GitHub PR has a review, comment, review-thread diff --git a/SETUP.md b/SETUP.md index 8de96000..40282278 100644 --- a/SETUP.md +++ b/SETUP.md @@ -65,6 +65,15 @@ Read the root instructions with the new line in place: - If `quest/README.md` was just created, ask the user what the project is working toward and write that as its Goal. +`quest start` claims each quest with a draft PR that holds only an empty +commit. If the repository's CI runs on pull requests, recommend skipping +drafts, since agents run the checks locally until a PR is ready for review. In +GitHub Actions, add `ready_for_review` to the `pull_request` trigger's `types` +(alongside the defaults `opened`, `synchronize`, and `reopened`) and give each +job the condition +`github.event_name != 'pull_request' || github.event.pull_request.draft == false`. +Change workflows only if the user agrees. + Run `quest check`, then suggest next steps: restart the agent session so it loads the new skills, then `/quest-plan` (`$quest-plan` in Codex) to plan work or `/quest-import` to bring in open GitHub issues. diff --git a/assets/AGENTS.md b/assets/AGENTS.md index c071251a..12200531 100644 --- a/assets/AGENTS.md +++ b/assets/AGENTS.md @@ -98,7 +98,8 @@ Unlike a blocked quest it stays ready, so it resurfaces every time ready work is ## Execution Start only ready quests. -`quest ready []` prints what blocks a quest, or every ready quest. +`quest unblocked` ranks the ready quests, and `quest unblocked ` prints what blocks one. +With `--unclaimed`, it skips quests someone holds and lists them on stderr. Work is taken when it has a live runner lock or an open change on the forge. kix.quest records each active job in its database and reassigns a job whose @@ -106,27 +107,29 @@ runner goes offline. Check those locks and open changes before starting. These states stay out of the quest file. On GitHub, a quest is claimed by an open PR whose head branch is the quest's -branch, from the upstream repository or a fork -(`gh pr list --repo --head `), or by that branch -upstream while its PR is being opened. -Check both against the upstream repository; in a fork's clone, `origin` is -usually the fork. -To claim a quest, push an empty commit to its branch, upstream if you have push -access and otherwise on your fork, then open a draft PR from it right away. +branch, from the upstream repository or a fork, or by that branch upstream +while its PR is being opened. +The earliest open PR holds the claim. +Claim a quest with `quest start `: it checks both, pushes an empty +claim commit to the quest's branch, and opens a draft PR from it right away. That PR becomes the quest's PR. -Then recheck: the earliest open PR holds the claim, and a later one closes and -stops. -If an upstream branch looks stale (old, with no open PR), offer the user to take -it over. +In a fork's clone, it pushes to the fork and opens the PR upstream. +It exits 3 when the quest is blocked or someone else holds it; pick other work. +`quest start --release ` gives back a claim before any work is pushed. +If a claim looks stale, such as a draft without `## Decisions needed` or a +branch with no open PR that has been quiet a long time, offer the user to take +it over, saying how long ago it was last active. A quest's branch is its path without `.md`. For example, `quest/m1/foo/bar.md` is branch `quest/m1/foo/bar`. -Push claims and updates with an explicit refspec: `git push refs/heads/:refs/heads/`. +Push updates with an explicit refspec: `git push refs/heads/:refs/heads/`. A bare branch name can push to the base branch instead, for example under `push.default=upstream` when the quest branch tracks it. +Run the repository's checks locally while the PR is a draft; CI may run only on PRs ready for review. When a quest is complete with no open decisions, mark its PR ready for review. -Otherwise keep it a draft and list its open decisions, or what remains unfinished, in the body. +Otherwise keep it a draft and hand it back: add a `## Decisions needed` section to the body listing its open decisions, or what remains unfinished. +Tools read only whether that heading is present; a draft without it still belongs to its starter. A draft suggests `/quest-iterate`; a PR ready for review suggests `/quest-merge`. Include a summary of the changes made and suggest follow-up quests based on issues encountered. Add a closing keyword for every issue under `Closes`. diff --git a/assets/skills/spawn.md b/assets/skills/spawn.md index cb8f7b62..84d25222 100644 --- a/assets/skills/spawn.md +++ b/assets/skills/spawn.md @@ -6,11 +6,12 @@ Before you begin, run `quest guide` and read its output completely. Your goal is to execute many quests in parallel. The scope consists of all ready quests that are not claimed. -Run `quest unblocked --json` for ready candidates in milestone priority, descending downstream impact, then tree order. +Run `quest unblocked --unclaimed --json` for unclaimed ready candidates in milestone priority, descending downstream impact, then tree order. The count is distinct transitive dependents; other prerequisites may still remain. Use that order when dispatching, and recompute after the quest tree changes. An explicit argument takes precedence: filter to the selected quests/epics and honor their requested order. -Ranking does not establish that work is unclaimed: verify live locks, open changes (including PRs from forks), and remote branch claims before starting. +It lists each skipped claim on stderr with how long ago it was last active; offer the user any that look stale, as the guide describes. +Each agent claims its quest with `quest start`, which refuses one claimed since; a refusal is a skip, not a failure. For a quest waiting on an outside condition, check whether it has cleared before recommending. Start every quest you would recommend starting, without asking: well planned, with no blockers or conflicts with quests already started. diff --git a/assets/skills/start.md b/assets/skills/start.md index 7b9fa00b..74a5b1e9 100644 --- a/assets/skills/start.md +++ b/assets/skills/start.md @@ -9,11 +9,10 @@ The argument is the quest to work on. If you are unsure on the best course of action, ask the user for direction. -Update your checkout, run `quest ready`, then confirm the quest is still ready and unclaimed. -Check the forge for a live runner lock or an open change; on GitHub, check the quest's upstream branch and open PRs from any fork. Confirm you can write in your worktree; if you cannot, stop and report without claiming. -Claim it as `quest guide` describes. -Release work you cannot finish; on GitHub, close its claim PR, delete its branch, and remove your isolated worktree. +Update your checkout and check the forge for a live runner lock, then claim the quest with `quest start `. +If it refuses, the quest is blocked or someone else holds it: report what it printed and stop. +If you cannot pursue a claim before pushing work, release it with `quest start --release ` and remove your isolated worktree; once work is pushed, hand it back as the guide describes. Implement the quest until it is complete, or some blocker is hit, then push to its PR and update the description. Never write to or clean up a directory other agents share, such as a session scratchpad. diff --git a/cloudflare/README.md b/cloudflare/README.md index 5fb87f75..235ff54e 100644 --- a/cloudflare/README.md +++ b/cloudflare/README.md @@ -16,7 +16,7 @@ its epics and quests with their statuses, filtered by status with `?show=available` and the like. Each quest has a page at its path without `.md`, such as `/repos/quest/quest/m0/kix/prompts`: its Markdown rendered with raw HTML escaped, its status, and what to do next. Readiness comes from -`quest/core`, so the board agrees with `quest ready`. Progress counts finished +`quest/core`, so the board agrees with `quest unblocked`. Progress counts finished quests, which merges delete, from Git history (`scripts/history.ts`), weighted by size. A deletion counts only when its commit changed something outside `quest/`, since deleting only plans abandons a quest. diff --git a/cloudflare/src/board/model.ts b/cloudflare/src/board/model.ts index b066fdee..94450e01 100644 --- a/cloudflare/src/board/model.ts +++ b/cloudflare/src/board/model.ts @@ -1,6 +1,6 @@ // The board's reading of a project: milestones, epics, and quests, each with its // status and progress. Readiness comes from the shared core, so the board -// always agrees with `quest ready`. +// always agrees with `quest unblocked`. import { type Doc, ROOT, blockers, children, comparePaths, entries, isEpic, parse, permanent, ready } from "quest/core"; import type { Size } from "../layout"; diff --git a/cloudflare/src/home.tsx b/cloudflare/src/home.tsx index 15e3cfbe..eff3631e 100644 --- a/cloudflare/src/home.tsx +++ b/cloudflare/src/home.tsx @@ -134,7 +134,7 @@ export function Home(props: { setup: string; board: Board }) { Available

- quest ready lists the quests with nothing standing in their way. + quest unblocked lists the quests with nothing standing in their way.

  • diff --git a/docs/getting-started.md b/docs/getting-started.md index 7c17a3d3..c3e62f51 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -45,17 +45,17 @@ From your repository root, validate the result and list the ready quests: ```sh quest check -quest ready +quest unblocked ``` Review the plan before setting out. `/quest-start` claims one quest and opens a pull request; `/quest-spawn` sends several agents out in parallel when your session supports them. Use `$quest-start` and `$quest-spawn` in Codex. -Check the forge for a live runner lock or an open change before starting. -kix.quest records each active job in its database and reassigns a job whose -runner goes offline. On GitHub, check the upstream repository for the quest's -branch and for open PRs from it, including PRs from forks. `quest ready` -reads dependencies in the quest tree; it does not look for these live states. +Both claim through `quest start`, which pushes the quest's branch and opens a +draft PR right away, and refuses a quest whose branch or PR, including one from +a fork, already exists. `quest unblocked --unclaimed` lists the ready quests +nobody holds. kix.quest records each active job in its database and reassigns a +job whose runner goes offline; check it for a live runner lock before starting. You review each change: `/quest-ready` takes a draft to review, and `/quest-merge` (`$quest-merge` in Codex) merges it once checks and reviews pass. diff --git a/quest/m0/helpers/README.md b/quest/m0/helpers/README.md index 1fff2916..80659353 100644 --- a/quest/m0/helpers/README.md +++ b/quest/m0/helpers/README.md @@ -52,7 +52,6 @@ commands from Sep-Oct. Session counts in each child come from that window. - [PR status](/quest/m0/helpers/status.md) - one call for head, checks, merge state, reviews by head, and unresolved threads - [Move and delete quests](/quest/m0/helpers/move.md) - `quest mv` and `quest rm` keep every root-absolute link valid - [CI checks in wait](/quest/m0/helpers/wait-checks.md) - `quest wait` wakes on required check results for the head -- [quest start](/quest/m0/helpers/start.md) - claim a quest with a draft PR, the `## Decisions needed` hand-back, `quest unblocked --unclaimed`, and CI on ready PRs only - [PR state commands](/quest/m0/helpers/pr-states.md) - `quest iterate`, `ready`, `merge`, and `release` label the one owner of a PR - [Unattended triage](/quest/m0/helpers/afk.md) - an `afk` argument that only asks about merges and deletes - [Headless agents](/quest/m0/helpers/headless-agents.md) - `/quest-spawn api` launches, resumes, and stops billed agents through one command auto mode allows diff --git a/quest/m0/helpers/pr-states.md b/quest/m0/helpers/pr-states.md index 92df7638..ba95c11b 100644 --- a/quest/m0/helpers/pr-states.md +++ b/quest/m0/helpers/pr-states.md @@ -11,7 +11,7 @@ way in shows `quest:merge` on GitHub. ## Plan Decided 2026-10-20 (user) from the transcript audit recorded in -[quest start](/quest/m0/helpers/start.md). Overlapping merge sessions in one +[quest start](https://github.com/kixelated/quest/blob/d265755/quest/m0/helpers/start.md). Overlapping merge sessions in one repository drew "another agent was merging, I told them to stop, can you take over anything needed?", and the same-machine guard flagged sessions in other repositories ("those are in another repo?"). @@ -23,7 +23,7 @@ repositories ("those are in another repo?"). - Decided (user): a label per state, with no owner comment: `quest:iterate`, `quest:ready`, and `quest:merge`. At most one is on a PR. Planning PRs get none. A draft that its starter still holds, one with no - `## Decisions needed` section (see [quest start](/quest/m0/helpers/start.md)), + `## Decisions needed` section (the guide's Execution section), counts as owned too, so every command refuses it until the starter hands back; taking it over from a stale starter is the user's choice. Decided 2026-10-21 (user), replacing a `quest:start` label. @@ -69,7 +69,3 @@ repositories ("those are in another repo?"). with a stale token, a refused swap, landing on a moved head, a PR leaving the queue, and a repository whose root instructions override landing. - -## Required - -- [quest start](/quest/m0/helpers/start.md) - the hand-back marker and the freed `ready` name diff --git a/quest/m0/helpers/start.md b/quest/m0/helpers/start.md deleted file mode 100644 index 0985d531..00000000 --- a/quest/m0/helpers/start.md +++ /dev/null @@ -1,81 +0,0 @@ -# [M] Claim a quest with a draft PR through quest start - -## Goal - -`quest start ` claims a ready quest by pushing its empty claim commit -and immediately opening a draft PR, so every claim is visible on GitHub. `quest unblocked --unclaimed` lists ready quests nobody -holds, and `quest unblocked ` takes over printing what blocks one quest -from `quest ready`. Agents stop running `git ls-remote origin -'refs/heads/quest/...'` and `gh pr list --head/--search` by hand before -starting or spawning (105 of 396 sessions), and two sessions never implement -the same quest. - -## Plan - -Decided 2026-10-20 (user) from an audit of Claude Code and Codex transcripts -(566 and 465 sessions, Sep 22-Oct 21). This replaces the planned -`quest claim` quest. Every collision in that audit was two sessions owning -one PR or branch ("Another session force-pushed over my agent", "reverting -each other"); none came from two quests touching the same files. - -- Decided (user): one owner per quest or PR, recorded on GitHub. A kix - lease service with release on disconnect was weighed and dropped as more - machinery than the problem needs; GitHub timestamps already say who came - first and who went quiet. -- Decided (user): the draft PR is the claim. `quest start` pushes an empty - claim commit with the explicit refspec - `refs/heads/:refs/heads/`, never forcing, then opens a - draft PR from it. A rejected push means someone else - holds the branch. Give each claim commit a random nonce, so two claimers on - the same base, identity, and second never build the same commit and both - see success. Distinct exit codes separate "taken" from a failure. -- Contributors without push access open the draft PR from their fork. A - collision is any open PR whose head branch is the quest's branch, from any - repository; the earliest `createdAt` wins and later claimers stop. -- Decided 2026-10-21 (user): there is no `quest:start` label. A draft PR - on a quest branch belongs to its starter, so fork contributors, whose - tokens usually cannot label base-repository PRs, claim the same way as - everyone else. -- Decided (user): a starter that hands back with decisions or unfinished - work left keeps the PR a draft and adds a `## Decisions needed` section to - its body. Otherwise it marks the PR ready. The guide defines that heading - as the hand-back marker; tools read only whether it is present, never its - contents. So: - - a draft without the heading and with no `quest:*` label is still its - starter's; - - a draft with the heading, or a ready PR, with no `quest:*` label is - idle and waits on the maintainer; - - the [PR state commands](/quest/m0/helpers/pr-states.md) label a PR - whenever another session takes it. -- A draft without the heading that looks stale is offered to the user for - takeover, showing how long ago its last push or comment was. The user - judges; there is no fixed timeout. -- `quest start --release ` closes the draft PR and deletes the claim - branch only while its tip is still the empty claim commit, using a lease on - that SHA; otherwise it refuses and says why. -- Decided (user): CI runs on ready PRs only, so placeholder drafts cost - nothing. GitHub supports this with `ready_for_review` in the - `pull_request` types and a `github.event.pull_request.draft == false` job - condition. Recommend that pattern in the guide and `SETUP.md`, and adopt it - in this repository's `.github/workflows/check.yml`. Agents rely on local - checks while in draft. Switching moq-dev/moq and moq-dev/moq.pro is - follow-up work in those repositories. -- Decided (user): free the `ready` name for the PR state by moving - `quest ready [path]` into `quest unblocked [path]`, which prints what - blocks one quest. This breaks the v0.1.0 `quest ready`; update every use in - the guide, skills, README, `SETUP.md`, and the kix Worker in the same PR. -- `quest unblocked --unclaimed` uses one `ls-remote` for `refs/heads/quest/*` - and one query for open PRs, and prints skipped claims on stderr with their - age and PR, so stale claims can be offered for takeover. -- The branch check is plain git. Only the PR steps are GitHub-specific; with - a non-GitHub origin, open no PR and warn. Default the remote to origin, - with `--remote` to override. kix.quest jobs claim the same way through its - bot, so they count as claims here too. -- Update the guide's Execution section ("When a quest is complete, create a - draft PR" becomes "open it when claiming", plus the hand-back rule), and the `start` and `spawn` - skill prose to use these commands. The user asked for those edits through - this plan. -- Test claiming free and taken quests, a race between two claimers, a fork - PR that claims first, releasing a claim, refusing to release a branch with - work on it, a hand-back with and without `## Decisions needed`, and the - filtered ranking. diff --git a/quest/m0/kix/README.md b/quest/m0/kix/README.md index 529aa45f..b2d783d9 100644 --- a/quest/m0/kix/README.md +++ b/quest/m0/kix/README.md @@ -84,7 +84,7 @@ operator-paid-hosting decisions: quest branches and PRs, which count as claims and get the usual PR decision items. There is no plugin or session mirroring. - **One owner per quest and PR** (decided 2026-10-20 from a transcript - audit, see [quest start](/quest/m0/helpers/start.md)). Claims are draft + audit, see [quest start](https://github.com/kixelated/quest/blob/d265755/quest/m0/helpers/start.md)). Claims are draft PRs. A PR is owned while it is a draft without a `## Decisions needed` section or carries one `quest:*` state label, shared with local sessions. There is no repository-wide merge agent and no user-facing parallelism limit; runner slots and account quota cap it. diff --git a/quest/m0/kix/execution.md b/quest/m0/kix/execution.md index 993ffa66..e583a57b 100644 --- a/quest/m0/kix/execution.md +++ b/quest/m0/kix/execution.md @@ -10,7 +10,7 @@ requested iteration, in the same decision UI. ## Plan Decided 2026-10-20 (user) from the transcript audit recorded in -[quest start](/quest/m0/helpers/start.md). Locally the user runs +[quest start](https://github.com/kixelated/quest/blob/d265755/quest/m0/helpers/start.md). Locally the user runs `/quest-spawn next 16` or `next 32` twice a day per repository, only ever asks for more parallelism, and accepts the recommended option 73% of the time: diff --git a/quest/m0/kix/merge-job.md b/quest/m0/kix/merge-job.md index e6a8f85a..957d6af9 100644 --- a/quest/m0/kix/merge-job.md +++ b/quest/m0/kix/merge-job.md @@ -12,7 +12,7 @@ PR. ## Plan Decided 2026-10-20 (user) from the transcript audit recorded in -[quest start](/quest/m0/helpers/start.md). Locally, a long-lived +[quest start](https://github.com/kixelated/quest/blob/d265755/quest/m0/helpers/start.md). Locally, a long-lived `/quest-complete` session per repository (median about 31 PR merges in moq) sends sub-agents to fix PRs, and the user's pick covers a PR until it lands or needs them. diff --git a/quest/m0/kix/planning.md b/quest/m0/kix/planning.md index 3d0e8956..55f2d44b 100644 --- a/quest/m0/kix/planning.md +++ b/quest/m0/kix/planning.md @@ -10,7 +10,7 @@ interview runs on one of their runners; they never see a terminal. ## Plan Decided 2026-10-20 (user) from the transcript audit recorded in -[quest start](/quest/m0/helpers/start.md), where `/quest-plan` arguments +[quest start](https://github.com/kixelated/quest/blob/d265755/quest/m0/helpers/start.md), where `/quest-plan` arguments were mostly free-form ideas, pasted outside input (emails, call notes, findings), and "follow-ups": diff --git a/src/cli/start.ts b/src/cli/start.ts index deea0cb8..1ec65a93 100644 --- a/src/cli/start.ts +++ b/src/cli/start.ts @@ -44,7 +44,8 @@ export async function startQuest( // Build the claim on the upstream default branch, which must still have the quest. const base = remoteHead(root, up.git); - if (!succeeds(root, ["cat-file", "-e", `${base}:${path}`])) { + const prefix = gitOutput(root, ["rev-parse", "--show-prefix"]); + if (!succeeds(root, ["cat-file", "-e", `${base}:${prefix}${path}`])) { throw new Refusal(`${path} is not on the upstream default branch; update your checkout`); } // A nonce keeps two claimers on the same base, identity, and second from building the same commit. @@ -170,9 +171,10 @@ export async function releaseQuest( return { branch, closed: pulls.map((pull) => pull.url) }; } -/** How long ago `time` was, roughly: minutes, hours, then days. */ +/** How long ago `time` was, roughly: just now, minutes, hours, then days. */ export function ago(now: number, time: number): string { const minutes = Math.max(0, Math.round((now - time) / 60_000)); + if (minutes === 0) return "just now"; const [count, unit] = minutes < 60 ? [minutes, "minute"] diff --git a/test/start.test.ts b/test/start.test.ts index a707dfcd..4dd2523e 100644 --- a/test/start.test.ts +++ b/test/start.test.ts @@ -3,7 +3,7 @@ // github.com remote name reach a local bare repository. import { execFileSync } from "node:child_process"; -import { appendFileSync, writeFileSync } from "node:fs"; +import { appendFileSync, cpSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; @@ -200,7 +200,7 @@ describe("start on GitHub", () => { const pull = github.open("example/project", BRANCH, "someone/project"); const start = () => startQuest(work, ONE, "[S] One", "origin", deps(github)); - await expect(start()).rejects.toThrow(`${ONE} is held by ${pull.html_url} (draft, last active 0 minutes ago)`); + await expect(start()).rejects.toThrow(`${ONE} is held by ${pull.html_url} (draft, last active just now)`); pull.body = "Work so far.\n\n## Decisions needed\n\n1. Which name?\n"; await expect(start()).rejects.toThrow(`held by ${pull.html_url} (draft, decisions needed,`); pull.draft = false; @@ -349,6 +349,24 @@ describe("start on another forge", () => { expect(second.stderr).toContain(`quest: ${ONE} is held by branch ${BRANCH}, with no open PR\n`); }); + test("claims a quest whose tree is below the repository root", async () => { + const bare = tempDir(); + git(bare, "init", "-q", "--bare", "-b", "main"); + const work = tempDir(); + setup(work); + mkdirSync(join(work, "sub")); + cpSync(join(Tree.baseline().path, "quest"), join(work, "sub", "quest"), { recursive: true }); + git(work, "add", "."); + git(work, "commit", "-q", "-m", "seed"); + git(work, "remote", "add", "origin", bare); + git(work, "push", "-q", "origin", "HEAD:refs/heads/main"); + + const out = await cli("--root", join(work, "sub"), "start", ONE); + expect(out.code, out.stderr).toBe(0); + expect(out.stdout).toBe(`${BRANCH}\n`); + expect(tip(bare)).not.toBe(""); + }); + test("refuses a blocked quest with exit 3", async () => { const { bare, work } = plain(); const out = await cli("--root", work, "start", "quest/m0/epic/two.md"); @@ -404,6 +422,7 @@ test("claims reports the earliest open PR on each branch", async () => { test("ago rounds to minutes, hours, then days", () => { const now = Date.parse("2026-10-21T12:00:00Z"); + expect(ago(now, now - 10_000)).toBe("just now"); expect(ago(now, now - 60_000)).toBe("1 minute ago"); expect(ago(now, now - 3 * 3_600_000)).toBe("3 hours ago"); expect(ago(now, now - 3 * 86_400_000)).toBe("3 days ago"); From 878e919de9a47aa9513f3423f4643cee4b3e4448 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 12:11:24 -0700 Subject: [PATCH 4/7] fix(start): switch to the claim, check the tree first, and keep the fork's URL scheme Co-Authored-By: Claude Opus 5.5 --- README.md | 8 ++-- src/cli/claims.ts | 6 ++- src/cli/main.ts | 23 ++++++++---- src/cli/start.ts | 31 ++++++++++++++-- test/start.test.ts | 92 +++++++++++++++++++++++++++++++++++++++------- 5 files changed, 130 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 397c52ad..107a5604 100644 --- a/README.md +++ b/README.md @@ -157,9 +157,11 @@ quest start --release quest/m0/export.md It pushes an empty claim commit, built on the upstream default branch, to the quest's branch (its path without `.md`) without forcing, then opens a draft PR -from it right away and prints the PR's URL. It also creates the local branch -when that name is free. A fork pushes to itself and opens the PR on its parent. -It refuses a blocked quest and one someone else holds; a rejected push means +from it right away and prints the PR's URL. It switches your checkout to the +claim on a new local branch, or fast-forwards that branch if it is already +checked out. A fork pushes to itself and opens the PR on its parent. It fails +on a tree `quest check` rejects, which can hide a blocker, and refuses a +blocked quest and one someone else holds; a rejected push means another claimer got there first, and when two PRs race, the earliest open one keeps the claim and the later one is closed. Off GitHub, it pushes the branch without a PR. diff --git a/src/cli/claims.ts b/src/cli/claims.ts index c89b37ca..635f2a7e 100644 --- a/src/cli/claims.ts +++ b/src/cli/claims.ts @@ -76,7 +76,8 @@ export async function upstream(root: string, remote: string, deps: ClaimDependen // The configured URL first, so a URL rewrite (`insteadOf`) still names the GitHub repository. const configured = gitOutput(root, ["config", "--get", `remote.${remote}.url`]); const url = remoteUrl(root, remote); - const named = githubRepository(configured) ?? githubRepository(url); + const source = githubRepository(configured) ? configured : url; + const named = githubRepository(source); if (named === null) return { git: remote, github: null }; const client = new GitHub(deps.github()); @@ -85,7 +86,8 @@ export async function upstream(root: string, remote: string, deps: ClaimDependen if (info.fork === true && info.parent !== undefined) { const parent = object(info.parent); return { - git: string(/^https:/.test(configured || url) ? parent.clone_url : parent.ssh_url), + // Reach the parent the way the remote is reached, so the same credentials work. + git: string(/^https:/.test(source) ? parent.clone_url : parent.ssh_url), github: { client, repository: { owner: string(object(parent.owner).login), repo: string(parent.name) }, diff --git a/src/cli/main.ts b/src/cli/main.ts index f02d5f5b..72ea9ad1 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -87,6 +87,14 @@ export function main(argv: string[], out: Output): number | Promise { return doc; }; + /** Whether the tree passes `quest check`; otherwise report its findings and fail. */ + const valid = (docs: Doc[]): boolean => { + const findings = check(docs, (path) => exists(root(), path)); + for (const finding of findings) warn(formatFinding(finding)); + if (findings.length > 0) code = 1; + return findings.length === 0; + }; + /** Print what blocks `doc`: the themed log on a terminal, else the blocker list with advice on stderr. */ const explain = (docs: Doc[], doc: Doc, path: string) => { const found = blockers(docs, doc); @@ -159,12 +167,7 @@ export function main(argv: string[], out: Output): number | Promise { explain(docs, find(docs, path), path); return; } - const findings = check(docs, (path) => exists(root(), path)); - if (findings.length > 0) { - for (const finding of findings) warn(formatFinding(finding)); - code = 1; - return; - } + if (!valid(docs)) return; const ranked = unblocked(docs); const show = (quests: RankedQuest[]) => { const shown = quests.slice(0, options.limit); @@ -198,8 +201,10 @@ export function main(argv: string[], out: Output): number | Promise { .summary("Claim a ready quest with a draft PR, or release that claim") .description( "Claim a ready quest: push an empty claim commit, built on the upstream default branch, to the quest's " + - "branch (its path without .md) without forcing, then open a draft PR from it right away. Creates the " + - "local branch when that name is free, and prints the PR URL, or the branch off GitHub.\n\n" + + "branch (its path without .md) without forcing, then open a draft PR from it right away. Switches the " + + "checkout to the claim on a new local branch, or fast-forwards that branch when it is checked out, " + + "and prints the PR URL, or the branch off GitHub.\n\n" + + "Fails on a tree quest check rejects, which can hide a blocker. " + "Refuses a blocked quest, one missing from the upstream default branch, and one already held: a " + "branch upstream or an open PR from that branch in any repository. A rejected push means someone " + "else pushed the branch first. When two PRs race, as from forks, the earliest open PR keeps the " + @@ -225,6 +230,8 @@ export function main(argv: string[], out: Output): number | Promise { }); return; } + // A malformed tree can hide a blocker, as a misspelled `## Required` does. + if (!valid(docs)) return; if (blockers(docs, doc).length > 0) { explain(docs, doc, path); code = 3; diff --git a/src/cli/start.ts b/src/cli/start.ts index 1ec65a93..54f6f839 100644 --- a/src/cli/start.ts +++ b/src/cli/start.ts @@ -69,13 +69,36 @@ export async function startQuest( url = await openDraft(root, up, remote, path, title, branch, sha); } - // A local branch to build on, unless one already exists. - if (!succeeds(root, ["update-ref", `refs/heads/${branch}`, sha, ""])) { - deps.warn(`local branch ${branch} already exists; the claim commit is ${sha}`); - } + checkout(root, branch, sha, deps.warn); return { branch, sha, url }; } +/** + * Put the checkout on the claim, so work lands on the quest's branch. The claim + * changes no files, so this carries uncommitted changes along. Whatever cannot + * be done safely is left as a warning saying what to run. + */ +function checkout(root: string, branch: string, sha: string, warn: (message: string) => void): void { + const ref = `refs/heads/${branch}`; + let current = ""; + try { + current = gitOutput(root, ["symbolic-ref", "-q", "HEAD"]); + } catch { + // Detached HEAD. + } + if (current === ref) { + // Already on the quest's branch, as after `git worktree add -b`: catch it up to the claim. + if (!succeeds(root, ["merge", "--ff-only", "--quiet", sha])) { + warn(`${branch} has diverged from the claim commit; merge ${sha} into it before pushing`); + } + } else if (succeeds(root, ["rev-parse", "--verify", "--quiet", ref])) { + warn(`local branch ${branch} already exists; merge the claim commit ${sha} into it before pushing`); + } else if (!succeeds(root, ["switch", "--quiet", "-c", branch, sha])) { + gitOutput(root, ["update-ref", ref, sha, ""]); + warn(`created ${branch} but could not switch to it; run git switch ${branch}`); + } +} + /** Open the draft PR, then keep it only if it is the earliest open PR on the branch. */ async function openDraft( root: string, diff --git a/test/start.test.ts b/test/start.test.ts index 4dd2523e..3516dcda 100644 --- a/test/start.test.ts +++ b/test/start.test.ts @@ -12,7 +12,7 @@ import { claims } from "../src/cli/claims"; import { type GitHubDependencies, type ObjectValue } from "../src/cli/github"; import { type Output, main } from "../src/cli/main"; import { Refusal, ago, releaseQuest, startQuest } from "../src/cli/start"; -import { Tree, tempDir } from "./fixture"; +import { TWO, Tree, tempDir } from "./fixture"; const ONE = "quest/m0/epic/one.md"; const BRANCH = "quest/m0/epic/one"; @@ -35,19 +35,28 @@ function upstream(): string { return bare; } -/** Identity and no signing, so commits work under any user configuration. */ +/** A new repository on main. */ function setup(dir: string): void { git(dir, "init", "-q", "-b", "main"); + identity(dir); +} + +/** Identity and no signing, so commits work under any user configuration. */ +function identity(dir: string): void { git(dir, "config", "user.name", "Test"); git(dir, "config", "user.email", "test@example.com"); git(dir, "config", "commit.gpgsign", "false"); } -/** A clone whose origin is `url`, rewritten to the bare repositories in `routes`. */ -function clone(url: string, routes: Record): string { - const dir = Tree.baseline().path; - setup(dir); - git(dir, "remote", "add", "origin", url); +/** + * A clone of the upstream bare repository whose origin is `url`, rewritten to + * the bare repositories in `routes`. Without routes, origin is the bare path. + */ +function clone(url: string, routes: Record = {}): string { + const dir = tempDir(); + git(dir, "clone", "-q", routes[UPSTREAM] ?? url, "."); + identity(dir); + git(dir, "remote", "set-url", "origin", url); for (const [from, to] of Object.entries(routes)) git(dir, "config", `url.${to}.insteadOf`, from); return dir; } @@ -177,13 +186,64 @@ describe("start on GitHub", () => { expect(git(bare, "log", "-1", "--format=%B", started.sha)).toMatch( /^chore\(quest\): claim quest\/m0\/epic\/one\.md\n\nQuest-Claim: [0-9a-f]{16}$/, ); - expect(git(work, "rev-parse", `refs/heads/${BRANCH}`)).toBe(started.sha); + expect(git(work, "symbolic-ref", "HEAD")).toBe(`refs/heads/${BRANCH}`); + expect(git(work, "rev-parse", "HEAD")).toBe(started.sha); expect(github.pulls).toMatchObject([ { base: "example/project", head: { ref: BRANCH, repo: { full_name: "example/project" } }, draft: true }, ]); expect(github.pulls[0].body).toContain(ONE); }); + test("fast-forwards the quest branch a worktree already has checked out", async () => { + const bare = upstream(); + const work = clone(UPSTREAM, { [UPSTREAM]: bare }); + git(work, "switch", "-q", "-c", BRANCH); + const warnings: string[] = []; + const started = await startQuest(work, ONE, "[S] One", "origin", deps(new FakeGitHub(), warnings)); + expect(warnings).toEqual([]); + expect(git(work, "rev-parse", "HEAD")).toBe(started.sha); + }); + + test("warns rather than moving a diverged local branch", async () => { + const bare = upstream(); + const work = clone(UPSTREAM, { [UPSTREAM]: bare }); + git(work, "branch", BRANCH); + const warnings: string[] = []; + const started = await startQuest(work, ONE, "[S] One", "origin", deps(new FakeGitHub(), warnings)); + expect(warnings).toEqual([ + `local branch ${BRANCH} already exists; merge the claim commit ${started.sha} into it before pushing`, + ]); + expect(git(work, "symbolic-ref", "HEAD")).toBe("refs/heads/main"); + }); + + test("reaches a fork's parent with the scheme a URL alias resolves to", async () => { + const bare = upstream(); + const forkBare = tempDir(); + git(forkBare, "init", "-q", "--bare", "-b", "main"); + const github = new FakeGitHub(); + github.repo("me/project", { + fork: true, + default_branch: "main", + parent: { + owner: { login: "example" }, + name: "project", + clone_url: UPSTREAM, + ssh_url: "git@github.com:example/project.git", + default_branch: "main", + }, + }); + // `corp:me/project` expands to the fork's HTTPS URL, so the parent is read over HTTPS too. + // The SSH form leads nowhere, so choosing it fails fast; pushes go to the fork's bare repository. + const work = clone("corp:me/project.git", { + [UPSTREAM]: bare, + "git@github.com:example/project.git": join(tempDir(), "missing"), + }); + git(work, "config", "url.https://github.com/.insteadOf", "corp:"); + git(work, "config", "remote.origin.pushurl", forkBare); + const started = await startQuest(work, ONE, "[S] One", "origin", deps(github)); + expect(tip(forkBare)).toBe(started.sha); + }); + test("refuses a quest whose branch is already upstream", async () => { const bare = upstream(); git(bare, "branch", BRANCH, "main"); @@ -309,7 +369,6 @@ describe("start on GitHub", () => { const github = new FakeGitHub(); const work = clone(UPSTREAM, { [UPSTREAM]: bare }); await startQuest(work, ONE, "[S] One", "origin", deps(github)); - git(work, "checkout", "-q", "-f", BRANCH); appendFileSync(join(work, ONE), "\nWork in progress.\n"); git(work, "commit", "-q", "-m", "work", "--", ONE); git(work, "push", "-q", "origin", `refs/heads/${BRANCH}:refs/heads/${BRANCH}`); @@ -328,10 +387,7 @@ describe("start on another forge", () => { /** A clone whose origin is a plain bare repository path. */ function plain(): { bare: string; work: string } { const bare = upstream(); - const work = Tree.baseline().path; - setup(work); - git(work, "remote", "add", "origin", bare); - return { bare, work }; + return { bare, work: clone(bare) }; } test("pushes the claim branch without a PR, then refuses a second claim", async () => { @@ -367,6 +423,16 @@ describe("start on another forge", () => { expect(tip(bare)).not.toBe(""); }); + test("fails on a malformed tree before touching the remote", async () => { + const { bare, work } = plain(); + // A misspelled `## Required` hides the blocker from the readiness check. + writeFileSync(join(work, "quest/m0/epic/two.md"), TWO.replace("## Required", "## Requires")); + const out = await cli("--root", work, "start", "quest/m0/epic/two.md"); + expect(out.code).toBe(1); + expect(out.stderr).toContain("unknown '## Requires'"); + expect(tip(bare, "quest/m0/epic/two")).toBe(""); + }); + test("refuses a blocked quest with exit 3", async () => { const { bare, work } = plain(); const out = await cli("--root", work, "start", "quest/m0/epic/two.md"); From 148637b344a123f2855a392da8d8cfa0a97c0480 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 12:15:48 -0700 Subject: [PATCH 5/7] test(start): copy seeded repositories instead of rebuilding them per test Windows ARM runners spend seconds spawning git; the first test hit vitest's 5 s default. Co-Authored-By: Claude Opus 5.5 --- test/start.test.ts | 68 ++++++++++++++++++++++++++++++---------------- 1 file changed, 44 insertions(+), 24 deletions(-) diff --git a/test/start.test.ts b/test/start.test.ts index 3516dcda..8828d562 100644 --- a/test/start.test.ts +++ b/test/start.test.ts @@ -3,10 +3,11 @@ // github.com remote name reach a local bare repository. import { execFileSync } from "node:child_process"; -import { appendFileSync, cpSync, mkdirSync, writeFileSync } from "node:fs"; +import { appendFileSync, cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; -import { describe, expect, test } from "vitest"; +import { afterAll, describe, expect, test, vi } from "vitest"; import { claims } from "../src/cli/claims"; import { type GitHubDependencies, type ObjectValue } from "../src/cli/github"; @@ -19,45 +20,64 @@ const BRANCH = "quest/m0/epic/one"; const UPSTREAM = "https://github.com/example/project.git"; const FORK = "https://github.com/me/project.git"; +// Each test spawns git a few dozen times, which takes seconds on Windows runners. +vi.setConfig({ testTimeout: 30_000 }); + function git(cwd: string, ...args: string[]): string { return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); } -/** A bare repository whose default branch holds the baseline tree. */ -function upstream(): string { - const bare = tempDir(); - git(bare, "init", "-q", "--bare", "-b", "main"); - const seed = Tree.baseline().path; - setup(seed); - git(seed, "add", "."); - git(seed, "commit", "-q", "-m", "seed"); - git(seed, "push", "-q", bare, "HEAD:refs/heads/main"); - return bare; -} +/** Identity and no signing, so commits work under any user configuration. */ +const IDENTITY = "[user]\n\tname = Test\n\temail = test@example.com\n[commit]\n\tgpgsign = false\n"; /** A new repository on main. */ function setup(dir: string): void { git(dir, "init", "-q", "-b", "main"); - identity(dir); + appendFileSync(join(dir, ".git", "config"), IDENTITY); } -/** Identity and no signing, so commits work under any user configuration. */ -function identity(dir: string): void { - git(dir, "config", "user.name", "Test"); - git(dir, "config", "user.email", "test@example.com"); - git(dir, "config", "commit.gpgsign", "false"); +/** + * Built once and copied per test, since spawning git dominates these tests: a + * bare repository whose main holds the baseline tree, and a clone of it. + */ +const templates = (() => { + let built: { bare: string; clone: string } | undefined; + const root = mkdtempSync(join(tmpdir(), "quest-start-")); + afterAll(() => rmSync(root, { recursive: true, force: true })); + return () => { + if (built) return built; + const bare = join(root, "bare"); + const clone = join(root, "clone"); + cpSync(Tree.baseline().path, clone, { recursive: true }); + setup(clone); + git(clone, "add", "."); + git(clone, "commit", "-q", "-m", "seed"); + git(root, "clone", "-q", "--bare", clone, bare); + git(clone, "remote", "add", "origin", bare); + git(clone, "fetch", "-q", "origin"); + git(clone, "branch", "-q", "--set-upstream-to", "origin/main"); + built = { bare, clone }; + return built; + }; +})(); + +/** A bare repository whose default branch holds the baseline tree. */ +function upstream(): string { + const bare = tempDir(); + cpSync(templates().bare, bare, { recursive: true }); + return bare; } /** - * A clone of the upstream bare repository whose origin is `url`, rewritten to - * the bare repositories in `routes`. Without routes, origin is the bare path. + * A clone of the baseline whose origin is `url`, rewritten to the bare + * repositories in `routes`. Without routes, origin is a bare path. */ function clone(url: string, routes: Record = {}): string { const dir = tempDir(); - git(dir, "clone", "-q", routes[UPSTREAM] ?? url, "."); - identity(dir); + cpSync(templates().clone, dir, { recursive: true }); git(dir, "remote", "set-url", "origin", url); - for (const [from, to] of Object.entries(routes)) git(dir, "config", `url.${to}.insteadOf`, from); + const rewrites = Object.entries(routes).map(([from, to]) => `[url "${to}"]\n\tinsteadOf = ${from}\n`); + appendFileSync(join(dir, ".git", "config"), rewrites.join("")); return dir; } From d9a47c9f8ce66edc9b658c5de01e0e37fe12b48e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 12:19:02 -0700 Subject: [PATCH 6/7] test(start): write URL rewrites with git config so Windows paths are escaped Co-Authored-By: Claude Opus 5.5 --- test/start.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/start.test.ts b/test/start.test.ts index 8828d562..08f17765 100644 --- a/test/start.test.ts +++ b/test/start.test.ts @@ -76,8 +76,8 @@ function clone(url: string, routes: Record = {}): string { const dir = tempDir(); cpSync(templates().clone, dir, { recursive: true }); git(dir, "remote", "set-url", "origin", url); - const rewrites = Object.entries(routes).map(([from, to]) => `[url "${to}"]\n\tinsteadOf = ${from}\n`); - appendFileSync(join(dir, ".git", "config"), rewrites.join("")); + // git config escapes Windows paths, which a hand-written section would not. + for (const [from, to] of Object.entries(routes)) git(dir, "config", `url.${to}.insteadOf`, from); return dir; } From afa357c791f008e64308ee5a60f6534899dbf4ff Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 13:16:45 -0700 Subject: [PATCH 7/7] fix(start): link the quest by its repository path below the top level Co-Authored-By: Claude Opus 5.5 --- src/cli/start.ts | 4 +++- test/start.test.ts | 20 +++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/cli/start.ts b/src/cli/start.ts index 4a083668..9721d449 100644 --- a/src/cli/start.ts +++ b/src/cli/start.ts @@ -113,6 +113,8 @@ async function openDraft( const github = up.github!; const { owner, repo } = github.repository; const fork = github.head.owner !== owner || github.head.repo !== repo; + // The quest's path in the repository, which differs from `path` when the tree sits below the top level. + const file = encodeURI(gitOutput(root, ["rev-parse", "--show-prefix"]) + path); let created: OpenPull; try { const response = await github.client.request(`${repositoryPath(github.repository)}/pulls`, { @@ -121,7 +123,7 @@ async function openDraft( head: fork ? `${github.head.owner}:${branch}` : branch, base: github.defaultBranch, draft: true, - body: `Claims [${path}](https://github.com/${owner}/${repo}/blob/${github.defaultBranch}/${path}).`, + body: `Claims [${path}](https://github.com/${owner}/${repo}/blob/${github.defaultBranch}/${file}).`, }, }); const row = object(await response.json()); diff --git a/test/start.test.ts b/test/start.test.ts index 4df38782..d09bafbb 100644 --- a/test/start.test.ts +++ b/test/start.test.ts @@ -211,7 +211,25 @@ describe("start on GitHub", () => { expect(github.pulls).toMatchObject([ { base: "example/project", head: { ref: BRANCH, repo: { full_name: "example/project" } }, draft: true }, ]); - expect(github.pulls[0].body).toContain(ONE); + expect(github.pulls[0].body).toBe(`Claims [${ONE}](https://github.com/example/project/blob/main/${ONE}).`); + }); + + test("links the quest by its repository path when the tree is below the top level", async () => { + const bare = tempDir(); + git(bare, "init", "-q", "--bare", "-b", "main"); + const work = tempDir(); + setup(work); + mkdirSync(join(work, "sub")); + cpSync(join(Tree.baseline().path, "quest"), join(work, "sub", "quest"), { recursive: true }); + git(work, "add", "."); + git(work, "commit", "-q", "-m", "seed"); + git(work, "remote", "add", "origin", UPSTREAM); + git(work, "config", `url.${bare}.insteadOf`, UPSTREAM); + git(work, "push", "-q", "origin", "HEAD:refs/heads/main"); + const github = new FakeGitHub(); + + await startQuest(join(work, "sub"), ONE, "[S] One", "origin", deps(github)); + expect(github.pulls[0].body).toBe(`Claims [${ONE}](https://github.com/example/project/blob/main/sub/${ONE}).`); }); test("fast-forwards the quest branch a worktree already has checked out", async () => {