Repository navigation
feat: move and delete quests with quest mv and quest rm #164
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1383840
0e00ba7
c9f7b6e
8ebde16
99a3071
73d51ad
0adfbf3
322a3d1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| // Who holds a quest: its claim branch on the remote, or an open PR from a | ||
| // branch of that name in any repository. Commands that must not disturb held | ||
| // work look here first. | ||
| import { execFileSync } from "node:child_process"; | ||
|
|
||
| import { | ||
| type GitHubDependencies, | ||
| type Repository, | ||
| GitHub, | ||
| githubRepository, | ||
| object, | ||
| remoteUrl, | ||
| string, | ||
| } from "./github"; | ||
|
|
||
| /** A quest's branch: its path without `.md`. */ | ||
| export function questBranch(path: string): string { | ||
| return path.replace(/\.md$/, ""); | ||
| } | ||
|
|
||
| /** A held quest branch, and the open PR from it if there is one. */ | ||
| export interface Claim { | ||
| branch: string; | ||
| url: 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; | ||
| } | ||
|
|
||
| /** The `quest/*` branches on `remote`. Plain git, so it works with any forge. */ | ||
| export function remoteBranches(root: string, remote: string): string[] { | ||
| const out = execFileSync("git", ["-C", root, "ls-remote", remote, "refs/heads/quest/*"], { | ||
| encoding: "utf8", | ||
| stdio: ["ignore", "pipe", "pipe"], | ||
| }); | ||
| return out | ||
| .split("\n") | ||
| .filter((line) => line !== "") | ||
| .map((line) => line.slice(line.indexOf("\t") + 1).replace(/^refs\/heads\//, "")); | ||
| } | ||
|
|
||
| /** Every open PR in `repository`, from any head repository, by head branch. */ | ||
| export async function openPullRequests( | ||
| github: GitHub, | ||
| repository: Repository, | ||
| ): Promise<{ branch: string; url: string }[]> { | ||
| const rows = await github.all(`${path(repository)}/pulls?state=open&per_page=100`); | ||
| return rows.map((row) => ({ branch: string(object(row.head).ref), url: string(row.html_url) })); | ||
| } | ||
|
|
||
| /** | ||
| * The repository `repository` was forked from, or `null` if it is not a fork. | ||
| * A fork's clone names the fork `origin`, while its quests are claimed upstream. | ||
| */ | ||
| export async function forkParent(github: GitHub, repository: Repository): Promise<Repository | null> { | ||
| const parent = object(await (await github.request(path(repository))).json()).parent; | ||
| if (parent === undefined || parent === null) return null; | ||
| const [owner, repo] = string(object(parent).full_name).split("/"); | ||
| return { owner, repo }; | ||
| } | ||
|
|
||
| /** The `quest/*` branches in a GitHub `repository`, for one that is not a local remote. */ | ||
| export async function githubBranches(github: GitHub, repository: Repository): Promise<string[]> { | ||
| const rows = await github.all(`${path(repository)}/git/matching-refs/heads/quest/`); | ||
| return rows.map((row) => string(row.ref).replace(/^refs\/heads\//, "")); | ||
| } | ||
|
|
||
| function path(repository: Repository): string { | ||
| return `/repos/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repo)}`; | ||
| } | ||
|
|
||
| /** | ||
| * Which of `branches` someone holds: pushed to `remote`, or the head of an | ||
| * open PR there. When `remote` is a GitHub fork, its parent's branches and | ||
| * open PRs count too. 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<Claim[]> { | ||
| let url: string; | ||
| try { | ||
| url = remoteUrl(root, remote); | ||
| } catch { | ||
| deps.warn(`no ${remote} remote, so claims were not checked`); | ||
| return []; | ||
| } | ||
| const wanted = new Set(branches); | ||
| const held = new Map<string, string | null>(); | ||
| for (const branch of remoteBranches(root, remote)) if (wanted.has(branch)) held.set(branch, null); | ||
| const repository = githubRepository(url); | ||
| if (repository === null) { | ||
| deps.warn(`${remote} is not on GitHub, so only its branches were checked for claims`); | ||
| } else { | ||
| const github = new GitHub(deps.github()); | ||
| const parent = await forkParent(github, repository); | ||
| if (parent !== null) { | ||
| for (const branch of await githubBranches(github, parent)) if (wanted.has(branch)) held.set(branch, null); | ||
| } | ||
| for (const each of parent === null ? [repository] : [repository, parent]) { | ||
| for (const pr of await openPullRequests(github, each)) { | ||
| if (wanted.has(pr.branch)) held.set(pr.branch, pr.url); | ||
| } | ||
| } | ||
| } | ||
| return [...held].map(([branch, url]) => ({ branch, url })).sort((a, b) => (a.branch < b.branch ? -1 : 1)); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,17 @@ | ||
| // The GitHub client shared by the PR commands, outside the repository-independent core. | ||
| // The GitHub client shared by the commands that talk to GitHub, outside the repository-independent core. | ||
| import { execFileSync } from "node:child_process"; | ||
| import { setTimeout as sleep } from "node:timers/promises"; | ||
|
|
||
| const API = "https://api.github.com"; | ||
| const MAX_SLEEP = 2_147_483_647; | ||
|
|
||
| export interface PullRequest { | ||
| /** A repository on github.com. */ | ||
| export interface Repository { | ||
| owner: string; | ||
| repo: string; | ||
| } | ||
|
|
||
| export interface PullRequest extends Repository { | ||
| number: number; | ||
| } | ||
|
|
||
|
|
@@ -34,22 +38,30 @@ 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 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 GitHubInputError("expected a GitHub pull request URL or a PR number in origin"); | ||
| return { owner: match[1], repo: match[2], number: Number(match[3]) }; | ||
| } | ||
|
|
||
| /** 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; | ||
|
Comment on lines
+59
to
+62
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Recorded as a follow-up rather than fixed here: this regex predates the PR (it came from (Written by Claude Opus 5.5) |
||
| } | ||
|
|
||
| export function gitHubDependencies(warn: (message: string) => void): GitHubDependencies { | ||
| let token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; | ||
| if (!token) { | ||
|
|
@@ -136,6 +148,24 @@ export class GitHub { | |
| } | ||
| } | ||
|
|
||
| /** 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<ObjectValue[]> { | ||
| 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; | ||
| } | ||
|
|
||
| /** Run a GraphQL query and return its data, failing on any reported error. */ | ||
| async graphql(query: string, variables: ObjectValue): Promise<ObjectValue> { | ||
| const response = await this.request("/graphql", undefined, { query, variables }); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
originis a fork, this checks branch claims only in that fork. The new parent-repository lookup fixes the previously reported open-PR case, but a quest branch pushed directly to the upstream repository while its draft PR is being opened still appears in neitherremoteBranches(origin)nor the open-PR results, soquest mvcan rename actively claimed work. Query the resolved upstream repository's branches as well before allowing the move.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 73d51ad: when
originis a GitHub fork, claims now also include the parent'squest/*branches, listed withgit/matching-refs, alongside its open PRs. This supersedes my earlier reply that left upstream branches out.(Written by Claude Opus 5.5)