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: 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 - Create, approve, and merge MRs for the current branch
- Approve or revoke approval for an MR - Read and edit an MR description
- Add or remove reviewers and assignees - Add or remove reviewers and assignees
- Resolve, reply to, and unresolve discussion threads - Resolve, reply to, and unresolve discussion threads
- Create, edit, delete, and reply to comments on an MR - Create, edit, delete, and reply to comments
- View and Manage Pipeline Jobs - View and manage pipeline Jobs
- Upload files, jump to the browser, and a lot more!
And a lot more!
https://github.com/harrisoncramer/gitlab.nvim/assets/32515581/dc5c07de-4ae6-4335-afe1-d554e3804372 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) - [Connecting to Gitlab](#connecting-to-gitlab)
- [Configuring the Plugin](#configuring-the-plugin) - [Configuring the Plugin](#configuring-the-plugin)
- [Usage](#usage) - [Usage](#usage)
- [The summary command](#summary) - [The Summary view](#the-summary-view)
- [Reviewing Diffs](#reviewing-diffs) - [Reviewing an MR](#reviewing-an-mr)
- [Merging](#merging-an-mr) - [Merging](#merging-an-mr)
- [Discussions and Notes](#discussions-and-notes) - [Discussions and Notes](#discussions-and-notes)
- [Discussion signs and diagnostics](#discussion-signs-and-diagnostics) - [Signs and Diagnostics](#signs-and-diagnostics)
- [Uploading Files](#uploading-files) - [Uploading Files](#uploading-files)
- [MR Approvals](#mr-approvals) - [Approvals](#mr-approvals)
- [Creating an MR](#creating-an-mr)
- [Pipelines](#pipelines) - [Pipelines](#pipelines)
- [Reviewers and Assignees](#reviewers-and-assignees) - [Reviewers and Assignees](#reviewers-and-assignees)
- [Restarting or Shutting down](#restarting-or-shutting-down) - [Restarting or Shutting down](#restarting-or-shutting-down)
@@ -147,15 +147,16 @@ require("gitlab").setup({
toggle_node = "t", -- Opens or closes the discussion toggle_node = "t", -- Opens or closes the discussion
toggle_resolved = "p" -- Toggles the resolved status of the whole discussion toggle_resolved = "p" -- Toggles the resolved status of the whole discussion
position = "left", -- "top", "right", "bottom" or "left" 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 size = "20%", -- Size of split
relative = "editor", -- Position of tree split relative to "editor" or "window" relative = "editor", -- Position of tree split relative to "editor" or "window"
resolved = '', -- Symbol to show next to resolved discussions 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 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) 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. -- 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, enabled = true,
horizontal = false, -- Display metadata to the left of the summary rather than underneath 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 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 display_opts = {}, -- see opts in vim.diagnostic.set
}, },
pipeline = { pipeline = {
created = "", created = "",
pending = "", pending = "",
preparing = "", preparing = "",
scheduled = "", scheduled = "",
running = "", running = "",
canceled = "", canceled = "",
skipped = "", skipped = "",
success = "", success = "",
failed = "", failed = "",
}, },
@@ -216,6 +217,10 @@ require("gitlab").setup({
squash = false, squash = false,
delete_branch = 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 = { colors = {
discussion_tree = { discussion_tree = {
username = "Keyword", username = "Keyword",
@@ -239,7 +244,7 @@ git checkout feature-branch
Then open Neovim. To begin, try running the `summary` command or the `review` command. 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. 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. 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. 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. 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."
@@ -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() 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. 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() 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 ### Pipelines
You can view the status of the pipeline for the current MR with the `pipeline` action. 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("n", "glc", gitlab.create_comment)
vim.keymap.set("v", "glc", gitlab.create_multiline_comment) vim.keymap.set("v", "glc", gitlab.create_multiline_comment)
vim.keymap.set("v", "glC", gitlab.create_comment_suggestion) 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", "glm", gitlab.move_to_discussion_tree_from_diagnostic)
vim.keymap.set("n", "gln", gitlab.create_note) vim.keymap.set("n", "gln", gitlab.create_note)
vim.keymap.set("n", "gld", gitlab.toggle_discussions) 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: 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 { 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 return
} }

View File

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

View File

@@ -36,7 +36,7 @@ func withMockFileReader(a *api) error {
func TestAttachmentHandler(t *testing.T) { func TestAttachmentHandler(t *testing.T) {
t.Run("Returns 200-status response after upload", func(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) router, _ := createRouterAndApi(fakeClient{uploadFile: uploadFile}, withMockFileReader)
data := serveRequest(t, router, request, AttachmentResponse{}) data := serveRequest(t, router, request, AttachmentResponse{})
assert(t, data.SuccessResponse.Status, http.StatusOK) 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) { 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) router, _ := createRouterAndApi(fakeClient{uploadFile: uploadFile}, withMockFileReader)
data := serveRequest(t, router, request, ErrorResponse{}) data := serveRequest(t, router, request, ErrorResponse{})
checkBadMethod(t, *data, http.MethodPost) checkBadMethod(t, *data, http.MethodPost)
}) })
t.Run("Handles errors from Gitlab client", func(t *testing.T) { 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) router, _ := createRouterAndApi(fakeClient{uploadFile: uploadFileErr}, withMockFileReader)
data := serveRequest(t, router, request, ErrorResponse{}) data := serveRequest(t, router, request, ErrorResponse{})
checkErrorFromGitlab(t, *data, "Could not upload some_file_name to Gitlab") checkErrorFromGitlab(t, *data, "Could not upload some_file_name to Gitlab")
}) })
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { 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) router, _ := createRouterAndApi(fakeClient{uploadFile: uploadFileNon200}, withMockFileReader)
data := serveRequest(t, router, request, ErrorResponse{}) 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"
"net/http/httputil" "net/http/httputil"
"os" "os"
"strconv"
"github.com/hashicorp/go-retryablehttp" "github.com/hashicorp/go-retryablehttp"
"github.com/xanzy/go-gitlab" "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) { func initProjectSettings(c *Client, gitInfo GitProjectInfo) (error, *ProjectInfo) {
opt := gitlab.GetProjectOptions{} opt := gitlab.GetProjectOptions{}
@@ -109,31 +108,10 @@ func initProjectSettings(c *Client, gitInfo GitProjectInfo) (error, *ProjectInfo
projectId := fmt.Sprint(project.ID) 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{ return nil, &ProjectInfo{
MergeId: mergeIdInt,
ProjectId: projectId, ProjectId: projectId,
} }
} }
/* handleError is a utililty handler that returns errors to the client along with their statuses and messages */ /* 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 { 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 return
} }
@@ -186,7 +186,7 @@ func (a *api) postComment(w http.ResponseWriter, r *http.Request) {
} }
if res.StatusCode >= 300 { 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 return
} }
@@ -236,7 +236,7 @@ func (a *api) editComment(w http.ResponseWriter, r *http.Request) {
} }
if res.StatusCode >= 300 { 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 return
} }

View File

@@ -22,7 +22,7 @@ func createMergeRequestDiscussionErr(pid interface{}, mergeRequest int, opt *git
func TestPostComment(t *testing.T) { func TestPostComment(t *testing.T) {
t.Run("Creates a new note (unlinked comment)", func(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}) server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussion})
data := serveRequest(t, server, request, CommentResponse{}) data := serveRequest(t, server, request, CommentResponse{})
assert(t, data.SuccessResponse.Message, "Note created successfully") 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) { 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}) server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussion})
data := serveRequest(t, server, request, CommentResponse{}) data := serveRequest(t, server, request, CommentResponse{})
assert(t, data.SuccessResponse.Message, "Comment created successfully") 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) { 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", FileName: "some_file.txt",
LineRange: &LineRange{ LineRange: &LineRange{
StartRange: &LinePosition{}, /* These would have real data */ 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) { 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}) server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussionErr})
data := serveRequest(t, server, request, ErrorResponse{}) data := serveRequest(t, server, request, ErrorResponse{})
checkErrorFromGitlab(t, *data, "Could not create discussion") checkErrorFromGitlab(t, *data, "Could not create discussion")
}) })
t.Run("Handles non-200s from Gitlab", func(t *testing.T) { 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}) server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussionNon200})
data := serveRequest(t, server, request, ErrorResponse{}) 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) { func TestDeleteComment(t *testing.T) {
t.Run("Deletes a comment", func(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}) server, _ := createRouterAndApi(fakeClient{deleteMergeRequestDiscussionNote: deleteMergeRequestDiscussionNote})
data := serveRequest(t, server, request, CommentResponse{}) data := serveRequest(t, server, request, CommentResponse{})
assert(t, data.SuccessResponse.Message, "Comment deleted successfully") 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) { 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}) server, _ := createRouterAndApi(fakeClient{deleteMergeRequestDiscussionNote: deleteMergeRequestDiscussionNoteErr})
data := serveRequest(t, server, request, ErrorResponse{}) data := serveRequest(t, server, request, ErrorResponse{})
checkErrorFromGitlab(t, *data, "Could not delete comment") checkErrorFromGitlab(t, *data, "Could not delete comment")
}) })
t.Run("Handles non-200s from Gitlab", func(t *testing.T) { 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}) server, _ := createRouterAndApi(fakeClient{deleteMergeRequestDiscussionNote: deleteMergeRequestDiscussionNoteNon200})
data := serveRequest(t, server, request, ErrorResponse{}) 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) { func TestEditComment(t *testing.T) {
t.Run("Edits a comment", func(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}) server, _ := createRouterAndApi(fakeClient{updateMergeRequestDiscussionNote: updateMergeRequestDiscussionNote})
data := serveRequest(t, server, request, CommentResponse{}) data := serveRequest(t, server, request, CommentResponse{})
assert(t, data.SuccessResponse.Message, "Comment updated successfully") 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) { 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}) server, _ := createRouterAndApi(fakeClient{updateMergeRequestDiscussionNote: updateMergeRequestDiscussionNoteErr})
data := serveRequest(t, server, request, ErrorResponse{}) data := serveRequest(t, server, request, ErrorResponse{})
checkErrorFromGitlab(t, *data, "Could not update comment") checkErrorFromGitlab(t, *data, "Could not update comment")
}) })
t.Run("Handles non-200s from Gitlab", func(t *testing.T) { 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}) server, _ := createRouterAndApi(fakeClient{updateMergeRequestDiscussionNote: updateMergeRequestDiscussionNoteNon200})
data := serveRequest(t, server, request, ErrorResponse{}) 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 { 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 return
} }

