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>
83 lines
3.0 KiB
TypeScript
83 lines
3.0 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useReducer } from "react";
|
|
import { getRepoMergeMethods } from "@/lib/api/domains/github-api";
|
|
import type { RepoMergeMethods } from "@/lib/types/github";
|
|
|
|
// Module-level cache: merge settings are repo-scoped and rarely change, and
|
|
// the backend already TTL-caches them. Coalescing concurrent fetches here
|
|
// keeps multiple merge buttons on the same page from triggering duplicate
|
|
// round-trips before the backend cache warms. Match the backend's 5-minute
|
|
// window so an admin flipping a merge strategy mid-session is reflected
|
|
// without a full page reload.
|
|
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
|
|
type CachedEntry = { value: RepoMergeMethods; expiresAt: number };
|
|
const completed = new Map<string, CachedEntry>();
|
|
// The pending promise is only used to coalesce concurrent fetches — consumers
|
|
// re-read from `completed` after it settles, so the resolved value is irrelevant.
|
|
// Typing it as `void` lets the chain swallow rejections cleanly (no rethrow,
|
|
// no unhandled-rejection warnings in the browser console).
|
|
const pending = new Map<string, Promise<void>>();
|
|
|
|
function repoKey(owner: string, repo: string) {
|
|
return `${owner}/${repo}`;
|
|
}
|
|
|
|
function readFresh(key: string): RepoMergeMethods | null {
|
|
const entry = completed.get(key);
|
|
if (!entry) return null;
|
|
if (entry.expiresAt <= Date.now()) {
|
|
completed.delete(key);
|
|
return null;
|
|
}
|
|
return entry.value;
|
|
}
|
|
|
|
/**
|
|
* Fetches the merge methods a repository allows so callers can populate the
|
|
* merge-method dropdown without sending a request to GitHub that 405s on
|
|
* squash-only / rebase-only repos.
|
|
*
|
|
* Returns `null` while the fetch is in flight, on failure, or when
|
|
* owner/repo are missing. Callers should treat `null` as "use the backend
|
|
* resolver" — never as "hide the merge UI", because a transient lookup
|
|
* failure would otherwise lock the user out of merging.
|
|
*/
|
|
export function useRepoMergeMethods(
|
|
owner: string | null,
|
|
repo: string | null,
|
|
): RepoMergeMethods | null {
|
|
const [, rerender] = useReducer((c: number) => c + 1, 0);
|
|
|
|
useEffect(() => {
|
|
if (!owner || !repo) return;
|
|
const key = repoKey(owner, repo);
|
|
if (readFresh(key)) return;
|
|
const existing = pending.get(key);
|
|
if (existing) {
|
|
void existing.then(rerender);
|
|
return;
|
|
}
|
|
const promise = getRepoMergeMethods(owner, repo)
|
|
.then((result) => {
|
|
completed.set(key, { value: result, expiresAt: Date.now() + CACHE_TTL_MS });
|
|
pending.delete(key);
|
|
rerender();
|
|
})
|
|
.catch(() => {
|
|
// Don't poison the cache on transient failures — the next mount or
|
|
// render will retry. Swallow the rejection here so the originating
|
|
// consumer's promise doesn't surface as an unhandled rejection in
|
|
// the browser console; co-waiting consumers re-render and read
|
|
// null, which behaves identically to a fresh "not-loaded" state.
|
|
pending.delete(key);
|
|
rerender();
|
|
});
|
|
pending.set(key, promise);
|
|
}, [owner, repo]);
|
|
|
|
if (!owner || !repo) return null;
|
|
return readFresh(repoKey(owner, repo));
|
|
}
|