feat(github): add merge button when PR is ready to merge (#979)
* feat(github): add merge button when PR is ready to merge Surfaces a one-click merge action in the PR detail panel header and as a compact green pill in the PR topbar hover popover, gated by the existing isPRReadyToMerge predicate (open, CI success, mergeable_state=clean, and either approved or no review process). Backend adds MergePR to the Client interface, implements it across PAT/gh/Noop/Mock clients, exposes PUT /api/v1/github/prs/:owner/:repo/:number/merge, and maps GitHub 405/409 to HTTP 409 so the UI can show a clear error. * fix(github): address PR review feedback on merge endpoint - Map gh CLI 404/403/405/409 stderr to *GitHubAPIError via a new ghMergeStatusCode helper, so merge conflicts surface as HTTP 409 for gh CLI users (previously only PAT users got the right status). - Service.MergePR returns ErrNoClient when unconfigured; httpMergePR translates that to HTTP 503 with github_not_configured, matching httpListRepoBranches. - httpMergePR distinguishes empty body (io.EOF, fall through to default merge method) from malformed JSON (HTTP 400 invalid payload). - PRMergeButton tracks a local merged flag so the button stays hidden across the async refresh window after a successful merge. * test(github): cover merge helpers and extract PRMergeButton - TestGhMergeStatusCode locks in the gh CLI stderr → status-code parser for nil, unrelated errors, the HTTP / status: / bare-phrase forms of 404/403/405/409, and a 500 non-match guard. - Broaden ghMergeStatusCode to also match "405 Method Not Allowed" / "409 Conflict" phrase forms so the merge endpoint's 409 mapping does not depend on the gh CLI keeping its current "HTTP <code>" prefix. - TestHttpMergePR_MalformedJSON covers the new non-EOF bind-error branch (truncated body → 400). TestHttpMergePR_NoClient_Returns503 covers the ErrNoClient → 503 path on Service.MergePR. - Document the post/put error-type divergence on PATClient.post so a future caller does not silently lose per-status mapping. - Move PRMergeButton out of pr-shared.tsx into its own file, and reset the local "merged" flag when taskPR.id changes so an instance that re-renders for a different PR does not stay hidden. --------- Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user