Feat: Adds Ability to Merge MR (#147)
This adds the ability to merge an MR from within `gitlab.nvim` directly. If the reviewer is open, it'll be closed automatically. Users may configure whether they'd like to squash commits on the merge, as well as whether they'd like to delete the original source branch on a merge. If squashing, users are prompted to provide an optional custom squash message for the squash commit.
This commit is contained in:
committed by
GitHub
parent
e254100a72
commit
64b36ac51d
20
README.md
20
README.md
@@ -23,6 +23,7 @@ https://github.com/harrisoncramer/gitlab.nvim/assets/32515581/dc5c07de-4ae6-4335
|
||||
- [Usage](#usage)
|
||||
- [The summary command](#summary)
|
||||
- [Reviewing Diffs](#reviewing-diffs)
|
||||
- [Merging](#merging-an-mr)
|
||||
- [Discussions and Notes](#discussions-and-notes)
|
||||
- [Discussion signs and diagnostics](#discussion-signs-and-diagnostics)
|
||||
- [Uploading Files](#uploading-files)
|
||||
@@ -131,6 +132,7 @@ require("gitlab").setup({
|
||||
note = nil,
|
||||
pipeline = nil,
|
||||
reply = nil,
|
||||
squash_message = nil,
|
||||
},
|
||||
discussion_tree = { -- The discussion tree that holds all comments
|
||||
auto_open = true, -- Automatically open when the reviewer is opened
|
||||
@@ -210,6 +212,10 @@ require("gitlab").setup({
|
||||
success = "✓",
|
||||
failed = "",
|
||||
},
|
||||
merge = { -- The default behaviors when merging an MR, see "Merging an MR"
|
||||
squash = false,
|
||||
delete_branch = false,
|
||||
},
|
||||
colors = {
|
||||
discussion_tree = {
|
||||
username = "Keyword",
|
||||
@@ -258,6 +264,19 @@ require("gitlab").create_comment_suggestion()
|
||||
|
||||
For suggesting changes you can use `create_comment_suggestion` in visual mode which works similar to `create_multiline_comment` but prefills the comment window with Gitlab's [suggest changes](https://docs.gitlab.com/ee/user/project/merge_requests/reviews/suggestions.html) code block with prefilled code from the visual selection.
|
||||
|
||||
### Merging an MR
|
||||
|
||||
The `merge` action will merge an MR. The MR must be in a "mergeable" state for this command to work.
|
||||
|
||||
```lua
|
||||
require("gitlab").merge()
|
||||
require("gitlab").merge({ squash = false, delete_branch = false })
|
||||
```
|
||||
|
||||
You can configure default behaviors via the setup function, values passed into this function will override the defaults.
|
||||
|
||||
If you enable `squash` you will be prompted for a squash message. To use the default message, leave the popup empty. Use the `settings.popup.perform_action` to merge the MR with your message.
|
||||
|
||||
### Discussions and Notes
|
||||
|
||||
Gitlab groups threads of comments together into "discussions."
|
||||
@@ -391,6 +410,7 @@ vim.keymap.set("n", "glra", gitlab.add_reviewer)
|
||||
vim.keymap.set("n", "glrd", gitlab.delete_reviewer)
|
||||
vim.keymap.set("n", "glp", gitlab.pipeline)
|
||||
vim.keymap.set("n", "glo", gitlab.open_in_browser)
|
||||
vim.keymap.set("n", "glM", gitlab.merge)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
71
cmd/merge.go
Normal file
71
cmd/merge.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
type AcceptMergeRequestRequest struct {
|
||||
Squash bool `json:"squash"`
|
||||
SquashMessage string `json:"squash_message"`
|
||||
DeleteBranch bool `json:"delete_branch"`
|
||||
}
|
||||
|
||||
/* acceptAndMergeHandler merges a given merge request into the target branch */
|
||||
func (a *api) acceptAndMergeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Methods", http.MethodGet)
|
||||
if r.Method != http.MethodPost {
|
||||
handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var acceptAndMergeRequest AcceptMergeRequestRequest
|
||||
err = json.Unmarshal(body, &acceptAndMergeRequest)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not unmarshal request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
opts := gitlab.AcceptMergeRequestOptions{
|
||||
Squash: &acceptAndMergeRequest.Squash,
|
||||
ShouldRemoveSourceBranch: &acceptAndMergeRequest.DeleteBranch,
|
||||
}
|
||||
|
||||
if acceptAndMergeRequest.SquashMessage != "" {
|
||||
opts.SquashCommitMessage = &acceptAndMergeRequest.SquashMessage
|
||||
}
|
||||
|
||||
_, res, err := a.client.AcceptMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opts)
|
||||
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not merge MR", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
handleError(w, GenericError{endpoint: "/merge"}, "Could not merge MR", res.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
response := SuccessResponse{
|
||||
Status: http.StatusOK,
|
||||
Message: "MR merged successfully",
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
err = json.NewEncoder(w).Encode(response)
|
||||
if err != nil {
|
||||
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
52
cmd/merge_test.go
Normal file
52
cmd/merge_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/xanzy/go-gitlab"
|
||||
)
|
||||
|
||||
func acceptAndMergeFn(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) {
|
||||
return &gitlab.MergeRequest{}, makeResponse(http.StatusOK), nil
|
||||
}
|
||||
|
||||
func acceptAndMergeFnErr(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) {
|
||||
return nil, nil, errors.New("Some error from Gitlab")
|
||||
}
|
||||
|
||||
func acceptAndMergeNon200(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) {
|
||||
return nil, makeResponse(http.StatusSeeOther), nil
|
||||
}
|
||||
|
||||
func TestAcceptAndMergeHandler(t *testing.T) {
|
||||
t.Run("Accepts and merges a merge request", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/merge", AcceptMergeRequestRequest{})
|
||||
server, _ := createRouterAndApi(fakeClient{acceptAndMergeFn: acceptAndMergeFn})
|
||||
data := serveRequest(t, server, request, SuccessResponse{})
|
||||
assert(t, data.Message, "MR merged successfully")
|
||||
assert(t, data.Status, http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("Disallows non-POST methods", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodGet, "/merge", AcceptMergeRequestRequest{})
|
||||
server, _ := createRouterAndApi(fakeClient{acceptAndMergeFn: acceptAndMergeFn})
|
||||
data := serveRequest(t, server, request, ErrorResponse{})
|
||||
checkBadMethod(t, *data, http.MethodPost)
|
||||
})
|
||||
|
||||
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/merge", AcceptMergeRequestRequest{})
|
||||
server, _ := createRouterAndApi(fakeClient{acceptAndMergeFn: acceptAndMergeFnErr})
|
||||
data := serveRequest(t, server, request, ErrorResponse{})
|
||||
checkErrorFromGitlab(t, *data, "Could not merge MR")
|
||||
})
|
||||
|
||||
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
|
||||
request := makeRequest(t, http.MethodPost, "/merge", AcceptMergeRequestRequest{})
|
||||
server, _ := createRouterAndApi(fakeClient{acceptAndMergeFn: acceptAndMergeNon200})
|
||||
data := serveRequest(t, server, request, ErrorResponse{})
|
||||
checkNon200(t, *data, "Could not merge MR", "/merge")
|
||||
})
|
||||
}
|
||||
@@ -105,6 +105,7 @@ func createRouterAndApi(client ClientInterface, optFuncs ...optFunc) (*http.Serv
|
||||
m.HandleFunc("/shutdown", a.shutdownHandler)
|
||||
m.HandleFunc("/approve", a.approveHandler)
|
||||
m.HandleFunc("/comment", a.commentHandler)
|
||||
m.HandleFunc("/merge", a.acceptAndMergeHandler)
|
||||
m.HandleFunc("/discussions/list", a.listDiscussionsHandler)
|
||||
m.HandleFunc("/discussions/resolve", a.discussionsResolveHandler)
|
||||
m.HandleFunc("/info", a.infoHandler)
|
||||
|
||||
@@ -20,6 +20,7 @@ The FakeHandlerClient is used to create a fake gitlab client for testing our han
|
||||
type fakeClient struct {
|
||||
getMergeRequestFn func(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error)
|
||||
updateMergeRequestFn func(pid interface{}, mergeRequest int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error)
|
||||
acceptAndMergeFn func(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error)
|
||||
unapprorveMergeRequestFn func(pid interface{}, mr int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
|
||||
uploadFile func(pid interface{}, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error)
|
||||
getMergeRequestDiffVersions func(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestDiffVersionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequestDiffVersion, *gitlab.Response, error)
|
||||
@@ -46,6 +47,10 @@ type Author struct {
|
||||
WebURL string `json:"web_url"`
|
||||
}
|
||||
|
||||
func (f fakeClient) AcceptMergeRequest(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) {
|
||||
return f.acceptAndMergeFn(pid, mergeRequest, opt, options...)
|
||||
}
|
||||
|
||||
func (f fakeClient) GetMergeRequest(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) {
|
||||
return f.getMergeRequestFn(pid, mergeRequest, opt, options...)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ func (e InvalidRequestError) Error() string {
|
||||
/* The ClientInterface interface implements all the methods that our handlers need */
|
||||
type ClientInterface interface {
|
||||
GetMergeRequest(pid interface{}, mr int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error)
|
||||
AcceptMergeRequest(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error)
|
||||
UpdateMergeRequest(pid interface{}, mr int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error)
|
||||
UploadFile(pid interface{}, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error)
|
||||
GetMergeRequestDiffVersions(pid interface{}, mr int, opt *gitlab.GetMergeRequestDiffVersionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequestDiffVersion, *gitlab.Response, error)
|
||||
|
||||
55
lua/gitlab/actions/merge.lua
Normal file
55
lua/gitlab/actions/merge.lua
Normal file
@@ -0,0 +1,55 @@
|
||||
local u = require("gitlab.utils")
|
||||
local Popup = require("nui.popup")
|
||||
local state = require("gitlab.state")
|
||||
local job = require("gitlab.job")
|
||||
local reviewer = require("gitlab.reviewer")
|
||||
|
||||
local M = {}
|
||||
|
||||
local function create_squash_message_popup()
|
||||
return Popup(u.create_popup_state("Squash Commit Message", state.settings.popup.squash_message))
|
||||
end
|
||||
|
||||
---@class MergeOpts
|
||||
---@field delete_branch boolean?
|
||||
---@field squash boolean?
|
||||
---@field squash_message string?
|
||||
|
||||
---@param opts MergeOpts
|
||||
M.merge = function(opts)
|
||||
local merge_body = { squash = state.settings.merge.squash, delete_branch = state.settings.merge.delete_branch }
|
||||
if opts then
|
||||
merge_body.squash = opts.squash ~= nil and opts.squash
|
||||
merge_body.delete_branch = opts.delete_branch ~= nil and opts.delete_branch
|
||||
end
|
||||
|
||||
if state.INFO.detailed_merge_status ~= "mergeable" then
|
||||
u.notify(string.format("MR not mergeable, currently '%s'", state.INFO.detailed_merge_status), vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
if merge_body.squash then
|
||||
local squash_message_popup = create_squash_message_popup()
|
||||
squash_message_popup:mount()
|
||||
state.set_popup_keymaps(squash_message_popup, function(text)
|
||||
M.confirm_merge(merge_body, text)
|
||||
end)
|
||||
else
|
||||
M.confirm_merge(merge_body)
|
||||
end
|
||||
end
|
||||
|
||||
---@param merge_body MergeOpts
|
||||
---@param squash_message string?
|
||||
M.confirm_merge = function(merge_body, squash_message)
|
||||
if squash_message ~= nil then
|
||||
merge_body.squash_message = squash_message
|
||||
end
|
||||
|
||||
job.run_job("/merge", "POST", merge_body, function(data)
|
||||
reviewer.close()
|
||||
u.notify(data.message, vim.log.levels.INFO)
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -122,7 +122,7 @@ M.build_info_lines = function()
|
||||
author = { title = "Author", content = "@" .. info.author.username .. " (" .. info.author.name .. ")" },
|
||||
created_at = { title = "Created", content = u.format_to_local(info.created_at, vim.fn.strftime("%z")) },
|
||||
updated_at = { title = "Updated", content = u.format_to_local(info.updated_at, vim.fn.strftime("%z")) },
|
||||
merge_status = { title = "Status", content = info.detailed_merge_status },
|
||||
detailed_merge_status = { title = "Status", content = info.detailed_merge_status },
|
||||
draft = { title = "Draft", content = (info.draft and "Yes" or "No") },
|
||||
conflicts = { title = "Merge Conflicts", content = (info.has_conflicts and "Yes" or "No") },
|
||||
assignees = { title = "Assignees", content = u.make_readable_list(info.assignees, "name") },
|
||||
@@ -138,6 +138,9 @@ M.build_info_lines = function()
|
||||
|
||||
local longest_used = ""
|
||||
for _, v in ipairs(state.settings.info.fields) do
|
||||
if v == "merge_status" then
|
||||
v = "detailed_merge_status"
|
||||
end -- merge_status was deprecated, see https://gitlab.com/gitlab-org/gitlab/-/issues/3169#note_1162532204
|
||||
local title = options[v].title
|
||||
if string.len(title) > string.len(longest_used) then
|
||||
longest_used = title
|
||||
@@ -151,6 +154,9 @@ M.build_info_lines = function()
|
||||
|
||||
local lines = {}
|
||||
for _, v in ipairs(state.settings.info.fields) do
|
||||
if v == "merge_status" then
|
||||
v = "detailed_merge_status"
|
||||
end
|
||||
local row = options[v]
|
||||
local line = "* " .. row.title .. row_offset(row.title)
|
||||
if type(row.content) == "function" then
|
||||
|
||||
@@ -4,6 +4,7 @@ local server = require("gitlab.server")
|
||||
local state = require("gitlab.state")
|
||||
local reviewer = require("gitlab.reviewer")
|
||||
local discussions = require("gitlab.actions.discussions")
|
||||
local merge = require("gitlab.actions.merge")
|
||||
local summary = require("gitlab.actions.summary")
|
||||
local assignees_and_reviewers = require("gitlab.actions.assignees_and_reviewers")
|
||||
local comment = require("gitlab.actions.comment")
|
||||
@@ -27,7 +28,7 @@ return {
|
||||
discussions.initialize_discussions() -- place signs / diagnostics for discussions in reviewer
|
||||
end,
|
||||
-- Global Actions 🌎
|
||||
summary = async.sequence({ info }, summary.summary),
|
||||
summary = async.sequence({ u.merge(info, { refresh = true }) }, summary.summary),
|
||||
approve = async.sequence({ info }, approvals.approve),
|
||||
revoke = async.sequence({ info }, approvals.revoke),
|
||||
add_reviewer = async.sequence({ info, project_members }, assignees_and_reviewers.add_reviewer),
|
||||
@@ -42,7 +43,11 @@ return {
|
||||
review = async.sequence({ u.merge(info, { refresh = true }), revisions }, function()
|
||||
reviewer.open()
|
||||
end),
|
||||
close_review = function()
|
||||
reviewer.close()
|
||||
end,
|
||||
pipeline = async.sequence({ info }, pipeline.open),
|
||||
merge = async.sequence({ u.merge(info, { refresh = true }) }, merge.merge),
|
||||
-- Discussion Tree Actions 🌴
|
||||
toggle_discussions = async.sequence({ info }, discussions.toggle),
|
||||
edit_comment = async.sequence({ info }, discussions.edit_comment),
|
||||
|
||||
@@ -47,6 +47,12 @@ M.open = function()
|
||||
end
|
||||
end
|
||||
|
||||
M.close = function()
|
||||
vim.cmd("DiffviewClose")
|
||||
local discussions = require("gitlab.actions.discussions")
|
||||
discussions.close()
|
||||
end
|
||||
|
||||
M.jump = function(file_name, new_line, old_line)
|
||||
if M.tabnr == nil then
|
||||
u.notify("Can't jump to Diffvew. Is it open?", vim.log.levels.ERROR)
|
||||
|
||||
@@ -22,6 +22,9 @@ M.init = function()
|
||||
M.open = reviewer.open
|
||||
-- Opens the reviewer window
|
||||
|
||||
M.close = reviewer.close
|
||||
-- Closes the reviewer and cleans up
|
||||
|
||||
M.jump = reviewer.jump
|
||||
-- Jumps to the location provided in the reviewer window
|
||||
-- Parameters:
|
||||
|
||||
@@ -29,6 +29,7 @@ M.settings = {
|
||||
note = nil,
|
||||
help = nil,
|
||||
pipeline = nil,
|
||||
squash_message = nil,
|
||||
},
|
||||
discussion_tree = {
|
||||
auto_open = true,
|
||||
@@ -66,6 +67,10 @@ M.settings = {
|
||||
return " " .. discussions_content .. " %#Comment#| " .. notes_content
|
||||
end,
|
||||
},
|
||||
merge = {
|
||||
squash = false,
|
||||
delete_branch = false,
|
||||
},
|
||||
info = {
|
||||
enabled = true,
|
||||
horizontal = false,
|
||||
|
||||
Reference in New Issue
Block a user