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>
This commit is contained in:
João Salavisa
2026-05-21 23:22:31 +01:00
committed by GitHub
parent f8654880f3
commit b8a09bc5ea
16 changed files with 778 additions and 36 deletions
+3
View File
@@ -68,6 +68,9 @@ type Client interface {
// ListRepoBranches lists branches for a repository.
ListRepoBranches(ctx context.Context, owner, repo string) ([]RepoBranch, error)
// GetRepoMergeMethods reports which merge methods a repository allows.
GetRepoMergeMethods(ctx context.Context, owner, repo string) (RepoMergeMethods, error)
// ListIssues searches for open issues (not PRs) matching the given query.
// filter is an optional additional search qualifier (e.g. "repo:owner/name" or "label:bug").
// customQuery, when non-empty, replaces the entire generated query.
@@ -66,6 +66,7 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) {
api.GET("/orgs", c.httpListUserOrgs)
api.GET("/repos/search", c.httpSearchRepos)
api.GET("/repos/:owner/:repo/branches", c.httpListRepoBranches)
api.GET("/repos/:owner/:repo/merge-methods", c.httpGetRepoMergeMethods)
api.GET("/user/prs", c.httpSearchUserPRs)
api.GET("/user/issues", c.httpSearchUserIssues)
@@ -525,6 +526,36 @@ func (c *Controller) httpListRepoBranches(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"branches": branches})
}
func (c *Controller) httpGetRepoMergeMethods(ctx *gin.Context) {
owner := ctx.Param("owner")
repo := ctx.Param("repo")
methods, err := c.service.GetRepoMergeMethods(ctx.Request.Context(), owner, repo)
if err != nil {
if errors.Is(err, ErrNoClient) {
ctx.JSON(http.StatusServiceUnavailable, gin.H{
"error": "GitHub is not configured. Install the gh CLI and run 'gh auth login', or add a GITHUB_TOKEN secret.",
"code": "github_not_configured",
})
return
}
status := http.StatusInternalServerError
var apiErr *GitHubAPIError
if errors.As(err, &apiErr) {
switch apiErr.StatusCode {
case http.StatusNotFound:
status = http.StatusNotFound
case http.StatusUnauthorized:
status = http.StatusUnauthorized
case http.StatusForbidden:
status = http.StatusForbidden
}
}
ctx.JSON(status, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, methods)
}
// httpSearchUserPRs searches for pull requests. Accepts:
// - query: the complete GitHub search query (e.g. "is:pr author:@me state:open")
// - filter: additional qualifiers appended to the default `type:pr`
+157 -4
View File
@@ -17,8 +17,9 @@ import (
// stubClient implements Client with no-op defaults; override fields as needed.
type stubClient struct {
getPRFunc func(ctx context.Context, owner, repo string, number int) (*PR, error)
mergePRFn func(ctx context.Context, owner, repo string, number int, mergeMethod string) error
getPRFunc func(ctx context.Context, owner, repo string, number int) (*PR, error)
mergePRFn func(ctx context.Context, owner, repo string, number int, mergeMethod string) error
getRepoMergeMethodsFn func() (RepoMergeMethods, error)
}
func (s *stubClient) IsAuthenticated(context.Context) (bool, error) { return true, nil }
@@ -80,6 +81,12 @@ func (s *stubClient) MergePR(ctx context.Context, owner, repo string, number int
func (s *stubClient) ListRepoBranches(context.Context, string, string) ([]RepoBranch, error) {
return nil, nil
}
func (s *stubClient) GetRepoMergeMethods(context.Context, string, string) (RepoMergeMethods, error) {
if s.getRepoMergeMethodsFn != nil {
return s.getRepoMergeMethodsFn()
}
return RepoMergeMethods{Merge: true, Squash: true, Rebase: true}, nil
}
func (s *stubClient) ListIssues(context.Context, string, string) ([]*Issue, error) {
return nil, nil
}
@@ -217,13 +224,82 @@ func TestHttpMergePR_InvalidMethod(t *testing.T) {
}
}
func TestHttpMergePR_EmptyBody_UsesDefaultMethod(t *testing.T) {
func TestHttpMergePR_EmptyBody_ResolvesToAllowedMethod(t *testing.T) {
// Empty merge_method must NOT propagate to GitHub — that would let
// GitHub default to "merge" and 405 on squash-only repos. The service
// should resolve to the first allowed method instead.
var gotMethod string
sc := &stubClient{
mergePRFn: func(_ context.Context, _, _ string, _ int, mergeMethod string) error {
gotMethod = mergeMethod
return nil
},
getRepoMergeMethodsFn: func() (RepoMergeMethods, error) {
// Squash-only repo (the case that surfaced this bug).
return RepoMergeMethods{Squash: true}, nil
},
}
router, _ := setupControllerTest(sc)
req := httptest.NewRequest(http.MethodPut, "/api/v1/github/prs/acme/widget/42/merge", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if gotMethod != "squash" {
t.Errorf("expected merge_method=squash, got %q", gotMethod)
}
}
func TestHttpMergePR_ExplicitMethod_Passthrough(t *testing.T) {
// When the user picks a method from the dropdown, the service must
// NOT second-guess it via the repo lookup.
var gotMethod string
var lookupCalls int
sc := &stubClient{
mergePRFn: func(_ context.Context, _, _ string, _ int, mergeMethod string) error {
gotMethod = mergeMethod
return nil
},
getRepoMergeMethodsFn: func() (RepoMergeMethods, error) {
lookupCalls++
return RepoMergeMethods{Merge: true}, nil
},
}
router, _ := setupControllerTest(sc)
body := bytes.NewBufferString(`{"merge_method":"rebase"}`)
req := httptest.NewRequest(http.MethodPut, "/api/v1/github/prs/acme/widget/42/merge", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if gotMethod != "rebase" {
t.Errorf("expected merge_method=rebase, got %q", gotMethod)
}
if lookupCalls != 0 {
t.Errorf("expected no merge-methods lookup for explicit pin, got %d", lookupCalls)
}
}
func TestHttpMergePR_EmptyBody_LookupFails_FallsBackToGitHubDefault(t *testing.T) {
// If the repo lookup itself errors, we still attempt the merge with an
// empty method and let GitHub surface whatever error it surfaces — better
// than refusing to merge because of an unrelated lookup failure.
var gotMethod string
sc := &stubClient{
mergePRFn: func(_ context.Context, _, _ string, _ int, mergeMethod string) error {
gotMethod = mergeMethod
return nil
},
getRepoMergeMethodsFn: func() (RepoMergeMethods, error) {
return RepoMergeMethods{}, fmt.Errorf("rate limited")
},
}
router, _ := setupControllerTest(sc)
@@ -235,7 +311,84 @@ func TestHttpMergePR_EmptyBody_UsesDefaultMethod(t *testing.T) {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if gotMethod != "" {
t.Errorf("expected empty merge_method, got %q", gotMethod)
t.Errorf("expected empty merge_method (fall back to GitHub default), got %q", gotMethod)
}
}
func TestHttpGetRepoMergeMethods_OK(t *testing.T) {
sc := &stubClient{
getRepoMergeMethodsFn: func() (RepoMergeMethods, error) {
return RepoMergeMethods{Squash: true, Rebase: true}, nil
},
}
router, _ := setupControllerTest(sc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/github/repos/acme/widget/merge-methods", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var got RepoMergeMethods
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
want := RepoMergeMethods{Squash: true, Rebase: true}
if got != want {
t.Errorf("got %+v, want %+v", got, want)
}
}
func TestHttpGetRepoMergeMethods_NoClient_Returns503(t *testing.T) {
gin.SetMode(gin.TestMode)
log := newControllerTestLogger()
// nil client triggers ErrNoClient via Service.GetRepoMergeMethods.
svc := NewService(nil, "none", nil, nil, nil, log)
ctrl := NewController(svc, log)
router := gin.New()
ctrl.RegisterHTTPRoutes(router)
req := httptest.NewRequest(http.MethodGet, "/api/v1/github/repos/acme/widget/merge-methods", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d: %s", w.Code, w.Body.String())
}
}
func TestHttpGetRepoMergeMethods_NotFound_Returns404(t *testing.T) {
sc := &stubClient{
getRepoMergeMethodsFn: func() (RepoMergeMethods, error) {
return RepoMergeMethods{}, &GitHubAPIError{StatusCode: http.StatusNotFound, Endpoint: "/repos/acme/widget"}
},
}
router, _ := setupControllerTest(sc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/github/repos/acme/widget/merge-methods", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
}
}
func TestHttpGetRepoMergeMethods_OtherError_Returns500(t *testing.T) {
sc := &stubClient{
getRepoMergeMethodsFn: func() (RepoMergeMethods, error) {
return RepoMergeMethods{}, fmt.Errorf("unexpected upstream failure")
},
}
router, _ := setupControllerTest(sc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/github/repos/acme/widget/merge-methods", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
}
}
+26
View File
@@ -646,6 +646,32 @@ func ghMergeStatusCode(err error) (int, bool) {
return 0, false
}
func (c *GHClient) GetRepoMergeMethods(ctx context.Context, owner, repo string) (RepoMergeMethods, error) {
out, err := c.run(ctx, "api", fmt.Sprintf("repos/%s/%s", owner, repo))
if err != nil {
return RepoMergeMethods{}, fmt.Errorf("get repo merge methods: %w", err)
}
var raw struct {
AllowMergeCommit *bool `json:"allow_merge_commit"`
AllowSquashMerge *bool `json:"allow_squash_merge"`
AllowRebaseMerge *bool `json:"allow_rebase_merge"`
}
if err := json.Unmarshal([]byte(out), &raw); err != nil {
return RepoMergeMethods{}, fmt.Errorf("parse repo: %w", err)
}
// Conservative read: missing field → false. A permission-gated response
// that omits allow_* would otherwise let us pick a disallowed method
// (e.g. "merge" on a rebase-only repo), reproducing the 405 this fix is
// designed to prevent. Callers that fall back to GitHub's default on an
// empty pick get a meaningful error instead of a wrong-method 405.
allowed := func(p *bool) bool { return p != nil && *p }
return RepoMergeMethods{
Merge: allowed(raw.AllowMergeCommit),
Squash: allowed(raw.AllowSquashMerge),
Rebase: allowed(raw.AllowRebaseMerge),
}, nil
}
func (c *GHClient) ListRepoBranches(ctx context.Context, owner, repo string) ([]RepoBranch, error) {
out, err := c.run(ctx, "api",
fmt.Sprintf("repos/%s/%s/branches", owner, repo),
@@ -73,6 +73,7 @@ type MockClient struct {
commits map[prKey][]PRCommitInfo
submittedReviews []submittedReview
mergedPRs []mergedPR
mergeMethods map[repoKey]RepoMergeMethods
}
// NewMockClient creates a new MockClient with default values.
@@ -89,6 +90,7 @@ func NewMockClient() *MockClient {
checks: make(map[checkKey][]CheckRun),
files: make(map[prKey][]PRFile),
commits: make(map[prKey][]PRCommitInfo),
mergeMethods: make(map[repoKey]RepoMergeMethods),
}
}
@@ -275,6 +277,25 @@ func (m *MockClient) SubmitReview(_ context.Context, owner, repo string, number
return nil
}
func (m *MockClient) GetRepoMergeMethods(_ context.Context, owner, repo string) (RepoMergeMethods, error) {
m.mu.RLock()
defer m.mu.RUnlock()
if methods, ok := m.mergeMethods[repoKey{owner, repo}]; ok {
return methods, nil
}
// Default to all three allowed so existing e2e fixtures don't have to
// seed merge settings just to exercise the merge button.
return RepoMergeMethods{Merge: true, Squash: true, Rebase: true}, nil
}
// SetRepoMergeMethods overrides the allowed merge methods for a repo.
// Used by e2e fixtures to exercise the squash-only / rebase-only paths.
func (m *MockClient) SetRepoMergeMethods(owner, repo string, methods RepoMergeMethods) {
m.mu.Lock()
defer m.mu.Unlock()
m.mergeMethods[repoKey{owner, repo}] = methods
}
func (m *MockClient) MergePR(_ context.Context, owner, repo string, number int, mergeMethod string) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -429,6 +450,7 @@ func (m *MockClient) Reset() {
m.commits = make(map[prKey][]PRCommitInfo)
m.submittedReviews = nil
m.mergedPRs = nil
m.mergeMethods = make(map[repoKey]RepoMergeMethods)
}
// SubmittedReviews returns all recorded SubmitReview calls.
+8
View File
@@ -296,6 +296,14 @@ type RepoBranch struct {
Name string `json:"name"`
}
// RepoMergeMethods reports which merge methods a repository allows. Mirrors
// the allow_*_merge booleans from GET /repos/{owner}/{repo}.
type RepoMergeMethods struct {
Merge bool `json:"merge"`
Squash bool `json:"squash"`
Rebase bool `json:"rebase"`
}
// GitHubStatus represents GitHub connection status.
type GitHubStatus struct {
Authenticated bool `json:"authenticated"`
@@ -77,6 +77,10 @@ func (c *NoopClient) ListRepoBranches(context.Context, string, string) ([]RepoBr
return nil, ErrNoClient
}
func (c *NoopClient) GetRepoMergeMethods(context.Context, string, string) (RepoMergeMethods, error) {
return RepoMergeMethods{}, ErrNoClient
}
func (c *NoopClient) SubmitReview(context.Context, string, string, int, string, string) error {
return ErrNoClient
}
@@ -440,6 +440,27 @@ func (c *PATClient) ListRepoBranches(ctx context.Context, owner, repo string) ([
return branches, nil
}
func (c *PATClient) GetRepoMergeMethods(ctx context.Context, owner, repo string) (RepoMergeMethods, error) {
var raw struct {
AllowMergeCommit *bool `json:"allow_merge_commit"`
AllowSquashMerge *bool `json:"allow_squash_merge"`
AllowRebaseMerge *bool `json:"allow_rebase_merge"`
}
if err := c.get(ctx, fmt.Sprintf("/repos/%s/%s", owner, repo), &raw); err != nil {
return RepoMergeMethods{}, fmt.Errorf("get repo merge methods: %w", err)
}
// Conservative read: missing field → false. A permission-gated response
// that omits allow_* would otherwise let us pick a disallowed method
// (e.g. "merge" on a rebase-only repo), reproducing the 405 this fix is
// designed to prevent.
allowed := func(p *bool) bool { return p != nil && *p }
return RepoMergeMethods{
Merge: allowed(raw.AllowMergeCommit),
Squash: allowed(raw.AllowSquashMerge),
Rebase: allowed(raw.AllowRebaseMerge),
}, nil
}
func (c *PATClient) SubmitReview(ctx context.Context, owner, repo string, number int, event, body string) error {
endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews", owner, repo, number)
payload := map[string]string{"event": event}
+59 -3
View File
@@ -108,6 +108,7 @@ type Service struct {
taskEventSubs []bus.Subscription
searchCache *ttlCache
prStatusCache *ttlCache
mergeMethodsCache *ttlCache
protectionCache *branchProtectionCache
rateTracker *RateTracker
@@ -129,6 +130,7 @@ func NewService(client Client, authMethod string, secrets SecretProvider, store
logger: log,
searchCache: newTTLCache(),
prStatusCache: newTTLCache(),
mergeMethodsCache: newMergeMethodsCache(),
protectionCache: newBranchProtectionCache(),
rateTracker: NewRateTracker(eventBus, log),
cleanupFailureCounts: make(map[string]int),
@@ -432,16 +434,70 @@ func (s *Service) SubmitReview(ctx context.Context, owner, repo string, number i
}
// MergePR merges a pull request. mergeMethod is one of "merge", "squash",
// "rebase"; an empty string lets GitHub apply the repo's default. The caller
// is expected to refresh PR feedback after a successful merge — the background
// poller will catch the merged state on its next pass.
// "rebase"; an empty string asks the service to pick the first method the
// repo allows. The caller is expected to refresh PR feedback after a
// successful merge — the background poller will catch the merged state on
// its next pass.
func (s *Service) MergePR(ctx context.Context, owner, repo string, number int, mergeMethod string) error {
if s.client == nil {
return ErrNoClient
}
if mergeMethod == "" {
// Resolve to an allowed method up-front so we don't rely on GitHub's
// "default to merge" behavior, which 405s on repos that disallow
// merge commits (squash-only / rebase-only). Best-effort: if the
// lookup fails (or — degenerate config — reports no method allowed),
// fall back to GitHub's default and surface its error rather than
// blocking the merge attempt.
if methods, err := s.GetRepoMergeMethods(ctx, owner, repo); err == nil {
if pick := pickDefaultMergeMethod(methods); pick != "" {
mergeMethod = pick
}
}
}
return s.client.MergePR(ctx, owner, repo, number, mergeMethod)
}
// GetRepoMergeMethods returns the merge methods a repo allows, cached for
// a few minutes since repo settings rarely change.
func (s *Service) GetRepoMergeMethods(ctx context.Context, owner, repo string) (RepoMergeMethods, error) {
if s.client == nil {
return RepoMergeMethods{}, ErrNoClient
}
key := owner + "/" + repo
v, err := s.mergeMethodsCache.doOrFetch(key, func() (any, error) {
return s.client.GetRepoMergeMethods(ctx, owner, repo)
})
if err != nil {
return RepoMergeMethods{}, err
}
return v.(RepoMergeMethods), nil
}
// Merge method identifiers accepted by GitHub's pulls/{number}/merge endpoint
// and used throughout the merge resolution paths.
const (
mergeMethodMerge = "merge"
mergeMethodSquash = "squash"
mergeMethodRebase = "rebase"
)
// pickDefaultMergeMethod picks the merge method to use when the caller
// didn't pin one. Prefers squash (matches the convention most repos in
// this codebase follow) and falls back to merge, then rebase.
func pickDefaultMergeMethod(m RepoMergeMethods) string {
switch {
case m.Squash:
return mergeMethodSquash
case m.Merge:
return mergeMethodMerge
case m.Rebase:
return mergeMethodRebase
default:
return ""
}
}
// --- PR Watch operations ---
// CreatePRWatch creates a new PR watch for a (session, repository) pair.
@@ -0,0 +1,56 @@
package github
import (
"context"
"testing"
"github.com/kandev/kandev/internal/common/logger"
)
func TestPickDefaultMergeMethod(t *testing.T) {
tests := []struct {
name string
methods RepoMergeMethods
want string
}{
{"squash preferred over merge", RepoMergeMethods{Merge: true, Squash: true, Rebase: true}, "squash"},
{"squash-only repo", RepoMergeMethods{Squash: true}, "squash"},
{"merge when squash disabled", RepoMergeMethods{Merge: true, Rebase: true}, "merge"},
{"rebase-only repo", RepoMergeMethods{Rebase: true}, "rebase"},
{"none allowed → empty string", RepoMergeMethods{}, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := pickDefaultMergeMethod(tt.methods); got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
func TestService_GetRepoMergeMethods_Cached(t *testing.T) {
mc := NewMockClient()
mc.SetRepoMergeMethods("acme", "widget", RepoMergeMethods{Squash: true})
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
svc := NewService(mc, AuthMethodPAT, nil, nil, nil, log)
// Two consecutive calls — cache must serve the second one without re-hitting
// the client. We can't directly count calls into MockClient, but seeding a
// different value after the first call lets us prove the cache won.
first, err := svc.GetRepoMergeMethods(context.Background(), "acme", "widget")
if err != nil {
t.Fatalf("first: %v", err)
}
if !first.Squash || first.Merge || first.Rebase {
t.Errorf("first: got %+v, want squash-only", first)
}
mc.SetRepoMergeMethods("acme", "widget", RepoMergeMethods{Merge: true, Rebase: true})
second, err := svc.GetRepoMergeMethods(context.Background(), "acme", "widget")
if err != nil {
t.Fatalf("second: %v", err)
}
if second != first {
t.Errorf("expected cached result %+v, got %+v", first, second)
}
}
@@ -42,6 +42,15 @@ func newTTLCache() *ttlCache {
}
}
// newMergeMethodsCache uses a longer TTL than the default search/status
// caches: repo merge settings rarely change, so a 5-minute window cuts the
// per-PR-view API calls without making "I just toggled squash" feel stuck.
func newMergeMethodsCache() *ttlCache {
c := newTTLCache()
c.ttl = 5 * time.Minute
return c
}
func (c *ttlCache) get(key string) (any, bool) {
c.mu.Lock()
defer c.mu.Unlock()
+138 -27
View File
@@ -1,18 +1,27 @@
"use client";
import { useEffect, useState } from "react";
import { IconGitMerge } from "@tabler/icons-react";
import { IconChevronDown, IconGitMerge } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { useToast } from "@/components/toast-provider";
import { useRepoMergeMethods } from "@/hooks/domains/github/use-repo-merge-methods";
import { mergePR } from "@/lib/api/domains/github-api";
import type { TaskPR } from "@/lib/types/github";
import type { MergeMethod, TaskPR } from "@/lib/types/github";
import { isPRReadyToMerge } from "./pr-task-icon";
// Renders nothing unless the PR is fully green (CI success + mergeable +
// approval or no-review-needed). Shared by the PR detail panel header and
// the topbar hover popover so a "ready" PR can be merged from either
// surface. `compact` switches to the smaller pill variant used inside the
// dense popover.
// approval or no-review-needed). When `useRepoMergeMethods` hasn't yet
// returned the repo's allowed methods (loading, failure, or expired cache)
// the button still renders — clicks fall through to the backend resolver.
// Shared by the PR detail panel header and the topbar hover popover so a
// "ready" PR can be merged from either surface. `compact` switches to the
// smaller pill variant used inside the dense popover.
export function PRMergeButton({
taskPR,
onMerged,
@@ -28,6 +37,7 @@ export function PRMergeButton({
// state="merged" — otherwise the button briefly re-enables during the async
// refresh window and a double-click would hit the GitHub API again.
const [merged, setMerged] = useState(false);
const methods = useRepoMergeMethods(taskPR.owner, taskPR.repo);
// If the same component instance ever renders a different PR (e.g. the user
// switches the active task while the panel/popover stays mounted), the
@@ -39,12 +49,19 @@ export function PRMergeButton({
}, [taskPR.id]);
if (merged || !isPRReadyToMerge(taskPR)) return null;
// `methods` may be null on first render, on lookup failure, or after the
// 5-minute cache window. We still render the button — clicking with no
// method routes through the backend's GetRepoMergeMethods resolver, so
// the merge succeeds either way. The trade-off is a brief label flicker
// from "Merge PR" → "Squash and merge" on first load; locking the user
// out of merging on a transient fetch failure is the worse alternative.
const allowed = allowedMethods(methods);
const primary = pickPrimaryMethod(allowed);
const handleMerge = async (e: React.MouseEvent) => {
e.stopPropagation();
const runMerge = async (method?: MergeMethod) => {
setMerging(true);
try {
await mergePR(taskPR.owner, taskPR.repo, taskPR.pr_number);
await mergePR(taskPR.owner, taskPR.repo, taskPR.pr_number, method);
setMerged(true);
toast({ description: "PR merged", variant: "success" });
onMerged?.();
@@ -59,31 +76,125 @@ export function PRMergeButton({
}
};
if (compact) {
return (
<button
type="button"
data-testid="pr-merge-button"
onClick={handleMerge}
disabled={merging}
className="self-end inline-flex items-center gap-1 rounded-full bg-green-600 px-2 py-0.5 text-[11px] font-medium text-white hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-500 disabled:opacity-60 cursor-pointer"
>
<IconGitMerge className="h-3 w-3" />
{merging ? "Merging..." : "Merge"}
</button>
);
}
const handlePrimary = (e: React.MouseEvent) => {
e.stopPropagation();
void runMerge(primary);
};
return (
<MergeButtonShell
compact={compact}
label={merging ? "Merging..." : mergeLabel(primary)}
disabled={merging}
onPrimaryClick={handlePrimary}
extraMethods={allowed.filter((m) => m !== primary)}
onPickMethod={(m) => void runMerge(m)}
/>
);
}
function MergeButtonShell({
compact,
label,
disabled,
onPrimaryClick,
extraMethods,
onPickMethod,
}: {
compact: boolean;
label: string;
disabled: boolean;
onPrimaryClick: (e: React.MouseEvent) => void;
extraMethods: MergeMethod[];
onPickMethod: (m: MergeMethod) => void;
}) {
const showDropdown = extraMethods.length > 0;
const primaryBtn = compact ? (
<button
type="button"
data-testid="pr-merge-button"
onClick={onPrimaryClick}
disabled={disabled}
className={`inline-flex items-center gap-1 bg-green-600 px-2 py-0.5 text-[11px] font-medium text-white hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-500 disabled:opacity-60 cursor-pointer ${showDropdown ? "rounded-l-full" : "rounded-full"}`}
>
<IconGitMerge className="h-3 w-3" />
{label}
</button>
) : (
<Button
data-testid="pr-merge-button"
size="sm"
className="cursor-pointer gap-1.5 border-0 bg-green-600 text-white hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-500"
onClick={handleMerge}
disabled={merging}
className={`cursor-pointer gap-1.5 border-0 bg-green-600 text-white hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-500 ${showDropdown ? "rounded-r-none" : ""}`}
onClick={onPrimaryClick}
disabled={disabled}
>
<IconGitMerge className="h-3.5 w-3.5" />
{merging ? "Merging..." : "Merge PR"}
{label}
</Button>
);
if (!showDropdown) {
return compact ? primaryBtn : <span className="self-end">{primaryBtn}</span>;
}
return (
<span className={compact ? "inline-flex" : "self-end inline-flex"}>
{primaryBtn}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
data-testid="pr-merge-button-more"
aria-label="Choose merge method"
disabled={disabled}
onClick={(e) => e.stopPropagation()}
className={
compact
? "inline-flex items-center rounded-r-full border-l border-green-700/40 bg-green-600 px-1.5 py-0.5 text-white hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-500 disabled:opacity-60 cursor-pointer"
: "inline-flex items-center rounded-r-md border-l border-green-700/40 bg-green-600 px-2 text-white hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-500 disabled:opacity-60 cursor-pointer"
}
>
<IconChevronDown className={compact ? "h-3 w-3" : "h-3.5 w-3.5"} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-auto">
{extraMethods.map((m) => (
<DropdownMenuItem key={m} onSelect={() => onPickMethod(m)}>
{mergeLabel(m)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</span>
);
}
function mergeLabel(method?: MergeMethod): string {
switch (method) {
case "squash":
return "Squash and merge";
case "rebase":
return "Rebase and merge";
case "merge":
return "Create a merge commit";
default:
return "Merge PR";
}
}
// Returns the methods the repo allows, in the order we prefer to surface
// them (squash → merge → rebase, matching GitHub's own button defaults).
// When the lookup hasn't resolved (or failed), returns an empty array so
// the button still renders without a dropdown and the backend's resolver
// picks the method at merge time.
function allowedMethods(
methods: { merge: boolean; squash: boolean; rebase: boolean } | null,
): MergeMethod[] {
if (!methods) return [];
const order: MergeMethod[] = ["squash", "merge", "rebase"];
return order.filter((m) => methods[m]);
}
function pickPrimaryMethod(allowed: MergeMethod[]): MergeMethod | undefined {
return allowed[0];
}
@@ -0,0 +1,134 @@
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);
});
});
@@ -0,0 +1,82 @@
"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));
}
+20 -2
View File
@@ -25,6 +25,8 @@ import type {
GitHubActionPresets,
UpdateGitHubActionPresetsRequest,
CleanupTasksResponse,
MergeMethod,
RepoMergeMethods,
} from "@/lib/types/github";
// Status
@@ -135,12 +137,14 @@ export async function submitPRReview(
);
}
// Merge a pull request. Omit mergeMethod to use the repo's default.
// Merge a pull request. Omit mergeMethod to let the backend pick the first
// method the repo allows (avoids GitHub's "default to merge commit" 405 on
// squash-only / rebase-only repos).
export async function mergePR(
owner: string,
repo: string,
number: number,
mergeMethod?: "merge" | "squash" | "rebase",
mergeMethod?: MergeMethod,
) {
return fetchJson<{ merged: boolean }>(`/api/v1/github/prs/${owner}/${repo}/${number}/merge`, {
init: {
@@ -150,6 +154,20 @@ export async function mergePR(
});
}
// Fetch the merge methods a repository allows (allow_merge_commit /
// allow_squash_merge / allow_rebase_merge). Used by the merge button to
// hide disallowed options and avoid 405s.
export async function getRepoMergeMethods(
owner: string,
repo: string,
options?: ApiRequestOptions,
) {
return fetchJson<RepoMergeMethods>(
`/api/v1/github/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/merge-methods`,
options,
);
}
// PR watches
export async function listPRWatches(options?: ApiRequestOptions) {
return fetchJson<PRWatchesResponse>("/api/v1/github/watches/pr", options);
+8
View File
@@ -123,6 +123,14 @@ export type GitHubPRStatus = {
checks_passing: number;
};
export type MergeMethod = "merge" | "squash" | "rebase";
export type RepoMergeMethods = {
merge: boolean;
squash: boolean;
rebase: boolean;
};
export type MergeableState =
| "clean"
| "blocked"