View File

@@ -22,7 +22,7 @@ func getInfoErr(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestsO
func TestInfoHandler(t *testing.T) { func TestInfoHandler(t *testing.T) {
t.Run("Returns normal information", func(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}) server, _ := createRouterAndApi(fakeClient{getMergeRequestFn: getInfo})
data := serveRequest(t, server, request, InfoResponse{}) data := serveRequest(t, server, request, InfoResponse{})
assert(t, data.Info.Title, "Some Title") 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) { 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}) server, _ := createRouterAndApi(fakeClient{getMergeRequestFn: getInfo})
data := serveRequest(t, server, request, ErrorResponse{}) data := serveRequest(t, server, request, ErrorResponse{})
checkBadMethod(t, *data, http.MethodGet) checkBadMethod(t, *data, http.MethodGet)
}) })
t.Run("Handles errors from Gitlab client", func(t *testing.T) { 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}) server, _ := createRouterAndApi(fakeClient{getMergeRequestFn: getInfoErr})
data := serveRequest(t, server, request, ErrorResponse{}) data := serveRequest(t, server, request, ErrorResponse{})
checkErrorFromGitlab(t, *data, "Could not get project info") checkErrorFromGitlab(t, *data, "Could not get project info")
}) })
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { 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}) server, _ := createRouterAndApi(fakeClient{getMergeRequestFn: getInfoNon200})
data := serveRequest(t, server, request, ErrorResponse{}) 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 { 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 return
} }

