From 062f85cf1bfd66613c93d825b7ed536985ddef3d Mon Sep 17 00:00:00 2001 From: agent-company Date: Mon, 18 May 2026 21:44:16 +0000 Subject: [PATCH] feat: implement POST /pulls merge handler with merge button in PR detail Add MergePull HTTP handler that calls the existing client.MergePull() method, register the POST /pulls/{owner}/{repo}/{index}/merge route, and add a merge button/form to the PR detail template gated on Mergeable and open state. Supports merge, rebase, and squash styles. - Handler returns HTMX fragment on HX-Request, redirect otherwise - Error path returns inline error fragment for HTMX requests - Add mock merge endpoint and 3 integration tests - Merge button only shows when PR is mergeable and open Closes leeworks-agents/gitea-mobile#229 Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/handlers/handlers.go | 47 +++++++++++++++++++ internal/handlers/integration_test.go | 66 +++++++++++++++++++++++++++ internal/templates/pull_detail.html | 17 +++++++ 3 files changed, 130 insertions(+) diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 1134c72..517c409 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -56,6 +56,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /pulls/{owner}/{repo}/{index}", h.PullDetail) mux.HandleFunc("POST /pulls/{owner}/{repo}/{index}/review", h.SubmitReview) mux.HandleFunc("POST /pulls/{owner}/{repo}/{index}/state", h.SetPullState) + mux.HandleFunc("POST /pulls/{owner}/{repo}/{index}/merge", h.MergePull) // Settings (handled separately for auth bypass). settingsHandler := &SettingsHandler{ @@ -1102,3 +1103,49 @@ func (h *Handler) SubmitReview(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, fmt.Sprintf("/pulls/%s/%s/%d", owner, repo, index), http.StatusSeeOther) } + +// MergePull handles POST /pulls/{owner}/{repo}/{index}/merge. +func (h *Handler) MergePull(w http.ResponseWriter, r *http.Request) { + token := getToken(r) + owner := r.PathValue("owner") + repo := r.PathValue("repo") + indexStr := r.PathValue("index") + + index, err := strconv.ParseInt(indexStr, 10, 64) + if err != nil { + http.Error(w, "invalid PR index", http.StatusBadRequest) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + style := r.FormValue("Do") + if style == "" { + style = "merge" + } + title := r.FormValue("merge_title") + message := r.FormValue("merge_message") + + if err := h.Client.MergePull(r.Context(), token, owner, repo, index, style, title, message); err != nil { + slog.Error("failed to merge pull request", "error", err, "owner", owner, "repo", repo, "index", index) + if isHTMX(r) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, `Failed to merge pull request.`) + return + } + http.Error(w, "failed to merge pull request", http.StatusInternalServerError) + return + } + + if isHTMX(r) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, `Merged`) + return + } + + http.Redirect(w, r, fmt.Sprintf("/pulls/%s/%s/%d", owner, repo, index), http.StatusSeeOther) +} diff --git a/internal/handlers/integration_test.go b/internal/handlers/integration_test.go index b5dcd3e..5d2db74 100644 --- a/internal/handlers/integration_test.go +++ b/internal/handlers/integration_test.go @@ -185,6 +185,11 @@ func mockGiteaAPI(t *testing.T) *httptest.Server { json.NewEncoder(w).Encode(map[string]interface{}{"id": 1, "state": "APPROVED"}) }) + // POST /api/v1/repos/{owner}/{repo}/pulls/{index}/merge — merge PR. + mux.HandleFunc("POST /api/v1/repos/{owner}/{repo}/pulls/{index}/merge", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + // POST /api/v1/markdown — render markdown. mux.HandleFunc("POST /api/v1/markdown", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") @@ -917,6 +922,67 @@ func TestIntegration_SubmitReview_RequestChanges(t *testing.T) { } } +// --- Issue #229: Integration tests for POST /pulls/{owner}/{repo}/{index}/merge --- + +func TestIntegration_MergePull_Valid(t *testing.T) { + h, srv := newTestHandlerWithMock(t) + defer srv.Close() + + mux := http.NewServeMux() + mux.HandleFunc("POST /pulls/{owner}/{repo}/{index}/merge", h.MergePull) + + form := url.Values{"Do": {"merge"}} + req := reqWithToken(http.MethodPost, "/pulls/test-org/repo1/1/merge", form.Encode()) + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want %d", w.Code, http.StatusSeeOther) + } +} + +func TestIntegration_MergePull_HTMX(t *testing.T) { + h, srv := newTestHandlerWithMock(t) + defer srv.Close() + + mux := http.NewServeMux() + mux.HandleFunc("POST /pulls/{owner}/{repo}/{index}/merge", h.MergePull) + + form := url.Values{"Do": {"squash"}} + req := reqWithToken(http.MethodPost, "/pulls/test-org/repo1/1/merge", form.Encode()) + req.Header.Set("HX-Request", "true") + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + body := w.Body.String() + if !contains(body, "Merged") { + t.Errorf("expected 'Merged' in HTMX response, got: %s", body) + } +} + +func TestIntegration_MergePull_DefaultStyle(t *testing.T) { + h, srv := newTestHandlerWithMock(t) + defer srv.Close() + + mux := http.NewServeMux() + mux.HandleFunc("POST /pulls/{owner}/{repo}/{index}/merge", h.MergePull) + + // No "Do" form value — should default to "merge". + req := reqWithToken(http.MethodPost, "/pulls/test-org/repo1/1/merge", "") + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want %d", w.Code, http.StatusSeeOther) + } +} + // --- Issue #110: Integration tests for POST /issues/{owner}/{repo}/{index}/labels --- func TestIntegration_ApplyLabels_Valid(t *testing.T) { diff --git a/internal/templates/pull_detail.html b/internal/templates/pull_detail.html index f5a5c26..cf6678b 100644 --- a/internal/templates/pull_detail.html +++ b/internal/templates/pull_detail.html @@ -33,6 +33,23 @@ {{end}} +{{if and .Pull.Mergeable (eq .Pull.State "open")}} +
+

Merge Pull Request

+
+
+ + +
+ +
+
+{{end}} +

Submit Review