Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 17 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,13 +139,18 @@ when no quests are ready; `--limit <count>` requires a positive integer.
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
`--unclaimed` ranks the fetched upstream default branch's tree rather than
your checkout, so a stale checkout cannot offer a finished or deleted quest.
It then 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, and the
fork's own branches and PRs count too; `--remote` picks a remote other than
`origin`. Off GitHub, only branches are checked.
included. A branch whose tip is the head of a merged PR holds nothing, since
its work landed; a closed, unmerged PR's branch still holds its quest. 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, and the fork's own
branches and PRs count too; a remote that fetches from upstream and pushes to
a fork works the same way. `--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
Expand All @@ -158,14 +163,16 @@ 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
quest's branch (its path without `.md`), then opens a draft PR
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
checked out. A fork pushes to itself and opens the PR on its parent. It reads
readiness from the upstream default branch it builds on, not your checkout. 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
keeps the claim and the later one is closed. A branch left at a merged PR's
head is replaced, with a lease on that tip. Off GitHub, it pushes the branch
without a PR.

`--release` deletes the branch, with a lease on its tip, and closes its PR, but
Expand Down
1 change: 0 additions & 1 deletion quest/m0/helpers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ commands from Sep-Oct. Session counts in each child come from that window.
- [PR state commands](/quest/m0/helpers/pr-states.md) - `quest iterate`, `ready`, `merge`, and `release` label the one owner of a PR
- [Complete loop](/quest/m0/helpers/complete-loop.md) - `/quest-complete` re-scans PRs, keeps picks and holds, and counts rounds per PR
- [Headless agents](/quest/m0/helpers/headless-agents.md) - `/quest-spawn api` launches, resumes, and stops billed agents through one command auto mode allows
- [Upstream claims](/quest/m0/helpers/claims.md) - claims and readiness read from the upstream tree, ignoring merged branches
- [Wait gaps](/quest/m0/helpers/wait-gaps.md) - `quest wait` reports every event after its starting point exactly once
- [Guard in CI](/quest/m0/helpers/ci-guard.md) - pull request CI runs the landed-quest guard

Expand Down
32 changes: 0 additions & 32 deletions quest/m0/helpers/claims.md

This file was deleted.

167 changes: 139 additions & 28 deletions src/cli/claims.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
// 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.
// Who holds a quest: its claim branch upstream, unless that branch is a merged
// PR's head, 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, judging readiness by the upstream tree.
import { execFileSync } from "node:child_process";

import {
type GitHubDependencies,
type ObjectValue,
type Repository,
GitHub,
githubRepository,
object,
remoteRepository,
remoteUrl,
string,
} from "./github";
import { type Snapshot, snapshot } from "./tree";

/** The heading that hands a draft back to the maintainer; only its presence matters. */
const HAND_BACK = /^#{2}[ \t]+Decisions needed[ \t]*#*[ \t]*$/im;
Expand Down Expand Up @@ -70,40 +73,60 @@ export interface Upstream {
/**
* 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.
* its own upstream with no GitHub side. The head repository is where the
* remote pushes, which may be a fork even when it fetches from upstream.
*/
export async function upstream(root: string, remote: string, deps: ClaimDependencies): Promise<Upstream> {
// The configured URL first, so a URL rewrite (`insteadOf`) still names the GitHub repository.
const configured = gitOutput(root, ["config", "--get", `remote.${remote}.url`]);
const source = githubRepository(configured) ? configured : remoteUrl(root, remote);
const named = githubRepository(source);
const source = remoteRepository(root, remote);
const push = gitOutput(root, ["remote", "get-url", "--push", remote]);
if (named === null) return { git: remote, push, github: null };
if (source.repository === null) return { git: remote, push, 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 && info.parent !== null) {
const parent = object(info.parent);
const info = await repositoryInfo(client, source.repository);
const self = info.repository;
// Push somewhere other than the fetched repository, such as a fork, and that is the head; a push
// URL off GitHub can open no PR, so it falls back to the fetched repository.
const pushed = remoteRepository(root, remote, true).repository;
const head = pushed === null || same(pushed, self) ? self : (await repositoryInfo(client, pushed)).repository;
Comment on lines +89 to +90

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat a non-GitHub push target as PR-less

When a remote fetches from GitHub but has an explicit push URL on GitLab, a local server, or another non-GitHub forge, this fallback incorrectly identifies the fetched GitHub repository as the head repository. Consequently held() never checks branches at the actual push target, and quest start pushes there before trying to open a GitHub PR whose head branch does not exist on GitHub; the PR request fails and cleanup deletes the just-pushed branch. Preserve the GitHub repository for upstream-tree reads, but represent the non-GitHub push target separately so it is checked as a branch-only source and no PR is attempted.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this as is for now. Before this PR, the head was always the fetched repository whatever the push URL said, so the fallback keeps the old behavior for that setup instead of adding a third mode. It also fails loudly: GitHub refuses to open the PR, the just-pushed claim branch is deleted with its lease, and quest start exits 1. A GitHub fetch URL with a non-GitHub push URL (a mirror, say) is rare, and making it a branch-only claim source would need its own design, for example whether unblocked --unclaimed should check branches on two forges. I listed it as a suggested follow-up in the report.

(Written by Claude Opus 5.5)

if (info.parent !== null) {
const parent = info.parent;
return {
// 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),
git: string(/^https:/.test(source.url) ? parent.clone_url : parent.ssh_url),
push,
github: {
client,
repository: { owner: string(object(parent.owner).login), repo: string(parent.name) },
head: self,
head,
defaultBranch: string(parent.default_branch),
},
};
}
return {
git: remote,
push,
github: { client, repository: self, head: self, defaultBranch: string(info.default_branch) },
github: { client, repository: self, head, defaultBranch: info.defaultBranch },
};
}

/** A repository as GitHub names it, its default branch, and its parent when it is a fork. */
async function repositoryInfo(
client: GitHub,
named: Repository,
): Promise<{ repository: Repository; defaultBranch: string; parent: ObjectValue | null }> {
const info = object(await (await client.request(repositoryPath(named))).json());
return {
repository: { owner: string(object(info.owner).login), repo: string(info.name) },
defaultBranch: string(info.default_branch),
parent: info.fork === true && info.parent !== undefined && info.parent !== null ? object(info.parent) : null,
};
}

/** Whether two names are the same repository; GitHub ignores case. */
function same(a: Repository, b: Repository): boolean {
return a.owner.toLowerCase() === b.owner.toLowerCase() && a.repo.toLowerCase() === b.repo.toLowerCase();
}

/** The REST path of a repository. */
export function repositoryPath(repository: Repository): string {
return `/repos/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repo)}`;
Expand Down Expand Up @@ -155,38 +178,126 @@ export async function claims(
branches: string[],
deps: ClaimDependencies,
): Promise<Claim[]> {
const up = await reach(root, remote, deps);
return up === null ? [] : (await held(root, up, branches)).claims;
}

/**
* The upstream of `remote` for reading claims, warning when off GitHub that
* only branches count. Without the remote it warns and returns `null`.
*/
export async function reach(root: string, remote: string, deps: ClaimDependencies): Promise<Upstream | null> {
try {
remoteUrl(root, remote);
} catch {
deps.warn(`no ${remote} remote, so claims were not checked`);
return [];
return null;
}
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);
return up;
}

/** Which branches someone holds, and which landed branches a new claim replaces. */
export interface Holdings {
/** Sorted by branch. */
claims: Claim[];
/**
* Branches where the remote pushes whose tip is the head of a merged PR,
* by that tip. Their work landed, so they hold nothing and a claim may replace them.
*/
landed: Map<string, string>;
}

/**
* Which of `branches` someone holds, sorted by branch. A fork's own branches
* and PRs count too, so a claim pushed there is seen before its PR opens.
* Which of `branches` someone holds. A fork's own branches and PRs count too,
* so a claim pushed there is seen before its PR opens. On GitHub, a branch
* whose tip is a merged PR's head holds nothing: its work landed. A closed,
* unmerged PR's branch still holds its quest, since that work may be wanted back.
*/
export async function held(root: string, up: Upstream, branches: string[]): Promise<Claim[]> {
const pushed = new Set(remoteBranches(root, up.git).keys());
export async function held(root: string, up: Upstream, branches: string[]): Promise<Holdings> {
// Where claim branches live: upstream, then the fork the remote pushes to.
const sources: { repository: Repository | null; tips: Map<string, string> }[] = [
{ repository: up.github?.repository ?? null, tips: remoteBranches(root, up.git) },
];
const pulls: OpenPull[] = [];
if (up.github) {
pulls.push(...(await openPullRequests(up.github.client, up.github.repository)));
const { head, repository } = up.github;
if (head.owner !== repository.owner || head.repo !== repository.repo) {
for (const branch of remoteBranches(root, up.push).keys()) pushed.add(branch);
sources.push({ repository: head, tips: remoteBranches(root, up.push) });
pulls.push(...(await openPullRequests(up.github.client, head)));
}
}
const result: Claim[] = [];
for (const branch of [...new Set(branches)].sort()) {
const pushTo = sources[sources.length - 1];
const landed = new Map<string, string>();
const holds = async (branch: string): Promise<Claim | null> => {
const pull = pullsFrom(pulls, branch)[0] ?? null;
if (pull !== null || pushed.has(branch)) result.push({ branch, url: pull?.url ?? null, pull });
if (pull !== null) return { branch, url: pull.url, pull };
let holding = false;
for (const source of sources) {
const tip = source.tips.get(branch);
if (tip === undefined) continue;
if (
up.github &&
source.repository &&
(await merged(up.github.client, up.github.repository, source.repository, branch, tip))
) {
if (source === pushTo) landed.set(branch, tip);
} else {
holding = true;
}
}
return holding ? { branch, url: null, pull: null } : null;
};
const found = await Promise.all([...new Set(branches)].sort().map(holds));
return { claims: found.filter((claim): claim is Claim => claim !== null), landed };
}

/** Whether `tip` is the head of a PR from `head`'s `branch` that merged into `repository`. */
async function merged(
github: GitHub,
repository: Repository,
head: Repository,
branch: string,
tip: string,
): Promise<boolean> {
const query = new URLSearchParams({ state: "closed", head: `${head.owner}:${branch}`, per_page: "100" });
const rows = await github.all(`${repositoryPath(repository)}/pulls?${query}`);
return rows.some((row) => typeof row.merged_at === "string" && object(row.head).sha === tip);
}

/** Where a claim lands: the remote, its upstream, and the upstream default branch's tip and quest tree. */
export interface Target {
remote: string;
up: Upstream;
/** The upstream default branch's tip, fetched. */
base: string;
tree: Snapshot;
}

/** The target for claims pushed to `remote`, read from the fetched upstream default branch. */
export async function target(root: string, remote: string, deps: ClaimDependencies): Promise<Target> {
const up = await upstream(root, remote, deps);
const base = upstreamHead(root, up.git);
return { remote, up, base, tree: snapshot(root, base) };
}

/** The tip of `remote`'s default branch (a name or URL), fetched so it can be read and built on. */
export function upstreamHead(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`);
ensure(root, remote, sha);
return sha;
}