View File

@@ -49,7 +49,7 @@ func listMergeRequestDiscussionsNon200(pid interface{}, mergeRequest int, opt *g
func TestListDiscussionsHandler(t *testing.T) { func TestListDiscussionsHandler(t *testing.T) {
t.Run("Returns sorted discussions", func(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}) server, _ := createRouterAndApi(fakeClient{listMergeRequestDiscussions: listMergeRequestDiscussions})
data := serveRequest(t, server, request, DiscussionsResponse{}) data := serveRequest(t, server, request, DiscussionsResponse{})
assert(t, data.SuccessResponse.Message, "Discussions retrieved") 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) { 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}) server, _ := createRouterAndApi(fakeClient{listMergeRequestDiscussions: listMergeRequestDiscussions})
data := serveRequest(t, server, request, DiscussionsResponse{}) data := serveRequest(t, server, request, DiscussionsResponse{})
assert(t, data.SuccessResponse.Message, "Discussions retrieved") 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) { 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}) server, _ := createRouterAndApi(fakeClient{listMergeRequestDiscussions: listMergeRequestDiscussions})
data := serveRequest(t, server, request, ErrorResponse{}) data := serveRequest(t, server, request, ErrorResponse{})
checkBadMethod(t, *data, http.MethodPost) checkBadMethod(t, *data, http.MethodPost)
}) })
t.Run("Handles errors from Gitlab client", func(t *testing.T) { 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}) server, _ := createRouterAndApi(fakeClient{listMergeRequestDiscussions: listMergeRequestDiscussionsErr})
data := serveRequest(t, server, request, ErrorResponse{}) data := serveRequest(t, server, request, ErrorResponse{})
checkErrorFromGitlab(t, *data, "Could not list discussions") checkErrorFromGitlab(t, *data, "Could not list discussions")
}) })
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { 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}) server, _ := createRouterAndApi(fakeClient{listMergeRequestDiscussions: listMergeRequestDiscussionsNon200})
data := serveRequest(t, server, request, ErrorResponse{}) 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) 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 { 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 return
} }

