diff --git a/README.md b/README.md index 89dcc15..73919f6 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/cmd/approve.go b/cmd/approve.go index 54a9d08..4fcbdd5 100644 --- a/cmd/approve.go +++ b/cmd/approve.go @@ -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 } diff --git a/cmd/approve_test.go b/cmd/approve_test.go index ec13bc3..d614e87 100644 --- a/cmd/approve_test.go +++ b/cmd/approve_test.go @@ -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") }) } diff --git a/cmd/attachment.go b/cmd/attachment.go index b8941d1..3b8dbab 100644 --- a/cmd/attachment.go +++ b/cmd/attachment.go @@ -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 } diff --git a/cmd/attachment_test.go b/cmd/attachment_test.go index efce0b5..8f002a8 100644 --- a/cmd/attachment_test.go +++ b/cmd/attachment_test.go @@ -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") }) } diff --git a/cmd/client.go b/cmd/client.go index 60eff6d..acf154d 100644 --- a/cmd/client.go +++ b/cmd/client.go @@ -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 */ diff --git a/cmd/comment.go b/cmd/comment.go index 00446fe..d78e895 100644 --- a/cmd/comment.go +++ b/cmd/comment.go @@ -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 } diff --git a/cmd/comment_test.go b/cmd/comment_test.go index 6e920e5..1628eb7 100644 --- a/cmd/comment_test.go +++ b/cmd/comment_test.go @@ -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") }) } diff --git a/cmd/create_mr.go b/cmd/create_mr.go new file mode 100644 index 0000000..79b724a --- /dev/null +++ b/cmd/create_mr.go @@ -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) + } +} diff --git a/cmd/create_mr_test.go b/cmd/create_mr_test.go new file mode 100644 index 0000000..77365cd --- /dev/null +++ b/cmd/create_mr_test.go @@ -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") + }) +} diff --git a/cmd/info.go b/cmd/info.go index e733758..b99eb4c 100644 --- a/cmd/info.go +++ b/cmd/info.go @@ -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 } diff --git a/cmd/info_test.go b/cmd/info_test.go index c4b3db4..e64f4ea 100644 --- a/cmd/info_test.go +++ b/cmd/info_test.go @@ -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") }) } diff --git a/cmd/list_discussions.go b/cmd/list_discussions.go index 7f6fa42..909aa1f 100644 --- a/cmd/list_discussions.go +++ b/cmd/list_discussions.go @@ -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 } diff --git a/cmd/list_discussions_test.go b/cmd/list_discussions_test.go index 321dba2..b635eee 100644 --- a/cmd/list_discussions_test.go +++ b/cmd/list_discussions_test.go @@ -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") }) } diff --git a/cmd/main.go b/cmd/main.go index d495f6f..5c072e3 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -20,5 +20,5 @@ func main() { log.Fatalf("Failed to initialize project settings: %v", err) } - startServer(client, projectInfo) + startServer(client, projectInfo, gitInfo) } diff --git a/cmd/merge.go b/cmd/merge.go index d8fcb16..2586698 100644 --- a/cmd/merge.go +++ b/cmd/merge.go @@ -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 } diff --git a/cmd/merge_test.go b/cmd/merge_test.go index dbc3ef4..6d06821 100644 --- a/cmd/merge_test.go +++ b/cmd/merge_test.go @@ -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") }) } diff --git a/cmd/reply.go b/cmd/reply.go index b4a1f43..e2998b9 100644 --- a/cmd/reply.go +++ b/cmd/reply.go @@ -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 } diff --git a/cmd/reply_test.go b/cmd/reply_test.go index 9639cee..f5ff73d 100644 --- a/cmd/reply_test.go +++ b/cmd/reply_test.go @@ -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") }) } diff --git a/cmd/resolve_discussion.go b/cmd/resolve_discussion.go index 04b0fa3..3b9249c 100644 --- a/cmd/resolve_discussion.go +++ b/cmd/resolve_discussion.go @@ -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 } diff --git a/cmd/revoke.go b/cmd/revoke.go index c6dc293..00d473a 100644 --- a/cmd/revoke.go +++ b/cmd/revoke.go @@ -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 } diff --git a/cmd/server.go b/cmd/server.go index 2490c18..c91c1bc 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -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) + } +} diff --git a/cmd/test.go b/cmd/test.go index 10716db..a9923cf 100644 --- a/cmd/test.go +++ b/cmd/test.go @@ -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() diff --git a/cmd/types.go b/cmd/types.go index d085662..195d342 100644 --- a/cmd/types.go +++ b/cmd/types.go @@ -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) diff --git a/lua/gitlab/actions/approvals.lua b/lua/gitlab/actions/approvals.lua index b49f032..8ca7669 100644 --- a/lua/gitlab/actions/approvals.lua +++ b/lua/gitlab/actions/approvals.lua @@ -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 diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index be8dd86..5d2c96c 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -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() diff --git a/lua/gitlab/actions/create_mr.lua b/lua/gitlab/actions/create_mr.lua new file mode 100644 index 0000000..d402b10 --- /dev/null +++ b/lua/gitlab/actions/create_mr.lua @@ -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", "", 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 diff --git a/lua/gitlab/actions/discussions/annotations.lua b/lua/gitlab/actions/discussions/annotations.lua index 5083ce8..163d36c 100644 --- a/lua/gitlab/actions/discussions/annotations.lua +++ b/lua/gitlab/actions/discussions/annotations.lua @@ -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 diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index 7f18ad4..6fb834e 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -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", "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 diff --git a/lua/gitlab/actions/discussions/signs.lua b/lua/gitlab/actions/discussions/signs.lua index 3c5ff01..ffeb74e 100644 --- a/lua/gitlab/actions/discussions/signs.lua +++ b/lua/gitlab/actions/discussions/signs.lua @@ -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 diff --git a/lua/gitlab/actions/discussions/tree.lua b/lua/gitlab/actions/discussions/tree.lua index d5d49fe..458b9b4 100644 --- a/lua/gitlab/actions/discussions/tree.lua +++ b/lua/gitlab/actions/discussions/tree.lua @@ -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) diff --git a/lua/gitlab/actions/discussions/winbar.lua b/lua/gitlab/actions/discussions/winbar.lua index 1dd025f..dd6565f 100644 --- a/lua/gitlab/actions/discussions/winbar.lua +++ b/lua/gitlab/actions/discussions/winbar.lua @@ -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 diff --git a/lua/gitlab/actions/merge.lua b/lua/gitlab/actions/merge.lua index 9ba225c..324dc1e 100644 --- a/lua/gitlab/actions/merge.lua +++ b/lua/gitlab/actions/merge.lua @@ -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) diff --git a/lua/gitlab/actions/miscellaneous.lua b/lua/gitlab/actions/miscellaneous.lua index ac0edf0..e7550e8 100644 --- a/lua/gitlab/actions/miscellaneous.lua +++ b/lua/gitlab/actions/miscellaneous.lua @@ -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() diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index 8661bd0..7e92f58 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -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), } diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index f3ab62c..764f753 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -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) - popup:unmount() - if cb ~= nil then - cb() +local function exit(popup, opts) + if opts.action_before_exit and opts.cb ~= nil then + opts.cb() + popup:unmount() + 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", diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index c8916e6..add310b 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -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() - 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 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 diff --git a/tests/spec/discussions_tree_spec.lua b/tests/spec/discussions_tree_spec.lua index 0fa7325..629c0e9 100644 --- a/tests/spec/discussions_tree_spec.lua +++ b/tests/spec/discussions_tree_spec.lua @@ -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)