mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 10:25:03 +00:00
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:
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user