View File

@@ -22,7 +22,7 @@ func acceptAndMergeNon200(pid interface{}, mergeRequest int, opt *gitlab.AcceptM
func TestAcceptAndMergeHandler(t *testing.T) { func TestAcceptAndMergeHandler(t *testing.T) {
t.Run("Accepts and merges a merge request", func(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}) server, _ := createRouterAndApi(fakeClient{acceptAndMergeFn: acceptAndMergeFn})
data := serveRequest(t, server, request, SuccessResponse{}) data := serveRequest(t, server, request, SuccessResponse{})
assert(t, data.Message, "MR merged successfully") 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) { 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}) server, _ := createRouterAndApi(fakeClient{acceptAndMergeFn: acceptAndMergeFn})
data := serveRequest(t, server, request, ErrorResponse{}) data := serveRequest(t, server, request, ErrorResponse{})
checkBadMethod(t, *data, http.MethodPost) checkBadMethod(t, *data, http.MethodPost)
}) })
t.Run("Handles errors from Gitlab client", func(t *testing.T) { 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}) server, _ := createRouterAndApi(fakeClient{acceptAndMergeFn: acceptAndMergeFnErr})
data := serveRequest(t, server, request, ErrorResponse{}) data := serveRequest(t, server, request, ErrorResponse{})
checkErrorFromGitlab(t, *data, "Could not merge MR") checkErrorFromGitlab(t, *data, "Could not merge MR")
}) })
t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { 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}) server, _ := createRouterAndApi(fakeClient{acceptAndMergeFn: acceptAndMergeNon200})
data := serveRequest(t, server, request, ErrorResponse{}) 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 { 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 return
} }

