b8a09bc5ea
* fix(github): pick allowed merge method so squash-only repos stop 405ing The merge button sent an empty merge_method to GitHub, which defaults to "merge" regardless of repo settings — squash-only / rebase-only repos returned 405 "Merge commits are not allowed on this repository". The backend now resolves an empty method to the first allowed one via a new GetRepoMergeMethods endpoint (5-min cache), and the frontend renders a GitHub-style split button so users can pick among the allowed methods. * fix(github): address PR #999 review feedback - pr-merge-button: extract repo-methods fetch to useRepoMergeMethods hook (Claude bot); hide button while methods are loading so the primary label doesn't flicker (Claude bot); drop e.preventDefault on dropdown items so Radix closes the menu on selection (Greptile P1). - gh_client / pat_client: treat missing allow_* fields as false instead of true so a permission-gated response can't pick a disallowed merge method (cubic P2). - service.MergePR: only overwrite mergeMethod when pickDefaultMergeMethod returns non-empty, so a degenerate all-false config falls through to GitHub's default with the real error (Claude bot). * fix(github): don't hide merge button on lookup failure Round-2 PR feedback (Claude, Greptile, CodeRabbit, cubic): the `if (!methods) return null` guard added in the previous round to avoid label flicker turned a transient repo-methods lookup failure into a permanent UI lockout — the button would stay hidden until unmount/remount. - pr-merge-button: revert the null-guard; treat null as "use backend resolver", render the plain primary button without a dropdown. - use-repo-merge-methods: add a 5-min frontend TTL to match the backend cache so admin merge-strategy flips are visible without a page reload. Catch block now triggers a rerender, and co-waiting consumers attach both fulfilled/rejected handlers to avoid silent rejection swallowing. * test(github): cover useRepoMergeMethods + silence rejection warning Round-3 PR feedback (Claude): - Swallow rejections in the originating fetch chain instead of rethrowing, so the first consumer's promise never escapes the hook as an unhandled rejection. Coalesced consumers already re-render to null, which is the same state a fresh "not loaded" call would show. - Add use-repo-merge-methods.test.ts covering: null-on-missing-params, loading then resolution, cache hit on remount, concurrent-fetch coalescing, retry-on-failure (no cache poisoning, no unhandled rejection), and TTL-expiry refetch via fake timers. * test(github): cover httpGetRepoMergeMethods error branches Round-4 PR feedback (Claude): the new endpoint had only happy-path coverage. Add tests matching httpMergePR's existing shape: - nil client → 503 (ErrNoClient) - GitHubAPIError{404} → 404 - generic upstream error → 500 --------- Co-authored-by: Kandev Agent <agent@kandev.dev>
448 lines
14 KiB
Go
448 lines
14 KiB
Go
package github
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
)
|
|
|
|
// 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
|
|
getRepoMergeMethodsFn func() (RepoMergeMethods, error)
|
|
}
|
|
|
|
func (s *stubClient) IsAuthenticated(context.Context) (bool, error) { return true, nil }
|
|
func (s *stubClient) GetAuthenticatedUser(context.Context) (string, error) {
|
|
return "test-user", nil
|
|
}
|
|
func (s *stubClient) GetPR(ctx context.Context, owner, repo string, number int) (*PR, error) {
|
|
if s.getPRFunc != nil {
|
|
return s.getPRFunc(ctx, owner, repo, number)
|
|
}
|
|
return nil, fmt.Errorf("not implemented")
|
|
}
|
|
func (s *stubClient) FindPRByBranch(context.Context, string, string, string) (*PR, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubClient) ListAuthoredPRs(context.Context, string, string) ([]*PR, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubClient) SearchPRs(context.Context, string, string) ([]*PR, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubClient) SearchPRsPaged(context.Context, string, string, int, int) (*PRSearchPage, error) {
|
|
return &PRSearchPage{PRs: []*PR{}}, nil
|
|
}
|
|
func (s *stubClient) ListReviewRequestedPRs(context.Context, string, string, string) ([]*PR, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubClient) ListUserOrgs(context.Context) ([]GitHubOrg, error) { return nil, nil }
|
|
func (s *stubClient) SearchOrgRepos(context.Context, string, string, int) ([]GitHubRepo, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubClient) ListPRReviews(context.Context, string, string, int) ([]PRReview, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubClient) ListPRComments(context.Context, string, string, int, *time.Time) ([]PRComment, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubClient) ListCheckRuns(context.Context, string, string, string) ([]CheckRun, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubClient) GetPRFeedback(context.Context, string, string, int) (*PRFeedback, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubClient) ListPRFiles(context.Context, string, string, int) ([]PRFile, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubClient) ListPRCommits(context.Context, string, string, int) ([]PRCommitInfo, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubClient) SubmitReview(context.Context, string, string, int, string, string) error {
|
|
return nil
|
|
}
|
|
func (s *stubClient) MergePR(ctx context.Context, owner, repo string, number int, mergeMethod string) error {
|
|
if s.mergePRFn != nil {
|
|
return s.mergePRFn(ctx, owner, repo, number, mergeMethod)
|
|
}
|
|
return nil
|
|
}
|
|
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
|
|
}
|
|
func (s *stubClient) ListIssuesPaged(context.Context, string, string, int, int) (*IssueSearchPage, error) {
|
|
return &IssueSearchPage{Issues: []*Issue{}}, nil
|
|
}
|
|
func (s *stubClient) GetIssueState(context.Context, string, string, int) (string, error) {
|
|
return defaultPRState, nil
|
|
}
|
|
func (s *stubClient) GetPRStatus(context.Context, string, string, int) (*PRStatus, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func newControllerTestLogger() *logger.Logger {
|
|
log, _ := logger.NewLogger(logger.LoggingConfig{
|
|
Level: "error",
|
|
Format: "json",
|
|
})
|
|
return log
|
|
}
|
|
|
|
func setupControllerTest(client Client) (*gin.Engine, *Controller) {
|
|
gin.SetMode(gin.TestMode)
|
|
log := newControllerTestLogger()
|
|
svc := NewService(client, "pat", nil, nil, nil, log)
|
|
ctrl := NewController(svc, log)
|
|
router := gin.New()
|
|
ctrl.RegisterHTTPRoutes(router)
|
|
return router, ctrl
|
|
}
|
|
|
|
func TestHttpGetPRInfo_Success(t *testing.T) {
|
|
sc := &stubClient{
|
|
getPRFunc: func(_ context.Context, owner, repo string, number int) (*PR, error) {
|
|
if owner != "acme" || repo != "widget" || number != 42 {
|
|
t.Errorf("unexpected params: %s/%s#%d", owner, repo, number)
|
|
}
|
|
return &PR{Number: 42, Title: "feat: add widget"}, nil
|
|
},
|
|
}
|
|
router, _ := setupControllerTest(sc)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/github/prs/acme/widget/42/info", 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 pr PR
|
|
if err := json.NewDecoder(w.Body).Decode(&pr); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
if pr.Number != 42 {
|
|
t.Errorf("expected PR number 42, got %d", pr.Number)
|
|
}
|
|
if pr.Title != "feat: add widget" {
|
|
t.Errorf("expected title 'feat: add widget', got %q", pr.Title)
|
|
}
|
|
}
|
|
|
|
func TestHttpGetPRInfo_InvalidNumber(t *testing.T) {
|
|
router, _ := setupControllerTest(&stubClient{})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/github/prs/acme/widget/abc/info", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestHttpGetPRInfo_ServiceError(t *testing.T) {
|
|
sc := &stubClient{
|
|
getPRFunc: func(context.Context, string, string, int) (*PR, error) {
|
|
return nil, fmt.Errorf("not found")
|
|
},
|
|
}
|
|
router, _ := setupControllerTest(sc)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/github/prs/acme/widget/99/info", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Fatalf("expected 500, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestHttpMergePR_Success(t *testing.T) {
|
|
var called struct {
|
|
owner string
|
|
repo string
|
|
number int
|
|
mergeMethod string
|
|
}
|
|
sc := &stubClient{
|
|
mergePRFn: func(_ context.Context, owner, repo string, number int, mergeMethod string) error {
|
|
called.owner = owner
|
|
called.repo = repo
|
|
called.number = number
|
|
called.mergeMethod = mergeMethod
|
|
return nil
|
|
},
|
|
}
|
|
router, _ := setupControllerTest(sc)
|
|
|
|
body := bytes.NewBufferString(`{"merge_method":"squash"}`)
|
|
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 called.owner != "acme" || called.repo != "widget" || called.number != 42 || called.mergeMethod != "squash" {
|
|
t.Errorf("unexpected MergePR args: %+v", called)
|
|
}
|
|
}
|
|
|
|
func TestHttpMergePR_InvalidMethod(t *testing.T) {
|
|
router, _ := setupControllerTest(&stubClient{})
|
|
|
|
body := bytes.NewBufferString(`{"merge_method":"bogus"}`)
|
|
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.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
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 != "" {
|
|
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())
|
|
}
|
|
}
|
|
|
|
func TestHttpMergePR_MalformedJSON(t *testing.T) {
|
|
router, _ := setupControllerTest(&stubClient{})
|
|
|
|
// Truncated JSON: parser fails with a non-EOF error, which must now
|
|
// surface as 400 rather than silently falling through to the default
|
|
// merge method.
|
|
body := bytes.NewBufferString(`{"merge_method":"squash"`)
|
|
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.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHttpMergePR_NoClient_Returns503(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
log := newControllerTestLogger()
|
|
// nil client triggers ErrNoClient via Service.MergePR.
|
|
svc := NewService(nil, "none", nil, nil, nil, log)
|
|
ctrl := NewController(svc, log)
|
|
router := gin.New()
|
|
ctrl.RegisterHTTPRoutes(router)
|
|
|
|
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.StatusServiceUnavailable {
|
|
t.Fatalf("expected 503, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHttpMergePR_Conflict(t *testing.T) {
|
|
sc := &stubClient{
|
|
mergePRFn: func(context.Context, string, string, int, string) error {
|
|
return &GitHubAPIError{StatusCode: http.StatusMethodNotAllowed, Endpoint: "/merge", Body: "not mergeable"}
|
|
},
|
|
}
|
|
router, _ := setupControllerTest(sc)
|
|
|
|
body := bytes.NewBufferString(`{"merge_method":"merge"}`)
|
|
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.StatusConflict {
|
|
t.Fatalf("expected 409, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|