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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/cli-tools",
"version": "0.45.1",
"version": "0.45.2",
"private": true,
"description": "Local command-line tools, in TypeScript, exposed on PATH.",
"type": "module",
Expand Down
131 changes: 129 additions & 2 deletions src/gh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,52 @@ export function parseChecks(raw: unknown, where = 'gh pr checks'): Check[] {
});
}

/**
* The refusal GitHub returns from the GraphQL merge mutation for a pull
* request that belongs to a stack. Matched as a substring because the rest of
* the sentence carries a docs URL that is not ours to depend on.
*/
export const STACK_REFUSAL = 'asynchronous merge REST API';

/** `https://github.com/owner/repo/pull/7` → `{ slug: 'owner/repo', number: 7 }`. */
export function parsePullRequestUrl(
url: string,
): { slug: string; number: string } | undefined {
const match = /github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/.exec(url);
return match ? { slug: match[1]!, number: match[2]! } : undefined;
}

export interface MergeAsyncState {
status?: string | undefined;
uuid?: string | undefined;
message?: string | undefined;
}

/**
* Read one asynchronous-merge reply. Unparseable output is not an error here:
* the caller treats a missing uuid as "stop polling" and reports that the
* merge never settled, which is truer than inventing a status.
*/
export function parseMergeAsync(stdout: string): MergeAsyncState | undefined {
let raw: unknown;
try {
raw = JSON.parse(stdout);
} catch {
return undefined;
}

if (typeof raw !== 'object' || raw === null) return undefined;

const record = raw as Record<string, unknown>;
const details = (record.details ?? {}) as Record<string, unknown>;

return {
status: typeof record.status === 'string' ? record.status : undefined,
uuid: typeof details.uuid === 'string' ? details.uuid : undefined,
message: typeof details.message === 'string' ? details.message : undefined,
};
}