/** Make `sha` from `remote` available locally. */
export function ensure(root: string, remote: string, sha: string): void {
try {
gitOutput(root, ["cat-file", "-e", `${sha}^{commit}`]);
} catch {
gitOutput(root, ["fetch", "--quiet", "--no-tags", "--no-write-fetch-head", remote, sha]);
}
return result;
}

/** Run git in `root` and return its trimmed stdout; an unset config key is empty rather than an error. */
Expand Down
41 changes: 38 additions & 3 deletions src/cli/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export type ObjectValue = Record<string, unknown>;
/** 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 repository = githubRepository(remoteUrl(root, "origin"));
const { repository } = remoteRepository(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) };
}
Expand All @@ -55,10 +55,45 @@ export function remoteUrl(root: string, remote: string): string {
}).trim();
}

/** The github.com repository a remote URL names, or `null` for any other host. */
/**
* The URL `remote` fetches from, or pushes to with `push`, and the github.com
* repository it names. The configured URL comes first, so a rewrite
* (`insteadOf`) to another host still names GitHub; otherwise the rewritten one.
*/
export function remoteRepository(
root: string,
remote: string,
push = false,
): { url: string; repository: Repository | null } {
const git = (args: string[]) => {
try {
return execFileSync("git", ["-C", root, ...args], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
} catch {
// An unset config key exits 1.
return "";
}
};
const configured =
(push ? git(["config", "--get", `remote.${remote}.pushurl`]) : "") ||
git(["config", "--get", `remote.${remote}.url`]);
const named = githubRepository(configured);
if (named !== null) return { url: configured, repository: named };
const url = push ? git(["remote", "get-url", "--push", remote]) : remoteUrl(root, remote);
return { url, repository: githubRepository(url) };
}

/**
* The github.com repository a remote URL names, or `null` for any other host:
* HTTPS with or without credentials, `ssh://`, or the scp-like `user@github.com:` form.
*/
export function githubRepository(url: string): Repository | null {
const match =
/^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([^/]+)\/([^/]+?)(?:\.git)?$/.exec(url);
/^(?:https:\/\/(?:[^@/]+@)?github\.com\/|ssh:\/\/(?:[^@/]+@)?github\.com(?::\d+)?\/|[^@/:]+@github\.com:)([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(
url,
);
return match ? { owner: match[1], repo: match[2] } : null;
}

Expand Down
Loading
Loading