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({