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:
Harrison (Harry) Cramer
2023-12-17 14:28:21 -05:00
committed by GitHub
parent e254100a72
commit 64b36ac51d
12 changed files with 232 additions and 2 deletions

View File

@@ -23,6 +23,7 @@ https://github.com/harrisoncramer/gitlab.nvim/assets/32515581/dc5c07de-4ae6-4335
- [Usage](#usage) - [Usage](#usage)
- [The summary command](#summary) - [The summary command](#summary)
- [Reviewing Diffs](#reviewing-diffs) - [Reviewing Diffs](#reviewing-diffs)
- [Merging](#merging-an-mr)
- [Discussions and Notes](#discussions-and-notes) - [Discussions and Notes](#discussions-and-notes)
- [Discussion signs and diagnostics](#discussion-signs-and-diagnostics) - [Discussion signs and diagnostics](#discussion-signs-and-diagnostics)
- [Uploading Files](#uploading-files) - [Uploading Files](#uploading-files)
@@ -131,6 +132,7 @@ require("gitlab").setup({
note = nil, note = nil,
pipeline = nil, pipeline = nil,
reply = nil, reply = nil,
squash_message = nil,
}, },
discussion_tree = { -- The discussion tree that holds all comments discussion_tree = { -- The discussion tree that holds all comments
auto_open = true, -- Automatically open when the reviewer is opened auto_open = true, -- Automatically open when the reviewer is opened
@@ -210,6 +212,10 @@ require("gitlab").setup({
success = "", success = "",
failed = "", failed = "",
}, },
merge = { -- The default behaviors when merging an MR, see "Merging an MR"
squash = false,
delete_branch = false,
},
colors = { colors = {
discussion_tree = { discussion_tree = {
username = "Keyword", 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. 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 ### Discussions and Notes
Gitlab groups threads of comments together into "discussions." 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", "glrd", gitlab.delete_reviewer)
vim.keymap.set("n", "glp", gitlab.pipeline) vim.keymap.set("n", "glp", gitlab.pipeline)
vim.keymap.set("n", "glo", gitlab.open_in_browser) vim.keymap.set("n", "glo", gitlab.open_in_browser)
vim.keymap.set("n", "glM", gitlab.merge)
``` ```
## Troubleshooting ## Troubleshooting

71
cmd/merge.go Normal file
View 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
View 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")
})
}

View File

@@ -105,6 +105,7 @@ func createRouterAndApi(client ClientInterface, optFuncs ...optFunc) (*http.Serv
m.HandleFunc("/shutdown", a.shutdownHandler) m.HandleFunc("/shutdown", a.shutdownHandler)
m.HandleFunc("/approve", a.approveHandler) m.HandleFunc("/approve", a.approveHandler)
m.HandleFunc("/comment", a.commentHandler) m.HandleFunc("/comment", a.commentHandler)
m.HandleFunc("/merge", a.acceptAndMergeHandler)
m.HandleFunc("/discussions/list", a.listDiscussionsHandler) m.HandleFunc("/discussions/list", a.listDiscussionsHandler)
m.HandleFunc("/discussions/resolve", a.discussionsResolveHandler) m.HandleFunc("/discussions/resolve", a.discussionsResolveHandler)
m.HandleFunc("/info", a.infoHandler) m.HandleFunc("/info", a.infoHandler)

View File

@@ -20,6 +20,7 @@ The FakeHandlerClient is used to create a fake gitlab client for testing our han
type fakeClient struct { type fakeClient struct {
getMergeRequestFn func(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) 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) 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) 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) 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) 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"` 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) { 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...) return f.getMergeRequestFn(pid, mergeRequest, opt, options...)
} }

View File

@@ -36,6 +36,7 @@ func (e InvalidRequestError) Error() string {
/* The ClientInterface interface implements all the methods that our handlers need */ /* The ClientInterface interface implements all the methods that our handlers need */
type ClientInterface interface { type ClientInterface interface {
GetMergeRequest(pid interface{}, mr int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) 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) 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) 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) GetMergeRequestDiffVersions(pid interface{}, mr int, opt *gitlab.GetMergeRequestDiffVersionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequestDiffVersion, *gitlab.Response, error)

View 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

View File

@@ -122,7 +122,7 @@ M.build_info_lines = function()
author = { title = "Author", content = "@" .. info.author.username .. " (" .. info.author.name .. ")" }, 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")) }, 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")) }, 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") }, draft = { title = "Draft", content = (info.draft and "Yes" or "No") },
conflicts = { title = "Merge Conflicts", content = (info.has_conflicts 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") }, assignees = { title = "Assignees", content = u.make_readable_list(info.assignees, "name") },
@@ -138,6 +138,9 @@ M.build_info_lines = function()
local longest_used = "" local longest_used = ""
for _, v in ipairs(state.settings.info.fields) do 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 local title = options[v].title
if string.len(title) > string.len(longest_used) then if string.len(title) > string.len(longest_used) then
longest_used = title longest_used = title
@@ -151,6 +154,9 @@ M.build_info_lines = function()
local lines = {} local lines = {}
for _, v in ipairs(state.settings.info.fields) do for _, v in ipairs(state.settings.info.fields) do
if v == "merge_status" then
v = "detailed_merge_status"
end
local row = options[v] local row = options[v]
local line = "* " .. row.title .. row_offset(row.title) local line = "* " .. row.title .. row_offset(row.title)
if type(row.content) == "function" then if type(row.content) == "function" then

View File

@@ -4,6 +4,7 @@ local server = require("gitlab.server")
local state = require("gitlab.state") local state = require("gitlab.state")
local reviewer = require("gitlab.reviewer") local reviewer = require("gitlab.reviewer")
local discussions = require("gitlab.actions.discussions") local discussions = require("gitlab.actions.discussions")
local merge = require("gitlab.actions.merge")
local summary = require("gitlab.actions.summary") local summary = require("gitlab.actions.summary")
local assignees_and_reviewers = require("gitlab.actions.assignees_and_reviewers") local assignees_and_reviewers = require("gitlab.actions.assignees_and_reviewers")
local comment = require("gitlab.actions.comment") local comment = require("gitlab.actions.comment")
@@ -27,7 +28,7 @@ return {
discussions.initialize_discussions() -- place signs / diagnostics for discussions in reviewer discussions.initialize_discussions() -- place signs / diagnostics for discussions in reviewer
end, end,
-- Global Actions 🌎 -- Global Actions 🌎
summary = async.sequence({ info }, summary.summary), summary = async.sequence({ u.merge(info, { refresh = true }) }, summary.summary),
approve = async.sequence({ info }, approvals.approve), approve = async.sequence({ info }, approvals.approve),
revoke = async.sequence({ info }, approvals.revoke), revoke = async.sequence({ info }, approvals.revoke),
add_reviewer = async.sequence({ info, project_members }, assignees_and_reviewers.add_reviewer), 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() review = async.sequence({ u.merge(info, { refresh = true }), revisions }, function()
reviewer.open() reviewer.open()
end), end),
close_review = function()
reviewer.close()
end,
pipeline = async.sequence({ info }, pipeline.open), pipeline = async.sequence({ info }, pipeline.open),
merge = async.sequence({ u.merge(info, { refresh = true }) }, merge.merge),
-- Discussion Tree Actions 🌴 -- Discussion Tree Actions 🌴
toggle_discussions = async.sequence({ info }, discussions.toggle), toggle_discussions = async.sequence({ info }, discussions.toggle),
edit_comment = async.sequence({ info }, discussions.edit_comment), edit_comment = async.sequence({ info }, discussions.edit_comment),

View File

@@ -47,6 +47,12 @@ M.open = function()
end end
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) M.jump = function(file_name, new_line, old_line)
if M.tabnr == nil then if M.tabnr == nil then
u.notify("Can't jump to Diffvew. Is it open?", vim.log.levels.ERROR) u.notify("Can't jump to Diffvew. Is it open?", vim.log.levels.ERROR)

View File

@@ -22,6 +22,9 @@ M.init = function()
M.open = reviewer.open M.open = reviewer.open
-- Opens the reviewer window -- Opens the reviewer window
M.close = reviewer.close
-- Closes the reviewer and cleans up
M.jump = reviewer.jump M.jump = reviewer.jump
-- Jumps to the location provided in the reviewer window -- Jumps to the location provided in the reviewer window
-- Parameters: -- Parameters:

View File

@@ -29,6 +29,7 @@ M.settings = {
note = nil, note = nil,
help = nil, help = nil,
pipeline = nil, pipeline = nil,
squash_message = nil,
}, },
discussion_tree = { discussion_tree = {
auto_open = true, auto_open = true,
@@ -66,6 +67,10 @@ M.settings = {
return " " .. discussions_content .. " %#Comment#| " .. notes_content return " " .. discussions_content .. " %#Comment#| " .. notes_content
end, end,
}, },
merge = {
squash = false,
delete_branch = false,
},
info = { info = {
enabled = true, enabled = true,
horizontal = false, horizontal = false,