Files
kandev/apps/web/hooks/domains/github/use-repo-merge-methods.ts
T
João Salavisa b8a09bc5ea fix(github): pick allowed merge method so squash-only repos stop 405ing (#999)
* 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>
2026-05-21 23:22:31 +01:00

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));
}