Surface GitHub auto-merge actions (#1001)

* Surface GitHub auto-merge actions

* Namespace GitHub auto-merge RPC

* Update workspace git refresh test expectation

* Avoid redundant GitHub refresh queue
This commit is contained in:
Mohamed Boudra
2026-05-14 17:48:05 +08:00
committed by GitHub
parent 44292b2b69
commit eb7fa02ac8
27 changed files with 3029 additions and 105 deletions
@@ -115,6 +115,31 @@ function currentPullRequestJson(overrides: Record<string, unknown> = {}): string
});
}
function currentPullRequestGithubFactsJson(overrides: Record<string, unknown> = {}): string {
return JSON.stringify({
data: {
repository: {
autoMergeAllowed: true,
mergeCommitAllowed: false,
squashMergeAllowed: true,
rebaseMergeAllowed: false,
viewerDefaultMergeMethod: "SQUASH",
pullRequest: {
mergeStateStatus: "BLOCKED",
autoMergeRequest: null,
viewerCanEnableAutoMerge: true,
viewerCanDisableAutoMerge: false,
viewerCanMergeAsAdmin: false,
viewerCanUpdateBranch: true,
isMergeQueueEnabled: false,
isInMergeQueue: false,
},
...overrides,
},
},
});
}
function createCurrentPullRequestStatus(
overrides: Partial<GitHubCurrentPullRequestStatus> = {},
): GitHubCurrentPullRequestStatus {
@@ -137,6 +162,29 @@ function createCurrentPullRequestStatus(
};
}
function githubStatusFacts(
overrides: Partial<NonNullable<GitHubCurrentPullRequestStatus["github"]>> = {},
): NonNullable<GitHubCurrentPullRequestStatus["github"]> {
return {
mergeStateStatus: "CLEAN",
autoMergeRequest: null,
viewerCanEnableAutoMerge: false,
viewerCanDisableAutoMerge: false,
viewerCanMergeAsAdmin: false,
viewerCanUpdateBranch: false,
repository: {
autoMergeAllowed: true,
mergeCommitAllowed: true,
squashMergeAllowed: true,
rebaseMergeAllowed: true,
viewerDefaultMergeMethod: "SQUASH",
},
isMergeQueueEnabled: false,
isInMergeQueue: false,
...overrides,
};
}
function recordCurrentPullRequestStatusReads(service: ReturnType<typeof createGitHubService>) {
const reads: GitHubReadOptions[] = [];
const getCurrentPullRequestStatus = service.getCurrentPullRequestStatus.bind(service);
@@ -147,6 +195,12 @@ function recordCurrentPullRequestStatusReads(service: ReturnType<typeof createGi
return reads;
}
function currentPullRequestStatusCalls(calls: RunnerCall[]): RunnerCall[] {
return calls.filter(
(call) => call.args[0] === "pr" && (call.args[1] === "view" || call.args[1] === "list"),
);
}
function noPullRequestError(args: string[] = ["pr", "view"]): GitHubCommandError {
return new GitHubCommandError({
args,
@@ -314,6 +368,9 @@ describe("GitHubService", () => {
cwd: "/tmp/repo",
prNumber: 42,
mergeMethod,
status: createCurrentPullRequestStatus({
github: githubStatusFacts(),
}),
}),
).resolves.toEqual({ success: true });
@@ -326,6 +383,196 @@ describe("GitHubService", () => {
]);
});
it("rejects direct merge when GitHub facts are unavailable", async () => {
const runner = createRunner([""]);
const service = createGitHubService({
runner: runner.runner,
});
await expect(
service.mergePullRequest({
cwd: "/tmp/repo",
prNumber: 42,
mergeMethod: "squash",
status: createCurrentPullRequestStatus(),
}),
).rejects.toThrow("GitHub merge facts are unavailable");
expect(runner.calls).toEqual([]);
});
it.each(["BLOCKED", "DIRTY", null] as const)(
"rejects direct merge when GitHub mergeStateStatus is %s",
async (mergeStateStatus) => {
const runner = createRunner([""]);
const service = createGitHubService({
runner: runner.runner,
});
await expect(
service.mergePullRequest({
cwd: "/tmp/repo",
prNumber: 42,
mergeMethod: "squash",
status: createCurrentPullRequestStatus({
github: githubStatusFacts({ mergeStateStatus }),
}),
}),
).rejects.toThrow("ready for direct merge");
expect(runner.calls).toEqual([]);
},
);
it.each([
["merge queue enabled", { isMergeQueueEnabled: true }],
["PR already in merge queue", { isInMergeQueue: true }],
] as const)("rejects direct merge when %s", async (_name, overrides) => {
const runner = createRunner([""]);
const service = createGitHubService({
runner: runner.runner,
});
await expect(
service.mergePullRequest({
cwd: "/tmp/repo",
prNumber: 42,
mergeMethod: "squash",
status: createCurrentPullRequestStatus({
github: githubStatusFacts(overrides),
}),
}),
).rejects.toThrow("merge queue");
expect(runner.calls).toEqual([]);
});
it("rejects direct merge when auto-merge is already enabled", async () => {
const runner = createRunner([""]);
const service = createGitHubService({
runner: runner.runner,
});
await expect(
service.mergePullRequest({
cwd: "/tmp/repo",
prNumber: 42,
mergeMethod: "squash",
status: createCurrentPullRequestStatus({
github: githubStatusFacts({
autoMergeRequest: {
enabledAt: "2026-05-13T12:00:00Z",
mergeMethod: "SQUASH",
enabledBy: "octocat",
},
}),
}),
}),
).rejects.toThrow("auto-merge is already enabled");
expect(runner.calls).toEqual([]);
});
it("rejects direct merge when the requested method is disabled by repository policy", async () => {
const runner = createRunner([""]);
const service = createGitHubService({
runner: runner.runner,
});
await expect(
service.mergePullRequest({
cwd: "/tmp/repo",
prNumber: 42,
mergeMethod: "squash",
status: createCurrentPullRequestStatus({
github: githubStatusFacts({
repository: {
autoMergeAllowed: true,
mergeCommitAllowed: true,
squashMergeAllowed: false,
rebaseMergeAllowed: true,
viewerDefaultMergeMethod: "MERGE",
},
}),
}),
}),
).rejects.toThrow("squash is disabled");
expect(runner.calls).toEqual([]);
});
it.each([
["merge", ["pr", "merge", "42", "--auto", "--merge"]],
["squash", ["pr", "merge", "42", "--auto", "--squash"]],
["rebase", ["pr", "merge", "42", "--auto", "--rebase"]],
] as const)("enables auto-merge with gh using %s", async (mergeMethod, expectedArgs) => {
const runner = createRunner([""]);
const service = createGitHubService({
runner: runner.runner,
});
await expect(
service.enablePullRequestAutoMerge({
cwd: "/tmp/repo",
prNumber: 42,
mergeMethod,
status: createCurrentPullRequestStatus({
github: githubStatusFacts({
mergeStateStatus: "BLOCKED",
viewerCanEnableAutoMerge: true,
repository: {
autoMergeAllowed: true,
mergeCommitAllowed: true,
squashMergeAllowed: true,
rebaseMergeAllowed: true,
viewerDefaultMergeMethod: "SQUASH",
},
}),
}),
}),
).resolves.toEqual({ success: true });
expect(runner.calls).toEqual([
{
args: expectedArgs,
cwd: "/tmp/repo",
envOverlay: { GH_PROMPT_DISABLED: "1" },
},
]);
});
it("disables auto-merge with gh", async () => {
const runner = createRunner([""]);
const service = createGitHubService({
runner: runner.runner,
});
await expect(
service.disablePullRequestAutoMerge({
cwd: "/tmp/repo",
prNumber: 42,
status: createCurrentPullRequestStatus({
github: githubStatusFacts({
autoMergeRequest: {
enabledAt: "2026-05-13T12:00:00Z",
mergeMethod: "SQUASH",
enabledBy: "octocat",
},
viewerCanDisableAutoMerge: true,
}),
}),
}),
).resolves.toEqual({ success: true });
expect(runner.calls).toEqual([
{
args: ["pr", "merge", "42", "--disable-auto"],
cwd: "/tmp/repo",
envOverlay: { GH_PROMPT_DISABLED: "1" },
},
]);
});
it("computes fast cadence for pending and slow cadence for stable PR states", () => {
const pendingStatus = createCurrentPullRequestStatus({ checksStatus: "pending" });
const runningCheckStatus = createCurrentPullRequestStatus({
@@ -408,7 +655,7 @@ describe("GitHubService", () => {
now = EXPECTED_GITHUB_FAST_POLL_MS;
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS);
expect(runner.calls).toHaveLength(2);
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(2);
expect(reads.map((read) => read.reason)).toEqual([undefined, "self-heal-github"]);
subscription?.unsubscribe();
@@ -441,11 +688,11 @@ describe("GitHubService", () => {
now = EXPECTED_GITHUB_FAST_POLL_MS;
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS);
expect(runner.calls).toHaveLength(1);
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
now = EXPECTED_GITHUB_SLOW_POLL_MS;
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_SLOW_POLL_MS - EXPECTED_GITHUB_FAST_POLL_MS);
expect(runner.calls).toHaveLength(2);
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(2);
expect(reads.map((read) => read.reason)).toEqual([undefined, "self-heal-github"]);
subscription?.unsubscribe();
@@ -458,14 +705,17 @@ describe("GitHubService", () => {
currentPullRequestJson({
statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "PENDING" }],
}),
currentPullRequestGithubFactsJson(),
{ error: new Error("network down") },
{ error: new Error("network still down") },
currentPullRequestJson({
statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "SUCCESS" }],
}),
currentPullRequestGithubFactsJson(),
currentPullRequestJson({
statusCheckRollup: [{ __typename: "StatusContext", context: "ci", state: "SUCCESS" }],
}),
currentPullRequestGithubFactsJson(),
]);
const service = createGitHubService({
ttlMs: 0,
@@ -490,7 +740,7 @@ describe("GitHubService", () => {
now += EXPECTED_GITHUB_SLOW_POLL_MS;
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_SLOW_POLL_MS);
expect(runner.calls).toHaveLength(5);
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(5);
expect(reads.map((read) => read.reason)).toEqual([
undefined,
"self-heal-github",
@@ -527,7 +777,7 @@ describe("GitHubService", () => {
now = EXPECTED_GITHUB_FAST_POLL_MS;
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS);
expect(runner.calls).toHaveLength(1);
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
service.dispose?.();
});
@@ -553,7 +803,7 @@ describe("GitHubService", () => {
now = EXPECTED_GITHUB_FAST_POLL_MS;
await vi.advanceTimersByTimeAsync(EXPECTED_GITHUB_FAST_POLL_MS);
expect(runner.calls).toHaveLength(1);
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
});
it("fetches PR reviews and issue comments with one GraphQL call sorted chronologically", async () => {
@@ -1139,7 +1389,7 @@ describe("GitHubService", () => {
headRef: "feature/pr-pane",
});
expect(runner.calls.map((call) => call.args)).toEqual([
expect(runner.calls.slice(0, 2).map((call) => call.args)).toEqual([
["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS],
["pr", "view", "--json", CURRENT_PR_STATUS_BASE_FIELDS],
]);
@@ -1171,6 +1421,72 @@ describe("GitHubService", () => {
expect(status?.mergeable).toBe("UNKNOWN");
});
it("loads GitHub merge, auto-merge, permission, policy, and queue facts for PR 993 shape", async () => {
const runner = createScriptedRunner([
currentPullRequestJson({
number: 993,
url: "https://github.com/getpaseo/paseo/pull/993",
title: "Auto-merge UX",
headRefName: "github-pr-auto-merge-ux",
mergeable: "MERGEABLE",
reviewDecision: "APPROVED",
statusCheckRollup: [
{
__typename: "CheckRun",
name: "server tests",
workflowName: "CI",
status: "IN_PROGRESS",
conclusion: null,
detailsUrl: "https://github.com/getpaseo/paseo/actions/runs/993",
},
],
}),
currentPullRequestGithubFactsJson(),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "github-pr-auto-merge-ux",
});
expect(runner.calls.map((call) => call.args[0])).toEqual(["pr", "api"]);
expect(status).toMatchObject({
number: 993,
mergeable: "MERGEABLE",
checks: [
{
name: "server tests",
status: "pending",
url: "https://github.com/getpaseo/paseo/actions/runs/993",
workflow: "CI",
},
],
checksStatus: "pending",
github: {
mergeStateStatus: "BLOCKED",
autoMergeRequest: null,
viewerCanEnableAutoMerge: true,
viewerCanDisableAutoMerge: false,
viewerCanMergeAsAdmin: false,
viewerCanUpdateBranch: true,
repository: {
autoMergeAllowed: true,
mergeCommitAllowed: false,
squashMergeAllowed: true,
rebaseMergeAllowed: false,
viewerDefaultMergeMethod: "SQUASH",
},
isMergeQueueEnabled: false,
isInMergeQueue: false,
},
});
});
it("resolves fork PR heads to the parent repository when gh pr view returns a stale branch match", async () => {
const runner = createScriptedRunner([
currentPullRequestJson({
@@ -1231,7 +1547,7 @@ describe("GitHubService", () => {
title: "Real fork PR",
headRefName: "feature/fork",
});
expect(runner.calls.map((call) => call.args)).toEqual([
expect(runner.calls.slice(0, 3).map((call) => call.args)).toEqual([
["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS],
["repo", "view", "--json", "owner,name,parent"],
[
@@ -1314,7 +1630,7 @@ describe("GitHubService", () => {
checks: [],
checksStatus: "none",
});
expect(runner.calls.map((call) => call.args)).toEqual([
expect(runner.calls.slice(0, 4).map((call) => call.args)).toEqual([
["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS],
["repo", "view", "--json", "owner,name,parent"],
[
@@ -1452,7 +1768,7 @@ describe("GitHubService", () => {
repoName: "repo",
headRefName: "main",
});
expect(runner.calls.map((call) => call.args)).toEqual([
expect(runner.calls.slice(0, 2).map((call) => call.args)).toEqual([
["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS],
[
"pr",
@@ -1978,13 +2294,15 @@ describe("GitHubService", () => {
service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }),
).resolves.toMatchObject({ number: 42 });
expect(runner.calls).toHaveLength(1);
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
});
it("bypasses the warm PR status cache for forced reads", async () => {
const runner = createRunner([
currentPullRequestJson({ number: 41, title: "First" }),
currentPullRequestGithubFactsJson(),
currentPullRequestJson({ number: 42, title: "Forced" }),
currentPullRequestGithubFactsJson(),
]);
const service = createGitHubService({
runner: runner.runner,
@@ -2004,7 +2322,7 @@ describe("GitHubService", () => {
}),
).resolves.toMatchObject({ number: 42 });
expect(runner.calls).toHaveLength(2);
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(2);
});
it("coalesces concurrent PR status callers", async () => {
@@ -2019,8 +2337,13 @@ describe("GitHubService", () => {
const second = service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" });
await Promise.resolve();
expect(runner.calls).toHaveLength(1);
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
runner.resolveNext(currentPullRequestJson());
for (let i = 0; i < 10 && runner.calls.length < 2; i += 1) {
await Promise.resolve();
}
expect(runner.calls[1]?.args[0]).toBe("api");
runner.resolveNext(currentPullRequestGithubFactsJson());
await expect(Promise.all([first, second])).resolves.toEqual([
expect.objectContaining({ number: 42 }),
+345 -1
View File
@@ -93,6 +93,52 @@ const HeadRepositoryOwnerSchema = z
const PullRequestMergeableSchema = z.enum(["MERGEABLE", "CONFLICTING", "UNKNOWN"]).catch("UNKNOWN");
const GitHubAutoMergeRequestSchema = z
.object({
enabledAt: z.string().nullable().optional().catch(null),
mergeMethod: z.string().nullable().optional().catch(null),
enabledBy: z
.object({
login: z.string().nullable().optional().catch(null),
})
.nullable()
.optional()
.catch(null),
})
.nullable()
.optional()
.catch(null);
const GitHubPullRequestFactsGraphqlSchema = z.object({
data: z.object({
repository: z
.object({
autoMergeAllowed: z.boolean().optional().catch(false),
mergeCommitAllowed: z.boolean().optional().catch(false),
squashMergeAllowed: z.boolean().optional().catch(false),
rebaseMergeAllowed: z.boolean().optional().catch(false),
viewerDefaultMergeMethod: z.string().nullable().optional().catch(null),
pullRequest: z
.object({
mergeStateStatus: z.string().nullable().optional().catch(null),
autoMergeRequest: GitHubAutoMergeRequestSchema,
viewerCanEnableAutoMerge: z.boolean().optional().catch(false),
viewerCanDisableAutoMerge: z.boolean().optional().catch(false),
viewerCanMergeAsAdmin: z.boolean().optional().catch(false),
viewerCanUpdateBranch: z.boolean().optional().catch(false),
isMergeQueueEnabled: z.boolean().optional().catch(false),
isInMergeQueue: z.boolean().optional().catch(false),
})
.nullable()
.optional()
.catch(null),
})
.nullable()
.optional()
.catch(null),
}),
});
const CurrentPullRequestStatusSchema = z.object({
number: z.number().optional(),
url: z.string().catch(""),
@@ -240,6 +286,33 @@ const CURRENT_PR_STATUS_BASE_FIELDS =
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
const CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`;
const PULL_REQUEST_STATUS_FACTS_QUERY = `
query PullRequestStatusFacts($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
autoMergeAllowed
mergeCommitAllowed
squashMergeAllowed
rebaseMergeAllowed
viewerDefaultMergeMethod
pullRequest(number: $number) {
mergeStateStatus
autoMergeRequest {
enabledAt
mergeMethod
enabledBy {
login
}
}
viewerCanEnableAutoMerge
viewerCanDisableAutoMerge
viewerCanMergeAsAdmin
viewerCanUpdateBranch
isMergeQueueEnabled
isInMergeQueue
}
}
}`;
const PULL_REQUEST_TIMELINE_QUERY = `
query PullRequestTimeline($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
@@ -353,6 +426,28 @@ export type PullRequestChecksStatus = "none" | "pending" | "success" | "failure"
export type PullRequestReviewDecision = "approved" | "changes_requested" | "pending" | null;
export type PullRequestMergeable = "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
export interface GitHubPullRequestStatusFacts {
mergeStateStatus: string | null;
autoMergeRequest: {
enabledAt: string | null;
mergeMethod: string | null;
enabledBy: string | null;
} | null;
viewerCanEnableAutoMerge: boolean;
viewerCanDisableAutoMerge: boolean;
viewerCanMergeAsAdmin: boolean;
viewerCanUpdateBranch: boolean;
repository: {
autoMergeAllowed: boolean;
mergeCommitAllowed: boolean;
squashMergeAllowed: boolean;
rebaseMergeAllowed: boolean;
viewerDefaultMergeMethod: string | null;
};
isMergeQueueEnabled: boolean;
isInMergeQueue: boolean;
}
export interface GitHubCurrentPullRequestStatus {
number?: number;
repoOwner?: string;
@@ -368,6 +463,7 @@ export interface GitHubCurrentPullRequestStatus {
checks: PullRequestCheck[];
checksStatus: PullRequestChecksStatus;
reviewDecision: PullRequestReviewDecision;
github?: GitHubPullRequestStatusFacts;
}
export type PullRequestTimelineReviewState = "approved" | "changes_requested" | "commented";
@@ -412,17 +508,41 @@ export interface GitHubPullRequestCreateResult {
}
export type GitHubPullRequestMergeMethod = "merge" | "squash" | "rebase";
const DIRECT_PULL_REQUEST_MERGE_STATE_ALLOWLIST = new Set(["CLEAN", "HAS_HOOKS"]);
export interface GitHubPullRequestCommandStatus {
mergeable?: PullRequestMergeable;
github?: GitHubPullRequestStatusFacts;
}
export interface MergeGitHubPullRequestOptions {
cwd: string;
prNumber: number;
mergeMethod: GitHubPullRequestMergeMethod;
status?: GitHubPullRequestCommandStatus | null;
}
export interface EnableGitHubPullRequestAutoMergeOptions {
cwd: string;
prNumber: number;
mergeMethod: GitHubPullRequestMergeMethod;
status?: GitHubPullRequestCommandStatus | null;
}
export interface DisableGitHubPullRequestAutoMergeOptions {
cwd: string;
prNumber: number;
status?: GitHubPullRequestCommandStatus | null;
}
export interface GitHubPullRequestMergeResult {
success: true;
}
export interface GitHubPullRequestAutoMergeResult {
success: true;
}
export type GitHubReadOptions =
| {
force?: false;
@@ -512,6 +632,12 @@ export interface GitHubService {
options: CreateGitHubPullRequestOptions,
): Promise<GitHubPullRequestCreateResult>;
mergePullRequest(options: MergeGitHubPullRequestOptions): Promise<GitHubPullRequestMergeResult>;
enablePullRequestAutoMerge(
options: EnableGitHubPullRequestAutoMergeOptions,
): Promise<GitHubPullRequestAutoMergeResult>;
disablePullRequestAutoMerge(
options: DisableGitHubPullRequestAutoMergeOptions,
): Promise<GitHubPullRequestAutoMergeResult>;
isAuthenticated(options: { cwd: string } & GitHubReadOptions): Promise<boolean>;
retainCurrentPullRequestStatusPoll?(options: {
cwd: string;
@@ -577,6 +703,13 @@ interface CommandFailureLike {
type PullRequestCheckRunNode = z.infer<typeof PullRequestCheckRunNodeSchema>;
type PullRequestStatusContextNode = z.infer<typeof PullRequestStatusContextNodeSchema>;
type CurrentPullRequestStatusItem = z.infer<typeof CurrentPullRequestStatusSchema>;
type GitHubPullRequestFactsGraphql = z.infer<typeof GitHubPullRequestFactsGraphqlSchema>;
type GitHubPullRequestFactsRepository = NonNullable<
GitHubPullRequestFactsGraphql["data"]["repository"]
>;
type GitHubPullRequestFactsPullRequest = NonNullable<
GitHubPullRequestFactsRepository["pullRequest"]
>;
interface InFlightCacheEntry {
cwd: string;
@@ -882,12 +1015,13 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
},
readOptions: input,
load: async () => {
return resolveCurrentPullRequestView({
const status = await resolveCurrentPullRequestView({
cwd: input.cwd,
headRef: input.headRef,
headRepositoryOwner: input.headRepositoryOwner,
run,
});
return addCurrentPullRequestGithubFacts({ cwd: input.cwd, status, run });
},
}).then((status) => {
updatePollTargetAfterSuccess({
@@ -1051,6 +1185,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
},
async mergePullRequest(input) {
assertDirectPullRequestMergeReady(input);
await run(["pr", "merge", String(input.prNumber), `--${input.mergeMethod}`], {
cwd: input.cwd,
envOverlay: { GH_PROMPT_DISABLED: "1" },
@@ -1058,6 +1193,24 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
return { success: true };
},
async enablePullRequestAutoMerge(input) {
assertPullRequestAutoMergeEnableReady(input);
await run(["pr", "merge", String(input.prNumber), "--auto", `--${input.mergeMethod}`], {
cwd: input.cwd,
envOverlay: { GH_PROMPT_DISABLED: "1" },
});
return { success: true };
},
async disablePullRequestAutoMerge(input) {
assertPullRequestAutoMergeDisableReady(input);
await run(["pr", "merge", String(input.prNumber), "--disable-auto"], {
cwd: input.cwd,
envOverlay: { GH_PROMPT_DISABLED: "1" },
});
return { success: true };
},
isAuthenticated(input) {
return cached({
cwd: input.cwd,
@@ -1161,6 +1314,89 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
return api;
}
function assertDirectPullRequestMergeReady(input: MergeGitHubPullRequestOptions): void {
const github = input.status?.github;
if (!github) {
throw new Error("GitHub merge facts are unavailable for this pull request");
}
if (!DIRECT_PULL_REQUEST_MERGE_STATE_ALLOWLIST.has(github.mergeStateStatus ?? "")) {
throw new Error("GitHub does not report this pull request as ready for direct merge");
}
if (github.isMergeQueueEnabled || github.isInMergeQueue) {
throw new Error("Direct merge is not available because this repository uses a merge queue");
}
if (github.autoMergeRequest !== null) {
throw new Error("Direct merge is not available because auto-merge is already enabled");
}
if (!isPullRequestMergeMethodAllowed(github.repository, input.mergeMethod)) {
throw new Error(`Direct merge is not available because ${input.mergeMethod} is disabled`);
}
}
export function assertPullRequestAutoMergeEnableReady(
input: Pick<EnableGitHubPullRequestAutoMergeOptions, "mergeMethod" | "status">,
): void {
const github = input.status?.github;
if (!github) {
throw new Error("GitHub auto-merge facts are unavailable for this pull request");
}
if (github.mergeStateStatus !== "BLOCKED") {
throw new Error("GitHub does not report this pull request as blocked for auto-merge");
}
if (!github.viewerCanEnableAutoMerge) {
throw new Error("GitHub does not allow this viewer to enable auto-merge");
}
if (!github.repository.autoMergeAllowed) {
throw new Error("Auto-merge is disabled for this repository");
}
if (!isPullRequestMergeMethodAllowed(github.repository, input.mergeMethod)) {
throw new Error(`Auto-merge is not available because ${input.mergeMethod} is disabled`);
}
if (github.autoMergeRequest !== null) {
throw new Error("Auto-merge is already enabled for this pull request");
}
if (github.isMergeQueueEnabled || github.isInMergeQueue) {
throw new Error("Auto-merge is not available because this repository uses a merge queue");
}
if (input.status?.mergeable === "CONFLICTING") {
throw new Error("Auto-merge is not available because this pull request has conflicts");
}
}
export function assertPullRequestAutoMergeDisableReady(
input: Pick<DisableGitHubPullRequestAutoMergeOptions, "status">,
): void {
const github = input.status?.github;
if (!github) {
throw new Error("GitHub auto-merge facts are unavailable for this pull request");
}
if (github.autoMergeRequest === null) {
throw new Error("Auto-merge is not enabled for this pull request");
}
if (!github.viewerCanDisableAutoMerge) {
throw new Error("GitHub does not allow this viewer to disable auto-merge");
}
if (github.isMergeQueueEnabled || github.isInMergeQueue) {
throw new Error("Auto-merge is not available because this repository uses a merge queue");
}
}
export function isPullRequestMergeMethodAllowed(
repository: GitHubPullRequestStatusFacts["repository"],
method: GitHubPullRequestMergeMethod,
): boolean {
if (method === "squash") {
return repository.squashMergeAllowed;
}
if (method === "merge") {
return repository.mergeCommitAllowed;
}
return repository.rebaseMergeAllowed;
}
export function computeGithubNextInterval(
status: GitHubCurrentPullRequestStatus | null,
consecutiveErrors: number,
@@ -1372,6 +1608,68 @@ async function resolveCurrentPullRequestView(options: {
return match?.status ?? null;
}
async function addCurrentPullRequestGithubFacts(options: {
cwd: string;
status: GitHubCurrentPullRequestStatus | null;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<GitHubCurrentPullRequestStatus | null> {
const { status } = options;
if (!status?.repoOwner || !status.repoName || typeof status.number !== "number") {
return status;
}
const facts = await loadPullRequestGithubFacts({
cwd: options.cwd,
owner: status.repoOwner,
name: status.repoName,
number: status.number,
run: options.run,
});
if (!facts) {
return status;
}
return {
...status,
github: facts,
};
}
async function loadPullRequestGithubFacts(options: {
cwd: string;
owner: string;
name: string;
number: number;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<GitHubPullRequestStatusFacts | null> {
try {
const stdout = await options.run(
[
"api",
"graphql",
"-f",
`query=${PULL_REQUEST_STATUS_FACTS_QUERY}`,
"-F",
`owner=${options.owner}`,
"-F",
`name=${options.name}`,
"-F",
`number=${options.number}`,
],
{ cwd: options.cwd },
);
return parsePullRequestGithubFacts(stdout);
} catch (error) {
if (
error instanceof GitHubCommandError ||
error instanceof z.ZodError ||
error instanceof SyntaxError
) {
return null;
}
throw error;
}
}
async function tryCurrentPullRequestView(options: {
cwd: string;
headRef: string;
@@ -1469,6 +1767,52 @@ function parseCurrentPullRequestCandidateList(
.filter((candidate): candidate is ResolvedPullRequestCandidate => candidate !== null);
}
function parsePullRequestGithubFacts(stdout: string): GitHubPullRequestStatusFacts | null {
const parsed = GitHubPullRequestFactsGraphqlSchema.parse(JSON.parse(stdout || "{}"));
const repository = parsed.data.repository;
const pullRequest = repository?.pullRequest;
if (!repository || !pullRequest) {
return null;
}
return {
mergeStateStatus: pullRequest.mergeStateStatus ?? null,
autoMergeRequest: toGitHubAutoMergeRequest(pullRequest.autoMergeRequest),
viewerCanEnableAutoMerge: pullRequest.viewerCanEnableAutoMerge ?? false,
viewerCanDisableAutoMerge: pullRequest.viewerCanDisableAutoMerge ?? false,
viewerCanMergeAsAdmin: pullRequest.viewerCanMergeAsAdmin ?? false,
viewerCanUpdateBranch: pullRequest.viewerCanUpdateBranch ?? false,
repository: toGitHubRepositoryMergePolicy(repository),
isMergeQueueEnabled: pullRequest.isMergeQueueEnabled ?? false,
isInMergeQueue: pullRequest.isInMergeQueue ?? false,
};
}
function toGitHubAutoMergeRequest(
request: GitHubPullRequestFactsPullRequest["autoMergeRequest"],
): GitHubPullRequestStatusFacts["autoMergeRequest"] {
if (!request) {
return null;
}
return {
enabledAt: request.enabledAt ?? null,
mergeMethod: request.mergeMethod ?? null,
enabledBy: request.enabledBy?.login ?? null,
};
}
function toGitHubRepositoryMergePolicy(
repository: GitHubPullRequestFactsRepository,
): GitHubPullRequestStatusFacts["repository"] {
return {
autoMergeAllowed: repository.autoMergeAllowed ?? false,
mergeCommitAllowed: repository.mergeCommitAllowed ?? false,
squashMergeAllowed: repository.squashMergeAllowed ?? false,
rebaseMergeAllowed: repository.rebaseMergeAllowed ?? false,
viewerDefaultMergeMethod: repository.viewerDefaultMergeMethod ?? null,
};
}
function toCurrentPullRequestCandidate(
item: CurrentPullRequestStatusItem,
fallbackHeadRefName: string,