View File

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

View File

@@ -22,7 +22,7 @@ func (a *api) revokeHandler(w http.ResponseWriter, r *http.Request) {
} }
if res.StatusCode >= 300 { 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 return
} }

View File

@@ -7,14 +7,17 @@ import (
"net" "net"
"net/http" "net/http"
"os" "os"
"strconv"
"time" "time"
"github.com/xanzy/go-gitlab"
) )
/* /*
startSever starts the server and runs concurrent goroutines startSever starts the server and runs concurrent goroutines
to handle potential shutdown requests and incoming HTTP requests. 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, m, a := createRouterAndApi(client,
func(a *api) error { func(a *api) error {
@@ -24,6 +27,10 @@ func startServer(client *Client, projectInfo *ProjectInfo) {
func(a *api) error { func(a *api) error {
a.fileReader = attachmentReader{} a.fileReader = attachmentReader{}
return nil return nil
},
func(a *api) error {
a.gitInfo = &gitInfo
return nil
}) })
l := createListener() l := createListener()
@@ -73,6 +80,7 @@ with the client value, which is a go-gitlab client.
type api struct { type api struct {
client ClientInterface client ClientInterface
projectInfo *ProjectInfo projectInfo *ProjectInfo
gitInfo *GitProjectInfo
fileReader FileReader fileReader FileReader
sigCh chan os.Signal 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 iterates over all option functions to configure API fields such as the project information and default
file reader functionality file reader functionality
*/ */
func createRouterAndApi(client ClientInterface, optFuncs ...optFunc) (*http.ServeMux, api) { func createRouterAndApi(client ClientInterface, optFuncs ...optFunc) (*http.ServeMux, api) {
m := http.NewServeMux() m := http.NewServeMux()
a := api{ a := api{
client: client, client: client,
projectInfo: &ProjectInfo{}, projectInfo: &ProjectInfo{},
gitInfo: &GitProjectInfo{},
fileReader: nil, fileReader: nil,
sigCh: make(chan os.Signal, 1), 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("/mr/approve", a.withMr(a.approveHandler))
m.HandleFunc("/shutdown", a.shutdownHandler) m.HandleFunc("/mr/comment", a.withMr(a.commentHandler))
m.HandleFunc("/approve", a.approveHandler) m.HandleFunc("/mr/merge", a.withMr(a.acceptAndMergeHandler))
m.HandleFunc("/comment", a.commentHandler) m.HandleFunc("/mr/discussions/list", a.withMr(a.listDiscussionsHandler))
m.HandleFunc("/merge", a.acceptAndMergeHandler) m.HandleFunc("/mr/discussions/resolve", a.withMr(a.discussionsResolveHandler))
m.HandleFunc("/discussions/list", a.listDiscussionsHandler) m.HandleFunc("/mr/info", a.withMr(a.infoHandler))
m.HandleFunc("/discussions/resolve", a.discussionsResolveHandler) m.HandleFunc("/mr/assignee", a.withMr(a.assigneesHandler))
m.HandleFunc("/info", a.infoHandler) 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("/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("/pipeline/", a.pipelineHandler)
m.HandleFunc("/project/members", a.projectMembersHandler) m.HandleFunc("/project/members", a.projectMembersHandler)
m.HandleFunc("/reply", a.replyHandler) m.HandleFunc("/shutdown", a.shutdownHandler)
m.HandleFunc("/revoke", a.revokeHandler)
m.Handle("/ping", http.HandlerFunc(pingHandler))
return m, a return m, a
} }
@@ -157,3 +170,41 @@ func createListener() (l net.Listener) {
return l 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 { 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) 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) 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"` 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) { 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...) 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) { 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...) 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) { 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...) 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 */ /* The assert function is a helper function used to check two comparables */
func assert[T comparable](t *testing.T, got T, want T) { func assert[T comparable](t *testing.T, got T, want T) {
t.Helper() t.Helper()

View File

@@ -35,6 +35,8 @@ 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 {
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) 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) 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)

View File

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

View File

@@ -126,7 +126,7 @@ M.confirm_create_comment = function(text, range, unlinked)
if unlinked then if unlinked then
local body = { comment = text } 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) u.notify("Note created!", vim.log.levels.INFO)
discussions.add_discussion({ data = data, unlinked = true }) discussions.add_discussion({ data = data, unlinked = true })
discussions.refresh_discussion_data() discussions.refresh_discussion_data()
@@ -152,7 +152,7 @@ M.confirm_create_comment = function(text, range, unlinked)
line_range = reviewer_info.range_info, 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) u.notify("Comment created!", vim.log.levels.INFO)
discussions.add_discussion({ data = data, unlinked = false }) discussions.add_discussion({ data = data, unlinked = false })
discussions.refresh_discussion_data() 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_by Author
---@field resolved_at string? ---@field resolved_at string?
---@field noteable_iid integer ---@field noteable_iid integer
---@field url string?
---@class UnlinkedNote: Note ---@class UnlinkedNote: Note
---@field position nil ---@field position nil

View File

@@ -37,7 +37,7 @@ local M = {
---callback with data ---callback with data
---@param callback (fun(data: DiscussionData): nil)? ---@param callback (fun(data: DiscussionData): nil)?
M.load_discussions = function(callback) 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.discussions = data.discussions ~= vim.NIL and data.discussions or {}
M.unlinked_discussions = data.unlinked_discussions ~= vim.NIL and data.unlinked_discussions or {} M.unlinked_discussions = data.unlinked_discussions ~= vim.NIL and data.unlinked_discussions or {}
if type(callback) == "function" then 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. -- 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) reviewer.set_callback_for_file_changed(M.refresh_discussion_data)
-- Setup callback to clear signs and diagnostics whenever reviewer is left. -- 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 end
---Refresh discussion data, signs, diagnostics, and winbar with new data from API ---Refresh discussion data, signs, diagnostics, and winbar with new data from API
@@ -209,7 +209,7 @@ end
M.send_reply = function(tree, discussion_id) M.send_reply = function(tree, discussion_id)
return function(text) return function(text)
local body = { discussion_id = discussion_id, reply = 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) u.notify("Sent reply!", vim.log.levels.INFO)
M.add_reply_to_tree(tree, data.note, discussion_id) M.add_reply_to_tree(tree, data.note, discussion_id)
M.load_discussions() 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) } 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) u.notify(data.message, vim.log.levels.INFO)
if not note_node.is_root then if not note_node.is_root then
tree:remove_node("-" .. note_id) -- Note is not a discussion root, safe to remove 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, note_id = note_id,
comment = text, 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) u.notify(data.message, vim.log.levels.INFO)
M.rebuild_discussion_tree() M.rebuild_discussion_tree()
if unlinked then if unlinked then
@@ -327,7 +327,7 @@ M.toggle_discussion_resolved = function(tree)
resolved = not note.resolved, 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) u.notify(data.message, vim.log.levels.INFO)
M.redraw_resolved_status(tree, note, not note.resolved) M.redraw_resolved_status(tree, note, not note.resolved)
M.refresh_discussion_data() M.refresh_discussion_data()
@@ -572,6 +572,12 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked)
end end
end, { buffer = bufnr, desc = "Jump to reviewer" }) end, { buffer = bufnr, desc = "Jump to reviewer" })
end 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 end
M.redraw_resolved_status = function(tree, note, mark_resolved) M.redraw_resolved_status = function(tree, note, mark_resolved)
@@ -683,4 +689,26 @@ M.get_note_location = function(tree)
nil nil
end 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 return M

