diff --git a/apps/backend/internal/github/client.go b/apps/backend/internal/github/client.go index b78c662827..2a6883ba6a 100644 --- a/apps/backend/internal/github/client.go +++ b/apps/backend/internal/github/client.go @@ -62,6 +62,9 @@ type Client interface { // event is one of "APPROVE", "COMMENT", "REQUEST_CHANGES". SubmitReview(ctx context.Context, owner, repo string, number int, event, body string) error + // MergePR merges a pull request. mergeMethod is one of "merge", "squash", "rebase". + MergePR(ctx context.Context, owner, repo string, number int, mergeMethod string) error + // ListRepoBranches lists branches for a repository. ListRepoBranches(ctx context.Context, owner, repo string) ([]RepoBranch, error) diff --git a/apps/backend/internal/github/controller.go b/apps/backend/internal/github/controller.go index ebcb85bd9d..feda28c5e5 100644 --- a/apps/backend/internal/github/controller.go +++ b/apps/backend/internal/github/controller.go @@ -2,6 +2,7 @@ package github import ( "errors" + "io" "net/http" "strconv" "strings" @@ -40,6 +41,7 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { api.GET("/prs/:owner/:repo/:number/status", c.httpGetPRStatus) api.POST("/prs/statuses", c.httpGetPRStatusesBatch) api.POST("/prs/:owner/:repo/:number/reviews", c.httpSubmitReview) + api.PUT("/prs/:owner/:repo/:number/merge", c.httpMergePR) api.GET("/watches/pr", c.httpListPRWatches) api.DELETE("/watches/pr/:id", c.httpDeletePRWatch) @@ -303,6 +305,58 @@ func (c *Controller) httpSubmitReview(ctx *gin.Context) { ctx.JSON(http.StatusOK, gin.H{"submitted": true}) } +func (c *Controller) httpMergePR(ctx *gin.Context) { + owner := ctx.Param("owner") + repo := ctx.Param("repo") + numberStr := ctx.Param("number") + number, err := strconv.Atoi(numberStr) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid PR number"}) + return + } + var req struct { + MergeMethod string `json:"merge_method"` + } + // Body is optional — an empty body (io.EOF) means "use the repo default + // merge method". A non-empty but malformed body is a client bug, not a + // silent "use default", so reject it with 400. + if bindErr := ctx.ShouldBindJSON(&req); bindErr != nil && !errors.Is(bindErr, io.EOF) { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + validMethods := map[string]bool{"": true, "merge": true, "squash": true, "rebase": true} + if !validMethods[req.MergeMethod] { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "merge_method must be merge, squash, or rebase"}) + return + } + if err := c.service.MergePR(ctx.Request.Context(), owner, repo, number, req.MergeMethod); 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.StatusMethodNotAllowed, http.StatusConflict: + status = http.StatusConflict + case http.StatusUnauthorized: + status = http.StatusUnauthorized + case http.StatusForbidden: + status = http.StatusForbidden + case http.StatusNotFound: + status = http.StatusNotFound + } + } + ctx.JSON(status, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"merged": true}) +} + func (c *Controller) httpListPRWatches(ctx *gin.Context) { watches, err := c.service.ListActivePRWatches(ctx.Request.Context()) if err != nil { diff --git a/apps/backend/internal/github/controller_test.go b/apps/backend/internal/github/controller_test.go index f778b6b2b0..0afe8b2ad3 100644 --- a/apps/backend/internal/github/controller_test.go +++ b/apps/backend/internal/github/controller_test.go @@ -1,6 +1,7 @@ package github import ( + "bytes" "context" "encoding/json" "fmt" @@ -17,6 +18,7 @@ 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 } func (s *stubClient) IsAuthenticated(context.Context) (bool, error) { return true, nil } @@ -69,6 +71,12 @@ func (s *stubClient) ListPRCommits(context.Context, string, string, int) ([]PRCo 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 } @@ -162,3 +170,125 @@ func TestHttpGetPRInfo_ServiceError(t *testing.T) { 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_UsesDefaultMethod(t *testing.T) { + var gotMethod string + sc := &stubClient{ + mergePRFn: func(_ context.Context, _, _ string, _ int, mergeMethod string) error { + gotMethod = mergeMethod + return 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 != "" { + t.Errorf("expected empty merge_method, got %q", gotMethod) + } +} + +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()) + } +} diff --git a/apps/backend/internal/github/gh_client.go b/apps/backend/internal/github/gh_client.go index 17ac5156b7..d889eed302 100644 --- a/apps/backend/internal/github/gh_client.go +++ b/apps/backend/internal/github/gh_client.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "net/http" "net/url" "os/exec" "strings" @@ -600,6 +601,51 @@ func (c *GHClient) SubmitReview(ctx context.Context, owner, repo string, number return nil } +func (c *GHClient) MergePR(ctx context.Context, owner, repo string, number int, mergeMethod string) error { + endpoint := fmt.Sprintf("repos/%s/%s/pulls/%d/merge", owner, repo, number) + args := []string{"api", endpoint, "-X", "PUT"} + if mergeMethod != "" { + args = append(args, "-f", "merge_method="+mergeMethod) + } + _, err := c.run(ctx, args...) + if err != nil { + // Surface status-based errors as GitHubAPIError so httpMergePR can + // translate 405 (not mergeable) / 409 (conflict) to HTTP 409 for + // gh CLI users too, matching the PAT path. + if code, ok := ghMergeStatusCode(err); ok { + return &GitHubAPIError{StatusCode: code, Endpoint: endpoint, Body: err.Error()} + } + return fmt.Errorf("merge PR #%d: %w", number, err) + } + return nil +} + +// ghMergeStatusCode extracts the HTTP status code from a gh CLI merge error. +// Returns the code and true when the stderr matches one of GitHub's +// merge-API failure shapes (404/403/405/409); false otherwise so the caller +// falls back to a generic wrap. +func ghMergeStatusCode(err error) (int, bool) { + if err == nil { + return 0, false + } + if isNotFoundErr(err) { + return http.StatusNotFound, true + } + if isForbiddenErr(err) { + return http.StatusForbidden, true + } + s := err.Error() + if strings.Contains(s, "HTTP 405") || strings.Contains(s, "status: 405") || + strings.Contains(s, "405 Method Not Allowed") { + return http.StatusMethodNotAllowed, true + } + if strings.Contains(s, "HTTP 409") || strings.Contains(s, "status: 409") || + strings.Contains(s, "409 Conflict") { + return http.StatusConflict, true + } + return 0, false +} + 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), diff --git a/apps/backend/internal/github/gh_client_test.go b/apps/backend/internal/github/gh_client_test.go index 4836ecc459..c4c3ba20bb 100644 --- a/apps/backend/internal/github/gh_client_test.go +++ b/apps/backend/internal/github/gh_client_test.go @@ -52,6 +52,38 @@ func TestIsForbiddenErr(t *testing.T) { } } +func TestGhMergeStatusCode(t *testing.T) { + cases := []struct { + name string + err error + wantOK bool + wantCode int + }{ + {"nil", nil, false, 0}, + {"unrelated", errors.New("connection refused"), false, 0}, + {"HTTP 404", errors.New("HTTP 404: Not Found"), true, 404}, + {"404 Not Found phrase", errors.New("404 Not Found"), true, 404}, + {"status 404", errors.New("request failed (status: 404)"), true, 404}, + {"HTTP 403", errors.New("HTTP 403: Forbidden"), true, 403}, + {"HTTP 405", errors.New("gh: HTTP 405: Method Not Allowed"), true, 405}, + {"status 405", errors.New("status: 405"), true, 405}, + {"405 phrase", errors.New("405 Method Not Allowed"), true, 405}, + {"HTTP 409", errors.New("HTTP 409: Conflict"), true, 409}, + {"status 409", errors.New("status: 409"), true, 409}, + {"409 phrase", errors.New("409 Conflict"), true, 409}, + {"500 not mapped", errors.New("HTTP 500: server error"), false, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + code, ok := ghMergeStatusCode(tc.err) + if ok != tc.wantOK || code != tc.wantCode { + t.Fatalf("ghMergeStatusCode(%v) = (%d, %v), want (%d, %v)", + tc.err, code, ok, tc.wantCode, tc.wantOK) + } + }) + } +} + func TestParseTimePtr(t *testing.T) { tests := []struct { name string diff --git a/apps/backend/internal/github/mock_client.go b/apps/backend/internal/github/mock_client.go index 74db6dcbf6..986256aed3 100644 --- a/apps/backend/internal/github/mock_client.go +++ b/apps/backend/internal/github/mock_client.go @@ -40,6 +40,14 @@ type submittedReview struct { Body string `json:"body"` } +// mergedPR records a MergePR call for test assertions. +type mergedPR struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Number int `json:"number"` + MergeMethod string `json:"merge_method"` +} + // repoKey is a composite key for per-repo lookups by owner/repo. type repoKey struct { Owner string @@ -64,6 +72,7 @@ type MockClient struct { files map[prKey][]PRFile commits map[prKey][]PRCommitInfo submittedReviews []submittedReview + mergedPRs []mergedPR } // NewMockClient creates a new MockClient with default values. @@ -266,6 +275,21 @@ func (m *MockClient) SubmitReview(_ context.Context, owner, repo string, number return nil } +func (m *MockClient) MergePR(_ context.Context, owner, repo string, number int, mergeMethod string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.mergedPRs = append(m.mergedPRs, mergedPR{ + Owner: owner, Repo: repo, Number: number, MergeMethod: mergeMethod, + }) + now := time.Now().UTC() + if pr, ok := m.prs[prKey{owner, repo, number}]; ok { + pr.State = "merged" + pr.MergedAt = &now + pr.Mergeable = false + } + return nil +} + // --- Setter methods for HTTP control endpoints --- // SetUser sets the authenticated username. @@ -404,6 +428,7 @@ func (m *MockClient) Reset() { m.files = make(map[prKey][]PRFile) m.commits = make(map[prKey][]PRCommitInfo) m.submittedReviews = nil + m.mergedPRs = nil } // SubmittedReviews returns all recorded SubmitReview calls. @@ -414,3 +439,12 @@ func (m *MockClient) SubmittedReviews() []submittedReview { copy(result, m.submittedReviews) return result } + +// MergedPRs returns all recorded MergePR calls. +func (m *MockClient) MergedPRs() []mergedPR { + m.mu.RLock() + defer m.mu.RUnlock() + result := make([]mergedPR, len(m.mergedPRs)) + copy(result, m.mergedPRs) + return result +} diff --git a/apps/backend/internal/github/noop_client.go b/apps/backend/internal/github/noop_client.go index d9e0668bc2..e15d87c44d 100644 --- a/apps/backend/internal/github/noop_client.go +++ b/apps/backend/internal/github/noop_client.go @@ -81,6 +81,10 @@ func (c *NoopClient) SubmitReview(context.Context, string, string, int, string, return ErrNoClient } +func (c *NoopClient) MergePR(context.Context, string, string, int, string) error { + return ErrNoClient +} + func (c *NoopClient) ListIssues(context.Context, string, string) ([]*Issue, error) { return nil, ErrNoClient } diff --git a/apps/backend/internal/github/pat_client.go b/apps/backend/internal/github/pat_client.go index be45ec0738..de2f692579 100644 --- a/apps/backend/internal/github/pat_client.go +++ b/apps/backend/internal/github/pat_client.go @@ -453,6 +453,24 @@ func (c *PATClient) SubmitReview(ctx context.Context, owner, repo string, number return c.post(ctx, endpoint, jsonBody) } +func (c *PATClient) MergePR(ctx context.Context, owner, repo string, number int, mergeMethod string) error { + endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/merge", owner, repo, number) + payload := map[string]string{} + if mergeMethod != "" { + payload["merge_method"] = mergeMethod + } + jsonBody, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal merge payload: %w", err) + } + return c.put(ctx, endpoint, jsonBody) +} + +// post makes an authenticated HTTP POST to the GitHub API. +// Errors on non-2xx are returned as plain `fmt.Errorf` (not `*GitHubAPIError`), +// so callers cannot recover the HTTP status via `errors.As`. Use `put` (or +// switch to wrapping in `GitHubAPIError`) if the caller needs per-status +// mapping like the merge endpoint does. func (c *PATClient) post(ctx context.Context, endpoint string, body []byte) error { u := githubAPIBase + endpoint req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body)) @@ -477,6 +495,30 @@ func (c *PATClient) post(ctx context.Context, endpoint string, body []byte) erro return nil } +func (c *PATClient) put(ctx context.Context, endpoint string, body []byte) error { + u := githubAPIBase + endpoint + req, err := http.NewRequestWithContext(ctx, http.MethodPut, u, bytes.NewReader(body)) + if err != nil { + return err + } + c.setGitHubHeaders(req) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("request PUT %s: %w", endpoint, err) + } + defer func() { _ = resp.Body.Close() }() + c.recordRateHeaders(resp, endpoint) + + if resp.StatusCode >= 400 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + c.maybeMarkRateExhaustedFromBody(endpoint, resp.StatusCode, respBody) + return &GitHubAPIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: string(respBody)} + } + return nil +} + func (c *PATClient) get(ctx context.Context, endpoint string, result interface{}) error { url := githubAPIBase + endpoint req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) diff --git a/apps/backend/internal/github/service.go b/apps/backend/internal/github/service.go index 09dabda50b..d4c29fb36c 100644 --- a/apps/backend/internal/github/service.go +++ b/apps/backend/internal/github/service.go @@ -392,6 +392,17 @@ func (s *Service) SubmitReview(ctx context.Context, owner, repo string, number i return s.client.SubmitReview(ctx, owner, repo, number, event, body) } +// 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. +func (s *Service) MergePR(ctx context.Context, owner, repo string, number int, mergeMethod string) error { + if s.client == nil { + return ErrNoClient + } + return s.client.MergePR(ctx, owner, repo, number, mergeMethod) +} + // --- PR Watch operations --- // CreatePRWatch creates a new PR watch for a (session, repository) pair. diff --git a/apps/web/components/github/pr-ci-popover.tsx b/apps/web/components/github/pr-ci-popover.tsx index 01c2640776..5dce1b834a 100644 --- a/apps/web/components/github/pr-ci-popover.tsx +++ b/apps/web/components/github/pr-ci-popover.tsx @@ -26,6 +26,7 @@ import { type WorkflowGroup, } from "@/lib/github/check-buckets"; import type { CheckRun, TaskPR } from "@/lib/types/github"; +import { PRMergeButton } from "./pr-merge-button"; type CountsView = { passed: number; @@ -489,7 +490,7 @@ export function PRCIPopover({ const authLost = ghStatus !== null && !ghStatus.authenticated; // Trigger an initial status load from the same hook the rest of the app uses. useGitHubStatus(); - const { feedback, isFetching, lastUpdatedAt } = usePRCIPopover(pr, enabled && !authLost); + const { feedback, isFetching, lastUpdatedAt, refetch } = usePRCIPopover(pr, enabled && !authLost); const onAddAsContext = useAddCheckToContext(pr); return ( @@ -513,6 +514,7 @@ export function PRCIPopover({ + )} {onOpenDetailPanel && ( diff --git a/apps/web/components/github/pr-detail-panel.tsx b/apps/web/components/github/pr-detail-panel.tsx index 8bd7ef6b4f..3cd69d8abb 100644 --- a/apps/web/components/github/pr-detail-panel.tsx +++ b/apps/web/components/github/pr-detail-panel.tsx @@ -32,6 +32,7 @@ import { CollapsibleSection, PRMarkdownBody, } from "./pr-shared"; +import { PRMergeButton } from "./pr-merge-button"; import { ReviewStateBadge } from "./pr-reviews-section"; import { ChecksSection } from "./pr-checks-section"; import { ReviewsSection } from "./pr-reviews-section"; @@ -475,6 +476,7 @@ function PRHeader({ +
diff --git a/apps/web/components/github/pr-merge-button.tsx b/apps/web/components/github/pr-merge-button.tsx new file mode 100644 index 0000000000..b9a3661097 --- /dev/null +++ b/apps/web/components/github/pr-merge-button.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { IconGitMerge } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { useToast } from "@/components/toast-provider"; +import { mergePR } from "@/lib/api/domains/github-api"; +import type { 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. +export function PRMergeButton({ + taskPR, + onMerged, + compact = false, +}: { + taskPR: TaskPR; + onMerged?: () => void; + compact?: boolean; +}) { + const { toast } = useToast(); + const [merging, setMerging] = useState(false); + // After a successful merge we stay hidden until the store catches up to + // 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); + + // 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 + // sticky `merged` flag from a previous merge would hide the button for an + // unrelated, still-mergeable PR. Reset it whenever the underlying PR id + // changes. + useEffect(() => { + setMerged(false); + }, [taskPR.id]); + + if (merged || !isPRReadyToMerge(taskPR)) return null; + + const handleMerge = async (e: React.MouseEvent) => { + e.stopPropagation(); + setMerging(true); + try { + await mergePR(taskPR.owner, taskPR.repo, taskPR.pr_number); + setMerged(true); + toast({ description: "PR merged", variant: "success" }); + onMerged?.(); + } catch (err) { + toast({ + title: "Failed to merge", + description: err instanceof Error ? err.message : "An error occurred", + variant: "error", + }); + } finally { + setMerging(false); + } + }; + + if (compact) { + return ( + + ); + } + + return ( + + ); +} diff --git a/apps/web/lib/api/domains/github-api.ts b/apps/web/lib/api/domains/github-api.ts index 8a8c459fe8..e12c21236e 100644 --- a/apps/web/lib/api/domains/github-api.ts +++ b/apps/web/lib/api/domains/github-api.ts @@ -134,6 +134,21 @@ export async function submitPRReview( ); } +// Merge a pull request. Omit mergeMethod to use the repo's default. +export async function mergePR( + owner: string, + repo: string, + number: number, + mergeMethod?: "merge" | "squash" | "rebase", +) { + return fetchJson<{ merged: boolean }>(`/api/v1/github/prs/${owner}/${repo}/${number}/merge`, { + init: { + method: "PUT", + body: JSON.stringify({ merge_method: mergeMethod ?? "" }), + }, + }); +} + // PR watches export async function listPRWatches(options?: ApiRequestOptions) { return fetchJson("/api/v1/github/watches/pr", options);