b8a09bc5ea
* fix(github): pick allowed merge method so squash-only repos stop 405ing The merge button sent an empty merge_method to GitHub, which defaults to "merge" regardless of repo settings — squash-only / rebase-only repos returned 405 "Merge commits are not allowed on this repository". The backend now resolves an empty method to the first allowed one via a new GetRepoMergeMethods endpoint (5-min cache), and the frontend renders a GitHub-style split button so users can pick among the allowed methods. * fix(github): address PR #999 review feedback - pr-merge-button: extract repo-methods fetch to useRepoMergeMethods hook (Claude bot); hide button while methods are loading so the primary label doesn't flicker (Claude bot); drop e.preventDefault on dropdown items so Radix closes the menu on selection (Greptile P1). - gh_client / pat_client: treat missing allow_* fields as false instead of true so a permission-gated response can't pick a disallowed merge method (cubic P2). - service.MergePR: only overwrite mergeMethod when pickDefaultMergeMethod returns non-empty, so a degenerate all-false config falls through to GitHub's default with the real error (Claude bot). * fix(github): don't hide merge button on lookup failure Round-2 PR feedback (Claude, Greptile, CodeRabbit, cubic): the `if (!methods) return null` guard added in the previous round to avoid label flicker turned a transient repo-methods lookup failure into a permanent UI lockout — the button would stay hidden until unmount/remount. - pr-merge-button: revert the null-guard; treat null as "use backend resolver", render the plain primary button without a dropdown. - use-repo-merge-methods: add a 5-min frontend TTL to match the backend cache so admin merge-strategy flips are visible without a page reload. Catch block now triggers a rerender, and co-waiting consumers attach both fulfilled/rejected handlers to avoid silent rejection swallowing. * test(github): cover useRepoMergeMethods + silence rejection warning Round-3 PR feedback (Claude): - Swallow rejections in the originating fetch chain instead of rethrowing, so the first consumer's promise never escapes the hook as an unhandled rejection. Coalesced consumers already re-render to null, which is the same state a fresh "not loaded" call would show. - Add use-repo-merge-methods.test.ts covering: null-on-missing-params, loading then resolution, cache hit on remount, concurrent-fetch coalescing, retry-on-failure (no cache poisoning, no unhandled rejection), and TTL-expiry refetch via fake timers. * test(github): cover httpGetRepoMergeMethods error branches Round-4 PR feedback (Claude): the new endpoint had only happy-path coverage. Add tests matching httpMergePR's existing shape: - nil client → 503 (ErrNoClient) - GitHubAPIError{404} → 404 - generic upstream error → 500 --------- Co-authored-by: Kandev Agent <agent@kandev.dev>
135 lines
5.4 KiB
TypeScript
135 lines
5.4 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
|
|
|
const getRepoMergeMethodsMock = vi.fn();
|
|
|
|
vi.mock("@/lib/api/domains/github-api", () => ({
|
|
getRepoMergeMethods: (...args: unknown[]) => getRepoMergeMethodsMock(...args),
|
|
}));
|
|
|
|
// Module-level cache survives across tests in the same module — give each
|
|
// test a unique owner so the cache state from one doesn't leak into the next.
|
|
let repoCounter = 0;
|
|
function uniqueRepo() {
|
|
repoCounter += 1;
|
|
return { owner: `owner-${repoCounter}`, repo: `repo-${repoCounter}` };
|
|
}
|
|
|
|
// Import after mocks so the hook picks up the mocked module.
|
|
import { useRepoMergeMethods } from "./use-repo-merge-methods";
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
getRepoMergeMethodsMock.mockReset();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe("useRepoMergeMethods", () => {
|
|
it("returns null when owner or repo are missing", () => {
|
|
const { result } = renderHook(() => useRepoMergeMethods(null, null));
|
|
expect(result.current).toBeNull();
|
|
expect(getRepoMergeMethodsMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns null while in flight, then the resolved value after settling", async () => {
|
|
const { owner, repo } = uniqueRepo();
|
|
const methods = { merge: false, squash: true, rebase: false };
|
|
getRepoMergeMethodsMock.mockResolvedValueOnce(methods);
|
|
|
|
const { result } = renderHook(() => useRepoMergeMethods(owner, repo));
|
|
expect(result.current).toBeNull();
|
|
|
|
await waitFor(() => expect(result.current).toEqual(methods));
|
|
expect(getRepoMergeMethodsMock).toHaveBeenCalledTimes(1);
|
|
expect(getRepoMergeMethodsMock).toHaveBeenCalledWith(owner, repo);
|
|
});
|
|
|
|
it("returns the cached value on subsequent mounts without refetching", async () => {
|
|
const { owner, repo } = uniqueRepo();
|
|
const methods = { merge: true, squash: false, rebase: false };
|
|
getRepoMergeMethodsMock.mockResolvedValueOnce(methods);
|
|
|
|
const first = renderHook(() => useRepoMergeMethods(owner, repo));
|
|
await waitFor(() => expect(first.result.current).toEqual(methods));
|
|
first.unmount();
|
|
|
|
const second = renderHook(() => useRepoMergeMethods(owner, repo));
|
|
// Cache hit: synchronously returns the stored value on first render.
|
|
expect(second.result.current).toEqual(methods);
|
|
expect(getRepoMergeMethodsMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("coalesces concurrent fetches for the same repo into a single request", async () => {
|
|
const { owner, repo } = uniqueRepo();
|
|
const methods = { merge: true, squash: true, rebase: true };
|
|
getRepoMergeMethodsMock.mockResolvedValueOnce(methods);
|
|
|
|
const first = renderHook(() => useRepoMergeMethods(owner, repo));
|
|
const second = renderHook(() => useRepoMergeMethods(owner, repo));
|
|
|
|
await waitFor(() => {
|
|
expect(first.result.current).toEqual(methods);
|
|
expect(second.result.current).toEqual(methods);
|
|
});
|
|
// Only one upstream call despite two consumers mounting back-to-back.
|
|
expect(getRepoMergeMethodsMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("returns null on fetch failure without throwing or poisoning the cache", async () => {
|
|
const { owner, repo } = uniqueRepo();
|
|
getRepoMergeMethodsMock
|
|
.mockRejectedValueOnce(new Error("network down"))
|
|
.mockResolvedValueOnce({ merge: false, squash: true, rebase: false });
|
|
|
|
// Catch unhandled rejections so a regression here (rejection escaping
|
|
// the hook) fails this test instead of leaking into the test runner.
|
|
const unhandled: unknown[] = [];
|
|
const onRejection = (event: PromiseRejectionEvent) => {
|
|
unhandled.push(event.reason);
|
|
event.preventDefault();
|
|
};
|
|
window.addEventListener("unhandledrejection", onRejection);
|
|
|
|
const first = renderHook(() => useRepoMergeMethods(owner, repo));
|
|
await waitFor(() => expect(getRepoMergeMethodsMock).toHaveBeenCalledTimes(1));
|
|
expect(first.result.current).toBeNull();
|
|
first.unmount();
|
|
|
|
// Failed fetch must NOT have cached null — the next mount retries.
|
|
const second = renderHook(() => useRepoMergeMethods(owner, repo));
|
|
await waitFor(() => expect(getRepoMergeMethodsMock).toHaveBeenCalledTimes(2));
|
|
await waitFor(() =>
|
|
expect(second.result.current).toEqual({ merge: false, squash: true, rebase: false }),
|
|
);
|
|
|
|
window.removeEventListener("unhandledrejection", onRejection);
|
|
expect(unhandled).toEqual([]);
|
|
});
|
|
|
|
it("refetches after the cache entry expires", async () => {
|
|
const { owner, repo } = uniqueRepo();
|
|
const first = { merge: false, squash: true, rebase: false };
|
|
const second = { merge: true, squash: false, rebase: true };
|
|
getRepoMergeMethodsMock.mockResolvedValueOnce(first).mockResolvedValueOnce(second);
|
|
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
const start = new Date("2026-01-01T00:00:00Z");
|
|
vi.setSystemTime(start);
|
|
|
|
const firstMount = renderHook(() => useRepoMergeMethods(owner, repo));
|
|
await waitFor(() => expect(firstMount.result.current).toEqual(first));
|
|
firstMount.unmount();
|
|
|
|
// Move past the 5-minute TTL.
|
|
act(() => {
|
|
vi.setSystemTime(new Date(start.getTime() + 6 * 60 * 1000));
|
|
});
|
|
|
|
const secondMount = renderHook(() => useRepoMergeMethods(owner, repo));
|
|
// Expired entry should be evicted on read → null while refetch is in flight.
|
|
expect(secondMount.result.current).toBeNull();
|
|
await waitFor(() => expect(secondMount.result.current).toEqual(second));
|
|
expect(getRepoMergeMethodsMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|