View File

@@ -11,7 +11,7 @@ local M = {}
M.diagnostics_namespace = diagnostics_namespace M.diagnostics_namespace = diagnostics_namespace
---Clear all signs and diagnostics ---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.fn.sign_unplace(discussion_sign_name)
vim.diagnostic.reset(diagnostics_namespace) vim.diagnostic.reset(diagnostics_namespace)
end 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), file_name = (type(note.position) == "table" and note.position.new_path),
new_line = (type(note.position) == "table" and note.position.new_line), new_line = (type(note.position) == "table" and note.position.new_line),
old_line = (type(note.position) == "table" and note.position.old_line), old_line = (type(note.position) == "table" and note.position.old_line),
url = state.INFO.web_url .. "#note_" .. note.id,
type = "note", type = "note",
}, text_nodes) }, text_nodes)
@@ -161,11 +162,11 @@ M.add_discussions_to_table = function(items, unlinked)
local undefined_type = false local undefined_type = false
local root_new_line = nil local root_new_line = nil
local root_old_line = nil local root_old_line = nil
local root_url
for j, note in ipairs(discussion.notes) do for j, note in ipairs(discussion.notes) do
if j == 1 then if j == 1 then
_, root_text, root_text_nodes = M.build_note(note, { resolved = note.resolved, resolvable = note.resolvable }) _, 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_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_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) 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) root_note_id = tostring(note.id)
resolvable = note.resolvable resolvable = note.resolvable
resolved = note.resolved 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 -- 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 -- We link these comments to the old file by default
@@ -203,6 +205,7 @@ M.add_discussions_to_table = function(items, unlinked)
resolvable = resolvable, resolvable = resolvable,
resolved = resolved, resolved = resolved,
undefined_type = undefined_type, undefined_type = undefined_type,
url = root_url,
}, body) }, body)
table.insert(t, root_node) table.insert(t, root_node)

