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:
@@ -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`
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user