export interface GhOptions {
/** Swap in a fake for tests. */
exec?: typeof run;
Expand Down Expand Up @@ -291,8 +337,89 @@ export class Gh {
* lands on a commit nothing verified.
*
* Deliberately no `--admin`. Branch protections stay enforced.
*
* `gh pr merge` calls the GraphQL mergePullRequest mutation, and GitHub
* refuses that mutation outright for a PR that belongs to a stack, naming
* the asynchronous merge REST endpoint instead. Nothing is wrong with such
* a PR — it reports MERGEABLE and CLEAN — so there is no repair to attempt
* and no honest way to call it a refusal. Fall back to that endpoint.
*/
async squashMerge(url: string, headSha: string): Promise<RunResult> {
return this.call(['pr', 'merge', url, '--squash', '--match-head-commit', headSha]);
async squashMerge(
url: string,
headSha: string,
{ pollMs = 2_000, attempts = 30 }: { pollMs?: number; attempts?: number } = {},
): Promise<RunResult> {
const direct = await this.call([
'pr',
'merge',
url,
'--squash',
'--match-head-commit',
headSha,
]);

if (direct.code === 0) return direct;

// Keyed on the message rather than on the base branch: GitHub still calls
// a PR stacked after it has retargeted it onto the default branch.
if (!`${direct.stderr}${direct.stdout}`.includes(STACK_REFUSAL)) return direct;

return this.mergeAsync(url, headSha, { pollMs, attempts });
}

/**
* The asynchronous merge endpoint enqueues the squash and hands back a uuid
* to poll. The head stays pinned and no `--admin` is passed, so the PR is
* held to exactly the rules it would have been held to above.
*/
private async mergeAsync(
url: string,
headSha: string,
{ pollMs, attempts }: { pollMs: number; attempts: number },
): Promise<RunResult> {
const target = parsePullRequestUrl(url);
if (!target) {
return { code: 1, stdout: '', stderr: `cannot read owner/repo from ${url}` };
}

const endpoint = `repos/${target.slug}/pulls/${target.number}/merge-async`;

let response = await this.call([
'api',
'--method',
'PUT',
endpoint,
'-f',
'merge_method=squash',
'-f',
`sha=${headSha}`,
]);

for (let attempt = 0; attempt < attempts; attempt += 1) {
if (response.code !== 0) return response;

const state = parseMergeAsync(response.stdout);

// `enqueued` means a merge queue owns it from here, which is as merged
// as this tool can make it.
if (state?.status === 'merged' || state?.status === 'enqueued') {
return {
code: 0,
stdout: `Merged ${url} via the asynchronous merge API`,
stderr: '',
};
}

if (state?.status === 'failed') {
return { code: 1, stdout: '', stderr: state.message ?? 'async merge failed' };
}

if (!state?.uuid) break;

await sleep(pollMs);
response = await this.call(['api', `${endpoint}/${state.uuid}`]);
}

return { code: 1, stdout: '', stderr: `async merge never settled for ${url}` };
}
}
169 changes: 168 additions & 1 deletion test/prs-merge.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest';
import { Gh, parseChecks, parsePullRequest, GhError } from '../src/gh.ts';
import {
Gh,
parseChecks,
parseMergeAsync,
parsePullRequest,
parsePullRequestUrl,
GhError,
} from '../src/gh.ts';
import type { RunResult } from '../src/exec.ts';
import {
defaults,
Expand Down Expand Up @@ -32,10 +39,15 @@ function stubGh(options: {
steps: StubStep[];
updateBranchFails?: boolean;
mergeFails?: boolean;
/** Refuse `pr merge` the way GitHub refuses a PR that is part of a stack. */
stacked?: boolean;
/** How many polls the asynchronous merge takes before it reports merged. */
asyncPolls?: number;
}) {
const calls: string[] = [];
let viewIndex = 0;
let checkIndex = 0;
let asyncPolls = options.asyncPolls ?? 0;

const step = (index: number): StubStep =>
options.steps[Math.min(index, options.steps.length - 1)]!;
Expand Down Expand Up @@ -83,11 +95,29 @@ function stubGh(options: {
}

if (args[0] === 'pr' && args[1] === 'merge') {
if (options.stacked) {
return {
code: 1,
stdout: '',
stderr:
'GraphQL: This pull request is part of a stack and must be merged ' +
'using the asynchronous merge REST API. (mergePullRequest)',
};
}
return options.mergeFails
? { code: 1, stdout: '', stderr: 'refused' }
: ok('Merged');
}

// The asynchronous merge endpoint: the PUT enqueues, each GET polls.
if (args[0] === 'api' && args.some((a) => a.includes('merge-async'))) {
if (asyncPolls > 0) {
asyncPolls -= 1;
return ok(JSON.stringify({ status: 'pending', details: { uuid: 'u-1' } }));
}
return ok(JSON.stringify({ status: 'merged', details: { sha: 'cafe' } }));
}

if (args[0] === 'pr' && args[1] === 'ready') return ok('');

return ok('');
Expand Down Expand Up @@ -282,6 +312,26 @@ describe('sweep', () => {
expect(summary.merged).toBe(0);
});

it('merges a stacked PR through the asynchronous merge endpoint', async () => {
const { gh, calls } = stubGh({ steps: [{}], stacked: true });
const { summary } = await runSweep(gh, baseOptions());

expect(summary.merged).toBe(1);
expect(summary.failed).toBe(0);
expect(calls.some((c) => c.includes('merge-async'))).toBe(true);
});

it('pins the head commit on the asynchronous merge too', async () => {
const { gh, calls } = stubGh({ steps: [{}], stacked: true });
await runSweep(gh, baseOptions());

const put = calls.find((c) => c.startsWith('api --method PUT'));
expect(put).toContain('sha=deadbeef');
expect(put).toContain('merge_method=squash');
// Still no admin override on this path.
expect(calls.some((c) => c.includes('--admin'))).toBe(false);
});

it('marks a draft ready, then judges it normally', async () => {
const { gh, calls } = stubGh({ steps: [{ isDraft: true }, { isDraft: false }] });
const { summary } = await runSweep(gh, baseOptions());
Expand Down Expand Up @@ -320,3 +370,120 @@ describe('defaults', () => {
expect(defaults.pollMs).toBe(20_000);
});
});

describe('asynchronous merge', () => {
const PR = 'https://github.com/acme/repo/pull/7';

/** A `gh` that refuses the mutation, then answers the REST endpoint. */
function stubAsync(replies: string[], { refuse = true } = {}) {
const calls: string[] = [];
let index = 0;

const exec = async (_f: string, args: readonly string[]): Promise<RunResult> => {
calls.push(args.join(' '));

if (args[0] === 'pr' && args[1] === 'merge') {
return refuse
? {
code: 1,
stdout: '',
stderr: 'must be merged using the asynchronous merge REST API. (mergePullRequest)',
}
: { code: 0, stdout: 'Merged', stderr: '' };
}

const reply = replies[Math.min(index, replies.length - 1)]!;
index += 1;
return { code: 0, stdout: reply, stderr: '' };
};

return { gh: new Gh({ exec }), calls };
}

it('polls the uuid until the merge settles', async () => {
const { gh, calls } = stubAsync([
JSON.stringify({ status: 'pending', details: { uuid: 'u-1' } }),
JSON.stringify({ status: 'pending', details: { uuid: 'u-1' } }),
JSON.stringify({ status: 'merged', details: { sha: 'cafe' } }),
]);

const result = await gh.squashMerge(PR, 'deadbeef', { pollMs: 1 });

expect(result.code).toBe(0);
expect(calls.filter((c) => c.startsWith('api repos/acme/repo/pulls/7/merge-async/u-1')))
.toHaveLength(2);
});

it('treats enqueued as merged, because a merge queue owns it from there', async () => {
const { gh } = stubAsync([JSON.stringify({ status: 'enqueued', details: { uuid: 'u-1' } })]);
const result = await gh.squashMerge(PR, 'deadbeef', { pollMs: 1 });

expect(result.code).toBe(0);
});

it('reports a failed asynchronous merge with the reason GitHub gave', async () => {
const { gh } = stubAsync([
JSON.stringify({ status: 'failed', details: { message: 'head moved' } }),
]);
const result = await gh.squashMerge(PR, 'deadbeef', { pollMs: 1 });

expect(result.code).toBe(1);
expect(result.stderr).toContain('head moved');
});

it('gives up rather than polling forever', async () => {
const { gh } = stubAsync([JSON.stringify({ status: 'pending', details: { uuid: 'u-1' } })]);
const result = await gh.squashMerge(PR, 'deadbeef', { pollMs: 1, attempts: 3 });

expect(result.code).toBe(1);
expect(result.stderr).toContain('never settled');
});

it('does not reach for the endpoint when the refusal is a different one', async () => {
const calls: string[] = [];
const gh = new Gh({
exec: async (_f, args) => {
calls.push(args.join(' '));
return { code: 1, stdout: '', stderr: 'Pull request is not mergeable' };
},
});

const result = await gh.squashMerge(PR, 'deadbeef', { pollMs: 1 });

expect(result.code).toBe(1);
expect(calls.some((c) => c.includes('merge-async'))).toBe(false);
});

it('never merges twice when the mutation already worked', async () => {
const { gh, calls } = stubAsync([], { refuse: false });
const result = await gh.squashMerge(PR, 'deadbeef', { pollMs: 1 });

expect(result.code).toBe(0);
expect(calls.some((c) => c.includes('merge-async'))).toBe(false);
});
});

describe('parsePullRequestUrl', () => {
it('reads owner, repo and number', () => {
expect(parsePullRequestUrl('https://github.com/acme/repo/pull/7')).toEqual({
slug: 'acme/repo',
number: '7',
});
});

it('returns nothing for something that is not a PR url', () => {
expect(parsePullRequestUrl('https://example.com/acme/repo')).toBeUndefined();
});
});

describe('parseMergeAsync', () => {
it('lifts status, uuid and message out of the reply', () => {
expect(
parseMergeAsync(JSON.stringify({ status: 'failed', details: { message: 'no' } })),
).toEqual({ status: 'failed', uuid: undefined, message: 'no' });
});

it('returns nothing rather than inventing a status', () => {
expect(parseMergeAsync('not json')).toBeUndefined();
});
});
Loading