View File

@@ -52,7 +52,8 @@ end
M.update_winbar = function(discussions, unlinked_discussions, base_title) M.update_winbar = function(discussions, unlinked_discussions, base_title)
local d = require("gitlab.actions.discussions") local d = require("gitlab.actions.discussions")
local winId = d.split.winid 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 end
return M return M

View File

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

View File

@@ -3,21 +3,6 @@ local u = require("gitlab.utils")
local job = require("gitlab.job") local job = require("gitlab.job")
local M = {} 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() M.attach_file = function()
local attachment_dir = state.settings.attachment_dir local attachment_dir = state.settings.attachment_dir
if not attachment_dir or attachment_dir == "" then if not attachment_dir or attachment_dir == "" then
@@ -40,7 +25,7 @@ M.attach_file = function()
end end
local full_path = attachment_dir .. u.path_separator .. choice local full_path = attachment_dir .. u.path_separator .. choice
local body = { file_path = full_path, file_name = 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 markdown = data.markdown
local current_line = u.get_current_line_number() local current_line = u.get_current_line_number()
local bufnr = vim.api.nvim_get_current_buf() 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 assignees_and_reviewers = require("gitlab.actions.assignees_and_reviewers")
local comment = require("gitlab.actions.comment") local comment = require("gitlab.actions.comment")
local pipeline = require("gitlab.actions.pipeline") local pipeline = require("gitlab.actions.pipeline")
local create_mr = require("gitlab.actions.create_mr")
local approvals = require("gitlab.actions.approvals") local approvals = require("gitlab.actions.approvals")
local miscellaneous = require("gitlab.actions.miscellaneous")
local info = state.dependencies.info local info = state.dependencies.info
local project_members = state.dependencies.project_members local project_members = state.dependencies.project_members
@@ -40,6 +40,7 @@ return {
create_comment_suggestion = async.sequence({ info, revisions }, comment.create_comment_suggestion), create_comment_suggestion = async.sequence({ info, revisions }, comment.create_comment_suggestion),
move_to_discussion_tree_from_diagnostic = async.sequence({}, discussions.move_to_discussion_tree), move_to_discussion_tree_from_diagnostic = async.sequence({}, discussions.move_to_discussion_tree),
create_note = async.sequence({ info }, comment.create_note), 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() review = async.sequence({ u.merge(info, { refresh = true }), revisions }, function()
reviewer.open() reviewer.open()
end), end),
@@ -57,5 +58,11 @@ return {
-- Other functions 🤷 -- Other functions 🤷
state = state, state = state,
print_settings = state.print_settings, 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", jump_to_reviewer = "m",
edit_comment = "e", edit_comment = "e",
delete_comment = "dd", delete_comment = "dd",
open_in_browser = "b",
reply = "r", reply = "r",
toggle_node = "t", toggle_node = "t",
toggle_resolved = "p", toggle_resolved = "p",
@@ -71,6 +72,10 @@ M.settings = {
squash = false, squash = false,
delete_branch = false, delete_branch = false,
}, },
create_mr = {
target = nil,
template_file = nil,
},
info = { info = {
enabled = true, enabled = true,
horizontal = false, horizontal = false,
@@ -123,8 +128,8 @@ M.settings = {
preparing = "", preparing = "",
scheduled = "", scheduled = "",
running = "", running = "",
canceled = "", canceled = "",
skipped = "", skipped = "",
success = "", success = "",
failed = "", failed = "",
}, },
@@ -204,7 +209,7 @@ M.setPluginConfiguration = function()
end end
local config_file_path = base_path .. M.settings.file_separator .. ".gitlab.nvim" 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 = {} local file_properties = {}
if config_file_content ~= nil then if config_file_content ~= nil then
@@ -231,10 +236,15 @@ M.setPluginConfiguration = function()
return true return true
end end
local function exit(popup, cb) local function exit(popup, opts)
popup:unmount() if opts.action_before_exit and opts.cb ~= nil then
if cb ~= nil then opts.cb()
cb() popup:unmount()
else
popup:unmount()
if opts.cb ~= nil then
opts.cb()
end
end end
end end
@@ -244,7 +254,7 @@ M.set_popup_keymaps = function(popup, action, linewise_action, opts)
opts = {} opts = {}
end end
vim.keymap.set("n", M.settings.popup.exit, function() vim.keymap.set("n", M.settings.popup.exit, function()
exit(popup, opts.cb) exit(popup, opts)
end, { buffer = popup.bufnr, desc = "Exit popup" }) end, { buffer = popup.bufnr, desc = "Exit popup" })
if action ~= "Help" then -- Don't show help on the help 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) local text = u.get_buffer_text(popup.bufnr)
if opts.action_before_close then if opts.action_before_close then
action(text, popup.bufnr) action(text, popup.bufnr)
exit(popup) exit(popup, opts)
else else
exit(popup) exit(popup, opts)
action(text, popup.bufnr) action(text, popup.bufnr)
end end
end, { buffer = popup.bufnr, desc = "Perform action" }) 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 -- for each of the actions to occur. This is necessary because some Gitlab behaviors (like
-- adding a reviewer) requires some initial state. -- adding a reviewer) requires some initial state.
M.dependencies = { 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 }, revisions = { endpoint = "/mr/revisions", key = "Revisions", state = "MR_REVISIONS", refresh = false },
project_members = { project_members = {
endpoint = "/project/members", endpoint = "/project/members",

View File

@@ -382,14 +382,18 @@ M.create_popup_state = function(title, settings, width, height, zindex)
return view_opts return view_opts
end end
M.read_file = function(file_path) M.read_file = function(file_path, opts)
local file = io.open(file_path, "r") local file = io.open(file_path, "r")
if file == nil then if file == nil then
return nil return nil
end end
local file_contents = file:read("*all") local file_contents = file:read("*all")
file:close() file:close()
file_contents = string.gsub(file_contents, "\n", "")
if opts and opts.remove_newlines then
file_contents = string.gsub(file_contents, "\n", "")
end
return file_contents return file_contents
end end
@@ -633,9 +637,46 @@ M.get_icon = function(filename)
end end
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) M.basename = function(str)
local name = string.gsub(str, "(.*/)(.*)", "%2") local name = string.gsub(str, "(.*/)(.*)", "%2")
return name return name
end 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 return M

View File

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