Feat: Create Merge Request (#149)

- Adds the ability to create MRs to the plugin
- Adds the ability to jump to specific discussions/notes in the browser
- Fixes stale icons
- Adds debug keybinding for discussion tree for developers
This commit is contained in:
Harrison (Harry) Cramer
2023-12-19 13:41:07 -05:00
committed by GitHub
parent 35f0bc16a5
commit 37a53842d0
38 changed files with 814 additions and 204 deletions

View File

@@ -2,14 +2,13 @@
This Neovim plugin is designed to make it easy to review Gitlab MRs from within the editor. This means you can do things like:
- Read and Edit an MR description
- Approve or revoke approval for an MR
- Create, approve, and merge MRs for the current branch
- Read and edit an MR description
- Add or remove reviewers and assignees
- Resolve, reply to, and unresolve discussion threads
- Create, edit, delete, and reply to comments on an MR
- View and Manage Pipeline Jobs
And a lot more!
- Create, edit, delete, and reply to comments
- View and manage pipeline Jobs
- Upload files, jump to the browser, and a lot more!
https://github.com/harrisoncramer/gitlab.nvim/assets/32515581/dc5c07de-4ae6-4335-afe1-d554e3804372
@@ -21,13 +20,14 @@ https://github.com/harrisoncramer/gitlab.nvim/assets/32515581/dc5c07de-4ae6-4335
- [Connecting to Gitlab](#connecting-to-gitlab)
- [Configuring the Plugin](#configuring-the-plugin)
- [Usage](#usage)
- [The summary command](#summary)
- [Reviewing Diffs](#reviewing-diffs)
- [The Summary view](#the-summary-view)
- [Reviewing an MR](#reviewing-an-mr)
- [Merging](#merging-an-mr)
- [Discussions and Notes](#discussions-and-notes)
- [Discussion signs and diagnostics](#discussion-signs-and-diagnostics)
- [Signs and Diagnostics](#signs-and-diagnostics)
- [Uploading Files](#uploading-files)
- [MR Approvals](#mr-approvals)
- [Approvals](#mr-approvals)
- [Creating an MR](#creating-an-mr)
- [Pipelines](#pipelines)
- [Reviewers and Assignees](#reviewers-and-assignees)
- [Restarting or Shutting down](#restarting-or-shutting-down)
@@ -147,15 +147,16 @@ require("gitlab").setup({
toggle_node = "t", -- Opens or closes the discussion
toggle_resolved = "p" -- Toggles the resolved status of the whole discussion
position = "left", -- "top", "right", "bottom" or "left"
open_in_browser = "b" -- Jump to the URL of the current note/discussion
size = "20%", -- Size of split
relative = "editor", -- Position of tree split relative to "editor" or "window"
resolved = '', -- Symbol to show next to resolved discussions
unresolved = '', -- Symbol to show next to unresolved discussions
unresolved = '-', -- Symbol to show next to unresolved discussions
tree_type = "simple", -- Type of discussion tree - "simple" means just list of discussions, "by_file_name" means file tree with discussions under file
winbar = nil -- Custom function to return winbar title, should return a string. Provided with WinbarTable (defined in annotations.lua)
-- If using lualine, please add "gitlab" to disabled file types, otherwise you will not see the winbar.
},
info = { -- Show additional fields in the summary pane
info = { -- Show additional fields in the summary view
enabled = true,
horizontal = false, -- Display metadata to the left of the summary rather than underneath
fields = { -- The fields listed here will be displayed, in whatever order you choose
@@ -202,13 +203,13 @@ require("gitlab").setup({
display_opts = {}, -- see opts in vim.diagnostic.set
},
pipeline = {
created = "",
created = "",
pending = "",
preparing = "",
scheduled = "",
running = "",
canceled = "",
skipped = "",
running = "",
canceled = "",
skipped = "",
success = "",
failed = "",
},
@@ -216,6 +217,10 @@ require("gitlab").setup({
squash = false,
delete_branch = false,
},
create_mr = {
target = nil, -- Default branch to target when creating an MR
template_file = nil, -- Default MR template in .gitlab/merge_request_templates
},
colors = {
discussion_tree = {
username = "Keyword",
@@ -239,7 +244,7 @@ git checkout feature-branch
Then open Neovim. To begin, try running the `summary` command or the `review` command.
### Summary
### The Summary view
The `summary` action will open the MR title and description.
@@ -251,7 +256,7 @@ After editing the description or title, you may save your changes via the `setti
By default this plugin will also show additional metadata about the MR in a separate pane underneath the description. This can be disabled, and these fields can be reordered or removed. Please see the `settings.info` section of the configuration.
### Reviewing Diffs
### Reviewing an MR
The `review` action will open a diff of the changes. You can leave comments using the `create_comment` action. In visual mode, add multiline comments with the `create_multiline_comment` command, and add suggested changes with the `create_comment_suggestion` command.
@@ -264,19 +269,6 @@ 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."
@@ -297,7 +289,7 @@ If you'd like to create a note in an MR (like a comment, but not linked to a spe
require("gitlab").create_note()
```
### Discussion signs and diagnostics
### Signs and diagnostics
By default when reviewing files you will see signs and diagnostics (if enabled in configuration). When cursor is on diagnostic line you can view discussion thread by using `vim.diagnostic.show`. You can also jump to discussion tree where you can reply, edit or delete discussion.
@@ -333,6 +325,32 @@ require("gitlab").approve()
require("gitlab").revoke()
```
### 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 the `merge` action 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.
### Creating an MR
To create an MR for the current branch, make sure you have the branch checked out. Then, use the `create_mr` action.
```lua
require("gitlab").create_mr()
require("gitlab").create_mr({ target = "main" })
require("gitlab").create_mr({ target = "main", template_file = "my-template.md" })
```
You can configure default behaviors via the setup function, values passed into the `create_mr` action will override your defaults.
### Pipelines
You can view the status of the pipeline for the current MR with the `pipeline` action.
@@ -401,6 +419,7 @@ vim.keymap.set("n", "glR", gitlab.revoke)
vim.keymap.set("n", "glc", gitlab.create_comment)
vim.keymap.set("v", "glc", gitlab.create_multiline_comment)
vim.keymap.set("v", "glC", gitlab.create_comment_suggestion)
vim.keymap.set("n", "glO", gitlab.create_mr)
vim.keymap.set("n", "glm", gitlab.move_to_discussion_tree_from_diagnostic)
vim.keymap.set("n", "gln", gitlab.create_note)
vim.keymap.set("n", "gld", gitlab.toggle_discussions)
@@ -427,5 +446,5 @@ This plugin uses a Go server to reach out to Gitlab. It's possible that somethin
The easiest way to debug what's going wrong is to turn on the `debug` options in your setup function. This will allow you to see requests leaving the Go server, and the responses coming back from Gitlab. Once the server is running, you can also interact with the Go server like any other process:
```
curl --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" localhost:21036/info
curl --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" localhost:21036/mr/info
```

View File

@@ -22,7 +22,7 @@ func (a *api) approveHandler(w http.ResponseWriter, r *http.Request) {
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/approve"}, "Could not approve merge request", res.StatusCode)
handleError(w, GenericError{endpoint: "/mr/approve"}, "Could not approve merge request", res.StatusCode)
return
}

View File

@@ -22,7 +22,7 @@ func approveMergeRequestErr(pid interface{}, mr int, opt *gitlab.ApproveMergeReq
func TestApproveHandler(t *testing.T) {
t.Run("Approves merge request", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/approve", nil)
request := makeRequest(t, http.MethodPost, "/mr/approve", nil)
server, _ := createRouterAndApi(fakeClient{approveMergeRequest: approveMergeRequest})
data := serveRequest(t, server, request, SuccessResponse{})
assert(t, data.Message, "Approved MR")
@@ -30,23 +30,23 @@ func TestApproveHandler(t *testing.T) {
})
t.Run("Disallows non-POST method", func(t *testing.T) {
request := makeRequest(t, http.MethodPut, "/approve", nil)
request := makeRequest(t, http.MethodPut, "/mr/approve", nil)
server, _ := createRouterAndApi(fakeClient{approveMergeRequest: approveMergeRequest})
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, "/approve", nil)
request := makeRequest(t, http.MethodPost, "/mr/approve", nil)
server, _ := createRouterAndApi(fakeClient{approveMergeRequest: approveMergeRequestErr})
data := serveRequest(t, server, request, ErrorResponse{})
checkErrorFromGitlab(t, *data, "Could not approve merge request")
})
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/approve", nil)
request := makeRequest(t, http.MethodPost, "/mr/approve", nil)
server, _ := createRouterAndApi(fakeClient{approveMergeRequest: approveMergeRequestNon200})
data := serveRequest(t, server, request, ErrorResponse{})
checkNon200(t, *data, "Could not approve merge request", "/approve")
checkNon200(t, *data, "Could not approve merge request", "/mr/approve")
})
}

View File

@@ -83,7 +83,7 @@ func (a *api) attachmentHandler(w http.ResponseWriter, r *http.Request) {
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/mr/attachment"}, fmt.Sprintf("Could not upload %s to Gitlab", attachmentRequest.FileName), res.StatusCode)
handleError(w, GenericError{endpoint: "/attachment"}, fmt.Sprintf("Could not upload %s to Gitlab", attachmentRequest.FileName), res.StatusCode)
return
}

View File

@@ -36,7 +36,7 @@ func withMockFileReader(a *api) error {
func TestAttachmentHandler(t *testing.T) {
t.Run("Returns 200-status response after upload", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/attachment", AttachmentRequest{FilePath: "some_file_path", FileName: "some_file_name"})
request := makeRequest(t, http.MethodPost, "/attachment", AttachmentRequest{FilePath: "some_file_path", FileName: "some_file_name"})
router, _ := createRouterAndApi(fakeClient{uploadFile: uploadFile}, withMockFileReader)
data := serveRequest(t, router, request, AttachmentResponse{})
assert(t, data.SuccessResponse.Status, http.StatusOK)
@@ -44,23 +44,23 @@ func TestAttachmentHandler(t *testing.T) {
})
t.Run("Disallows non-POST method", func(t *testing.T) {
request := makeRequest(t, http.MethodPut, "/mr/attachment", AttachmentRequest{FilePath: "some_file_path", FileName: "some_file_name"})
request := makeRequest(t, http.MethodPut, "/attachment", AttachmentRequest{FilePath: "some_file_path", FileName: "some_file_name"})
router, _ := createRouterAndApi(fakeClient{uploadFile: uploadFile}, withMockFileReader)
data := serveRequest(t, router, request, ErrorResponse{})
checkBadMethod(t, *data, http.MethodPost)
})
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/attachment", AttachmentRequest{FilePath: "some_file_path", FileName: "some_file_name"})
request := makeRequest(t, http.MethodPost, "/attachment", AttachmentRequest{FilePath: "some_file_path", FileName: "some_file_name"})
router, _ := createRouterAndApi(fakeClient{uploadFile: uploadFileErr}, withMockFileReader)
data := serveRequest(t, router, request, ErrorResponse{})
checkErrorFromGitlab(t, *data, "Could not upload some_file_name to Gitlab")
})
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/mr/attachment", AttachmentRequest{FilePath: "some_file_path", FileName: "some_file_name"})
request := makeRequest(t, http.MethodPost, "/attachment", AttachmentRequest{FilePath: "some_file_path", FileName: "some_file_name"})
router, _ := createRouterAndApi(fakeClient{uploadFile: uploadFileNon200}, withMockFileReader)
data := serveRequest(t, router, request, ErrorResponse{})
checkNon200(t, *data, "Could not upload some_file_name to Gitlab", "/mr/attachment")
checkNon200(t, *data, "Could not upload some_file_name to Gitlab", "/attachment")
})
}

View File

@@ -8,7 +8,6 @@ import (
"net/http"
"net/http/httputil"
"os"
"strconv"
"github.com/hashicorp/go-retryablehttp"
"github.com/xanzy/go-gitlab"
@@ -90,7 +89,7 @@ func initGitlabClient() (error, *Client) {
}
}
/* initProjectSettings fetch the project ID and merge request ID using the client. */
/* initProjectSettings fetch the project ID using the client */
func initProjectSettings(c *Client, gitInfo GitProjectInfo) (error, *ProjectInfo) {
opt := gitlab.GetProjectOptions{}
@@ -109,31 +108,10 @@ func initProjectSettings(c *Client, gitInfo GitProjectInfo) (error, *ProjectInfo
projectId := fmt.Sprint(project.ID)
options := gitlab.ListProjectMergeRequestsOptions{
Scope: gitlab.String("all"),
State: gitlab.String("opened"),
SourceBranch: &gitInfo.BranchName,
}
mergeRequests, _, err := c.ListProjectMergeRequests(projectId, &options)
if err != nil {
return fmt.Errorf("Failed to list merge requests: %w", err), nil
}
if len(mergeRequests) == 0 {
return errors.New("No merge requests found"), nil
}
mergeId := strconv.Itoa(mergeRequests[0].IID)
mergeIdInt, err := strconv.Atoi(mergeId)
if err != nil {
return err, nil
}
return nil, &ProjectInfo{
MergeId: mergeIdInt,
ProjectId: projectId,
}
}
/* handleError is a utililty handler that returns errors to the client along with their statuses and messages */

View File

@@ -95,7 +95,7 @@ func (a *api) deleteComment(w http.ResponseWriter, r *http.Request) {
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/comment"}, "Could not delete comment", res.StatusCode)
handleError(w, GenericError{endpoint: "/mr/comment"}, "Could not delete comment", res.StatusCode)
return
}
@@ -186,7 +186,7 @@ func (a *api) postComment(w http.ResponseWriter, r *http.Request) {
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/comment"}, "Could not create discussion", res.StatusCode)
handleError(w, GenericError{endpoint: "/mr/comment"}, "Could not create discussion", res.StatusCode)
return
}
@@ -236,7 +236,7 @@ func (a *api) editComment(w http.ResponseWriter, r *http.Request) {
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/comment"}, "Could not update comment", res.StatusCode)
handleError(w, GenericError{endpoint: "/mr/comment"}, "Could not update comment", res.StatusCode)
return
}

View File

@@ -22,7 +22,7 @@ func createMergeRequestDiscussionErr(pid interface{}, mergeRequest int, opt *git
func TestPostComment(t *testing.T) {
t.Run("Creates a new note (unlinked comment)", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/comment", PostCommentRequest{})
request := makeRequest(t, http.MethodPost, "/mr/comment", PostCommentRequest{})
server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussion})
data := serveRequest(t, server, request, CommentResponse{})
assert(t, data.SuccessResponse.Message, "Note created successfully")
@@ -30,7 +30,7 @@ func TestPostComment(t *testing.T) {
})
t.Run("Creates a new comment", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/comment", PostCommentRequest{FileName: "some_file.txt"})
request := makeRequest(t, http.MethodPost, "/mr/comment", PostCommentRequest{FileName: "some_file.txt"})
server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussion})
data := serveRequest(t, server, request, CommentResponse{})
assert(t, data.SuccessResponse.Message, "Comment created successfully")
@@ -38,7 +38,7 @@ func TestPostComment(t *testing.T) {
})
t.Run("Creates a new multiline comment", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/comment", PostCommentRequest{
request := makeRequest(t, http.MethodPost, "/mr/comment", PostCommentRequest{
FileName: "some_file.txt",
LineRange: &LineRange{
StartRange: &LinePosition{}, /* These would have real data */
@@ -52,17 +52,17 @@ func TestPostComment(t *testing.T) {
})
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/comment", PostCommentRequest{})
request := makeRequest(t, http.MethodPost, "/mr/comment", PostCommentRequest{})
server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussionErr})
data := serveRequest(t, server, request, ErrorResponse{})
checkErrorFromGitlab(t, *data, "Could not create discussion")
})
t.Run("Handles non-200s from Gitlab", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/comment", PostCommentRequest{})
request := makeRequest(t, http.MethodPost, "/mr/comment", PostCommentRequest{})
server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussionNon200})
data := serveRequest(t, server, request, ErrorResponse{})
checkNon200(t, *data, "Could not create discussion", "/comment")
checkNon200(t, *data, "Could not create discussion", "/mr/comment")
})
}
@@ -80,7 +80,7 @@ func deleteMergeRequestDiscussionNoteNon200(pid interface{}, mergeRequest int, d
func TestDeleteComment(t *testing.T) {
t.Run("Deletes a comment", func(t *testing.T) {
request := makeRequest(t, http.MethodDelete, "/comment", DeleteCommentRequest{})
request := makeRequest(t, http.MethodDelete, "/mr/comment", DeleteCommentRequest{})
server, _ := createRouterAndApi(fakeClient{deleteMergeRequestDiscussionNote: deleteMergeRequestDiscussionNote})
data := serveRequest(t, server, request, CommentResponse{})
assert(t, data.SuccessResponse.Message, "Comment deleted successfully")
@@ -88,17 +88,17 @@ func TestDeleteComment(t *testing.T) {
})
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodDelete, "/comment", DeleteCommentRequest{})
request := makeRequest(t, http.MethodDelete, "/mr/comment", DeleteCommentRequest{})
server, _ := createRouterAndApi(fakeClient{deleteMergeRequestDiscussionNote: deleteMergeRequestDiscussionNoteErr})
data := serveRequest(t, server, request, ErrorResponse{})
checkErrorFromGitlab(t, *data, "Could not delete comment")
})
t.Run("Handles non-200s from Gitlab", func(t *testing.T) {
request := makeRequest(t, http.MethodDelete, "/comment", DeleteCommentRequest{})
request := makeRequest(t, http.MethodDelete, "/mr/comment", DeleteCommentRequest{})
server, _ := createRouterAndApi(fakeClient{deleteMergeRequestDiscussionNote: deleteMergeRequestDiscussionNoteNon200})
data := serveRequest(t, server, request, ErrorResponse{})
checkNon200(t, *data, "Could not delete comment", "/comment")
checkNon200(t, *data, "Could not delete comment", "/mr/comment")
})
}
@@ -116,7 +116,7 @@ func updateMergeRequestDiscussionNoteNon200(pid interface{}, mergeRequest int, d
func TestEditComment(t *testing.T) {
t.Run("Edits a comment", func(t *testing.T) {
request := makeRequest(t, http.MethodPatch, "/comment", EditCommentRequest{})
request := makeRequest(t, http.MethodPatch, "/mr/comment", EditCommentRequest{})
server, _ := createRouterAndApi(fakeClient{updateMergeRequestDiscussionNote: updateMergeRequestDiscussionNote})
data := serveRequest(t, server, request, CommentResponse{})
assert(t, data.SuccessResponse.Message, "Comment updated successfully")
@@ -124,16 +124,16 @@ func TestEditComment(t *testing.T) {
})
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPatch, "/comment", EditCommentRequest{})
request := makeRequest(t, http.MethodPatch, "/mr/comment", EditCommentRequest{})
server, _ := createRouterAndApi(fakeClient{updateMergeRequestDiscussionNote: updateMergeRequestDiscussionNoteErr})
data := serveRequest(t, server, request, ErrorResponse{})
checkErrorFromGitlab(t, *data, "Could not update comment")
})
t.Run("Handles non-200s from Gitlab", func(t *testing.T) {
request := makeRequest(t, http.MethodPatch, "/comment", EditCommentRequest{})
request := makeRequest(t, http.MethodPatch, "/mr/comment", EditCommentRequest{})
server, _ := createRouterAndApi(fakeClient{updateMergeRequestDiscussionNote: updateMergeRequestDiscussionNoteNon200})
data := serveRequest(t, server, request, ErrorResponse{})
checkNon200(t, *data, "Could not update comment", "/comment")
checkNon200(t, *data, "Could not update comment", "/mr/comment")
})
}

81
cmd/create_mr.go Normal file
View File

@@ -0,0 +1,81 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/xanzy/go-gitlab"
)
type CreateMrRequest struct {
Title string `json:"title"`
Description string `json:"description"`
TargetBranch string `json:"target_branch"`
}
/* createMr creates a merge request */
func (a *api) createMr(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 createMrRequest CreateMrRequest
err = json.Unmarshal(body, &createMrRequest)
if err != nil {
handleError(w, err, "Could not unmarshal request body", http.StatusBadRequest)
return
}
if createMrRequest.Title == "" {
handleError(w, errors.New("Title cannot be empty"), "Could not create MR", http.StatusBadRequest)
return
}
if createMrRequest.TargetBranch == "" {
handleError(w, errors.New("Target branch cannot be empty"), "Could not create MR", http.StatusBadRequest)
return
}
opts := gitlab.CreateMergeRequestOptions{
Title: &createMrRequest.Title,
Description: &createMrRequest.Description,
TargetBranch: &createMrRequest.TargetBranch,
SourceBranch: &a.gitInfo.BranchName,
}
_, res, err := a.client.CreateMergeRequest(a.projectInfo.ProjectId, &opts)
if err != nil {
handleError(w, err, "Could not create MR", http.StatusInternalServerError)
return
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/create_mr"}, "Could not create MR", res.StatusCode)
return
}
response := SuccessResponse{
Status: http.StatusOK,
Message: fmt.Sprintf("MR '%s' created", createMrRequest.Title),
}
w.WriteHeader(http.StatusOK)
err = json.NewEncoder(w).Encode(response)
if err != nil {
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
}
}

97
cmd/create_mr_test.go Normal file
View File

@@ -0,0 +1,97 @@
package main
import (
"errors"
"net/http"
"testing"
"github.com/xanzy/go-gitlab"
)
func createMrFn(pid interface{}, opt *gitlab.CreateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) {
return &gitlab.MergeRequest{}, makeResponse(http.StatusOK), nil
}
func createMrFnErr(pid interface{}, opt *gitlab.CreateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) {
return nil, nil, errors.New("Some error from Gitlab")
}
func createMrFnNon200(pid interface{}, opt *gitlab.CreateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) {
return nil, makeResponse(http.StatusSeeOther), nil
}
func TestCreateMr(t *testing.T) {
t.Run("Creates an MR", func(t *testing.T) {
body := CreateMrRequest{
Title: "Some title",
Description: "Some description",
TargetBranch: "main",
}
request := makeRequest(t, http.MethodPost, "/create_mr", body)
server, _ := createRouterAndApi(fakeClient{createMrFn: createMrFn})
data := serveRequest(t, server, request, SuccessResponse{})
assert(t, data.Message, "MR 'Some title' created")
assert(t, data.Status, http.StatusOK)
})
t.Run("Disallows non-POST methods", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/create_mr", CreateMrRequest{})
server, _ := createRouterAndApi(fakeClient{createMrFn: createMrFn})
data := serveRequest(t, server, request, ErrorResponse{})
checkBadMethod(t, *data, http.MethodPost)
})
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
body := CreateMrRequest{
Title: "Some title",
Description: "Some description",
TargetBranch: "main",
}
request := makeRequest(t, http.MethodPost, "/create_mr", body)
server, _ := createRouterAndApi(fakeClient{createMrFn: createMrFnErr})
data := serveRequest(t, server, request, ErrorResponse{})
checkErrorFromGitlab(t, *data, "Could not create MR")
})
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
body := CreateMrRequest{
Title: "Some title",
Description: "Some description",
TargetBranch: "main",
}
request := makeRequest(t, http.MethodPost, "/create_mr", body)
server, _ := createRouterAndApi(fakeClient{createMrFn: createMrFnNon200})
data := serveRequest(t, server, request, ErrorResponse{})
checkNon200(t, *data, "Could not create MR", "/create_mr")
})
t.Run("Handles missing titles", func(t *testing.T) {
body := CreateMrRequest{
Title: "",
Description: "Some description",
TargetBranch: "main",
}
request := makeRequest(t, http.MethodPost, "/create_mr", body)
server, _ := createRouterAndApi(fakeClient{createMrFn: createMrFn})
data := serveRequest(t, server, request, ErrorResponse{})
assert(t, data.Status, http.StatusBadRequest)
assert(t, data.Message, "Could not create MR")
assert(t, data.Details, "Title cannot be empty")
})
t.Run("Handles missing target branch", func(t *testing.T) {
body := CreateMrRequest{
Title: "Some title",
Description: "Some description",
TargetBranch: "",
}
request := makeRequest(t, http.MethodPost, "/create_mr", body)
server, _ := createRouterAndApi(fakeClient{createMrFn: createMrFn})
data := serveRequest(t, server, request, ErrorResponse{})
assert(t, data.Status, http.StatusBadRequest)
assert(t, data.Message, "Could not create MR")
assert(t, data.Details, "Target branch cannot be empty")
})
}

View File

@@ -28,7 +28,7 @@ func (a *api) infoHandler(w http.ResponseWriter, r *http.Request) {
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/info"}, "Could not get project info", res.StatusCode)
handleError(w, GenericError{endpoint: "/mr/info"}, "Could not get project info", res.StatusCode)
return
}

View File

@@ -22,7 +22,7 @@ func getInfoErr(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestsO
func TestInfoHandler(t *testing.T) {
t.Run("Returns normal information", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/info", nil)
request := makeRequest(t, http.MethodGet, "/mr/info", nil)
server, _ := createRouterAndApi(fakeClient{getMergeRequestFn: getInfo})
data := serveRequest(t, server, request, InfoResponse{})
assert(t, data.Info.Title, "Some Title")
@@ -31,23 +31,23 @@ func TestInfoHandler(t *testing.T) {
})
t.Run("Disallows non-GET method", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/info", nil)
request := makeRequest(t, http.MethodPost, "/mr/info", nil)
server, _ := createRouterAndApi(fakeClient{getMergeRequestFn: getInfo})
data := serveRequest(t, server, request, ErrorResponse{})
checkBadMethod(t, *data, http.MethodGet)
})
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/info", nil)
request := makeRequest(t, http.MethodGet, "/mr/info", nil)
server, _ := createRouterAndApi(fakeClient{getMergeRequestFn: getInfoErr})
data := serveRequest(t, server, request, ErrorResponse{})
checkErrorFromGitlab(t, *data, "Could not get project info")
})
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/info", nil)
request := makeRequest(t, http.MethodGet, "/mr/info", nil)
server, _ := createRouterAndApi(fakeClient{getMergeRequestFn: getInfoNon200})
data := serveRequest(t, server, request, ErrorResponse{})
checkNon200(t, *data, "Could not get project info", "/info")
checkNon200(t, *data, "Could not get project info", "/mr/info")
})
}

View File

@@ -75,7 +75,7 @@ func (a *api) listDiscussionsHandler(w http.ResponseWriter, r *http.Request) {
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/discussions/list"}, "Could not list discussions", res.StatusCode)
handleError(w, GenericError{endpoint: "/mr/discussions/list"}, "Could not list discussions", res.StatusCode)
return
}

View File

@@ -49,7 +49,7 @@ func listMergeRequestDiscussionsNon200(pid interface{}, mergeRequest int, opt *g
func TestListDiscussionsHandler(t *testing.T) {
t.Run("Returns sorted discussions", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/discussions/list", DiscussionsRequest{})
request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{})
server, _ := createRouterAndApi(fakeClient{listMergeRequestDiscussions: listMergeRequestDiscussions})
data := serveRequest(t, server, request, DiscussionsResponse{})
assert(t, data.SuccessResponse.Message, "Discussions retrieved")
@@ -59,7 +59,7 @@ func TestListDiscussionsHandler(t *testing.T) {
})
t.Run("Uses blacklist to filter unwanted authors", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/discussions/list", DiscussionsRequest{Blacklist: []string{"hcramer"}})
request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{Blacklist: []string{"hcramer"}})
server, _ := createRouterAndApi(fakeClient{listMergeRequestDiscussions: listMergeRequestDiscussions})
data := serveRequest(t, server, request, DiscussionsResponse{})
assert(t, data.SuccessResponse.Message, "Discussions retrieved")
@@ -69,23 +69,23 @@ func TestListDiscussionsHandler(t *testing.T) {
})
t.Run("Disallows non-POST method", func(t *testing.T) {
request := makeRequest(t, http.MethodPatch, "/discussions/list", DiscussionsRequest{})
request := makeRequest(t, http.MethodPatch, "/mr/discussions/list", DiscussionsRequest{})
server, _ := createRouterAndApi(fakeClient{listMergeRequestDiscussions: listMergeRequestDiscussions})
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, "/discussions/list", DiscussionsRequest{})
request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{})
server, _ := createRouterAndApi(fakeClient{listMergeRequestDiscussions: listMergeRequestDiscussionsErr})
data := serveRequest(t, server, request, ErrorResponse{})
checkErrorFromGitlab(t, *data, "Could not list discussions")
})
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/discussions/list", DiscussionsRequest{})
request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{})
server, _ := createRouterAndApi(fakeClient{listMergeRequestDiscussions: listMergeRequestDiscussionsNon200})
data := serveRequest(t, server, request, ErrorResponse{})
checkNon200(t, *data, "Could not list discussions", "/discussions/list")
checkNon200(t, *data, "Could not list discussions", "/mr/discussions/list")
})
}

View File

@@ -20,5 +20,5 @@ func main() {
log.Fatalf("Failed to initialize project settings: %v", err)
}
startServer(client, projectInfo)
startServer(client, projectInfo, gitInfo)
}

View File

@@ -53,7 +53,7 @@ func (a *api) acceptAndMergeHandler(w http.ResponseWriter, r *http.Request) {
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/merge"}, "Could not merge MR", res.StatusCode)
handleError(w, GenericError{endpoint: "/mr/merge"}, "Could not merge MR", res.StatusCode)
return
}

View File

@@ -22,7 +22,7 @@ func acceptAndMergeNon200(pid interface{}, mergeRequest int, opt *gitlab.AcceptM
func TestAcceptAndMergeHandler(t *testing.T) {
t.Run("Accepts and merges a merge request", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/merge", AcceptMergeRequestRequest{})
request := makeRequest(t, http.MethodPost, "/mr/merge", AcceptMergeRequestRequest{})
server, _ := createRouterAndApi(fakeClient{acceptAndMergeFn: acceptAndMergeFn})
data := serveRequest(t, server, request, SuccessResponse{})
assert(t, data.Message, "MR merged successfully")
@@ -30,23 +30,23 @@ func TestAcceptAndMergeHandler(t *testing.T) {
})
t.Run("Disallows non-POST methods", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/merge", AcceptMergeRequestRequest{})
request := makeRequest(t, http.MethodGet, "/mr/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{})
request := makeRequest(t, http.MethodPost, "/mr/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{})
request := makeRequest(t, http.MethodPost, "/mr/merge", AcceptMergeRequestRequest{})
server, _ := createRouterAndApi(fakeClient{acceptAndMergeFn: acceptAndMergeNon200})
data := serveRequest(t, server, request, ErrorResponse{})
checkNon200(t, *data, "Could not merge MR", "/merge")
checkNon200(t, *data, "Could not merge MR", "/mr/merge")
})
}

View File

@@ -57,7 +57,7 @@ func (a *api) replyHandler(w http.ResponseWriter, r *http.Request) {
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/reply"}, "Could not leave reply", res.StatusCode)
handleError(w, GenericError{endpoint: "/mr/reply"}, "Could not leave reply", res.StatusCode)
return
}

View File

@@ -22,7 +22,7 @@ func addMergeRequestDiscussionNoteNon200(pid interface{}, mergeRequest int, disc
func TestReplyHandler(t *testing.T) {
t.Run("Sends a reply", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/reply", ReplyRequest{})
request := makeRequest(t, http.MethodPost, "/mr/reply", ReplyRequest{})
server, _ := createRouterAndApi(fakeClient{addMergeRequestDiscussionNote: addMergeRequestDiscussionNote})
data := serveRequest(t, server, request, ReplyResponse{})
assert(t, data.SuccessResponse.Message, "Replied to comment")
@@ -30,23 +30,23 @@ func TestReplyHandler(t *testing.T) {
})
t.Run("Disallows non-POST methods", func(t *testing.T) {
request := makeRequest(t, http.MethodGet, "/reply", ReplyRequest{})
request := makeRequest(t, http.MethodGet, "/mr/reply", ReplyRequest{})
server, _ := createRouterAndApi(fakeClient{addMergeRequestDiscussionNote: addMergeRequestDiscussionNote})
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, "/reply", ReplyRequest{})
request := makeRequest(t, http.MethodPost, "/mr/reply", ReplyRequest{})
server, _ := createRouterAndApi(fakeClient{addMergeRequestDiscussionNote: addMergeRequestDiscussionNoteErr})
data := serveRequest(t, server, request, ErrorResponse{})
checkErrorFromGitlab(t, *data, "Could not leave reply")
})
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) {
request := makeRequest(t, http.MethodPost, "/reply", ReplyRequest{})
request := makeRequest(t, http.MethodPost, "/mr/reply", ReplyRequest{})
server, _ := createRouterAndApi(fakeClient{addMergeRequestDiscussionNote: addMergeRequestDiscussionNoteNon200})
data := serveRequest(t, server, request, ErrorResponse{})
checkNon200(t, *data, "Could not leave reply", "/reply")
checkNon200(t, *data, "Could not leave reply", "/mr/reply")
})
}

View File

@@ -57,7 +57,7 @@ func (a *api) discussionsResolveHandler(w http.ResponseWriter, r *http.Request)
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/discussions/resolve"}, fmt.Sprintf("Could not %s discussion", friendlyName), res.StatusCode)
handleError(w, GenericError{endpoint: "/mr/discussions/resolve"}, fmt.Sprintf("Could not %s discussion", friendlyName), res.StatusCode)
return
}

View File

@@ -22,7 +22,7 @@ func (a *api) revokeHandler(w http.ResponseWriter, r *http.Request) {
}
if res.StatusCode >= 300 {
handleError(w, GenericError{endpoint: "/revoke"}, "Could not revoke approval", res.StatusCode)
handleError(w, GenericError{endpoint: "/mr/revoke"}, "Could not revoke approval", res.StatusCode)
return
}

View File

@@ -7,14 +7,17 @@ import (
"net"
"net/http"
"os"
"strconv"
"time"
"github.com/xanzy/go-gitlab"
)
/*
startSever starts the server and runs concurrent goroutines
to handle potential shutdown requests and incoming HTTP requests.
*/
func startServer(client *Client, projectInfo *ProjectInfo) {
func startServer(client *Client, projectInfo *ProjectInfo, gitInfo GitProjectInfo) {
m, a := createRouterAndApi(client,
func(a *api) error {
@@ -24,6 +27,10 @@ func startServer(client *Client, projectInfo *ProjectInfo) {
func(a *api) error {
a.fileReader = attachmentReader{}
return nil
},
func(a *api) error {
a.gitInfo = &gitInfo
return nil
})
l := createListener()
@@ -73,6 +80,7 @@ with the client value, which is a go-gitlab client.
type api struct {
client ClientInterface
projectInfo *ProjectInfo
gitInfo *GitProjectInfo
fileReader FileReader
sigCh chan os.Signal
}
@@ -84,11 +92,13 @@ createRouterAndApi wires up the router and attaches all handlers to their respec
iterates over all option functions to configure API fields such as the project information and default
file reader functionality
*/
func createRouterAndApi(client ClientInterface, optFuncs ...optFunc) (*http.ServeMux, api) {
m := http.NewServeMux()
a := api{
client: client,
projectInfo: &ProjectInfo{},
gitInfo: &GitProjectInfo{},
fileReader: nil,
sigCh: make(chan os.Signal, 1),
}
@@ -101,24 +111,27 @@ func createRouterAndApi(client ClientInterface, optFuncs ...optFunc) (*http.Serv
}
}
m.Handle("/ping", http.HandlerFunc(pingHandler))
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)
m.HandleFunc("/mr/approve", a.withMr(a.approveHandler))
m.HandleFunc("/mr/comment", a.withMr(a.commentHandler))
m.HandleFunc("/mr/merge", a.withMr(a.acceptAndMergeHandler))
m.HandleFunc("/mr/discussions/list", a.withMr(a.listDiscussionsHandler))
m.HandleFunc("/mr/discussions/resolve", a.withMr(a.discussionsResolveHandler))
m.HandleFunc("/mr/info", a.withMr(a.infoHandler))
m.HandleFunc("/mr/assignee", a.withMr(a.assigneesHandler))
m.HandleFunc("/mr/summary", a.withMr(a.summaryHandler))
m.HandleFunc("/mr/reviewer", a.withMr(a.reviewersHandler))
m.HandleFunc("/mr/revisions", a.withMr(a.revisionsHandler))
m.HandleFunc("/mr/reply", a.withMr(a.replyHandler))
m.HandleFunc("/mr/revoke", a.withMr(a.revokeHandler))
m.HandleFunc("/attachment", a.attachmentHandler)
m.HandleFunc("/create_mr", a.createMr)
m.HandleFunc("/job", a.jobHandler)
m.HandleFunc("/mr/attachment", a.attachmentHandler)
m.HandleFunc("/mr/assignee", a.assigneesHandler)
m.HandleFunc("/mr/summary", a.summaryHandler)
m.HandleFunc("/mr/reviewer", a.reviewersHandler)
m.HandleFunc("/mr/revisions", a.revisionsHandler)
m.HandleFunc("/pipeline/", a.pipelineHandler)
m.HandleFunc("/project/members", a.projectMembersHandler)
m.HandleFunc("/reply", a.replyHandler)
m.HandleFunc("/revoke", a.revokeHandler)
m.HandleFunc("/shutdown", a.shutdownHandler)
m.Handle("/ping", http.HandlerFunc(pingHandler))
return m, a
}
@@ -157,3 +170,41 @@ func createListener() (l net.Listener) {
return l
}
/* withMr is a Middlware that gets the current merge request ID and attaches it to the projectInfo */
func (a *api) withMr(f func(w http.ResponseWriter, r *http.Request)) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if a.projectInfo.MergeId != 0 {
f(w, r)
return
}
options := gitlab.ListProjectMergeRequestsOptions{
Scope: gitlab.String("all"),
State: gitlab.String("opened"),
SourceBranch: &a.gitInfo.BranchName,
}
mergeRequests, _, err := a.client.ListProjectMergeRequests(a.projectInfo.ProjectId, &options)
if err != nil {
handleError(w, fmt.Errorf("Failed to list merge requests: %w", err), "Failed to list merge requests", http.StatusInternalServerError)
return
}
if len(mergeRequests) == 0 {
handleError(w, fmt.Errorf("No merge requests found for branch '%s'", a.gitInfo.BranchName), "No merge requests found", http.StatusBadRequest)
return
}
mergeId := strconv.Itoa(mergeRequests[0].IID)
mergeIdInt, err := strconv.Atoi(mergeId)
if err != nil {
handleError(w, err, "Could not convert merge ID to integer", http.StatusBadRequest)
return
}
a.projectInfo.MergeId = mergeIdInt
f(w, r)
}
}

View File

@@ -18,6 +18,7 @@ The FakeHandlerClient is used to create a fake gitlab client for testing our han
*/
type fakeClient struct {
createMrFn func(pid interface{}, opt *gitlab.CreateMergeRequestOptions, 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)
acceptAndMergeFn func(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error)
@@ -47,6 +48,10 @@ type Author struct {
WebURL string `json:"web_url"`
}
func (f fakeClient) CreateMergeRequest(pid interface{}, opt *gitlab.CreateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) {
return f.createMrFn(pid, opt, options...)
}
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...)
}
@@ -77,35 +82,6 @@ func (f fakeClient) ApproveMergeRequest(pid interface{}, mr int, opt *gitlab.App
func (f fakeClient) ListMergeRequestDiscussions(pid interface{}, mergeRequest int, opt *gitlab.ListMergeRequestDiscussionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Discussion, *gitlab.Response, error) {
return f.listMergeRequestDiscussions(pid, mergeRequest, opt, options...)
// now := time.Now()
// later := now.Add(time.Second * 100)
//
// discussions := []*gitlab.Discussion{
// {
// Notes: []*gitlab.Note{
// {
// CreatedAt: &now,
// Type: "DiffNote",
// Author: Author{
// Username: "hcramer",
// },
// },
// },
// },
// {
// Notes: []*gitlab.Note{
// {
// CreatedAt: &later,
// Type: "DiffNote",
// Author: Author{
// Username: "hcramer2",
// },
// },
// },
// },
// }
// return discussions, makeResponse(200), nil
}
func (f fakeClient) ResolveMergeRequestDiscussion(pid interface{}, mergeRequest int, discussion string, opt *gitlab.ResolveMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) {
@@ -144,6 +120,11 @@ func (f fakeClient) GetTraceFile(pid interface{}, jobID int, options ...gitlab.R
return f.getTraceFile(pid, jobID, options...)
}
/* This middleware function needs to return an ID for the rest of the handlers */
func (f fakeClient) ListProjectMergeRequests(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) {
return []*gitlab.MergeRequest{{ID: 1}}, &gitlab.Response{}, nil
}
/* The assert function is a helper function used to check two comparables */
func assert[T comparable](t *testing.T, got T, want T) {
t.Helper()

View File

@@ -35,6 +35,8 @@ func (e InvalidRequestError) Error() string {
/* The ClientInterface interface implements all the methods that our handlers need */
type ClientInterface interface {
CreateMergeRequest(pid interface{}, opt *gitlab.CreateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error)
ListProjectMergeRequests(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, 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)

View File

@@ -3,11 +3,11 @@ local job = require("gitlab.job")
local M = {}
M.approve = function()
job.run_job("/approve", "POST")
job.run_job("/mr/approve", "POST")
end
M.revoke = function()
job.run_job("/revoke", "POST")
job.run_job("/mr/revoke", "POST")
end
return M

View File

@@ -126,7 +126,7 @@ M.confirm_create_comment = function(text, range, unlinked)
if unlinked then
local body = { comment = text }
job.run_job("/comment", "POST", body, function(data)
job.run_job("/mr/comment", "POST", body, function(data)
u.notify("Note created!", vim.log.levels.INFO)
discussions.add_discussion({ data = data, unlinked = true })
discussions.refresh_discussion_data()
@@ -152,7 +152,7 @@ M.confirm_create_comment = function(text, range, unlinked)
line_range = reviewer_info.range_info,
}
job.run_job("/comment", "POST", body, function(data)
job.run_job("/mr/comment", "POST", body, function(data)
u.notify("Comment created!", vim.log.levels.INFO)
discussions.add_discussion({ data = data, unlinked = false })
discussions.refresh_discussion_data()

View File

@@ -0,0 +1,321 @@
-- This module is responsible for creating am MR
-- for the current branch
local Layout = require("nui.layout")
local Input = require("nui.input")
local Popup = require("nui.popup")
local job = require("gitlab.job")
local u = require("gitlab.utils")
local state = require("gitlab.state")
local miscellaneous = require("gitlab.actions.miscellaneous")
---@class Mr
---@field target? string
---@field title? string
---@field description? string
---@class Args
---@field target? string
---@field template_file? string
local M = {
started = false,
layout_visible = false,
layout = nil,
layout_buf = nil,
title_bufnr = nil,
description_bufnr = nil,
mr = {
target = "",
title = "",
description = "",
},
}
M.reset_state = function()
M.started = false
M.mr.title = ""
M.mr.target = ""
M.mr.description = ""
end
local title_popup_settings = {
buf_options = {
filetype = "markdown",
},
focusable = true,
border = {
style = "rounded",
text = {
top = "Title",
},
},
}
local target_popup_settings = {
buf_options = {
filetype = "markdown",
},
focusable = true,
border = {
style = "rounded",
text = {
top = "Target branch",
},
},
}
local description_popup_settings = {
buf_options = {
filetype = "markdown",
},
enter = true,
focusable = true,
border = {
style = "rounded",
text = {
top = "Description",
},
},
}
local title_input_options = {
position = "50%",
relative = "editor",
size = 40,
border = {
style = "rounded",
text = {
top = "Title",
},
},
}
---1. If the user has already begun writing an MR, prompt them to
--- continue working on it.
---@param args? Args
M.start = function(args)
if M.started then
vim.ui.select({ "Yes", "No" }, { prompt = "Continue your previous MR?" }, function(choice)
if choice == "Yes" then
M.open_confirmation_popup(M.mr)
return
else
M.reset_state()
M.pick_target(args)
end
end)
else
M.pick_target(args)
end
end
---2. Pick the target branch
---@param args? Args
M.pick_target = function(args)
if not args then
args = {}
end
if args.target ~= nil then
M.pick_template({ target = args.target }, args)
return
end
if state.settings.create_mr.target ~= nil then
M.pick_template({ target = state.settings.create_mr.target }, args)
return
end
local all_branch_names = u.get_all_git_branches(true)
vim.ui.select(all_branch_names, {
prompt = "Choose target branch for merge",
}, function(choice)
if choice then
M.pick_template({ target = choice }, args)
end
end)
end
local function make_template_path(t)
local base_dir = vim.fn.trim(vim.fn.system({ "git", "rev-parse", "--show-toplevel" }))
return base_dir
.. state.settings.file_separator
.. ".gitlab"
.. state.settings.file_separator
.. "merge_request_templates"
.. state.settings.file_separator
.. t
end
---3. Pick template (if applicable). This is used as the description
---@param mr Mr
---@param args Args
M.pick_template = function(mr, args)
if not args then
args = {}
end
local template_file = args.template_file or state.settings.create_mr.template_file
if template_file ~= nil then
local description = u.read_file(make_template_path(template_file))
M.add_title({ target = mr.target, description = description })
return
end
local all_templates = u.list_files_in_folder(".gitlab" .. state.settings.file_separator .. "merge_request_templates")
if all_templates == nil then
M.add_title({ target = mr.target })
return
end
local opts = { "Blank Template" }
for _, v in ipairs(all_templates) do
table.insert(opts, v)
end
vim.ui.select(opts, {
prompt = "Choose Template",
}, function(choice)
if choice then
local description = u.read_file(make_template_path(choice))
M.add_title({ target = mr.target, description = description })
elseif choice == "Blank Template" then
M.add_title({ target = mr.target })
end
end)
end
---4. Prompts the user for the title of the MR
---@param mr Mr
M.add_title = function(mr)
local input = Input(title_input_options, {
prompt = "",
default_value = "",
on_close = function() end,
on_submit = function(_value)
M.open_confirmation_popup(mr)
end,
on_change = function(value)
mr.title = value
end,
})
input:map("n", "<Esc>", function()
input:unmount()
end, { noremap = true })
input:mount()
end
---5. Show the final popup.
---The function will render a popup containing the MR title and MR description, and
---target branch. The title and description are editable.
---@param mr Mr
M.open_confirmation_popup = function(mr)
M.started = true
if M.layout_visible then
M.layout:unmount()
M.layout_visible = false
return
end
local layout, title_popup, description_popup, target_popup = M.create_layout()
M.layout = layout
M.layout_buf = layout.bufnr
M.layout_visible = true
local function exit()
local title = vim.fn.trim(u.get_buffer_text(M.title_bufnr))
local description = u.get_buffer_text(M.description_bufnr)
local target = vim.fn.trim(u.get_buffer_text(target_popup.bufnr))
M.mr = {
title = title,
description = description,
target = target,
}
layout:unmount()
M.layout_visible = false
end
local description_lines = mr.description and M.build_description_lines(mr.description) or { "" }
vim.schedule(function()
vim.api.nvim_buf_set_lines(description_popup.bufnr, 0, -1, false, description_lines)
vim.api.nvim_buf_set_lines(title_popup.bufnr, 0, -1, false, { mr.title })
vim.api.nvim_buf_set_lines(target_popup.bufnr, 0, -1, false, { mr.target })
local popup_opts = {
cb = exit,
action_before_close = true,
action_before_exit = true,
}
state.set_popup_keymaps(description_popup, M.create_mr, miscellaneous.attach_file, popup_opts)
state.set_popup_keymaps(title_popup, M.create_mr, nil, popup_opts)
state.set_popup_keymaps(target_popup, M.create_mr, nil, popup_opts)
vim.api.nvim_set_current_buf(description_popup.bufnr)
end)
end
---Builds a lua list of strings that contain the MR description
M.build_description_lines = function(template_content)
local description_lines = {}
for line in template_content:gmatch("[^\n]+") do
table.insert(description_lines, line)
table.insert(description_lines, "")
end
return description_lines
end
---This function will POST the new MR to create it
M.create_mr = function()
local description = u.get_buffer_text(M.description_bufnr)
local title = u.get_buffer_text(M.title_bufnr):gsub("\n", " ")
local target = u.get_buffer_text(M.target_bufnr):gsub("\n", " ")
local body = {
title = title,
description = description,
target_branch = target,
}
job.run_job("/create_mr", "POST", body, function(data)
u.notify(data.message, vim.log.levels.INFO)
M.reset_state()
M.layout:unmount()
M.layout_visible = false
end)
end
M.create_layout = function()
local title_popup = Popup(title_popup_settings)
M.title_bufnr = title_popup.bufnr
local description_popup = Popup(description_popup_settings)
M.description_bufnr = description_popup.bufnr
local target_branch_popup = Popup(target_popup_settings)
M.target_bufnr = target_branch_popup.bufnr
local internal_layout
internal_layout = Layout.Box({
Layout.Box({
Layout.Box(title_popup, { grow = 1 }),
Layout.Box(target_branch_popup, { grow = 1 }),
}, { size = 3 }),
Layout.Box(description_popup, { grow = 1 }),
}, { dir = "col" })
local layout = Layout({
position = "50%",
relative = "editor",
size = {
width = "95%",
height = "95%",
},
}, internal_layout)
layout:mount()
return layout, title_popup, description_popup, target_branch_popup
end
return M

View File

@@ -49,6 +49,7 @@
---@field resolved_by Author
---@field resolved_at string?
---@field noteable_iid integer
---@field url string?
---@class UnlinkedNote: Note
---@field position nil

View File

@@ -37,7 +37,7 @@ local M = {
---callback with data
---@param callback (fun(data: DiscussionData): nil)?
M.load_discussions = function(callback)
job.run_job("/discussions/list", "POST", { blacklist = state.settings.discussion_tree.blacklist }, function(data)
job.run_job("/mr/discussions/list", "POST", { blacklist = state.settings.discussion_tree.blacklist }, function(data)
M.discussions = data.discussions ~= vim.NIL and data.discussions or {}
M.unlinked_discussions = data.unlinked_discussions ~= vim.NIL and data.unlinked_discussions or {}
if type(callback) == "function" then
@@ -52,7 +52,7 @@ M.initialize_discussions = function()
-- Setup callback to refresh discussion data, discussion signs and diagnostics whenever the reviewed file changes.
reviewer.set_callback_for_file_changed(M.refresh_discussion_data)
-- Setup callback to clear signs and diagnostics whenever reviewer is left.
reviewer.set_callback_for_reviewer_leave(signs.clear_signs_and_discussions)
reviewer.set_callback_for_reviewer_leave(signs.clear_signs_and_diagnostics)
end
---Refresh discussion data, signs, diagnostics, and winbar with new data from API
@@ -209,7 +209,7 @@ end
M.send_reply = function(tree, discussion_id)
return function(text)
local body = { discussion_id = discussion_id, reply = text }
job.run_job("/reply", "POST", body, function(data)
job.run_job("/mr/reply", "POST", body, function(data)
u.notify("Sent reply!", vim.log.levels.INFO)
M.add_reply_to_tree(tree, data.note, discussion_id)
M.load_discussions()
@@ -239,7 +239,7 @@ M.send_deletion = function(tree, unlinked)
local body = { discussion_id = root_node.id, note_id = tonumber(note_id) }
job.run_job("/comment", "DELETE", body, function(data)
job.run_job("/mr/comment", "DELETE", body, function(data)
u.notify(data.message, vim.log.levels.INFO)
if not note_node.is_root then
tree:remove_node("-" .. note_id) -- Note is not a discussion root, safe to remove
@@ -301,7 +301,7 @@ M.send_edits = function(discussion_id, note_id, unlinked)
note_id = note_id,
comment = text,
}
job.run_job("/comment", "PATCH", body, function(data)
job.run_job("/mr/comment", "PATCH", body, function(data)
u.notify(data.message, vim.log.levels.INFO)
M.rebuild_discussion_tree()
if unlinked then
@@ -327,7 +327,7 @@ M.toggle_discussion_resolved = function(tree)
resolved = not note.resolved,
}
job.run_job("/discussions/resolve", "PUT", body, function(data)
job.run_job("/mr/discussions/resolve", "PUT", body, function(data)
u.notify(data.message, vim.log.levels.INFO)
M.redraw_resolved_status(tree, note, not note.resolved)
M.refresh_discussion_data()
@@ -572,6 +572,12 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked)
end
end, { buffer = bufnr, desc = "Jump to reviewer" })
end
vim.keymap.set("n", state.settings.discussion_tree.open_in_browser, function()
M.open_in_browser(tree)
end, { buffer = bufnr, desc = "Open the note in your browser" })
vim.keymap.set("n", "<leader>p", function()
M.print_node(tree)
end, { buffer = bufnr, desc = "dev_ Print current node (for debugging)" })
end
M.redraw_resolved_status = function(tree, note, mark_resolved)
@@ -683,4 +689,26 @@ M.get_note_location = function(tree)
nil
end
---@param tree NuiTree
M.open_in_browser = function(tree)
local current_node = tree:get_node()
local note_node = M.get_note_node(tree, current_node)
if note_node == nil then
return
end
local url = note_node.url
if url == nil then
u.notify("Could not get URL of note", vim.log.levels.ERROR)
return
end
u.open_in_browser(url)
end
-- For developers!
M.print_node = function(tree)
local current_node = tree:get_node()
vim.print(current_node)
end
return M

View File

@@ -11,7 +11,7 @@ local M = {}
M.diagnostics_namespace = diagnostics_namespace
---Clear all signs and diagnostics
M.clear_signs_and_discussions = function()
M.clear_signs_and_diagnostics = function()
vim.fn.sign_unplace(discussion_sign_name)
vim.diagnostic.reset(diagnostics_namespace)
end

View File

@@ -131,6 +131,7 @@ M.build_note = function(note, resolve_info)
file_name = (type(note.position) == "table" and note.position.new_path),
new_line = (type(note.position) == "table" and note.position.new_line),
old_line = (type(note.position) == "table" and note.position.old_line),
url = state.INFO.web_url .. "#note_" .. note.id,
type = "note",
}, text_nodes)
@@ -161,11 +162,11 @@ M.add_discussions_to_table = function(items, unlinked)
local undefined_type = false
local root_new_line = nil
local root_old_line = nil
local root_url
for j, note in ipairs(discussion.notes) do
if j == 1 then
_, root_text, root_text_nodes = M.build_note(note, { resolved = note.resolved, resolvable = note.resolvable })
root_file_name = (type(note.position) == "table" and note.position.new_path or nil)
root_new_line = (type(note.position) == "table" and note.position.new_line or nil)
root_old_line = (type(note.position) == "table" and note.position.old_line or nil)
@@ -173,6 +174,7 @@ M.add_discussions_to_table = function(items, unlinked)
root_note_id = tostring(note.id)
resolvable = note.resolvable
resolved = note.resolved
root_url = state.INFO.web_url .. "#note_" .. note.id
-- This appears to be a Gitlab 🐛 where the "type" is returned as an empty string in some cases
-- We link these comments to the old file by default
@@ -203,6 +205,7 @@ M.add_discussions_to_table = function(items, unlinked)
resolvable = resolvable,
resolved = resolved,
undefined_type = undefined_type,
url = root_url,
}, body)
table.insert(t, root_node)

View File

@@ -52,7 +52,8 @@ end
M.update_winbar = function(discussions, unlinked_discussions, base_title)
local d = require("gitlab.actions.discussions")
local winId = d.split.winid
vim.wo[winId].winbar = content(discussions, unlinked_discussions, base_title)
local c = content(discussions, unlinked_discussions, base_title)
vim.wo[winId].winbar = c
end
return M

View File

@@ -46,7 +46,7 @@ M.confirm_merge = function(merge_body, squash_message)
merge_body.squash_message = squash_message
end
job.run_job("/merge", "POST", merge_body, function(data)
job.run_job("/mr/merge", "POST", merge_body, function(data)
reviewer.close()
u.notify(data.message, vim.log.levels.INFO)
end)

View File

@@ -3,21 +3,6 @@ local u = require("gitlab.utils")
local job = require("gitlab.job")
local M = {}
M.open_in_browser = function()
local url = state.INFO.web_url
if url == nil then
u.notify("Could not get Gitlab URL", vim.log.levels.ERROR)
return
end
if vim.fn.has("mac") == 1 then
vim.fn.jobstart({ "open", url })
elseif vim.fn.has("unix") == 1 then
vim.fn.jobstart({ "xdg-open", url })
else
u.notify("Opening a Gitlab URL is not supported on this OS!", vim.log.levels.ERROR)
end
end
M.attach_file = function()
local attachment_dir = state.settings.attachment_dir
if not attachment_dir or attachment_dir == "" then
@@ -40,7 +25,7 @@ M.attach_file = function()
end
local full_path = attachment_dir .. u.path_separator .. choice
local body = { file_path = full_path, file_name = choice }
job.run_job("/mr/attachment", "POST", body, function(data)
job.run_job("/attachment", "POST", body, function(data)
local markdown = data.markdown
local current_line = u.get_current_line_number()
local bufnr = vim.api.nvim_get_current_buf()

View File

@@ -9,8 +9,8 @@ local summary = require("gitlab.actions.summary")
local assignees_and_reviewers = require("gitlab.actions.assignees_and_reviewers")
local comment = require("gitlab.actions.comment")
local pipeline = require("gitlab.actions.pipeline")
local create_mr = require("gitlab.actions.create_mr")
local approvals = require("gitlab.actions.approvals")
local miscellaneous = require("gitlab.actions.miscellaneous")
local info = state.dependencies.info
local project_members = state.dependencies.project_members
@@ -40,6 +40,7 @@ return {
create_comment_suggestion = async.sequence({ info, revisions }, comment.create_comment_suggestion),
move_to_discussion_tree_from_diagnostic = async.sequence({}, discussions.move_to_discussion_tree),
create_note = async.sequence({ info }, comment.create_note),
create_mr = async.sequence({}, create_mr.start),
review = async.sequence({ u.merge(info, { refresh = true }), revisions }, function()
reviewer.open()
end),
@@ -57,5 +58,11 @@ return {
-- Other functions 🤷
state = state,
print_settings = state.print_settings,
open_in_browser = async.sequence({ info }, miscellaneous.open_in_browser),
open_in_browser = async.sequence({ info }, function()
if state.INFO.web_url == nil then
u.notify("Could not get Gitlab URL", vim.log.levels.ERROR)
return
end
u.open_in_browser(state.INFO.web_url)
end),
}

View File

@@ -38,6 +38,7 @@ M.settings = {
jump_to_reviewer = "m",
edit_comment = "e",
delete_comment = "dd",
open_in_browser = "b",
reply = "r",
toggle_node = "t",
toggle_resolved = "p",
@@ -71,6 +72,10 @@ M.settings = {
squash = false,
delete_branch = false,
},
create_mr = {
target = nil,
template_file = nil,
},
info = {
enabled = true,
horizontal = false,
@@ -123,8 +128,8 @@ M.settings = {
preparing = "",
scheduled = "",
running = "",
canceled = "",
skipped = "",
canceled = "",
skipped = "",
success = "",
failed = "",
},
@@ -204,7 +209,7 @@ M.setPluginConfiguration = function()
end
local config_file_path = base_path .. M.settings.file_separator .. ".gitlab.nvim"
local config_file_content = u.read_file(config_file_path)
local config_file_content = u.read_file(config_file_path, { remove_newlines = true })
local file_properties = {}
if config_file_content ~= nil then
@@ -231,10 +236,15 @@ M.setPluginConfiguration = function()
return true
end
local function exit(popup, cb)
local function exit(popup, opts)
if opts.action_before_exit and opts.cb ~= nil then
opts.cb()
popup:unmount()
if cb ~= nil then
cb()
else
popup:unmount()
if opts.cb ~= nil then
opts.cb()
end
end
end
@@ -244,7 +254,7 @@ M.set_popup_keymaps = function(popup, action, linewise_action, opts)
opts = {}
end
vim.keymap.set("n", M.settings.popup.exit, function()
exit(popup, opts.cb)
exit(popup, opts)
end, { buffer = popup.bufnr, desc = "Exit popup" })
if action ~= "Help" then -- Don't show help on the help popup
@@ -258,9 +268,9 @@ M.set_popup_keymaps = function(popup, action, linewise_action, opts)
local text = u.get_buffer_text(popup.bufnr)
if opts.action_before_close then
action(text, popup.bufnr)
exit(popup)
exit(popup, opts)
else
exit(popup)
exit(popup, opts)
action(text, popup.bufnr)
end
end, { buffer = popup.bufnr, desc = "Perform action" })
@@ -282,7 +292,7 @@ end
-- for each of the actions to occur. This is necessary because some Gitlab behaviors (like
-- adding a reviewer) requires some initial state.
M.dependencies = {
info = { endpoint = "/info", key = "info", state = "INFO", refresh = false },
info = { endpoint = "/mr/info", key = "info", state = "INFO", refresh = false },
revisions = { endpoint = "/mr/revisions", key = "Revisions", state = "MR_REVISIONS", refresh = false },
project_members = {
endpoint = "/project/members",

View File

@@ -382,14 +382,18 @@ M.create_popup_state = function(title, settings, width, height, zindex)
return view_opts
end
M.read_file = function(file_path)
M.read_file = function(file_path, opts)
local file = io.open(file_path, "r")
if file == nil then
return nil
end
local file_contents = file:read("*all")
file:close()
if opts and opts.remove_newlines then
file_contents = string.gsub(file_contents, "\n", "")
end
return file_contents
end
@@ -633,9 +637,46 @@ M.get_icon = function(filename)
end
end
---@param remote? boolean
M.get_all_git_branches = function(remote)
local branches = {}
local handle = remote == true and io.popen("git branch -r 2>&1") or io.popen("git branch 2>&1")
if handle then
for line in handle:lines() do
local branch
if remote then
for res in line:gmatch("origin/([^\n]+)") do
branch = res -- Trim /origin
end
else
branch = line:gsub("^%s*%*?%s*", "") -- Trim leading whitespace and the "* " marker for the current branch
end
table.insert(branches, branch)
end
handle:close()
else
print("Error running 'git branch' command.")
end
return branches
end
M.basename = function(str)
local name = string.gsub(str, "(.*/)(.*)", "%2")
return name
end
---@param url string?
M.open_in_browser = function(url)
if vim.fn.has("mac") == 1 then
vim.fn.jobstart({ "open", url })
elseif vim.fn.has("unix") == 1 then
vim.fn.jobstart({ "xdg-open", url })
else
M.notify("Opening a Gitlab URL is not supported on this OS!", vim.log.levels.ERROR)
end
end
return M

View File

@@ -56,8 +56,12 @@ describe("gitlab/actions/discussions/tree.lua", function()
after_each(function()
utils.time_since = original_time_since
state.INFO = nil
end)
before_each(function()
state.INFO = {
web_url = "https://gitlab.com/some-org/-/merge_requests/4963",
}
spy_time_since = spy.new(function()
return "5 days ago"
end)