diff --git a/README.md b/README.md index 4eb50f9..063517f 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,9 @@ To view these help docs and to get more detailed help information, please run `: 1. Install Go 2. Add configuration (see Installation section) -3. Checkout your feature branch: `git checkout feature-branch` -4. Open Neovim -5. Run `:lua require("gitlab").review()` to open the reviewer pane +5. Run `:lua require("gitlab").choose_merge_request()` + +This will checkout the branch locally, and open the plugin's reviewer pane. For more detailed information about the Lua APIs please run `:h gitlab.nvim.api` @@ -56,20 +56,25 @@ return { And with Packer: ```lua -use { - 'harrisoncramer/gitlab.nvim', - requires = { - "MunifTanjim/nui.nvim", - "nvim-lua/plenary.nvim", - "sindrets/diffview.nvim", - "stevearc/dressing.nvim", -- Recommended but not required. Better UI for pickers. - "nvim-tree/nvim-web-devicons", -- Recommended but not required. Icons in discussion tree. - }, - run = function() require("gitlab.server").build(true) end, - config = function() - require("gitlab").setup() - end, -} + use { + "harrisoncramer/gitlab.nvim", + requires = { + "MunifTanjim/nui.nvim", + "nvim-lua/plenary.nvim", + "sindrets/diffview.nvim" + "stevearc/dressing.nvim", -- Recommended but not required. Better UI for pickers. + "nvim-tree/nvim-web-devicons", -- Recommended but not required. Icons in discussion tree. + }, + build = function() + require("gitlab.server").build() + end, + branch = "develop", + config = function() + require("diffview") -- We require some global state from diffview + local gitlab = require("gitlab") + gitlab.setup() + end, + } ``` ## Connecting to Gitlab @@ -92,6 +97,18 @@ gitlab_url=https://my-personal-gitlab-instance.com/ The plugin will look for the `.gitlab.nvim` file in the root of the current project by default. However, you may provide a custom path to the configuration file via the `config_path` option. This must be an absolute path to the directory that holds your `.gitlab.nvim` file. +In case even more control over the auth config is needed, there is the possibility to override the `auth_provider` settings field. It should be +a function that returns the `token` as well as the `gitlab_url` value, and a nilable error. If the `gitlab_url` is `nil`, `https://gitlab.com` is used as default. + +Here an example how to use a custom `auth_provider`: +```lua +require("gitlab").setup({ + auth_provider = function() + return "my_token", "https://custom.gitlab.instance.url", nil + end, +} +``` + For more settings, please see `:h gitlab.nvim.connecting-to-gitlab` ## Configuring the Plugin @@ -103,7 +120,10 @@ require("gitlab").setup({ port = nil, -- The port of the Go server, which runs in the background, if omitted or `nil` the port will be chosen automatically log_path = vim.fn.stdpath("cache") .. "/gitlab.nvim.log", -- Log path for the Go server config_path = nil, -- Custom path for `.gitlab.nvim` file, please read the "Connecting to Gitlab" section - debug = { go_request = false, go_response = false }, -- Which values to log + debug = { + go_request = false, + go_response = false, + }, attachment_dir = nil, -- The local directory for files (see the "summary" section) reviewer_settings = { diffview = { @@ -150,6 +170,7 @@ require("gitlab").setup({ toggle_resolved_discussions = "R", -- Open or close all resolved discussions toggle_unresolved_discussions = "U", -- Open or close all unresolved discussions keep_current_open = false, -- If true, current discussion stays open even if it should otherwise be closed when toggling + publish_draft = "P", -- Publishes the currently focused note/comment 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 @@ -160,9 +181,14 @@ require("gitlab").setup({ 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 toggle_tree_type = "i", -- Toggle type of discussion tree - "simple", or "by_file_name" + draft_mode = false, -- Whether comments are posted as drafts as part of a review + toggle_draft_mode = "D" -- Toggle between draft mode (comments posted as drafts) and live mode (comments are posted immediately) 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. }, + choose_merge_request = { + open_reviewer = true, -- Open the reviewer window automatically after switching merge requests + }, info = { -- Show additional fields in the summary view enabled = true, horizontal = false, -- Display metadata to the left of the summary rather than underneath @@ -246,6 +272,7 @@ you need to set them up yourself. Here's what I'm using: ```lua local gitlab = require("gitlab") local gitlab_server = require("gitlab.server") +vim.keymap.set("n", "glb", gitlab.choose_merge_request) vim.keymap.set("n", "glr", gitlab.review) vim.keymap.set("n", "gls", gitlab.summary) vim.keymap.set("n", "glA", gitlab.approve) @@ -266,6 +293,8 @@ vim.keymap.set("n", "glrd", gitlab.delete_reviewer) vim.keymap.set("n", "glp", gitlab.pipeline) vim.keymap.set("n", "glo", gitlab.open_in_browser) vim.keymap.set("n", "glM", gitlab.merge) +vim.keymap.set("n", "glu", gitlab.copy_mr_url) +vim.keymap.set("n", "glP", gitlab.publish_all_drafts) ``` For more information about each of these commands, and about the APIs in general, run `:h gitlab.nvim.api` diff --git a/after/syntax/gitlab.vim b/after/syntax/gitlab.vim index 937df5c..4647290 100644 --- a/after/syntax/gitlab.vim +++ b/after/syntax/gitlab.vim @@ -8,6 +8,7 @@ syntax match ChevronDown "" syntax match ChevronRight "" syntax match Resolved /\s✓\s\?/ syntax match Unresolved /\s-\s\?/ +syntax match Pencil // highlight link Username GitlabUsername highlight link Date GitlabDate @@ -15,5 +16,6 @@ highlight link ChevronDown GitlabChevron highlight link ChevronRight GitlabChevron highlight link Resolved GitlabResolved highlight link Unresolved GitlabUnresolved +highlight link Pencil GitlabDraft let b:current_syntax = "gitlab" diff --git a/cmd/assignee_test.go b/cmd/assignee_test.go index 08bd5d1..61def41 100644 --- a/cmd/assignee_test.go +++ b/cmd/assignee_test.go @@ -23,7 +23,7 @@ func updateAssigneesErr(pid interface{}, mergeRequest int, opt *gitlab.UpdateMer func TestAssigneeHandler(t *testing.T) { t.Run("Updates assignees", func(t *testing.T) { request := makeRequest(t, http.MethodPut, "/mr/assignee", AssigneeUpdateRequest{Ids: []int{1, 2}}) - server, _ := createRouterAndApi(fakeClient{updateMergeRequestFn: updateAssignees}) + server, _ := createRouterAndApi(fakeClient{updateMergeRequest: updateAssignees}) data := serveRequest(t, server, request, AssigneeUpdateResponse{}) assert(t, data.SuccessResponse.Message, "Assignees updated") assert(t, data.SuccessResponse.Status, http.StatusOK) @@ -31,7 +31,7 @@ func TestAssigneeHandler(t *testing.T) { t.Run("Disallows non-PUT method", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/assignee", nil) - server, _ := createRouterAndApi(fakeClient{updateMergeRequestFn: updateAssignees}) + server, _ := createRouterAndApi(fakeClient{updateMergeRequest: updateAssignees}) data := serveRequest(t, server, request, ErrorResponse{}) assert(t, data.Status, http.StatusMethodNotAllowed) assert(t, data.Details, "Invalid request type") @@ -40,7 +40,7 @@ func TestAssigneeHandler(t *testing.T) { t.Run("Handles errors from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodPut, "/mr/assignee", AssigneeUpdateRequest{Ids: []int{1, 2}}) - server, _ := createRouterAndApi(fakeClient{updateMergeRequestFn: updateAssigneesErr}) + server, _ := createRouterAndApi(fakeClient{updateMergeRequest: updateAssigneesErr}) data := serveRequest(t, server, request, ErrorResponse{}) assert(t, data.Status, http.StatusInternalServerError) assert(t, data.Message, "Could not modify merge request assignees") @@ -49,7 +49,7 @@ func TestAssigneeHandler(t *testing.T) { t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodPut, "/mr/assignee", AssigneeUpdateRequest{Ids: []int{1, 2}}) - server, _ := createRouterAndApi(fakeClient{updateMergeRequestFn: updateAssigneesNon200}) + server, _ := createRouterAndApi(fakeClient{updateMergeRequest: updateAssigneesNon200}) data := serveRequest(t, server, request, ErrorResponse{}) assert(t, data.Status, http.StatusSeeOther) assert(t, data.Message, "Could not modify merge request assignees") diff --git a/cmd/client.go b/cmd/client.go index 6ce0972..59de32c 100644 --- a/cmd/client.go +++ b/cmd/client.go @@ -40,6 +40,7 @@ type Client struct { *gitlab.LabelsService *gitlab.AwardEmojiService *gitlab.UsersService + *gitlab.DraftNotesService } /* initGitlabClient parses and validates the project settings and initializes the Gitlab client. */ @@ -116,6 +117,7 @@ func initGitlabClient() (error, *Client) { LabelsService: client.Labels, AwardEmojiService: client.AwardEmoji, UsersService: client.Users, + DraftNotesService: client.DraftNotes, } } diff --git a/cmd/comment.go b/cmd/comment.go index 879344f..bd13e25 100644 --- a/cmd/comment.go +++ b/cmd/comment.go @@ -1,7 +1,6 @@ package main import ( - "crypto/sha1" "encoding/json" "fmt" "io" @@ -11,28 +10,8 @@ import ( ) type PostCommentRequest struct { - Comment string `json:"comment"` - FileName string `json:"file_name"` - NewLine *int `json:"new_line,omitempty"` - OldLine *int `json:"old_line,omitempty"` - HeadCommitSHA string `json:"head_commit_sha"` - BaseCommitSHA string `json:"base_commit_sha"` - StartCommitSHA string `json:"start_commit_sha"` - Type string `json:"type"` - LineRange *LineRange `json:"line_range,omitempty"` -} - -/* LineRange represents the range of a note. */ -type LineRange struct { - StartRange *LinePosition `json:"start"` - EndRange *LinePosition `json:"end"` -} - -/* LinePosition represents a position in a line range. Unlike the Gitlab struct, this does not contain LineCode with a sha1 of the filename */ -type LinePosition struct { - Type string `json:"type"` - OldLine int `json:"old_line"` - NewLine int `json:"new_line"` + Comment string `json:"comment"` + PositionData } type DeleteCommentRequest struct { @@ -53,6 +32,15 @@ type CommentResponse struct { Discussion *gitlab.Discussion `json:"discussion"` } +/* CommentWithPosition is a comment with an (optional) position data value embedded in it. The position data will be non-nil for range-based comments. */ +type CommentWithPosition struct { + PositionData PositionData +} + +func (comment CommentWithPosition) GetPositionData() PositionData { + return comment.PositionData +} + /* commentHandler creates, edits, and deletes discussions (comments, multi-line comments) */ func (a *api) commentHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -133,46 +121,10 @@ func (a *api) postComment(w http.ResponseWriter, r *http.Request) { /* If we are leaving a comment on a line, leave position. Otherwise, we are leaving a note (unlinked comment) */ - var friendlyName = "Note" - if postCommentRequest.FileName != "" { - friendlyName = "Comment" - opt.Position = &gitlab.PositionOptions{ - PositionType: &postCommentRequest.Type, - StartSHA: &postCommentRequest.StartCommitSHA, - HeadSHA: &postCommentRequest.HeadCommitSHA, - BaseSHA: &postCommentRequest.BaseCommitSHA, - NewPath: &postCommentRequest.FileName, - OldPath: &postCommentRequest.FileName, - NewLine: postCommentRequest.NewLine, - OldLine: postCommentRequest.OldLine, - } - if postCommentRequest.LineRange != nil { - friendlyName = "Multiline Comment" - shaFormat := "%x_%d_%d" - startFilenameSha := fmt.Sprintf( - shaFormat, - sha1.Sum([]byte(postCommentRequest.FileName)), - postCommentRequest.LineRange.StartRange.OldLine, - postCommentRequest.LineRange.StartRange.NewLine, - ) - endFilenameSha := fmt.Sprintf( - shaFormat, - sha1.Sum([]byte(postCommentRequest.FileName)), - postCommentRequest.LineRange.EndRange.OldLine, - postCommentRequest.LineRange.EndRange.NewLine, - ) - opt.Position.LineRange = &gitlab.LineRangeOptions{ - Start: &gitlab.LinePositionOptions{ - Type: &postCommentRequest.LineRange.StartRange.Type, - LineCode: &startFilenameSha, - }, - End: &gitlab.LinePositionOptions{ - Type: &postCommentRequest.LineRange.EndRange.Type, - LineCode: &endFilenameSha, - }, - } - } + if postCommentRequest.FileName != "" { + commentWithPositionData := CommentWithPosition{postCommentRequest.PositionData} + opt.Position = buildCommentPosition(commentWithPositionData) } discussion, res, err := a.client.CreateMergeRequestDiscussion(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opt) @@ -190,7 +142,7 @@ func (a *api) postComment(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) response := CommentResponse{ SuccessResponse: SuccessResponse{ - Message: fmt.Sprintf("%s created successfully", friendlyName), + Message: "Comment created successfully", Status: http.StatusOK, }, Comment: discussion.Notes[0], diff --git a/cmd/comment_helpers.go b/cmd/comment_helpers.go new file mode 100644 index 0000000..bd69507 --- /dev/null +++ b/cmd/comment_helpers.go @@ -0,0 +1,82 @@ +package main + +import ( + "crypto/sha1" + "fmt" + + "github.com/xanzy/go-gitlab" +) + +/* LinePosition represents a position in a line range. Unlike the Gitlab struct, this does not contain LineCode with a sha1 of the filename */ +type LinePosition struct { + Type string `json:"type"` + OldLine int `json:"old_line"` + NewLine int `json:"new_line"` +} + +/* LineRange represents the range of a note. */ +type LineRange struct { + StartRange *LinePosition `json:"start"` + EndRange *LinePosition `json:"end"` +} + +/* PositionData represents the position of a comment or note (relative to a file diff) */ +type PositionData struct { + FileName string `json:"file_name"` + NewLine *int `json:"new_line,omitempty"` + OldLine *int `json:"old_line,omitempty"` + HeadCommitSHA string `json:"head_commit_sha"` + BaseCommitSHA string `json:"base_commit_sha"` + StartCommitSHA string `json:"start_commit_sha"` + Type string `json:"type"` + LineRange *LineRange `json:"line_range,omitempty"` +} + +/* RequestWithPosition is an interface that abstracts the handling of position data for a comment or a draft comment */ +type RequestWithPosition interface { + GetPositionData() PositionData +} + +/* buildCommentPosition takes a comment or draft comment request and builds the position data necessary for a location-based comment */ +func buildCommentPosition(commentWithPositionData RequestWithPosition) *gitlab.PositionOptions { + positionData := commentWithPositionData.GetPositionData() + + opt := &gitlab.PositionOptions{ + PositionType: &positionData.Type, + StartSHA: &positionData.StartCommitSHA, + HeadSHA: &positionData.HeadCommitSHA, + BaseSHA: &positionData.BaseCommitSHA, + NewPath: &positionData.FileName, + OldPath: &positionData.FileName, + NewLine: positionData.NewLine, + OldLine: positionData.OldLine, + } + + if positionData.LineRange != nil { + shaFormat := "%x_%d_%d" + startFilenameSha := fmt.Sprintf( + shaFormat, + sha1.Sum([]byte(positionData.FileName)), + positionData.LineRange.StartRange.OldLine, + positionData.LineRange.StartRange.NewLine, + ) + endFilenameSha := fmt.Sprintf( + shaFormat, + sha1.Sum([]byte(positionData.FileName)), + positionData.LineRange.EndRange.OldLine, + positionData.LineRange.EndRange.NewLine, + ) + opt.LineRange = &gitlab.LineRangeOptions{ + Start: &gitlab.LinePositionOptions{ + Type: &positionData.LineRange.StartRange.Type, + LineCode: &startFilenameSha, + }, + End: &gitlab.LinePositionOptions{ + Type: &positionData.LineRange.EndRange.Type, + LineCode: &endFilenameSha, + }, + } + } + + return opt +} diff --git a/cmd/comment_test.go b/cmd/comment_test.go index 1628eb7..7b7939a 100644 --- a/cmd/comment_test.go +++ b/cmd/comment_test.go @@ -25,12 +25,16 @@ func TestPostComment(t *testing.T) { 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") + assert(t, data.SuccessResponse.Message, "Comment created successfully") assert(t, data.SuccessResponse.Status, http.StatusOK) }) t.Run("Creates a new comment", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/mr/comment", PostCommentRequest{FileName: "some_file.txt"}) + request := makeRequest(t, http.MethodPost, "/mr/comment", PostCommentRequest{ + PositionData: PositionData{ + FileName: "some_file.txt", + }, + }) server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussion}) data := serveRequest(t, server, request, CommentResponse{}) assert(t, data.SuccessResponse.Message, "Comment created successfully") @@ -39,15 +43,17 @@ func TestPostComment(t *testing.T) { t.Run("Creates a new multiline comment", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/comment", PostCommentRequest{ - FileName: "some_file.txt", - LineRange: &LineRange{ - StartRange: &LinePosition{}, /* These would have real data */ - EndRange: &LinePosition{}, + PositionData: PositionData{ + FileName: "some_file.txt", + LineRange: &LineRange{ + StartRange: &LinePosition{}, /* These would have real data */ + EndRange: &LinePosition{}, + }, }, }) server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussion}) data := serveRequest(t, server, request, CommentResponse{}) - assert(t, data.SuccessResponse.Message, "Multiline Comment created successfully") + assert(t, data.SuccessResponse.Message, "Comment created successfully") assert(t, data.SuccessResponse.Status, http.StatusOK) }) diff --git a/cmd/draft_notes.go b/cmd/draft_notes.go new file mode 100644 index 0000000..6d8518f --- /dev/null +++ b/cmd/draft_notes.go @@ -0,0 +1,306 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "github.com/xanzy/go-gitlab" +) + +/* The data coming from the client when creating a draft note is the same, +as when they are creating a normal comment, but the Gitlab +endpoints + resources we handle are different */ + +type PostDraftNoteRequest struct { + Comment string `json:"comment"` + PositionData +} + +type UpdateDraftNoteRequest struct { + Note string `json:"note"` + Position gitlab.PositionOptions +} + +type DraftNotePublishRequest struct { + Note int `json:"note,omitempty"` + PublishAll bool `json:"publish_all"` +} + +type DraftNoteResponse struct { + SuccessResponse + DraftNote *gitlab.DraftNote `json:"draft_note"` +} + +type ListDraftNotesResponse struct { + SuccessResponse + DraftNotes []*gitlab.DraftNote `json:"draft_notes"` +} + +/* DraftNoteWithPosition is a draft comment with an (optional) position data value embedded in it. The position data will be non-nil for range-based draft comments. */ +type DraftNoteWithPosition struct { + PositionData PositionData +} + +func (draftNote DraftNoteWithPosition) GetPositionData() PositionData { + return draftNote.PositionData +} + +/* draftNoteHandler creates, edits, and deletes draft notes */ +func (a *api) draftNoteHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodGet: + a.listDraftNotes(w, r) + case http.MethodPost: + a.postDraftNote(w, r) + case http.MethodPatch: + a.updateDraftNote(w, r) + case http.MethodDelete: + a.deleteDraftNote(w, r) + default: + w.Header().Set("Access-Control-Allow-Methods", fmt.Sprintf("%s, %s, %s, %s", http.MethodDelete, http.MethodPost, http.MethodPatch, http.MethodGet)) + handleError(w, InvalidRequestError{}, "Expected DELETE, GET, POST or PATCH", http.StatusMethodNotAllowed) + } +} + +func (a *api) draftNotePublisher(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method != http.MethodPost { + w.Header().Set("Access-Control-Allow-Methods", 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 + } + + defer r.Body.Close() + var draftNotePublishRequest DraftNotePublishRequest + err = json.Unmarshal(body, &draftNotePublishRequest) + + if err != nil { + handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) + return + } + + var res *gitlab.Response + if draftNotePublishRequest.PublishAll { + res, err = a.client.PublishAllDraftNotes(a.projectInfo.ProjectId, a.projectInfo.MergeId) + } else { + if draftNotePublishRequest.Note == 0 { + handleError(w, errors.New("No ID provided"), "Must provide Note ID", http.StatusBadRequest) + return + } + res, err = a.client.PublishDraftNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, draftNotePublishRequest.Note) + } + + if err != nil { + handleError(w, err, "Could not publish draft note(s)", http.StatusInternalServerError) + return + } + + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/mr/draft_notes/publish"}, "Could not publish dfaft note", res.StatusCode) + return + } + + w.WriteHeader(http.StatusOK) + response := SuccessResponse{ + Message: "Draft note(s) published", + Status: http.StatusOK, + } + + err = json.NewEncoder(w).Encode(response) + if err != nil { + handleError(w, err, "Could not encode response", http.StatusInternalServerError) + } +} + +/* postDraftNote creates a draft note */ +func (a *api) postDraftNote(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + handleError(w, err, "Could not read request body", http.StatusBadRequest) + return + } + + defer r.Body.Close() + + var postDraftNoteRequest PostDraftNoteRequest + err = json.Unmarshal(body, &postDraftNoteRequest) + if err != nil { + handleError(w, err, "Could not unmarshal data from request body", http.StatusBadRequest) + return + } + + opt := gitlab.CreateDraftNoteOptions{ + Note: &postDraftNoteRequest.Comment, + // TODO: Support posting replies as drafts and rendering draft replies in the discussion tree + // instead of the notes tree + // InReplyToDiscussionID *string `url:"in_reply_to_discussion_id,omitempty" json:"in_reply_to_discussion_id,omitempty"` + } + + if postDraftNoteRequest.FileName != "" { + draftNoteWithPosition := DraftNoteWithPosition{postDraftNoteRequest.PositionData} + opt.Position = buildCommentPosition(draftNoteWithPosition) + } + + draftNote, res, err := a.client.CreateDraftNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opt) + + if err != nil { + handleError(w, err, "Could not create draft note", http.StatusInternalServerError) + return + } + + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/mr/draft_notes/"}, "Could not create draft note", res.StatusCode) + return + } + + w.WriteHeader(http.StatusOK) + response := DraftNoteResponse{ + SuccessResponse: SuccessResponse{ + Message: "Draft note created successfully", + Status: http.StatusOK, + }, + DraftNote: draftNote, + } + + err = json.NewEncoder(w).Encode(response) + if err != nil { + handleError(w, err, "Could not encode response", http.StatusInternalServerError) + } +} + +/* deleteDraftNote deletes a draft note */ +func (a *api) deleteDraftNote(w http.ResponseWriter, r *http.Request) { + suffix := strings.TrimPrefix(r.URL.Path, "/mr/draft_notes/") + id, err := strconv.Atoi(suffix) + if err != nil { + handleError(w, err, "Could not parse draft note ID", http.StatusBadRequest) + return + } + + res, err := a.client.DeleteDraftNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, id) + + if err != nil { + handleError(w, err, "Could not delete draft note", http.StatusInternalServerError) + return + } + + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/mr/draft_notes/"}, "Could not delete draft note", res.StatusCode) + return + } + + w.WriteHeader(http.StatusOK) + response := SuccessResponse{ + Message: "Draft note deleted", + Status: http.StatusOK, + } + + err = json.NewEncoder(w).Encode(response) + if err != nil { + handleError(w, err, "Could not encode response", http.StatusInternalServerError) + } +} + +/* updateDraftNote edits the text of a draft comment */ +func (a *api) updateDraftNote(w http.ResponseWriter, r *http.Request) { + suffix := strings.TrimPrefix(r.URL.Path, "/mr/draft_notes/") + id, err := strconv.Atoi(suffix) + if err != nil { + handleError(w, err, "Could not parse draft note ID", http.StatusBadRequest) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + handleError(w, err, "Could not read request body", http.StatusBadRequest) + return + } + + defer r.Body.Close() + + var updateDraftNoteRequest UpdateDraftNoteRequest + err = json.Unmarshal(body, &updateDraftNoteRequest) + if err != nil { + handleError(w, err, "Could not unmarshal data from request body", http.StatusBadRequest) + return + } + + if updateDraftNoteRequest.Note == "" { + handleError(w, errors.New("Draft note text missing"), "Must provide draft note text", http.StatusBadRequest) + return + } + + opt := gitlab.UpdateDraftNoteOptions{ + Note: &updateDraftNoteRequest.Note, + Position: &updateDraftNoteRequest.Position, + } + + draftNote, res, err := a.client.UpdateDraftNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, id, &opt) + + if err != nil { + handleError(w, err, "Could not update draft note", http.StatusInternalServerError) + return + } + + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/mr/draft_notes/"}, "Could not update draft note", res.StatusCode) + return + } + + w.WriteHeader(http.StatusOK) + response := DraftNoteResponse{ + SuccessResponse: SuccessResponse{ + Message: "Draft note updated", + Status: http.StatusOK, + }, + DraftNote: draftNote, + } + + err = json.NewEncoder(w).Encode(response) + if err != nil { + handleError(w, err, "Could not encode response", http.StatusInternalServerError) + } +} + +/* listDraftNotes lists all draft notes for the currently authenticated user */ +func (a *api) listDraftNotes(w http.ResponseWriter, r *http.Request) { + + opt := gitlab.ListDraftNotesOptions{} + draftNotes, res, err := a.client.ListDraftNotes(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opt) + + if err != nil { + handleError(w, err, "Could not get draft notes", http.StatusInternalServerError) + return + } + + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/mr/draft/comment"}, "Could not get draft notes", res.StatusCode) + return + } + + w.WriteHeader(http.StatusOK) + response := ListDraftNotesResponse{ + SuccessResponse: SuccessResponse{ + Message: "Draft notes fetched successfully", + Status: http.StatusOK, + }, + DraftNotes: draftNotes, + } + + err = json.NewEncoder(w).Encode(response) + if err != nil { + handleError(w, err, "Could not encode response", http.StatusInternalServerError) + } +} diff --git a/cmd/draft_notes_test.go b/cmd/draft_notes_test.go new file mode 100644 index 0000000..d4506d4 --- /dev/null +++ b/cmd/draft_notes_test.go @@ -0,0 +1,197 @@ +package main + +import ( + "errors" + "net/http" + "testing" + + "github.com/xanzy/go-gitlab" +) + +func listDraftNotes(pid interface{}, mergeRequest int, opt *gitlab.ListDraftNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.DraftNote, *gitlab.Response, error) { + return []*gitlab.DraftNote{}, makeResponse(http.StatusOK), nil +} + +func listDraftNotesErr(pid interface{}, mergeRequest int, opt *gitlab.ListDraftNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.DraftNote, *gitlab.Response, error) { + return nil, makeResponse(http.StatusInternalServerError), errors.New("Some error") +} + +func TestListDraftNotes(t *testing.T) { + t.Run("Lists all draft notes", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/mr/draft_notes/", nil) + server, _ := createRouterAndApi(fakeClient{listDraftNotes: listDraftNotes}) + data := serveRequest(t, server, request, ListDraftNotesResponse{}) + + assert(t, data.SuccessResponse.Message, "Draft notes fetched successfully") + assert(t, data.SuccessResponse.Status, http.StatusOK) + }) + + t.Run("Handles error", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/mr/draft_notes/", nil) + server, _ := createRouterAndApi(fakeClient{listDraftNotes: listDraftNotesErr}) + data := serveRequest(t, server, request, ErrorResponse{}) + + assert(t, data.Message, "Could not get draft notes") + assert(t, data.Status, http.StatusInternalServerError) + assert(t, data.Details, "Some error") + }) +} + +func createDraftNote(pid interface{}, mergeRequestIID int, opt *gitlab.CreateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) { + return &gitlab.DraftNote{}, makeResponse(http.StatusOK), nil +} + +func createDraftNoteErr(pid interface{}, mergeRequestIID int, opt *gitlab.CreateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) { + return nil, makeResponse(http.StatusInternalServerError), errors.New("Some error") +} + +func TestPostDraftNote(t *testing.T) { + t.Run("Posts new draft note", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/draft_notes/", PostDraftNoteRequest{}) + server, _ := createRouterAndApi(fakeClient{createDraftNote: createDraftNote}) + + data := serveRequest(t, server, request, DraftNoteResponse{}) + + assert(t, data.SuccessResponse.Message, "Draft note created successfully") + assert(t, data.SuccessResponse.Status, http.StatusOK) + }) + + t.Run("Handles errors on draft note creation", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/draft_notes/", PostDraftNoteRequest{}) + server, _ := createRouterAndApi(fakeClient{createDraftNote: createDraftNoteErr}) + data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Message, "Could not create draft note") + assert(t, data.Status, http.StatusInternalServerError) + assert(t, data.Details, "Some error") + }) +} + +func deleteDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + return makeResponse(http.StatusOK), nil +} + +func deleteDraftNoteErr(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + return makeResponse(http.StatusInternalServerError), errors.New("Something went wrong") +} + +func TestDeleteDraftNote(t *testing.T) { + t.Run("Deletes draft note", func(t *testing.T) { + request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/3", nil) + server, _ := createRouterAndApi(fakeClient{deleteDraftNote: deleteDraftNote}) + data := serveRequest(t, server, request, SuccessResponse{}) + assert(t, data.Message, "Draft note deleted") + assert(t, data.Status, http.StatusOK) + }) + + t.Run("Handles error", func(t *testing.T) { + request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/3", nil) + server, _ := createRouterAndApi(fakeClient{deleteDraftNote: deleteDraftNoteErr}) + data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Message, "Could not delete draft note") + assert(t, data.Status, http.StatusInternalServerError) + }) + + t.Run("Handles bad ID", func(t *testing.T) { + request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/abc", nil) + server, _ := createRouterAndApi(fakeClient{deleteDraftNote: deleteDraftNote}) + data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Message, "Could not parse draft note ID") + assert(t, data.Status, http.StatusBadRequest) + }) +} + +func updateDraftNote(pid interface{}, mergeRequest int, note int, opt *gitlab.UpdateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) { + return &gitlab.DraftNote{}, makeResponse(http.StatusOK), nil +} + +func TestEditDraftNote(t *testing.T) { + t.Run("Edits draft note", func(t *testing.T) { + request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/3", UpdateDraftNoteRequest{Note: "Some new note", Position: gitlab.PositionOptions{}}) + server, _ := createRouterAndApi(fakeClient{updateDraftNote: updateDraftNote}) + data := serveRequest(t, server, request, SuccessResponse{}) + assert(t, data.Message, "Draft note updated") + assert(t, data.Status, http.StatusOK) + }) + + t.Run("Handles bad ID", func(t *testing.T) { + request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/abc", nil) + server, _ := createRouterAndApi(fakeClient{updateDraftNote: updateDraftNote}) + data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Message, "Could not parse draft note ID") + assert(t, data.Status, http.StatusBadRequest) + }) + + t.Run("Handles empty note", func(t *testing.T) { + request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/3", UpdateDraftNoteRequest{Note: ""}) + server, _ := createRouterAndApi(fakeClient{updateDraftNote: updateDraftNote}) + data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Message, "Must provide draft note text") + assert(t, data.Status, http.StatusBadRequest) + }) +} + +func publishDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + return makeResponse(http.StatusOK), nil +} + +func publishDraftNoteErr(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + return nil, errors.New("Some error") +} + +func TestPublishDraftNote(t *testing.T) { + t.Run("Should publish a draft note", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", DraftNotePublishRequest{Note: 3, PublishAll: false}) + server, _ := createRouterAndApi(fakeClient{ + publishDraftNote: publishDraftNote, + }) + data := serveRequest(t, server, request, SuccessResponse{}) + assert(t, data.Message, "Draft note(s) published") + assert(t, data.Status, http.StatusOK) + }) + + t.Run("Handles bad/missing ID", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", DraftNotePublishRequest{PublishAll: false}) + server, _ := createRouterAndApi(fakeClient{publishDraftNote: publishDraftNote}) + data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Message, "Must provide Note ID") + assert(t, data.Status, http.StatusBadRequest) + }) + + t.Run("Handles error", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", DraftNotePublishRequest{PublishAll: false, Note: 3}) + server, _ := createRouterAndApi(fakeClient{publishDraftNote: publishDraftNoteErr}) + data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Message, "Could not publish draft note(s)") + assert(t, data.Status, http.StatusInternalServerError) + }) +} + +func publishAllDraftNotes(pid interface{}, mergeRequest int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + return makeResponse(http.StatusOK), nil +} + +func publishAllDraftNotesErr(pid interface{}, mergeRequest int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + return nil, errors.New("Some error") +} + +func TestPublishAllDraftNotes(t *testing.T) { + t.Run("Should publish all draft notes", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", DraftNotePublishRequest{PublishAll: true}) + server, _ := createRouterAndApi(fakeClient{ + publishAllDraftNotes: publishAllDraftNotes, + }) + data := serveRequest(t, server, request, SuccessResponse{}) + assert(t, data.Message, "Draft note(s) published") + assert(t, data.Status, http.StatusOK) + }) + + t.Run("Should handle an error", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", DraftNotePublishRequest{PublishAll: true}) + server, _ := createRouterAndApi(fakeClient{ + publishAllDraftNotes: publishAllDraftNotesErr, + }) + data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Message, "Could not publish draft note(s)") + assert(t, data.Status, http.StatusInternalServerError) + }) +} diff --git a/cmd/info_test.go b/cmd/info_test.go index e64f4ea..d6de9b2 100644 --- a/cmd/info_test.go +++ b/cmd/info_test.go @@ -23,7 +23,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, "/mr/info", nil) - server, _ := createRouterAndApi(fakeClient{getMergeRequestFn: getInfo}) + server, _ := createRouterAndApi(fakeClient{getMergeRequest: getInfo}) data := serveRequest(t, server, request, InfoResponse{}) assert(t, data.Info.Title, "Some Title") assert(t, data.SuccessResponse.Message, "Merge requests retrieved") @@ -32,21 +32,21 @@ func TestInfoHandler(t *testing.T) { t.Run("Disallows non-GET method", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/info", nil) - server, _ := createRouterAndApi(fakeClient{getMergeRequestFn: getInfo}) + server, _ := createRouterAndApi(fakeClient{getMergeRequest: 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, "/mr/info", nil) - server, _ := createRouterAndApi(fakeClient{getMergeRequestFn: getInfoErr}) + server, _ := createRouterAndApi(fakeClient{getMergeRequest: 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, "/mr/info", nil) - server, _ := createRouterAndApi(fakeClient{getMergeRequestFn: getInfoNon200}) + server, _ := createRouterAndApi(fakeClient{getMergeRequest: getInfoNon200}) data := serveRequest(t, server, request, ErrorResponse{}) checkNon200(t, *data, "Could not get project info", "/mr/info") }) diff --git a/cmd/main.go b/cmd/main.go index 5c072e3..02e1fae 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -5,9 +5,10 @@ import ( ) func main() { + log.SetFlags(0) gitInfo, err := extractGitInfo(RefreshProjectInfo, GetProjectUrlFromNativeGitCmd, GetCurrentBranchNameFromNativeGitCmd) if err != nil { - log.Fatalf("Failure initializing plugin with `git` commands: %v", err) + log.Fatalf("Failure initializing plugin: %v", err) } err, client := initGitlabClient() diff --git a/cmd/merge_requests.go b/cmd/merge_requests.go new file mode 100644 index 0000000..dff03de --- /dev/null +++ b/cmd/merge_requests.go @@ -0,0 +1,55 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/xanzy/go-gitlab" +) + +type ListMergeRequestResponse struct { + SuccessResponse + MergeRequests []*gitlab.MergeRequest `json:"merge_requests"` +} + +func (a *api) mergeRequestsHandler(w http.ResponseWriter, r *http.Request) { + + if r.Method != http.MethodGet { + w.Header().Set("Access-Control-Allow-Methods", http.MethodGet) + handleError(w, InvalidRequestError{}, "Expected GET", http.StatusMethodNotAllowed) + return + } + + options := gitlab.ListProjectMergeRequestsOptions{ + Scope: gitlab.Ptr("all"), + State: gitlab.Ptr("opened"), + } + + 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, errors.New("No merge requests found"), "No merge requests found", http.StatusNotFound) + return + } + + w.WriteHeader(http.StatusOK) + response := ListMergeRequestResponse{ + SuccessResponse: SuccessResponse{ + Message: "Merge requests fetched successfully", + Status: http.StatusOK, + }, + MergeRequests: mergeRequests, + } + + err = json.NewEncoder(w).Encode(response) + if err != nil { + handleError(w, err, "Could not encode response", http.StatusInternalServerError) + } + +} diff --git a/cmd/merge_requests_test.go b/cmd/merge_requests_test.go new file mode 100644 index 0000000..3a642b2 --- /dev/null +++ b/cmd/merge_requests_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "errors" + "net/http" + "testing" + + "github.com/xanzy/go-gitlab" +) + +func listProjectMergeRequests200(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) { + return []*gitlab.MergeRequest{{ID: 1}}, &gitlab.Response{}, nil +} + +func listProjectMergeRequestsEmpty(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) { + return []*gitlab.MergeRequest{}, &gitlab.Response{}, nil +} + +func listProjectMergeRequestsErr(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) { + return nil, nil, errors.New("Some error") +} + +func TestMergeRequestHandler(t *testing.T) { + t.Run("Should fetch merge requests", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/merge_requests", nil) + server, _ := createRouterAndApi(fakeClient{listProjectMergeRequests: listProjectMergeRequests200}) + data := serveRequest(t, server, request, ListMergeRequestResponse{}) + assert(t, data.Message, "Merge requests fetched successfully") + assert(t, data.Status, http.StatusOK) + }) + t.Run("Should handle an error", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/merge_requests", nil) + server, _ := createRouterAndApi(fakeClient{listProjectMergeRequests: listProjectMergeRequestsErr}) + data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Message, "Failed to list merge requests") + assert(t, data.Status, http.StatusInternalServerError) + }) + t.Run("Should handle not having any merge requests with 404", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/merge_requests", nil) + server, _ := createRouterAndApi(fakeClient{listProjectMergeRequests: listProjectMergeRequestsEmpty}) + data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Message, "No merge requests found") + assert(t, data.Status, http.StatusNotFound) + }) +} diff --git a/cmd/merge_test.go b/cmd/merge_test.go index 6d06821..a9339b0 100644 --- a/cmd/merge_test.go +++ b/cmd/merge_test.go @@ -8,11 +8,11 @@ import ( "github.com/xanzy/go-gitlab" ) -func acceptAndMergeFn(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { +func acceptMergeRequest(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { return &gitlab.MergeRequest{}, makeResponse(http.StatusOK), nil } -func acceptAndMergeFnErr(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { +func acceptMergeRequestErr(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { return nil, nil, errors.New("Some error from Gitlab") } @@ -23,7 +23,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, "/mr/merge", AcceptMergeRequestRequest{}) - server, _ := createRouterAndApi(fakeClient{acceptAndMergeFn: acceptAndMergeFn}) + server, _ := createRouterAndApi(fakeClient{acceptMergeRequest: acceptMergeRequest}) data := serveRequest(t, server, request, SuccessResponse{}) assert(t, data.Message, "MR merged successfully") assert(t, data.Status, http.StatusOK) @@ -31,21 +31,21 @@ func TestAcceptAndMergeHandler(t *testing.T) { t.Run("Disallows non-POST methods", func(t *testing.T) { request := makeRequest(t, http.MethodGet, "/mr/merge", AcceptMergeRequestRequest{}) - server, _ := createRouterAndApi(fakeClient{acceptAndMergeFn: acceptAndMergeFn}) + server, _ := createRouterAndApi(fakeClient{acceptMergeRequest: acceptMergeRequest}) 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, "/mr/merge", AcceptMergeRequestRequest{}) - server, _ := createRouterAndApi(fakeClient{acceptAndMergeFn: acceptAndMergeFnErr}) + server, _ := createRouterAndApi(fakeClient{acceptMergeRequest: acceptMergeRequestErr}) 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, "/mr/merge", AcceptMergeRequestRequest{}) - server, _ := createRouterAndApi(fakeClient{acceptAndMergeFn: acceptAndMergeNon200}) + server, _ := createRouterAndApi(fakeClient{acceptMergeRequest: acceptAndMergeNon200}) data := serveRequest(t, server, request, ErrorResponse{}) checkNon200(t, *data, "Could not merge MR", "/mr/merge") }) diff --git a/cmd/server.go b/cmd/server.go index c38d78a..4bf2054 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -134,6 +134,8 @@ func createRouterAndApi(client ClientInterface, optFuncs ...optFunc) (*http.Serv m.HandleFunc("/mr/label", a.withMr(a.labelHandler)) m.HandleFunc("/mr/revoke", a.withMr(a.revokeHandler)) m.HandleFunc("/mr/awardable/note/", a.withMr(a.emojiNoteHandler)) + m.HandleFunc("/mr/draft_notes/", a.withMr(a.draftNoteHandler)) + m.HandleFunc("/mr/draft_notes/publish", a.withMr(a.draftNotePublisher)) m.HandleFunc("/pipeline", a.pipelineHandler) m.HandleFunc("/pipeline/trigger/", a.pipelineHandler) @@ -143,6 +145,7 @@ func createRouterAndApi(client ClientInterface, optFuncs ...optFunc) (*http.Serv m.HandleFunc("/job", a.jobHandler) m.HandleFunc("/project/members", a.projectMembersHandler) m.HandleFunc("/shutdown", a.shutdownHandler) + m.HandleFunc("/merge_requests", a.mergeRequestsHandler) m.Handle("/ping", http.HandlerFunc(pingHandler)) diff --git a/cmd/test.go b/cmd/test.go index 29430a2..e22d75e 100644 --- a/cmd/test.go +++ b/cmd/test.go @@ -19,10 +19,10 @@ 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{}, mergeRequestIID int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) - updateMergeRequestFn func(pid interface{}, mergeRequestIID int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) - acceptAndMergeFn func(pid interface{}, mergeRequestIID int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) - unapprorveMergeRequestFn func(pid interface{}, mergeRequestIID int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) + getMergeRequest func(pid interface{}, mergeRequestIID int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) + updateMergeRequest func(pid interface{}, mergeRequestIID int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) + acceptMergeRequest func(pid interface{}, mergeRequestIID int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) + unapproveMergeRequest func(pid interface{}, mergeRequestIID int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) uploadFile func(pid interface{}, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error) getMergeRequestDiffVersions func(pid interface{}, mergeRequestIID int, opt *gitlab.GetMergeRequestDiffVersionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequestDiffVersion, *gitlab.Response, error) approveMergeRequest func(pid interface{}, mergeRequestIID int, opt *gitlab.ApproveMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequestApprovals, *gitlab.Response, error) @@ -41,6 +41,13 @@ type fakeClient struct { listMergeRequestAwardEmojiOnNote func(pid interface{}, mergeRequestIID, noteID int, opt *gitlab.ListAwardEmojiOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.AwardEmoji, *gitlab.Response, error) deleteMergeRequestAwardEmojiOnNote func(pid interface{}, mergeRequestIID, noteID, awardID int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) currentUser func(options ...gitlab.RequestOptionFunc) (*gitlab.User, *gitlab.Response, error) + createDraftNote func(pid interface{}, mergeRequestIID int, opt *gitlab.CreateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) + listDraftNotes func(pid interface{}, mergeRequest int, opt *gitlab.ListDraftNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.DraftNote, *gitlab.Response, error) + deleteDraftNote func(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) + updateDraftNote func(pid interface{}, mergeRequest int, note int, opt *gitlab.UpdateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) + publishAllDraftNotes func(pid interface{}, mergeRequest int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) + publishDraftNote func(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) + listProjectMergeRequests func(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) } type Author struct { @@ -58,19 +65,19 @@ func (f fakeClient) CreateMergeRequest(pid interface{}, opt *gitlab.CreateMergeR } func (f fakeClient) AcceptMergeRequest(pid interface{}, mergeRequestIID int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { - return f.acceptAndMergeFn(pid, mergeRequestIID, opt, options...) + return f.acceptMergeRequest(pid, mergeRequestIID, opt, options...) } func (f fakeClient) GetMergeRequest(pid interface{}, mergeRequestIID int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { - return f.getMergeRequestFn(pid, mergeRequestIID, opt, options...) + return f.getMergeRequest(pid, mergeRequestIID, opt, options...) } func (f fakeClient) UpdateMergeRequest(pid interface{}, mergeRequestIID int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { - return f.updateMergeRequestFn(pid, mergeRequestIID, opt, options...) + return f.updateMergeRequest(pid, mergeRequestIID, opt, options...) } func (f fakeClient) UnapproveMergeRequest(pid interface{}, mergeRequestIID int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { - return f.unapprorveMergeRequestFn(pid, mergeRequestIID, options...) + return f.unapproveMergeRequest(pid, mergeRequestIID, options...) } func (f fakeClient) UploadFile(pid interface{}, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error) { @@ -141,19 +148,47 @@ func (f fakeClient) DeleteMergeRequestAwardEmojiOnNote(pid interface{}, mergeReq return f.deleteMergeRequestAwardEmojiOnNote(pid, mergeRequestIID, noteID, awardID) } +func (f fakeClient) CreateDraftNote(pid interface{}, mergeRequestIID int, opt *gitlab.CreateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) { + return f.createDraftNote(pid, mergeRequestIID, opt) +} + +func (f fakeClient) ListDraftNotes(pid interface{}, mergeRequestIID int, opt *gitlab.ListDraftNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.DraftNote, *gitlab.Response, error) { + return f.listDraftNotes(pid, mergeRequestIID, opt) +} + func (f fakeClient) CurrentUser(options ...gitlab.RequestOptionFunc) (*gitlab.User, *gitlab.Response, error) { return f.currentUser() } -/* 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 -} - func (f fakeClient) CreateMergeRequestAwardEmojiOnNote(pid interface{}, mergeRequestIID, noteID int, opt *gitlab.CreateAwardEmojiOptions, options ...gitlab.RequestOptionFunc) (*gitlab.AwardEmoji, *gitlab.Response, error) { return &gitlab.AwardEmoji{}, &gitlab.Response{}, nil } +func (f fakeClient) UpdateDraftNote(pid interface{}, mergeRequest int, note int, opt *gitlab.UpdateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) { + return f.updateDraftNote(pid, mergeRequest, note, opt) +} + +func (f fakeClient) DeleteDraftNote(pid interface{}, mergeRequestIID int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + return f.deleteDraftNote(pid, mergeRequestIID, note) +} + +func (f fakeClient) PublishDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + return f.publishDraftNote(pid, mergeRequest, note) +} + +func (f fakeClient) PublishAllDraftNotes(pid interface{}, mergeRequest int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + return f.publishAllDraftNotes(pid, mergeRequest) +} + +/* 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) { + if f.listProjectMergeRequests == nil { + return []*gitlab.MergeRequest{{ID: 1}}, &gitlab.Response{}, nil + } else { + return f.listProjectMergeRequests(pid, opt) + } +} + /* 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 9434577..25bd408 100644 --- a/cmd/types.go +++ b/cmd/types.go @@ -49,6 +49,12 @@ type ClientInterface interface { CreateMergeRequestDiscussion(pid interface{}, mergeRequestIID int, opt *gitlab.CreateMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) UpdateMergeRequestDiscussionNote(pid interface{}, mergeRequestIID int, discussion string, note int, opt *gitlab.UpdateMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) DeleteMergeRequestDiscussionNote(pid interface{}, mergeRequestIID int, discussion string, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) + CreateDraftNote(pid interface{}, mergeRequestIID int, opt *gitlab.CreateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) + ListDraftNotes(pid interface{}, mergeRequest int, opt *gitlab.ListDraftNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.DraftNote, *gitlab.Response, error) + DeleteDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) + UpdateDraftNote(pid interface{}, mergeRequest int, note int, opt *gitlab.UpdateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) + PublishDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) + PublishAllDraftNotes(pid interface{}, mergeRequest int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) AddMergeRequestDiscussionNote(pid interface{}, mergeRequestIID int, discussion string, opt *gitlab.AddMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) ListAllProjectMembers(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error) RetryPipelineBuild(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index dad3cde..435b483 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -51,9 +51,9 @@ QUICK START *gitlab.nvim.quick-start* 1. Install Go 2. Add configuration (see Installation section) -3. Checkout your feature branch: `git checkout feature-branch` -4. Open Neovim -5. Run `:lua require("gitlab").review()` to open the reviewer pane +5. Run `:lua require("gitlab").choose_merge_request()` + +This will checkout the branch locally, and up the plugin's reviewer pane. INSTALLATION *gitlab.nvim.installation* @@ -78,20 +78,25 @@ With Lazy: < And with Packer: >lua - use { - 'harrisoncramer/gitlab.nvim', - requires = { - "MunifTanjim/nui.nvim", - "nvim-lua/plenary.nvim", - "sindrets/diffview.nvim", - "stevearc/dressing.nvim", -- Recommended but not required. Better UI for pickers. - "nvim-tree/nvim-web-devicons", -- Recommended but not required. Icons in discussion tree. - }, - run = function() require("gitlab.server").build(true) end, - config = function() - require("gitlab").setup() - end, - } + use { + "harrisoncramer/gitlab.nvim", + requires = { + "MunifTanjim/nui.nvim", + "nvim-lua/plenary.nvim", + "sindrets/diffview.nvim" + "stevearc/dressing.nvim", -- Recommended but not required. Better UI for pickers. + "nvim-tree/nvim-web-devicons", -- Recommended but not required. Icons in discussion tree. + }, + build = function() + require("gitlab.server").build() + end, + branch = "develop", + config = function() + require("diffview") -- We require some global state from diffview + local gitlab = require("gitlab") + gitlab.setup() + end, + } < CONNECTING TO GITLAB *gitlab.nvim.connecting-to-gitlab* @@ -122,6 +127,22 @@ directory that holds your `.gitlab.nvim` file. The `connection_settings` block in the `state.lua` file will be used to configure your connection to Gitlab. +In case even more control over the auth config is needed, there is the +possibility to override the `auth_provider` settings field. It should be +a function that returns the `token` as well as the `gitlab_url` value and +a nilable error value. + +If the `gitlab_url` is `nil`, `https://gitlab.com` is used as default. + +Here an example how to use a custom `auth_provider`: +>lua + require("gitlab").setup({ + auth_provider = function() + return "my_token", "https://custom.gitlab.instance.url", nil + end, + } +< + CONFIGURING THE PLUGIN *gitlab.nvim.configuring-the-plugin* @@ -175,6 +196,7 @@ you call this function with no values the defaults will be used: toggle_unresolved_discussions = "U", -- Open or close all unresolved discussions keep_current_open = false, -- If true, current discussion stays open even if it should otherwise be closed when toggling toggle_resolved = "p" -- Toggles the resolved status of the whole discussion + publish_draft = "P", -- Publishes the currently focused note/comment position = "left", -- "top", "right", "bottom" or "left" open_in_browser = "b" -- Jump to the URL of the current note/discussion copy_node_url = "u", -- Copy the URL of the current node to clipboard @@ -184,9 +206,14 @@ you call this function with no values the defaults will be used: 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 toggle_tree_type = "i", -- Toggle type of discussion tree - "simple", or "by_file_name" + draft_mode = false, -- Whether comments are posted as drafts as part of a review + toggle_draft_mode = "D" -- Toggle between draft mode and regular mode, where comments are posted immediately 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. }, + choose_merge_request = { + open_reviewer = true, -- Open the reviewer window automatically after switching merge requests + }, info = { -- Show additional fields in the summary view enabled = true, horizontal = false, -- Display metadata to the left of the summary rather than underneath @@ -297,6 +324,16 @@ code block with prefilled code from the visual selection. Just like the summary, all the different kinds of comments are saved via the `settings.popup.perform_action` keybinding. +DRAFT NOTES *gitlab.nvim.draft-comments* + +When you publish a "draft" of any of the above resources (configurable via the +`state.settings.comments.default_to_draft` setting) the comment will be added +to a review. You may publish all draft comments via the `gitlab.publish_all_drafts()` +function, and you can publish an individual comment or note by pressing the +`state.settings.discussion_tree.publish_draft` keybinding. + +Draft notes do not support editing, replying, or emojis. + TEMPORARY REGISTERS *gitlab.nvim.temp-registers* While writing a note/comment/suggestion/reply, you may need to interrupt the @@ -364,7 +401,7 @@ These labels will be visible in the summary panel, as long as you provide the SIGNS AND DIAGNOSTICS *gitlab.nvim.signs-and-diagnostics* -By default when reviewing files, you will see diagnostics for comments that +By default when reviewing files, you will see diagnostics for comments that have been added to a review. These are the default settings: >lua discussion_signs = { @@ -379,7 +416,7 @@ have been added to a review. These are the default settings: }, }, -When the cursor is on diagnostic line you can view discussion thread by using `vim.diagnostic.show()` +When the cursor is on diagnostic line you can view discussion thread by using `vim.diagnostic.show()` You can also jump to discussion tree for the given comment: >lua @@ -527,6 +564,7 @@ in normal mode): vim.keymap.set("n", "glo", gitlab.open_in_browser) vim.keymap.set("n", "glM", gitlab.merge) vim.keymap.set("n", "glu", gitlab.copy_mr_url) + vim.keymap.set("n", "glP", gitlab.publish_all_drafts) < TROUBLESHOOTING *gitlab.nvim.troubleshooting* @@ -567,6 +605,21 @@ default arguments outlined under "Configuring the Plugin". require("gitlab").setup({ port = 8392 }) require("gitlab").setup({ discussion_tree = { blacklist = { "some_bot"} } }) +< + *gitlab.nvim.choose_merge_request* +gitlab.choose_merge_request({opts}) ~ + +Choose a merge request from a list of those open in your current project to review. +This command will automatically check out that branch locally, and optionally +open the reviewer pane. This is the default behavior. +>lua + require("gitlab").choose_merge_request() + require("gitlab").choose_merge_request({ open_reviewer = false }) +< + Parameters: ~ + • {opts}: (table|nil) Keyword arguments to configure the checkout. + • {open_reviewer}: (boolean) Whether to open the reviewer after + switching branches. True by default. < *gitlab.nvim.review* gitlab.review() ~ @@ -708,6 +761,14 @@ Once the discussion tree is open, a number of different keybindings are availabl for interacting with different discussions. Please see the `settings.discussion_tree` section of the setup call for more information about different keybindings. + *gitlab.nvim.publish_all_drafts* +gitlab.publish_all_drafts() ~ + +Publishes all unpublished draft notes. Used to finish a review and make all notes and +comments visible. +>lua + require("gitlab").publish_all_drafts() +< *gitlab.nvim.add_assignee* gitlab.add_assignee() ~ @@ -829,6 +890,7 @@ execute and passed the data as an argument. • "pipeline": Information about the current branch's pipeline. Returns and object with `latest_pipeline` and `jobs` as fields. + • "draft_notes": The current user's unpublished notes • {refresh}: (bool) Whether to re-fetch the data from Gitlab or use the cached data locally, if available. • {cb}: (function) The callback function that runs after all of the diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index 1416822..d811fa1 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -1,126 +1,46 @@ --- This module is responsible for creating new comments --- in the reviewer's buffer. The reviewer will pass back --- to this module the data required to make the API calls +--- This module is responsible for creating new comments +--- in the reviewer's buffer. The reviewer will pass back +--- to this module the data required to make the API calls local Popup = require("nui.popup") +local Layout = require("nui.layout") local state = require("gitlab.state") local job = require("gitlab.job") local u = require("gitlab.utils") local git = require("gitlab.git") local discussions = require("gitlab.actions.discussions") +local draft_notes = require("gitlab.actions.draft_notes") local miscellaneous = require("gitlab.actions.miscellaneous") local reviewer = require("gitlab.reviewer") local Location = require("gitlab.reviewer.location") -local M = {} --- Popup creation is wrapped in a function so that it is performed *after* user --- configuration has been merged with default configuration, not when this file is being --- required. -local function create_comment_popup() - return Popup(u.create_popup_state("Comment", state.settings.popup.comment)) -end +local M = { + current_win = nil, + start_line = nil, + end_line = nil, +} --- This function will open a comment popup in order to create a comment on the changed/updated --- line in the current MR -M.create_comment = function() - local has_clean_tree = git.has_clean_tree() - local is_modified = vim.api.nvim_buf_get_option(0, "modified") - if state.settings.reviewer_settings.diffview.imply_local and (is_modified or not has_clean_tree) then - u.notify( - "Cannot leave comments on changed files. \n Please stash all local changes or push them to the feature branch.", - vim.log.levels.WARN - ) - return - end - local comment_popup = create_comment_popup() - comment_popup:mount() - state.set_popup_keymaps(comment_popup, function(text) - M.confirm_create_comment(text) - end, miscellaneous.attach_file, miscellaneous.editable_popup_opts) -end - ----Create multiline comment for the last selection. -M.create_multiline_comment = function() - if not u.check_visual_mode() then - return - end - local comment_popup = create_comment_popup() - local start_line, end_line = u.get_visual_selection_boundaries() - comment_popup:mount() - state.set_popup_keymaps(comment_popup, function(text) - M.confirm_create_comment(text, { start_line = start_line, end_line = end_line }) - end, miscellaneous.attach_file, miscellaneous.editable_popup_opts) -end - ----Create comment prepopulated with gitlab suggestion ----https://docs.gitlab.com/ee/user/project/merge_requests/reviews/suggestions.html -M.create_comment_suggestion = function() - if not u.check_visual_mode() then - return - end - local comment_popup = create_comment_popup() - local start_line, end_line = u.get_visual_selection_boundaries() - local current_line = vim.api.nvim_win_get_cursor(0)[1] - local range = end_line - start_line - local backticks = "```" - local selected_lines = u.get_lines(start_line, end_line) - - for line in ipairs(selected_lines) do - if string.match(line, "^```$") then - backticks = "````" - break - end - end - - local suggestion_start - if start_line == current_line then - suggestion_start = backticks .. "suggestion:-0+" .. range - elseif end_line == current_line then - suggestion_start = backticks .. "suggestion:-" .. range .. "+0" - else - -- This should never happen afaik - u.notify("Unexpected suggestion position", vim.log.levels.ERROR) - return - end - suggestion_start = suggestion_start - local suggestion_lines = {} - table.insert(suggestion_lines, suggestion_start) - vim.list_extend(suggestion_lines, selected_lines) - table.insert(suggestion_lines, backticks) - - comment_popup:mount() - vim.api.nvim_buf_set_lines(comment_popup.bufnr, 0, -1, false, suggestion_lines) - state.set_popup_keymaps(comment_popup, function(text) - if range > 0 then - M.confirm_create_comment(text, { start_line = start_line, end_line = end_line }) - else - M.confirm_create_comment(text, nil) - end - end, miscellaneous.attach_file, miscellaneous.editable_popup_opts) -end - -M.create_note = function() - local note_popup = Popup(u.create_popup_state("Note", state.settings.popup.note)) - note_popup:mount() - state.set_popup_keymaps(note_popup, function(text) - M.confirm_create_comment(text, nil, true) - end, miscellaneous.attach_file, miscellaneous.editable_popup_opts) -end - ----This function (settings.popup.perform_action) will send the comment to the Go server +---Fires the API that sends the comment data to the Go server, called when you "confirm" creation +---via the M.settings.popup.perform_action keybinding ---@param text string comment text ---@param visual_range LineRange | nil range of visual selection or nil ---@param unlinked boolean | nil if true, the comment is not linked to a line -M.confirm_create_comment = function(text, visual_range, unlinked) +local confirm_create_comment = function(text, visual_range, unlinked) if text == nil then u.notify("Reviewer did not provide text of change", vim.log.levels.ERROR) return end + local is_draft = M.draft_popup and u.string_to_bool(u.get_buffer_text(M.draft_popup.bufnr)) if unlinked then local body = { comment = text } - job.run_job("/mr/comment", "POST", body, function(data) - u.notify("Note created!", vim.log.levels.INFO) - discussions.add_discussion({ data = data, unlinked = true }) + local endpoint = is_draft and "/mr/draft_notes/" or "/mr/comment" + job.run_job(endpoint, "POST", body, function(data) + u.notify(is_draft and "Draft note created!" or "Note created!", vim.log.levels.INFO) + if is_draft then + draft_notes.add_draft_note({ draft_note = data.draft_note, unlinked = true }) + else + discussions.add_discussion({ data = data, unlinked = true }) + end discussions.refresh() end) return @@ -153,11 +73,194 @@ M.confirm_create_comment = function(text, visual_range, unlinked) line_range = location_data.line_range, } - job.run_job("/mr/comment", "POST", body, function(data) - u.notify("Comment created!", vim.log.levels.INFO) - discussions.add_discussion({ data = data, unlinked = false }) + local endpoint = is_draft and "/mr/draft_notes/" or "/mr/comment" + job.run_job(endpoint, "POST", body, function(data) + u.notify(is_draft and "Draft comment created!" or "Comment created!", vim.log.levels.INFO) + if is_draft then + draft_notes.add_draft_note({ draft_note = data.draft_note, unlinked = false }) + else + discussions.add_discussion({ data = data, has_position = true }) + end discussions.refresh() end) end +---@class LayoutOpts +---@field ranged boolean +---@field unlinked boolean + +---This function sets up the layout and popups needed to create a comment, note and +---multi-line comment. It also sets up the basic keybindings for switching between +---window panes, and for the non-primary sections. +---@param opts LayoutOpts|nil +---@return NuiLayout +local function create_comment_layout(opts) + if opts == nil then + opts = {} + end + + M.current_win = vim.api.nvim_get_current_win() + M.comment_popup = Popup(u.create_popup_state("Comment", state.settings.popup.comment)) + M.draft_popup = Popup(u.create_box_popup_state("Draft", false)) + M.start_line, M.end_line = u.get_visual_selection_boundaries() + + local internal_layout = Layout.Box({ + Layout.Box(M.comment_popup, { grow = 1 }), + Layout.Box(M.draft_popup, { size = 3 }), + }, { dir = "col" }) + + local layout = Layout({ + position = "50%", + relative = "editor", + size = { + width = "50%", + height = "55%", + }, + }, internal_layout) + + local popup_opts = { + action_before_close = true, + action_before_exit = false, + } + + miscellaneous.set_cycle_popups_keymaps({ M.comment_popup, M.draft_popup }) + + local range = opts.ranged and { start_line = M.start_line, end_line = M.end_line } or nil + local unlinked = opts.unlinked or false + + state.set_popup_keymaps(M.draft_popup, function() + local text = u.get_buffer_text(M.comment_popup.bufnr) + confirm_create_comment(text, range, unlinked) + vim.api.nvim_set_current_win(M.current_win) + end, miscellaneous.toggle_bool, popup_opts) + + state.set_popup_keymaps(M.comment_popup, function(text) + confirm_create_comment(text, range, unlinked) + vim.api.nvim_set_current_win(M.current_win) + end, miscellaneous.attach_file, popup_opts) + + vim.schedule(function() + local draft_mode = state.settings.discussion_tree.draft_mode + vim.api.nvim_buf_set_lines(M.draft_popup.bufnr, 0, -1, false, { u.bool_to_string(draft_mode) }) + end) + + return layout +end + +--- This function will open a comment popup in order to create a comment on the changed/updated +--- line in the current MR +M.create_comment = function() + local has_clean_tree, err = git.has_clean_tree() + if err ~= nil then + return + end + local is_modified = vim.api.nvim_buf_get_option(0, "modified") + if state.settings.reviewer_settings.diffview.imply_local and (is_modified or not has_clean_tree) then + u.notify( + "Cannot leave comments on changed files. \n Please stash all local changes or push them to the feature branch.", + vim.log.levels.WARN + ) + return + end + + if not M.sha_exists() then + return + end + + local layout = create_comment_layout() + layout:mount() +end + +--- This function will open a multi-line comment popup in order to create a multi-line comment +--- on the changed/updated line in the current MR +M.create_multiline_comment = function() + if not u.check_visual_mode() then + return + end + if not M.sha_exists() then + return + end + + local layout = create_comment_layout({ ranged = true, unlinked = false }) + layout:mount() +end + +--- This function will open a a popup to create a "note" (e.g. unlinked comment) +--- on the changed/updated line in the current MR +M.create_note = function() + local layout = create_comment_layout({ ranged = false, unlinked = true }) + layout:mount() +end + +---Given the current visually selected area of text, builds text to fill in the +---comment popup with a suggested change +---@return LineRange|nil +---@return integer +local build_suggestion = function() + local current_line = vim.api.nvim_win_get_cursor(0)[1] + M.start_line, M.end_line = u.get_visual_selection_boundaries() + + local range_length = M.end_line - M.start_line + local backticks = "```" + local selected_lines = u.get_lines(M.start_line, M.end_line) + + for line in ipairs(selected_lines) do + if string.match(line, "^```$") then + backticks = "````" + break + end + end + + local suggestion_start + if M.start_line == current_line then + suggestion_start = backticks .. "suggestion:-0+" .. range_length + elseif M.end_line == current_line then + suggestion_start = backticks .. "suggestion:-" .. range_length .. "+0" + else + --- This should never happen afaik + u.notify("Unexpected suggestion position", vim.log.levels.ERROR) + return nil, 0 + end + suggestion_start = suggestion_start + local suggestion_lines = {} + table.insert(suggestion_lines, suggestion_start) + vim.list_extend(suggestion_lines, selected_lines) + table.insert(suggestion_lines, backticks) + + return suggestion_lines, range_length +end + +--- This function will open a a popup to create a suggestion comment +--- on the changed/updated line in the current MR +--- See: https://docs.gitlab.com/ee/user/project/merge_requests/reviews/suggestions.html +M.create_comment_suggestion = function() + if not u.check_visual_mode() then + return + end + if not M.sha_exists() then + return + end + + local suggestion_lines, range_length = build_suggestion() + + local layout = create_comment_layout({ ranged = range_length > 0, unlinked = false }) + layout:mount() + vim.schedule(function() + if suggestion_lines then + vim.api.nvim_buf_set_lines(M.comment_popup.bufnr, 0, -1, false, suggestion_lines) + end + end) +end + +---Checks to see whether you are commenting on a valid buffer. The Diffview plugin names non-existent +---buffers as 'null' +---@return boolean +M.sha_exists = function() + if vim.fn.expand("%") == "diffview://null" then + u.notify("This file does not exist, please comment on the other buffer", vim.log.levels.ERROR) + return false + end + return true +end + return M diff --git a/lua/gitlab/actions/common.lua b/lua/gitlab/actions/common.lua new file mode 100644 index 0000000..a5046db --- /dev/null +++ b/lua/gitlab/actions/common.lua @@ -0,0 +1,281 @@ +-- This module contains code shared between at least two modules. This includes +-- actions common to multiple tree types, as well as general utility functions +-- that are specific to actions (like jumping to a file or opening a URL) +local List = require("gitlab.utils.list") +local u = require("gitlab.utils") +local reviewer = require("gitlab.reviewer") +local indicators_common = require("gitlab.indicators.common") +local common_indicators = require("gitlab.indicators.common") +local state = require("gitlab.state") +local M = {} + +---Build note header from note +---@param note Note|DraftNote +---@return string +M.build_note_header = function(note) + if note.note then + return "@" .. state.USER.username .. " " .. "" + end + return "@" .. note.author.username .. " " .. u.time_since(note.created_at) +end + +M.switch_can_edit_bufs = function(bool, ...) + local bufnrs = { ... } + ---@param v integer + for _, v in ipairs(bufnrs) do + u.switch_can_edit_buf(v, bool) + vim.api.nvim_set_option_value("filetype", "gitlab", { buf = v }) + end +end + +---Takes in a chunk of text separated by new line characters and returns a lua table +---@param content string +---@return table +M.build_content = function(content) + local description_lines = u.lines_into_table(content) + table.insert(description_lines, "") + return description_lines +end + +M.add_empty_titles = function() + local draft_notes = require("gitlab.actions.draft_notes") + local discussions = require("gitlab.actions.discussions") + local linked, unlinked, drafts = + List.new(u.ensure_table(state.DISCUSSION_DATA and state.DISCUSSION_DATA.discussions)), + List.new(u.ensure_table(state.DISCUSSION_DATA and state.DISCUSSION_DATA.unlinked_discussions)), + List.new(u.ensure_table(state.DRAFT_NOTES)) + + local position_drafts = drafts:filter(function(note) + return draft_notes.has_position(note) + end) + local non_positioned_drafts = drafts:filter(function(note) + return not draft_notes.has_position(note) + end) + + local fields = { + { + bufnr = discussions.linked_bufnr, + count = #linked + #position_drafts, + title = "No Discussions for this MR", + }, + { + bufnr = discussions.unlinked_bufnr, + count = #unlinked + #non_positioned_drafts, + title = "No Notes (Unlinked Discussions) for this MR", + }, + } + + for _, v in ipairs(fields) do + if v.bufnr ~= nil then + M.switch_can_edit_bufs(true, v.bufnr) + local ns_id = vim.api.nvim_create_namespace("GitlabNamespace") + vim.cmd("highlight default TitleHighlight guifg=#787878") + + -- Set empty title if applicable + if v.count == 0 then + vim.api.nvim_buf_set_lines(v.bufnr, 0, 1, false, { v.title }) + local linnr = 1 + vim.api.nvim_buf_set_extmark( + v.bufnr, + ns_id, + linnr - 1, + 0, + { end_row = linnr - 1, end_col = string.len(v.title), hl_group = "TitleHighlight" } + ) + end + end + end +end + +---@param tree NuiTree +M.get_url = 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 + return url +end + +---@param tree NuiTree +M.open_in_browser = function(tree) + local url = M.get_url(tree) + if url ~= nil then + u.open_in_browser(url) + end +end + +---@param tree NuiTree +M.copy_node_url = function(tree) + local url = M.get_url(tree) + if url == nil then + vim.fn.setreg("+", url) + u.notify("Copied '" .. url .. "' to clipboard", vim.log.levels.INFO) + end +end + +-- For developers! +M.print_node = function(tree) + local current_node = tree:get_node() + vim.print(current_node) +end + +---Check if type of node is note or note body +---@param node NuiTree.Node? +---@return boolean +M.is_node_note = function(node) + if node and (node.type == "note_body" or node.type == "note") then + return true + else + return false + end +end + +---Get root node +---@param tree NuiTree +---@param node NuiTree.Node? +---@return NuiTree.Node? +M.get_root_node = function(tree, node) + if not node then + return nil + end + if node.type == "note_body" or node.type == "note" and not node.is_root then + local parent_id = node:get_parent_id() + return M.get_root_node(tree, tree:get_node(parent_id)) + elseif node.is_root then + return node + end +end + +---Get note node +---@param tree NuiTree +---@param node NuiTree.Node? +---@return NuiTree.Node? +M.get_note_node = function(tree, node) + if not node then + return nil + end + + if node.type == "note_body" then + local parent_id = node:get_parent_id() + if parent_id == nil then + return node + end + return M.get_note_node(tree, tree:get_node(parent_id)) + elseif node.type == "note" then + return node + end +end + +---Takes a node and returns the line where the note is positioned in the new SHA. If +---the line is not in the new SHA, returns nil +---@param node NuiTree.Node +---@return number|nil +local function get_new_line(node) + ---@type GitlabLineRange|nil + local range = node.range + if range == nil then + return node.new_line + end + + local _, start_new_line = common_indicators.parse_line_code(range.start.line_code) + return start_new_line +end + +---Takes a node and returns the line where the note is positioned in the old SHA. If +---the line is not in the old SHA, returns nil +---@param node NuiTree.Node +---@return number|nil +local function get_old_line(node) + ---@type GitlabLineRange|nil + local range = node.range + if range == nil then + return node.old_line + end + + local start_old_line, _ = common_indicators.parse_line_code(range.start.line_code) + return start_old_line +end + +---@param id string|integer +---@return integer|nil +M.get_line_number = function(id) + ---@type Discussion|DraftNote|nil + local d_or_n + d_or_n = List.new(state.DISCUSSION_DATA.discussions or {}):find(function(d) + return d.id == id + end) or List.new(state.DRAFT_NOTES or {}):find(function(d) + return d.id == id + end) + + if d_or_n == nil then + return + end + + local first_note = indicators_common.get_first_note(d_or_n) + return (indicators_common.is_new_sha(d_or_n) and first_note.position.new_line or first_note.position.old_line) or 1 +end + +---@param root_node NuiTree.Node +---@return integer|nil +M.get_line_number_from_node = function(root_node) + if root_node.range then + local start_old_line, start_new_line = common_indicators.parse_line_code(root_node.range.start.line_code) + return root_node.old_line and start_old_line or start_new_line + else + return M.get_line_number(root_node.id) + end +end + +-- This function (settings.discussion_tree.jump_to_reviewer) will jump the cursor to the reviewer's location associated with the note. The implementation depends on the reviewer +M.jump_to_reviewer = function(tree, callback) + local node = tree:get_node() + local root_node = M.get_root_node(tree, node) + if root_node == nil then + u.notify("Could not get discussion node", vim.log.levels.ERROR) + return + end + local line_number = M.get_line_number_from_node(root_node) + if line_number == nil then + u.notify("Could not get line number", vim.log.levels.ERROR) + return + end + reviewer.jump(root_node.file_name, line_number, root_node.old_line == nil) + callback() +end + +-- This function (settings.discussion_tree.jump_to_file) will jump to the file changed in a new tab +M.jump_to_file = function(tree) + local node = tree:get_node() + local root_node = M.get_root_node(tree, node) + if root_node == nil then + u.notify("Could not get discussion node", vim.log.levels.ERROR) + return + end + if root_node.file_name == nil then + u.notify("This comment was not left on a particular location", vim.log.levels.WARN) + return + end + vim.cmd.tabnew() + local line_number = get_new_line(root_node) or get_old_line(root_node) + if line_number == nil then + line_number = 1 + end + local bufnr = vim.fn.bufnr(root_node.file_name) + if bufnr ~= -1 then + vim.cmd("buffer " .. bufnr) + vim.api.nvim_win_set_cursor(0, { line_number, 0 }) + return + end + + -- If buffer is not already open, open it + vim.cmd("edit " .. root_node.file_name) + vim.api.nvim_win_set_cursor(0, { line_number, 0 }) +end + +return M diff --git a/lua/gitlab/actions/create_mr.lua b/lua/gitlab/actions/create_mr.lua index 41b68f2..5f214b1 100644 --- a/lua/gitlab/actions/create_mr.lua +++ b/lua/gitlab/actions/create_mr.lua @@ -7,6 +7,7 @@ local job = require("gitlab.job") local u = require("gitlab.utils") local git = require("gitlab.git") local state = require("gitlab.state") +local common = require("gitlab.actions.common") local miscellaneous = require("gitlab.actions.miscellaneous") ---@class Mr @@ -42,6 +43,10 @@ end --- continue working on it. ---@param args? Mr M.start = function(args) + if not git.current_branch_up_to_date_on_remote(vim.log.levels.ERROR) then + return + end + if M.started then vim.ui.select({ "Yes", "No" }, { prompt = "Continue your previous MR?" }, function(choice) if choice == "Yes" then @@ -82,7 +87,10 @@ M.pick_target = function(mr) end local function make_template_path(t) - local base_dir = git.base_dir() + local base_dir, err = git.base_dir() + if err ~= nil then + return + end return base_dir .. state.settings.file_separator .. ".gitlab" @@ -202,7 +210,7 @@ M.open_confirmation_popup = function(mr) M.layout_visible = false end - local description_lines = mr.description and M.build_description_lines(mr.description) or { "" } + local description_lines = mr.description and common.build_content(mr.description) or { "" } local delete_branch = u.get_first_non_nil_value({ mr.delete_branch, state.settings.create_mr.delete_branch }) local squash = u.get_first_non_nil_value({ mr.squash, state.settings.create_mr.squash }) @@ -234,18 +242,6 @@ M.open_confirmation_popup = function(mr) 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 u.split_by_new_lines(template_content) do - table.insert(description_lines, line) - end - -- TODO: @harrisoncramer Same as in lua/gitlab/actions/summary.lua:114 - table.insert(description_lines, "") - - return description_lines -end - ---Prompts for interactive selection of a new target among remote-tracking branches M.select_new_target = function() local bufnr = vim.api.nvim_get_current_buf() diff --git a/lua/gitlab/actions/data.lua b/lua/gitlab/actions/data.lua index 3f1d98f..55aa794 100644 --- a/lua/gitlab/actions/data.lua +++ b/lua/gitlab/actions/data.lua @@ -9,6 +9,7 @@ local labels = state.dependencies.labels local project_members = state.dependencies.project_members local revisions = state.dependencies.revisions local latest_pipeline = state.dependencies.latest_pipeline +local draft_notes = state.dependencies.draft_notes M.data = function(resources, cb) if type(resources) ~= "table" or type(cb) ~= "function" then @@ -23,6 +24,7 @@ M.data = function(resources, cb) project_members = project_members, revisions = revisions, pipeline = latest_pipeline, + draft_notes = draft_notes, } local api_calls = {} diff --git a/lua/gitlab/actions/discussions/annotations.lua b/lua/gitlab/actions/discussions/annotations.lua index 92bbf4e..408cffe 100644 --- a/lua/gitlab/actions/discussions/annotations.lua +++ b/lua/gitlab/actions/discussions/annotations.lua @@ -79,9 +79,11 @@ ---@field moji string ---@class WinbarTable ----@field name string +---@field view_type string ---@field resolvable_discussions number ---@field resolved_discussions number +---@field inline_draft_notes number +---@field unlinked_draft_notes number ---@field resolvable_notes number ---@field resolved_notes number ---@field help_keymap string @@ -120,3 +122,14 @@ ---@field old_line integer | nil ---@field new_line integer | nil ---@field line_range ReviewerRangeInfo|nil + +---@class DraftNote +---@field note string +---@field id integer +---@field author_id integer +---@field merge_request_id integer +---@field resolve_discussion boolean +---@field discussion_id string -- This will always be "" +---@field commit_id string -- This will always be "" +---@field line_code string +---@field position NotePosition diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index 67f26c6..b5415a5 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -1,19 +1,21 @@ --- This module is responsible for the discussion tree. That includes things like --- editing existing notes in the tree, replying to notes in the tree, --- and marking discussions as resolved/unresolved. +-- This module is responsible for the notes and comments discussion tree. +-- That includes things like editing existing notes in the tree, +-- replying to notes in the tree, and marking discussions as resolved/unresolved. +-- Draft notes are managed separately, under lua/gitlab/actions/draft_notes/init.lua local Split = require("nui.split") local Popup = require("nui.popup") local NuiTree = require("nui.tree") -local NuiLine = require("nui.line") local job = require("gitlab.job") local u = require("gitlab.utils") local state = require("gitlab.state") local reviewer = require("gitlab.reviewer") +local common = require("gitlab.actions.common") local List = require("gitlab.utils.list") +local tree_utils = require("gitlab.actions.discussions.tree") local miscellaneous = require("gitlab.actions.miscellaneous") local discussions_tree = require("gitlab.actions.discussions.tree") +local draft_notes = require("gitlab.actions.draft_notes") local diffview_lib = require("diffview.lib") -local common = require("gitlab.indicators.common") local signs = require("gitlab.indicators.signs") local diagnostics = require("gitlab.indicators.diagnostics") local winbar = require("gitlab.actions.discussions.winbar") @@ -24,32 +26,22 @@ local M = { split_visible = false, split = nil, ---@type number - split_bufnr = nil, - ---@type Discussion[] - discussions = {}, - ---@type UnlinkedDiscussion[] - unlinked_discussions = {}, - ---@type EmojiMap - emojis = {}, - ---@type number linked_bufnr = nil, ---@type number unlinked_bufnr = nil, ---@type number - focused_bufnr = nil, discussion_tree = nil, } ----Makes API call to get the discussion data, store it in M.discussions and M.unlinked_discussions and call ----callback with data ----@param callback (fun(data: DiscussionData): nil)? +---Makes API call to get the discussion data, stores it in the state, and calls the callback +---@param callback function|nil M.load_discussions = function(callback) 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 {} - M.emojis = data.emojis or {} + state.DISCUSSION_DATA.discussions = u.ensure_table(data.discussions) + state.DISCUSSION_DATA.unlinked_discussions = u.ensure_table(data.unlinked_discussions) + state.DISCUSSION_DATA.emojis = u.ensure_table(data.emojis) if type(callback) == "function" then - callback(data) + callback() end end) end @@ -87,36 +79,22 @@ end ---Refresh discussion data, signs, diagnostics, and winbar with new data from API --- and rebuild the entire view -M.refresh = function() +M.refresh = function(cb) M.load_discussions(function() M.refresh_view() + if cb ~= nil then + cb() + end end) end --- Take existing data and refresh the diagnostics, the winbar, and the signs M.refresh_view = function() if state.settings.discussion_signs.enabled then - diagnostics.refresh_diagnostics(M.discussions) + diagnostics.refresh_diagnostics() end - if M.split_visible then - local linked_is_focused = M.linked_bufnr == M.focused_bufnr - winbar.update_winbar(M.discussions, M.unlinked_discussions, linked_is_focused and "Discussions" or "Notes") - end -end - ----Toggle Discussions tree type between "simple" and "by_file_name" ----@param unlinked boolean True if selected view type is Notes (unlinked discussions) -M.toggle_tree_type = function(unlinked) - if unlinked then - u.notify("Toggling tree type is only possible in Discussions", vim.log.levels.INFO) - return - end - if state.settings.discussion_tree.tree_type == "simple" then - state.settings.discussion_tree.tree_type = "by_file_name" - else - state.settings.discussion_tree.tree_type = "simple" - end - M.rebuild_discussion_tree() + winbar.update_winbar() + common.add_empty_titles() end ---Opens the discussion tree, sets the keybindings. It also @@ -128,64 +106,50 @@ M.toggle = function(callback) return end + state.DISCUSSION_DATA.discussions = u.ensure_table(state.DISCUSSION_DATA.discussions) + state.DISCUSSION_DATA.unlinked_discussions = u.ensure_table(state.DISCUSSION_DATA.unlinked_discussions) + state.DRAFT_NOTES = u.ensure_table(state.DRAFT_NOTES) + + -- Make buffers, get and set buffer numbers, set filetypes local split, linked_bufnr, unlinked_bufnr = M.create_split_and_bufs() + M.split = split M.linked_bufnr = linked_bufnr M.unlinked_bufnr = unlinked_bufnr - M.split = split - M.split_visible = true - M.split_bufnr = split.bufnr - split:mount() - M.switch_can_edit_bufs(true) - - vim.api.nvim_buf_set_lines(split.bufnr, 0, -1, false, { "Loading data..." }) - vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.split_bufnr }) + vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.split.bufnr }) vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.unlinked_bufnr }) vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.linked_bufnr }) - local default_discussions = state.settings.discussion_tree.default_view == "discussions" - winbar.update_winbar({}, {}, default_discussions and "Discussions" or "Notes") + M.split = split + M.split_visible = true + split:mount() - M.load_discussions(function() - if type(M.discussions) ~= "table" and type(M.unlinked_discussions) ~= "table" then - u.notify("No discussions or notes for this MR", vim.log.levels.WARN) - vim.api.nvim_buf_set_lines(split.bufnr, 0, -1, false, { "" }) - return - end + -- Initialize winbar module with data from buffers + winbar.set_buffers(M.linked_bufnr, M.unlinked_bufnr) + winbar.switch_view_type(state.settings.discussion_tree.default_view) - local current_window = vim.api.nvim_get_current_win() -- Save user's current window in case they switched while content was loading - vim.api.nvim_set_current_win(M.split.winid) + local current_window = vim.api.nvim_get_current_win() -- Save user's current window in case they switched while content was loading + vim.api.nvim_set_current_win(M.split.winid) - M.rebuild_discussion_tree() - M.rebuild_unlinked_discussion_tree() - M.add_empty_titles({ - { M.linked_bufnr, M.discussions, "No Discussions for this MR" }, - { M.unlinked_bufnr, M.unlinked_discussions, "No Notes (Unlinked Discussions) for this MR" }, - }) + common.switch_can_edit_bufs(true, M.linked_bufnr, M.unliked_bufnr) + M.rebuild_discussion_tree() + M.rebuild_unlinked_discussion_tree() - local default_buffer = default_discussions and M.linked_bufnr or M.unlinked_bufnr - vim.api.nvim_set_current_buf(default_buffer) - M.focused_bufnr = default_buffer + -- Set default buffer + local default_buffer = winbar.bufnr_map[state.settings.discussion_tree.default_view] + vim.api.nvim_set_current_buf(default_buffer) + common.switch_can_edit_bufs(false, M.linked_bufnr, M.unlinked_bufnr) - M.switch_can_edit_bufs(false) + vim.api.nvim_set_current_win(current_window) + if type(callback) == "function" then + callback() + end + + vim.schedule(function() M.refresh_view() - - vim.api.nvim_set_current_win(current_window) - if type(callback) == "function" then - callback() - end end) end --- Change between views in the discussion panel, either notes or discussions -local switch_view_type = function() - local change_to_unlinked = M.linked_bufnr == M.focused_bufnr - local new_bufnr = change_to_unlinked and M.unlinked_bufnr or M.linked_bufnr - vim.api.nvim_set_current_buf(new_bufnr) - winbar.update_winbar(M.discussions, M.unlinked_discussions, change_to_unlinked and "Notes" or "Discussions") - M.focused_bufnr = new_bufnr -end - -- Clears the discussion state and unmounts the split M.close = function() if M.split then @@ -251,9 +215,13 @@ end -- The reply popup will mount in a window when you trigger it (settings.discussion_tree.reply) when hovering over a node in the discussion tree. M.reply = function(tree) + if M.is_draft_note(tree) then + u.notify("Gitlab does not support replying to draft notes", vim.log.levels.WARN) + return + end local reply_popup = Popup(u.create_popup_state("Reply", state.settings.popup.reply)) local node = tree:get_node() - local discussion_node = M.get_root_node(tree, node) + local discussion_node = common.get_root_node(tree, node) local id = tostring(discussion_node.id) reply_popup:mount() state.set_popup_keymaps( @@ -268,6 +236,7 @@ end M.send_reply = function(tree, discussion_id) return function(text) local body = { discussion_id = discussion_id, reply = text } + 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) @@ -277,12 +246,12 @@ M.send_reply = function(tree, discussion_id) end -- This function (settings.discussion_tree.delete_comment) will trigger a popup prompting you to delete the current comment -M.delete_comment = function(tree, unlinked) +M.delete_comment = function(tree) vim.ui.select({ "Confirm", "Cancel" }, { prompt = "Delete comment?", }, function(choice) if choice == "Confirm" then - M.send_deletion(tree, unlinked) + M.send_deletion(tree) end end) end @@ -292,29 +261,40 @@ end M.send_deletion = function(tree) local current_node = tree:get_node() - local note_node = M.get_note_node(tree, current_node) - local root_node = M.get_root_node(tree, current_node) + local note_node = common.get_note_node(tree, current_node) + local root_node = common.get_root_node(tree, current_node) + if note_node == nil or root_node == nil then + u.notify("Could not get note or root node", vim.log.levels.ERROR) + return + end + + ---@type integer local note_id = note_node.is_root and root_node.root_note_id or note_node.id - local body = { discussion_id = root_node.id, note_id = tonumber(note_id) } - job.run_job("/mr/comment", "DELETE", body, function(data) - u.notify(data.message, vim.log.levels.INFO) - if note_node.is_root then - -- Replace root node w/ current node's contents... - tree:remove_node("-" .. root_node.id) - else - tree:remove_node("-" .. note_id) - end - tree:render() - M.refresh() - end) + + if root_node.is_draft then + draft_notes.send_deletion(tree) + else + local body = { discussion_id = root_node.id, note_id = tonumber(note_id) } + job.run_job("/mr/comment", "DELETE", body, function(data) + u.notify(data.message, vim.log.levels.INFO) + if note_node.is_root then + -- Replace root node w/ current node's contents... + tree:remove_node("-" .. root_node.id) + else + tree:remove_node("-" .. note_id) + end + tree:render() + M.refresh() + end) + end end -- This function (settings.discussion_tree.edit_comment) will open the edit popup for the current comment in the discussion tree M.edit_comment = function(tree, unlinked) local edit_popup = Popup(u.create_popup_state("Edit Comment", state.settings.popup.edit)) local current_node = tree:get_node() - local note_node = M.get_note_node(tree, current_node) - local root_node = M.get_root_node(tree, current_node) + local note_node = common.get_note_node(tree, current_node) + local root_node = common.get_root_node(tree, current_node) if note_node == nil or root_node == nil then u.notify("Could not get root or note node", vim.log.levels.ERROR) return @@ -334,12 +314,18 @@ M.edit_comment = function(tree, unlinked) local currentBuffer = vim.api.nvim_get_current_buf() vim.api.nvim_buf_set_lines(currentBuffer, 0, -1, false, lines) - state.set_popup_keymaps( - edit_popup, - M.send_edits(tostring(root_node.id), tonumber(note_node.root_note_id or note_node.id), unlinked), - nil, - miscellaneous.editable_popup_opts - ) + + -- Draft notes module handles edits for draft notes + if root_node.is_draft then + state.set_popup_keymaps(edit_popup, draft_notes.send_edits(root_node.id), nil, miscellaneous.editable_popup_opts) + else + state.set_popup_keymaps( + edit_popup, + M.send_edits(tostring(root_node.id), tonumber(note_node.root_note_id or note_node.id), unlinked), + nil, + miscellaneous.editable_popup_opts + ) + end end ---This function sends the edited comment to the Go server @@ -355,12 +341,11 @@ M.send_edits = function(discussion_id, note_id, unlinked) } job.run_job("/mr/comment", "PATCH", body, function(data) u.notify(data.message, vim.log.levels.INFO) - M.rebuild_discussion_tree() if unlinked then - M.replace_text(M.unlinked_discussions, discussion_id, note_id, text) + M.replace_text(state.DISCUSSION_DATA.unlinked_discussions, discussion_id, note_id, text) M.rebuild_unlinked_discussion_tree() else - M.replace_text(M.discussions, discussion_id, note_id, text) + M.replace_text(state.DISCUSSION_DATA.discussions, discussion_id, note_id, text) M.rebuild_discussion_tree() end end) @@ -375,8 +360,8 @@ M.toggle_discussion_resolved = function(tree) end -- Switch to the root node to enable toggling from child nodes and note bodies - if not note.resolvable and M.is_node_note(note) then - note = M.get_root_node(tree, note) + if not note.resolvable and common.is_node_note(note) then + note = common.get_root_node(tree, note) end if note == nil then return @@ -394,330 +379,100 @@ M.toggle_discussion_resolved = function(tree) end) end ----Takes a node and returns the line where the note is positioned in the new SHA. If ----the line is not in the new SHA, returns nil ----@param node any ----@return number|nil -local function get_new_line(node) - ---@type GitlabLineRange|nil - local range = node.range - if range == nil then - return node.new_line - end - - local _, start_new_line = common.parse_line_code(range.start.line_code) - return start_new_line -end - ----Takes a node and returns the line where the note is positioned in the old SHA. If ----the line is not in the old SHA, returns nil ----@param node any ----@return number|nil -local function get_old_line(node) - ---@type GitlabLineRange|nil - local range = node.range - if range == nil then - return node.old_line - end - - local start_old_line, _ = common.parse_line_code(range.start.line_code) - return start_old_line -end - --- This function (settings.discussion_tree.jump_to_reviewer) will jump the cursor to the reviewer's location associated with the note. The implementation depends on the reviewer -M.jump_to_reviewer = function(tree) - local node = tree:get_node() - local root_node = M.get_root_node(tree, node) - if root_node == nil then - u.notify("Could not get discussion node", vim.log.levels.ERROR) - return - end - local line_number = (root_node.new_line or root_node.old_line or 1) - if root_node.range then - local start_old_line, start_new_line = common.parse_line_code(root_node.range.start.line_code) - line_number = root_node.old_line and start_old_line or start_new_line - end - reviewer.jump(root_node.file_name, line_number, root_node.old_line == nil) - M.refresh_view() -end - --- This function (settings.discussion_tree.jump_to_file) will jump to the file changed in a new tab -M.jump_to_file = function(tree) - local node = tree:get_node() - local root_node = M.get_root_node(tree, node) - if root_node == nil then - u.notify("Could not get discussion node", vim.log.levels.ERROR) - return - end - vim.cmd.tabnew() - local line_number = get_new_line(root_node) or get_old_line(root_node) - if line_number == nil then - line_number = 1 - end - local bufnr = vim.fn.bufnr(root_node.file_name) - if bufnr ~= -1 then - vim.cmd("buffer " .. bufnr) - vim.api.nvim_win_set_cursor(0, { line_number, 0 }) - return - end - - -- If buffer is not already open, open it - vim.cmd("edit " .. root_node.file_name) - vim.api.nvim_win_set_cursor(0, { line_number, 0 }) -end - --- This function (settings.discussion_tree.toggle_node) expands/collapses the current node and its children -M.toggle_node = function(tree) - local node = tree:get_node() - if node == nil then - return - end - - -- Switch to the "note" node from "note_body" nodes to enable toggling discussions inside comments - if node.type == "note_body" then - node = tree:get_node(node:get_parent_id()) - end - if node == nil then - return - end - - local children = node:get_child_ids() - if node == nil then - return - end - if node:is_expanded() then - node:collapse() - if M.is_node_note(node) then - for _, child in ipairs(children) do - tree:get_node(child):collapse() - end - end - else - if M.is_node_note(node) then - for _, child in ipairs(children) do - tree:get_node(child):expand() - end - end - node:expand() - end - - tree:render() -end - ----@class ToggleNodesOptions ----@field toggle_resolved boolean Whether to toggle resolved discussions. ----@field toggle_unresolved boolean Whether to toggle unresolved discussions. ----@field keep_current_open boolean Whether to keep the current discussion open even if it should otherwise be closed. - ----This function (settings.discussion_tree.toggle_nodes) expands/collapses all nodes and their children according to the opts. ----@param tree NuiTree ----@param opts ToggleNodesOptions -M.toggle_nodes = function(tree, unlinked, opts) - local current_node = tree:get_node() - if current_node == nil then - return - end - local root_node = M.get_root_node(tree, current_node) - for _, node in ipairs(tree:get_nodes()) do - if opts.toggle_resolved then - if - (unlinked and state.unlinked_discussion_tree.resolved_expanded) - or (not unlinked and state.discussion_tree.resolved_expanded) - then - M.collapse_recursively(tree, node, root_node, opts.keep_current_open, true) - else - M.expand_recursively(tree, node, true) - end - end - if opts.toggle_unresolved then - if - (unlinked and state.unlinked_discussion_tree.unresolved_expanded) - or (not unlinked and state.discussion_tree.unresolved_expanded) - then - M.collapse_recursively(tree, node, root_node, opts.keep_current_open, false) - else - M.expand_recursively(tree, node, false) - end - end - end - -- Reset states of resolved discussions after toggling - if opts.toggle_resolved then - if unlinked then - state.unlinked_discussion_tree.resolved_expanded = not state.unlinked_discussion_tree.resolved_expanded - else - state.discussion_tree.resolved_expanded = not state.discussion_tree.resolved_expanded - end - end - -- Reset states of unresolved discussions after toggling - if opts.toggle_unresolved then - if unlinked then - state.unlinked_discussion_tree.unresolved_expanded = not state.unlinked_discussion_tree.unresolved_expanded - else - state.discussion_tree.unresolved_expanded = not state.discussion_tree.unresolved_expanded - end - end - tree:render() - M.restore_cursor_position(tree, current_node, root_node) -end - ----This function (settings.discussion_tree.collapse_recursively) collapses a node and its children. ----@param tree NuiTree ----@param node NuiTree.Node ----@param current_root_node NuiTree.Node The root node of the current node. ----@param keep_current_open boolean If true, the current node stays open, even if it should otherwise be collapsed. ----@param is_resolved boolean If true, collapse resolved discussions. If false, collapse unresolved discussions. -M.collapse_recursively = function(tree, node, current_root_node, keep_current_open, is_resolved) - if node == nil then - return - end - local root_node = M.get_root_node(tree, node) - if M.is_node_note(node) and root_node.resolved == is_resolved then - if keep_current_open and root_node == current_root_node then - return - end - node:collapse() - end - local children = node:get_child_ids() - for _, child in ipairs(children) do - M.collapse_recursively(tree, tree:get_node(child), current_root_node, keep_current_open, is_resolved) - end -end - ----This function (settings.discussion_tree.expand_recursively) expands a node and its children. ----@param tree NuiTree ----@param node NuiTree.Node ----@param is_resolved boolean If true, expand resolved discussions. If false, expand unresolved discussions. -M.expand_recursively = function(tree, node, is_resolved) - if node == nil then - return - end - if M.is_node_note(node) and M.get_root_node(tree, node).resolved == is_resolved then - node:expand() - end - local children = node:get_child_ids() - for _, child in ipairs(children) do - M.expand_recursively(tree, tree:get_node(child), is_resolved) - end -end - -- -- 🌲 Helper Functions -- ----Inspired by default func https://github.com/MunifTanjim/nui.nvim/blob/main/lua/nui/tree/util.lua#L38 -local function nui_tree_prepare_node(node) - if not node.text then - error("missing node.text") - end - - local texts = node.text - if type(node.text) ~= "table" or node.text.content then - texts = { node.text } - end - - local lines = {} - - for i, text in ipairs(texts) do - local line = NuiLine() - - line:append(string.rep(" ", node._depth - 1)) - - if i == 1 and node:has_children() then - line:append(node:is_expanded() and " " or " ") - if node.icon then - line:append(node.icon .. " ", node.icon_hl) - end - else - line:append(" ") - end - - line:append(text, node.text_hl) - - local note_id = tostring(node.is_root and node.root_note_id or node.id) - - local e = require("gitlab.emoji") - - ---@type Emoji[] - local emojis = M.emojis[note_id] - local placed_emojis = {} - if emojis ~= nil then - for _, v in ipairs(emojis) do - local icon = e.emoji_map[v.name] - if icon ~= nil and not u.contains(placed_emojis, icon.moji) then - line:append(" ") - line:append(icon.moji) - table.insert(placed_emojis, icon.moji) - end - end - end - - table.insert(lines, line) - end - - return lines -end +---Rebuilds the discussion tree, which contains all comments and draft comments +---linked to specific places in the code. M.rebuild_discussion_tree = function() if M.linked_bufnr == nil then return end - M.switch_can_edit_bufs(true) + common.switch_can_edit_bufs(true, M.linked_bufnr, M.unlinked_bufnr) vim.api.nvim_buf_set_lines(M.linked_bufnr, 0, -1, false, {}) - local discussion_tree_nodes = discussions_tree.add_discussions_to_table(M.discussions, false) - local discussion_tree = - NuiTree({ nodes = discussion_tree_nodes, bufnr = M.linked_bufnr, prepare_node = nui_tree_prepare_node }) + local existing_comment_nodes = discussions_tree.add_discussions_to_table(state.DISCUSSION_DATA.discussions, false) + local draft_comment_nodes = draft_notes.add_draft_notes_to_table(false) + + -- Combine inline draft notes with regular comments + local all_nodes = {} + for _, draft_node in ipairs(draft_comment_nodes) do + table.insert(all_nodes, draft_node) + end + for _, node in ipairs(existing_comment_nodes) do + table.insert(all_nodes, node) + end + + local discussion_tree = NuiTree({ + nodes = all_nodes, + bufnr = M.linked_bufnr, + prepare_node = tree_utils.nui_tree_prepare_node, + }) + discussion_tree:render() M.set_tree_keymaps(discussion_tree, M.linked_bufnr, false) M.discussion_tree = discussion_tree - M.switch_can_edit_bufs(false) + common.switch_can_edit_bufs(false, M.linked_bufnr, M.unlinked_bufnr) vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.linked_bufnr }) state.discussion_tree.resolved_expanded = false state.discussion_tree.unresolved_expanded = false end +---Rebuilds the unlinked discussion tree, which contains all notes and draft notes. M.rebuild_unlinked_discussion_tree = function() if M.unlinked_bufnr == nil then return end - M.switch_can_edit_bufs(true) + common.switch_can_edit_bufs(true, M.linked_bufnr, M.unlinked_bufnr) vim.api.nvim_buf_set_lines(M.unlinked_bufnr, 0, -1, false, {}) - local unlinked_discussion_tree_nodes = discussions_tree.add_discussions_to_table(M.unlinked_discussions, true) + local existing_note_nodes = + discussions_tree.add_discussions_to_table(state.DISCUSSION_DATA.unlinked_discussions, true) + local draft_comment_nodes = draft_notes.add_draft_notes_to_table(true) + + -- Combine draft notes with regular notes + local all_nodes = {} + for _, draft_node in ipairs(draft_comment_nodes) do + table.insert(all_nodes, draft_node) + end + for _, node in ipairs(existing_note_nodes) do + table.insert(all_nodes, node) + end + local unlinked_discussion_tree = NuiTree({ - nodes = unlinked_discussion_tree_nodes, + nodes = all_nodes, bufnr = M.unlinked_bufnr, - prepare_node = nui_tree_prepare_node, + prepare_node = tree_utils.nui_tree_prepare_node, }) unlinked_discussion_tree:render() M.set_tree_keymaps(unlinked_discussion_tree, M.unlinked_bufnr, true) M.unlinked_discussion_tree = unlinked_discussion_tree - M.switch_can_edit_bufs(false) + common.switch_can_edit_bufs(false, M.linked_bufnr, M.unlinked_bufnr) state.unlinked_discussion_tree.resolved_expanded = false state.unlinked_discussion_tree.unresolved_expanded = false end -M.switch_can_edit_bufs = function(bool) - u.switch_can_edit_buf(M.unlinked_bufnr, bool) - u.switch_can_edit_buf(M.linked_bufnr, bool) - vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.unlinked_bufnr }) - vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.linked_bufnr }) -end - +---Adds a discussion to the global state. Works for both notes (unlinked) and diff-linked comments, M.add_discussion = function(arg) local discussion = arg.data.discussion if arg.unlinked then - if type(M.unlinked_discussions) ~= "table" then - M.unlinked_discussions = {} + if type(state.DISCUSSION_DATA.unlinked_discussions) ~= "table" then + state.DISCUSSION_DATA.unlinked_discussions = {} end - table.insert(M.unlinked_discussions, 1, discussion) + table.insert(state.DISCUSSION_DATA.unlinked_discussions, 1, discussion) M.rebuild_unlinked_discussion_tree() - return + else + if type(state.DISCUSSION_DATA.discussions) ~= "table" then + state.DISCUSSION_DATA.discussions = {} + end + table.insert(state.DISCUSSION_DATA.discussions, 1, discussion) + M.rebuild_discussion_tree() end - if type(M.discussions) ~= "table" then - M.discussions = {} - end - table.insert(M.discussions, 1, discussion) - M.rebuild_discussion_tree() end +---Creates the split for the discussion tree and returns it, with both buffer numbers +---@return NuiSplit +---@return integer +---@return integer M.create_split_and_bufs = function() local position = state.settings.discussion_tree.position local size = state.settings.discussion_tree.size @@ -735,82 +490,72 @@ M.create_split_and_bufs = function() return split, linked_bufnr, unlinked_bufnr end -M.add_empty_titles = function(args) - M.switch_can_edit_bufs(true) - local ns_id = vim.api.nvim_create_namespace("GitlabNamespace") - vim.cmd("highlight default TitleHighlight guifg=#787878") - for _, section in ipairs(args) do - local bufnr, data, title = section[1], section[2], section[3] - if type(data) ~= "table" or #data == 0 then - vim.api.nvim_buf_set_lines(bufnr, 0, 1, false, { title }) - local linnr = 1 - vim.api.nvim_buf_set_extmark( - bufnr, - ns_id, - linnr - 1, - 0, - { end_row = linnr - 1, end_col = string.len(title), hl_group = "TitleHighlight" } - ) - end - end -end - ----Check if type of node is note or note body ----@param node NuiTree.Node? ----@return boolean -M.is_node_note = function(node) - if node and (node.type == "note_body" or node.type == "note") then - return true - else - return false - end -end - ---Check if type of current node is note or note body ---@param tree NuiTree ---@return boolean M.is_current_node_note = function(tree) - return M.is_node_note(tree:get_node()) + return common.is_node_note(tree:get_node()) end M.set_tree_keymaps = function(tree, bufnr, unlinked) - vim.keymap.set("n", state.settings.discussion_tree.toggle_tree_type, function() - M.toggle_tree_type(unlinked) - end, { buffer = bufnr, desc = "Toggle tree type between `simple` and `by_file_name`" }) + if not unlinked then + vim.keymap.set("n", state.settings.discussion_tree.jump_to_file, function() + if M.is_current_node_note(tree) then + common.jump_to_file(tree) + end + end, { buffer = bufnr, desc = "Jump to file" }) + vim.keymap.set("n", state.settings.discussion_tree.jump_to_reviewer, function() + if M.is_current_node_note(tree) then + common.jump_to_reviewer(tree, M.refresh_view) + end + end, { buffer = bufnr, desc = "Jump to reviewer" }) + vim.keymap.set("n", state.settings.discussion_tree.toggle_tree_type, function() + M.toggle_tree_type() + end, { buffer = bufnr, desc = "Toggle tree type between `simple` and `by_file_name`" }) + end vim.keymap.set("n", state.settings.discussion_tree.edit_comment, function() if M.is_current_node_note(tree) then M.edit_comment(tree, unlinked) end end, { buffer = bufnr, desc = "Edit comment" }) + vim.keymap.set("n", state.settings.discussion_tree.publish_draft, function() + if M.is_draft_note(tree) then + draft_notes.publish_draft(tree) + end + end, { buffer = bufnr, desc = "Publish draft" }) vim.keymap.set("n", state.settings.discussion_tree.delete_comment, function() if M.is_current_node_note(tree) then - M.delete_comment(tree, unlinked) + M.delete_comment(tree) end end, { buffer = bufnr, desc = "Delete comment" }) + vim.keymap.set("n", state.settings.discussion_tree.toggle_draft_mode, function() + state.settings.discussion_tree.draft_mode = not state.settings.discussion_tree.draft_mode + winbar.update_winbar() + end, { buffer = bufnr, desc = "Toggle between draft mode and live mode" }) vim.keymap.set("n", state.settings.discussion_tree.toggle_resolved, function() - if M.is_current_node_note(tree) then + if M.is_current_node_note(tree) and not M.is_draft_note(tree) then M.toggle_discussion_resolved(tree) end end, { buffer = bufnr, desc = "Toggle resolved" }) vim.keymap.set("n", state.settings.discussion_tree.toggle_node, function() - M.toggle_node(tree) + tree_utils.toggle_node(tree) end, { buffer = bufnr, desc = "Toggle node" }) vim.keymap.set("n", state.settings.discussion_tree.toggle_all_discussions, function() - M.toggle_nodes(tree, unlinked, { + tree_utils.toggle_nodes(M.split.winid, tree, unlinked, { toggle_resolved = true, toggle_unresolved = true, keep_current_open = state.settings.discussion_tree.keep_current_open, }) end, { buffer = bufnr, desc = "Toggle all nodes" }) vim.keymap.set("n", state.settings.discussion_tree.toggle_resolved_discussions, function() - M.toggle_nodes(tree, unlinked, { + tree_utils.toggle_nodes(M.split.winid, tree, unlinked, { toggle_resolved = true, toggle_unresolved = false, keep_current_open = state.settings.discussion_tree.keep_current_open, }) end, { buffer = bufnr, desc = "Toggle resolved nodes" }) vim.keymap.set("n", state.settings.discussion_tree.toggle_unresolved_discussions, function() - M.toggle_nodes(tree, unlinked, { + tree_utils.toggle_nodes(M.split.winid, tree, unlinked, { toggle_resolved = false, toggle_unresolved = true, keep_current_open = state.settings.discussion_tree.keep_current_open, @@ -822,31 +567,19 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked) end end, { buffer = bufnr, desc = "Reply" }) vim.keymap.set("n", state.settings.discussion_tree.switch_view, function() - switch_view_type() + winbar.switch_view_type() end, { buffer = bufnr, desc = "Switch view type" }) vim.keymap.set("n", state.settings.help, function() help.open() end, { buffer = bufnr, desc = "Open help popup" }) - if not unlinked then - vim.keymap.set("n", state.settings.discussion_tree.jump_to_file, function() - if M.is_current_node_note(tree) then - M.jump_to_file(tree) - end - end, { buffer = bufnr, desc = "Jump to file" }) - vim.keymap.set("n", state.settings.discussion_tree.jump_to_reviewer, function() - if M.is_current_node_note(tree) then - M.jump_to_reviewer(tree) - 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) + common.open_in_browser(tree) end, { buffer = bufnr, desc = "Open the note in your browser" }) vim.keymap.set("n", state.settings.discussion_tree.copy_node_url, function() - M.copy_node_url(tree) + common.copy_node_url(tree) end, { buffer = bufnr, desc = "Copy the URL of the current node to clipboard" }) vim.keymap.set("n", "p", function() - M.print_node(tree) + common.print_node(tree) end, { buffer = bufnr, desc = "Print current node (for debugging)" }) vim.keymap.set("n", state.settings.discussion_tree.add_emoji, function() M.add_emoji_to_note(tree, unlinked) @@ -858,6 +591,10 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked) emoji.init_popup(tree, bufnr) end +---Redraws the header of a node in a tree when it's been toggled to resolved/unresolved +---@param tree NuiTree +---@param note NuiTree.Node +---@param mark_resolved boolean M.redraw_resolved_status = function(tree, note, mark_resolved) local current_text = tree.nodes.by_id["-" .. note.id].text local target = mark_resolved and "resolved" or "unresolved" @@ -888,18 +625,6 @@ M.redraw_resolved_status = function(tree, note, mark_resolved) tree:render() end ----Restore cursor position to the original node if possible -M.restore_cursor_position = function(tree, original_node, root_node) - local _, line_number = tree:get_node("-" .. tostring(original_node.id)) - -- If current_node is has been collapsed, get line number of root node instead - if line_number == nil and root_node then - _, line_number = tree:get_node("-" .. tostring(root_node.id)) - end - if line_number ~= nil then - vim.api.nvim_win_set_cursor(M.split.winid, { line_number, 0 }) - end -end - ---Replace text in discussion after note update. ---@param data Discussion[]|UnlinkedDiscussion[] ---@param discussion_id string @@ -917,96 +642,54 @@ M.replace_text = function(data, discussion_id, note_id, text) end end ----Get root node ----@param tree NuiTree ----@param node NuiTree.Node? ----@return NuiTree.Node? -M.get_root_node = function(tree, node) - if not node then - return nil - end - if node.type == "note_body" or node.type == "note" and not node.is_root then - local parent_id = node:get_parent_id() - return M.get_root_node(tree, tree:get_node(parent_id)) - elseif node.is_root then - return node - end -end - ----Get note node ----@param tree NuiTree ----@param node NuiTree.Node? ----@return NuiTree.Node? -M.get_note_node = function(tree, node) - if not node then - return nil - end - - if node.type == "note_body" then - local parent_id = node:get_parent_id() - if parent_id == nil then - return node - end - return M.get_note_node(tree, tree:get_node(parent_id)) - elseif node.type == "note" then - return node - end -end - +---Given some note data, adds it to the tree and re-renders the tree +---@param tree any +---@param note any +---@param discussion_id any M.add_reply_to_tree = function(tree, note, discussion_id) - local note_node = discussions_tree.build_note(note) + local note_node = tree_utils.build_note(note) note_node:expand() tree:add_node(note_node, discussion_id and ("-" .. discussion_id) or nil) tree:render() end +---Toggle comments tree type between "simple" and "by_file_name" +M.toggle_tree_type = function() + if state.settings.discussion_tree.tree_type == "simple" then + state.settings.discussion_tree.tree_type = "by_file_name" + else + state.settings.discussion_tree.tree_type = "simple" + end + M.rebuild_discussion_tree() +end + +---Indicates whether the node under the cursor is a draft note or not ---@param tree NuiTree -M.get_url = function(tree) +---@return boolean +M.is_draft_note = 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 - return url -end - ----@param tree NuiTree -M.open_in_browser = function(tree) - local url = M.get_url(tree) - if url ~= nil then - u.open_in_browser(url) - end -end - ----@param tree NuiTree -M.copy_node_url = function(tree) - local url = M.get_url(tree) - if url ~= nil then - vim.fn.setreg("+", url) - u.notify("Copied '" .. url .. "' to clipboard", vim.log.levels.INFO) - end + local root_node = common.get_root_node(tree, current_node) + return root_node ~= nil and root_node.is_draft end +---Opens a popup prompting the user to choose an emoji to attach to the current node +---@param tree any +---@param unlinked boolean M.add_emoji_to_note = function(tree, unlinked) local node = tree:get_node() - local note_node = M.get_note_node(tree, node) - local root_node = M.get_root_node(tree, node) + local note_node = common.get_note_node(tree, node) + local root_node = common.get_root_node(tree, node) local note_id = tonumber(note_node.is_root and root_node.root_note_id or note_node.id) local note_id_str = tostring(note_id) local emojis = require("gitlab.emoji").emoji_list emoji.pick_emoji(emojis, function(name) local body = { emoji = name, note_id = note_id } job.run_job("/mr/awardable/note/", "POST", body, function(data) - if M.emojis[note_id_str] == nil then - M.emojis[note_id_str] = {} - table.insert(M.emojis[note_id_str], data.Emoji) + if state.DISCUSSION_DATA.emojis[note_id_str] == nil then + state.DISCUSSION_DATA.emojis[note_id_str] = {} + table.insert(state.DISCUSSION_DATA.emojis[note_id_str], data.Emoji) else - table.insert(M.emojis[note_id_str], data.Emoji) + table.insert(state.DISCUSSION_DATA.emojis[note_id_str], data.Emoji) end if unlinked then M.rebuild_unlinked_discussion_tree() @@ -1018,17 +701,20 @@ M.add_emoji_to_note = function(tree, unlinked) end) end +---Opens a popup prompting the user to choose an emoji to remove from the current node +---@param tree any +---@param unlinked boolean M.delete_emoji_from_note = function(tree, unlinked) local node = tree:get_node() - local note_node = M.get_note_node(tree, node) - local root_node = M.get_root_node(tree, node) + local note_node = common.get_note_node(tree, node) + local root_node = common.get_root_node(tree, node) local note_id = tonumber(note_node.is_root and root_node.root_note_id or note_node.id) local note_id_str = tostring(note_id) local e = require("gitlab.emoji") local emojis = {} - local current_emojis = M.emojis[note_id_str] + local current_emojis = state.DISCUSSION_DATA.emojis[note_id_str] for _, current_emoji in ipairs(current_emojis) do if state.USER.id == current_emoji.user.id then table.insert(emojis, e.emoji_map[current_emoji.name]) @@ -1045,12 +731,12 @@ M.delete_emoji_from_note = function(tree, unlinked) end job.run_job(string.format("/mr/awardable/note/%d/%d", note_id, awardable_id), "DELETE", nil, function(_) local keep = {} -- Emojis to keep after deletion in the UI - for _, saved in ipairs(M.emojis[note_id_str]) do + for _, saved in ipairs(state.DISCUSSION_DATA.emojis[note_id_str]) do if saved.name ~= name or saved.user.id ~= state.USER.id then table.insert(keep, saved) end end - M.emojis[note_id_str] = keep + state.DISCUSSION_DATA.emojis[note_id_str] = keep if unlinked then M.rebuild_unlinked_discussion_tree() else @@ -1062,10 +748,4 @@ M.delete_emoji_from_note = function(tree, unlinked) end) 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/tree.lua b/lua/gitlab/actions/discussions/tree.lua index 50ea676..024c447 100644 --- a/lua/gitlab/actions/discussions/tree.lua +++ b/lua/gitlab/actions/discussions/tree.lua @@ -1,149 +1,22 @@ -local state = require("gitlab.state") +-- This module contains tree code specific to the discussion tree, that +-- is not used in the draft notes tree local u = require("gitlab.utils") +local common = require("gitlab.actions.common") +local state = require("gitlab.state") local NuiTree = require("nui.tree") +local NuiLine = require("nui.line") local M = {} -local attach_uuid = function(str) - return { text = str, id = u.uuid() } -end - ----Create path node ----@param relative_path string ----@param full_path string ----@param child_nodes NuiTree.Node[]? ----@return NuiTree.Node -local function create_path_node(relative_path, full_path, child_nodes) - return NuiTree.Node({ - text = relative_path, - path = full_path, - id = full_path, - type = "path", - icon = " ", - icon_hl = "GitlabDirectoryIcon", - text_hl = "GitlabDirectory", - }, child_nodes or {}) -end - ----Create file name node ----@param file_name string ----@param full_file_path string ----@param child_nodes NuiTree.Node[]? ----@return NuiTree.Node -local function create_file_name_node(file_name, full_file_path, child_nodes) - local icon, icon_hl = u.get_icon(file_name) - return NuiTree.Node({ - text = file_name, - file_name = full_file_path, - id = full_file_path, - type = "file_name", - icon = icon, - icon_hl = icon_hl, - text_hl = "GitlabFileName", - }, child_nodes or {}) -end - ----Sort list of nodes (in place) of type "path" or "file_name" ----@param nodes NuiTree.Node[] -local function sort_nodes(nodes) - table.sort(nodes, function(node1, node2) - if node1.type == "path" and node2.type == "path" then - return node1.path < node2.path - elseif node1.type == "file_name" and node2.type == "file_name" then - return node1.file_name < node2.file_name - elseif node1.type == "path" and node2.type == "file_name" then - return true - else - return false - end - end) -end - ----Merge path nodes which have only single path child ----@param node NuiTree.Node -local function flatten_nodes(node) - if node.type ~= "path" then - return - end - for _, child in ipairs(node.__children) do - flatten_nodes(child) - end - if #node.__children == 1 and node.__children[1].type == "path" then - local child = node.__children[1] - node.__children = child.__children - node.id = child.id - node.path = child.path - node.text = node.text .. u.path_separator .. child.text - end - sort_nodes(node.__children) -end - ----Build note header from note. ----@param note Note ----@return string -M.build_note_header = function(note) - return "@" .. note.author.username .. " " .. u.time_since(note.created_at) -end - ----Build note node body ----@param note Note ----@param resolve_info table? ----@return string ----@return NuiTree.Node[] -local function build_note_body(note, resolve_info) - local text_nodes = {} - for bodyLine in u.split_by_new_lines(note.body) do - local line = attach_uuid(bodyLine) - table.insert( - text_nodes, - NuiTree.Node({ - new_line = (type(note.position) == "table" and note.position.new_line), - old_line = (type(note.position) == "table" and note.position.old_line), - text = line.text, - id = line.id, - type = "note_body", - }, {}) - ) - end - - local resolve_symbol = "" - if resolve_info ~= nil and resolve_info.resolvable then - resolve_symbol = resolve_info.resolved and state.settings.discussion_tree.resolved - or state.settings.discussion_tree.unresolved - end - - local noteHeader = M.build_note_header(note) .. " " .. resolve_symbol - - return noteHeader, text_nodes -end - ----Build note node ----@param note Note ----@param resolve_info table? ----@return NuiTree.Node ----@return string ----@return NuiTree.Node[] -M.build_note = function(note, resolve_info) - local text, text_nodes = build_note_body(note, resolve_info) - local note_node = NuiTree.Node({ - text = text, - id = note.id, - 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) - - return note_node, text, text_nodes -end - ---Create nodes for NuiTree from discussions ---@param items Discussion[] ---@param unlinked boolean? False or nil means that discussions are linked to code lines ---@return NuiTree.Node[] M.add_discussions_to_table = function(items, unlinked) local t = {} + if items == vim.NIL then + items = {} + end for _, discussion in ipairs(items) do local discussion_children = {} @@ -206,10 +79,85 @@ M.add_discussions_to_table = function(items, unlinked) return t end + return M.create_node_list_by_file_name(t) +end + +---Create path node +---@param relative_path string +---@param full_path string +---@param child_nodes NuiTree.Node[]? +---@return NuiTree.Node +local function create_path_node(relative_path, full_path, child_nodes) + return NuiTree.Node({ + text = relative_path, + path = full_path, + id = full_path, + type = "path", + icon = " ", + icon_hl = "GitlabDirectoryIcon", + text_hl = "GitlabDirectory", + }, child_nodes or {}) +end + +---Sort list of nodes (in place) of type "path" or "file_name" +---@param nodes NuiTree.Node[] +local function sort_nodes(nodes) + table.sort(nodes, function(node1, node2) + if node1.type == "path" and node2.type == "path" then + return node1.path < node2.path + elseif node1.type == "file_name" and node2.type == "file_name" then + return node1.file_name < node2.file_name + elseif node1.type == "path" and node2.type == "file_name" then + return true + else + return false + end + end) +end + +---Merge path nodes which have only single path child +---@param node NuiTree.Node +local function flatten_nodes(node) + if node.type ~= "path" then + return + end + for _, child in ipairs(node.__children) do + flatten_nodes(child) + end + if #node.__children == 1 and node.__children[1].type == "path" then + local child = node.__children[1] + node.__children = child.__children + node.id = child.id + node.path = child.path + node.text = node.text .. u.path_separator .. child.text + end + sort_nodes(node.__children) +end + +---Create file name node +---@param file_name string +---@param full_file_path string +---@param child_nodes NuiTree.Node[]? +---@return NuiTree.Node +local function create_file_name_node(file_name, full_file_path, child_nodes) + local icon, icon_hl = u.get_icon(file_name) + return NuiTree.Node({ + text = file_name, + file_name = full_file_path, + id = full_file_path, + type = "file_name", + icon = icon, + icon_hl = icon_hl, + text_hl = "GitlabFileName", + }, child_nodes or {}) +end + +local create_disscussions_by_file_name = function(node_list) -- Create all the folder and file name nodes. local discussion_by_file_name = {} local top_level_path_to_node = {} - for _, node in ipairs(t) do + + for _, node in ipairs(node_list) do local path = "" local parent_node = nil local path_parts = u.split_path(node.file_name) @@ -274,13 +222,280 @@ M.add_discussions_to_table = function(items, unlinked) end end + return discussion_by_file_name +end + +M.create_node_list_by_file_name = function(node_list) + -- Create all the folder and file name nodes. + local discussion_by_file_name = create_disscussions_by_file_name(node_list) + -- Flatten empty folders for _, node in ipairs(discussion_by_file_name) do flatten_nodes(node) end + sort_nodes(discussion_by_file_name) return discussion_by_file_name end +local attach_uuid = function(str) + return { text = str, id = u.uuid() } +end + +---Build note node body +---@param note Note|DraftNote +---@param resolve_info table? +---@return string +---@return NuiTree.Node[] +local function build_note_body(note, resolve_info) + local text_nodes = {} + for bodyLine in u.split_by_new_lines(note.body or note.note) do + local line = attach_uuid(bodyLine) + table.insert( + text_nodes, + NuiTree.Node({ + new_line = (type(note.position) == "table" and note.position.new_line), + old_line = (type(note.position) == "table" and note.position.old_line), + text = line.text, + id = line.id, + type = "note_body", + }, {}) + ) + end + + local resolve_symbol = "" + if resolve_info ~= nil and resolve_info.resolvable then + resolve_symbol = resolve_info.resolved and state.settings.discussion_tree.resolved + or state.settings.discussion_tree.unresolved + end + + local noteHeader = common.build_note_header(note) .. " " .. resolve_symbol + + return noteHeader, text_nodes +end + +---Build note node +---@param note Note|DraftNote +---@param resolve_info table? +---@return NuiTree.Node +---@return string +---@return NuiTree.Node[] +M.build_note = function(note, resolve_info) + local text, text_nodes = build_note_body(note, resolve_info) + local note_node = NuiTree.Node({ + text = text, + is_draft = note.note ~= nil, + id = note.id, + 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) + + return note_node, text, text_nodes +end + +---Inspired by default func https://github.com/MunifTanjim/nui.nvim/blob/main/lua/nui/tree/util.lua#L38 +M.nui_tree_prepare_node = function(node) + if not node.text then + error("missing node.text") + end + + local texts = node.text + if type(node.text) ~= "table" or node.text.content then + texts = { node.text } + end + + local lines = {} + + for i, text in ipairs(texts) do + local line = NuiLine() + + line:append(string.rep(" ", node._depth - 1)) + + if i == 1 and node:has_children() then + line:append(node:is_expanded() and " " or " ") + if node.icon then + line:append(node.icon .. " ", node.icon_hl) + end + else + line:append(" ") + end + + line:append(text, node.text_hl) + + local note_id = tostring(node.is_root and node.root_note_id or node.id) + + local e = require("gitlab.emoji") + + ---@type Emoji[] + local emojis = state.DISCUSSION_DATA.emojis[note_id] + local placed_emojis = {} + if emojis ~= nil then + for _, v in ipairs(emojis) do + local icon = e.emoji_map[v.name] + if icon ~= nil and not u.contains(placed_emojis, icon.moji) then + line:append(" ") + line:append(icon.moji) + table.insert(placed_emojis, icon.moji) + end + end + end + + table.insert(lines, line) + end + + return lines +end + +---@class ToggleNodesOptions +---@field toggle_resolved boolean Whether to toggle resolved discussions. +---@field toggle_unresolved boolean Whether to toggle unresolved discussions. +---@field keep_current_open boolean Whether to keep the current discussion open even if it should otherwise be closed. + +---This function (settings.discussion_tree.toggle_nodes) expands/collapses all nodes and their children according to the opts. +---@param tree NuiTree +---@param winid integer +---@param unlinked boolean +---@param opts ToggleNodesOptions +M.toggle_nodes = function(winid, tree, unlinked, opts) + local current_node = tree:get_node() + if current_node == nil then + return + end + local root_node = common.get_root_node(tree, current_node) + for _, node in ipairs(tree:get_nodes()) do + if opts.toggle_resolved then + if + (unlinked and state.unlinked_discussion_tree.resolved_expanded) + or (not unlinked and state.discussion_tree.resolved_expanded) + then + M.collapse_recursively(tree, node, root_node, opts.keep_current_open, true) + else + M.expand_recursively(tree, node, true) + end + end + if opts.toggle_unresolved then + if + (unlinked and state.unlinked_discussion_tree.unresolved_expanded) + or (not unlinked and state.discussion_tree.unresolved_expanded) + then + M.collapse_recursively(tree, node, root_node, opts.keep_current_open, false) + else + M.expand_recursively(tree, node, false) + end + end + end + -- Reset states of resolved discussions after toggling + if opts.toggle_resolved then + if unlinked then + state.unlinked_discussion_tree.resolved_expanded = not state.unlinked_discussion_tree.resolved_expanded + else + state.discussion_tree.resolved_expanded = not state.discussion_tree.resolved_expanded + end + end + -- Reset states of unresolved discussions after toggling + if opts.toggle_unresolved then + if unlinked then + state.unlinked_discussion_tree.unresolved_expanded = not state.unlinked_discussion_tree.unresolved_expanded + else + state.discussion_tree.unresolved_expanded = not state.discussion_tree.unresolved_expanded + end + end + tree:render() + M.restore_cursor_position(winid, tree, current_node, root_node) +end + +---Restore cursor position to the original node if possible +M.restore_cursor_position = function(winid, tree, original_node, root_node) + local _, line_number = tree:get_node("-" .. tostring(original_node.id)) + -- If current_node is has been collapsed, get line number of root node instead + if line_number == nil and root_node then + _, line_number = tree:get_node("-" .. tostring(root_node.id)) + end + if line_number ~= nil then + vim.api.nvim_win_set_cursor(winid, { line_number, 0 }) + end +end + +---This function (settings.discussion_tree.expand_recursively) expands a node and its children. +---@param tree NuiTree +---@param node NuiTree.Node +---@param is_resolved boolean If true, expand resolved discussions. If false, expand unresolved discussions. +M.expand_recursively = function(tree, node, is_resolved) + if node == nil then + return + end + if common.is_node_note(node) and common.get_root_node(tree, node).resolved == is_resolved then + node:expand() + end + local children = node:get_child_ids() + for _, child in ipairs(children) do + M.expand_recursively(tree, tree:get_node(child), is_resolved) + end +end + +---This function (settings.discussion_tree.collapse_recursively) collapses a node and its children. +---@param tree NuiTree +---@param node NuiTree.Node +---@param current_root_node NuiTree.Node The root node of the current node. +---@param keep_current_open boolean If true, the current node stays open, even if it should otherwise be collapsed. +---@param is_resolved boolean If true, collapse resolved discussions. If false, collapse unresolved discussions. +M.collapse_recursively = function(tree, node, current_root_node, keep_current_open, is_resolved) + if node == nil then + return + end + local root_node = common.get_root_node(tree, node) + if common.is_node_note(node) and root_node.resolved == is_resolved then + if keep_current_open and root_node == current_root_node then + return + end + node:collapse() + end + local children = node:get_child_ids() + for _, child in ipairs(children) do + M.collapse_recursively(tree, tree:get_node(child), current_root_node, keep_current_open, is_resolved) + end +end + +-- This function (settings.discussion_tree.toggle_node) expands/collapses the current node and its children +M.toggle_node = function(tree) + local node = tree:get_node() + if node == nil then + return + end + + -- Switch to the "note" node from "note_body" nodes to enable toggling discussions inside comments + if node.type == "note_body" then + node = tree:get_node(node:get_parent_id()) + end + if node == nil then + return + end + + local children = node:get_child_ids() + if node == nil then + return + end + if node:is_expanded() then + node:collapse() + if common.is_node_note(node) then + for _, child in ipairs(children) do + tree:get_node(child):collapse() + end + end + else + if common.is_node_note(node) then + for _, child in ipairs(children) do + tree:get_node(child):expand() + end + end + node:expand() + end + + tree:render() +end + return M diff --git a/lua/gitlab/actions/discussions/winbar.lua b/lua/gitlab/actions/discussions/winbar.lua index ada616c..ee0abd5 100644 --- a/lua/gitlab/actions/discussions/winbar.lua +++ b/lua/gitlab/actions/discussions/winbar.lua @@ -1,6 +1,20 @@ -local M = {} -local state = require("gitlab.state") local List = require("gitlab.utils.list") +local state = require("gitlab.state") + +local M = { + bufnr_map = { + discussions = nil, + notes = nil, + }, + current_view_type = state.settings.discussion_tree.default_view, +} + +M.set_buffers = function(linked_bufnr, unlinked_bufnr) + M.bufnr_map = { + discussions = linked_bufnr, + notes = unlinked_bufnr, + } +end ---@param nodes Discussion[]|UnlinkedDiscussion[]|nil ---@return number, number @@ -30,36 +44,131 @@ local get_data = function(nodes) return total_resolvable, total_resolved end ----@param discussions Discussion[]|nil ----@param unlinked_discussions UnlinkedDiscussion[]|nil ----@param file_name string -local function content(discussions, unlinked_discussions, file_name) - local resolvable_discussions, resolved_discussions = get_data(discussions) - local resolvable_notes, resolved_notes = get_data(unlinked_discussions) +local function content() + local resolvable_discussions, resolved_discussions = get_data(state.DISCUSSION_DATA.discussions) + local resolvable_notes, resolved_notes = get_data(state.DISCUSSION_DATA.unlinked_discussions) + + local draft_notes = require("gitlab.actions.draft_notes") + local inline_draft_notes = List.new(state.DRAFT_NOTES):filter(draft_notes.has_position) + local unlinked_draft_notes = List.new(state.DRAFT_NOTES):filter(function(note) + return not draft_notes.has_position(note) + end) local t = { - name = file_name, resolvable_discussions = resolvable_discussions, resolved_discussions = resolved_discussions, + inline_draft_notes = #inline_draft_notes, + unlinked_draft_notes = #unlinked_draft_notes, resolvable_notes = resolvable_notes, resolved_notes = resolved_notes, help_keymap = state.settings.help, } - return state.settings.discussion_tree.winbar(t) + return M.make_winbar(t) end ---This function updates the winbar ----@param discussions Discussion[] ----@param unlinked_discussions UnlinkedDiscussion[] ----@param base_title string -M.update_winbar = function(discussions, unlinked_discussions, base_title) +M.update_winbar = function() local d = require("gitlab.actions.discussions") - local winId = d.split.winid - local c = content(discussions, unlinked_discussions, base_title) - if vim.wo[winId] then - vim.wo[winId].winbar = c + if d.split == nil then + return + end + + local win_id = d.split.winid + if win_id == nil then + return + end + + if not vim.api.nvim_win_is_valid(win_id) then + return + end + + local c = content() + vim.api.nvim_set_option_value("winbar", c, { scope = "local", win = win_id }) +end + +---Builds the title string for both sections, using the count of resolvable and draft nodes +---@param base_title string +---@param resolvable_count integer +---@param resolved_count integer +---@param drafts_count integer +---@return string +local add_drafts_and_resolvable = function(base_title, resolvable_count, resolved_count, drafts_count) + if resolvable_count ~= 0 then + base_title = base_title .. string.format(" (%d/%d resolved", resolvable_count, resolved_count) + end + if drafts_count ~= 0 then + if resolvable_count ~= 0 then + base_title = base_title .. string.format("; %d drafts)", drafts_count) + else + base_title = base_title .. string.format(" (%d drafts)", drafts_count) + end + elseif resolvable_count ~= 0 then + base_title = base_title .. ")" + end + + return base_title +end + +---@param t WinbarTable +M.make_winbar = function(t) + local discussion_title = + add_drafts_and_resolvable("Inline Comments", t.resolvable_discussions, t.resolved_discussions, t.inline_draft_notes) + local notes_title = add_drafts_and_resolvable("Notes", t.resolvable_notes, t.resolved_notes, t.unlinked_draft_notes) + + -- Colorize the active tab + if M.current_view_type == "discussions" then + discussion_title = "%#Text#" .. discussion_title + notes_title = "%#Comment#" .. notes_title + elseif M.current_view_type == "notes" then + discussion_title = "%#Comment#" .. discussion_title + notes_title = "%#Text#" .. notes_title + end + + local mode = M.get_mode() + + -- Join everything together and return it + local separator = "%#Comment#|" + local end_section = "%=" + local help = "%#Comment#Help: " .. t.help_keymap:gsub(" ", "") .. " " + return string.format( + " %s %s %s %s %s %s %s", + discussion_title, + separator, + notes_title, + end_section, + mode, + separator, + help + ) +end + +---Returns a string for the winbar indicating the mode type, live or draft +---@return string +M.get_mode = function() + if state.settings.discussion_tree.draft_mode then + return "%#DiagnosticWarn#Draft Mode" + else + return "%#DiagnosticOK#Live Mode" end end +---Sets the current view type (if provided an argument) +---and then updates the view +---@param override any +M.switch_view_type = function(override) + if override then + M.current_view_type = override + else + if M.current_view_type == "discussions" then + M.current_view_type = "notes" + elseif M.current_view_type == "notes" then + M.current_view_type = "discussions" + end + end + + vim.api.nvim_set_current_buf(M.bufnr_map[M.current_view_type]) + M.update_winbar() +end + return M diff --git a/lua/gitlab/actions/draft_notes/init.lua b/lua/gitlab/actions/draft_notes/init.lua new file mode 100755 index 0000000..6ea4aa3 --- /dev/null +++ b/lua/gitlab/actions/draft_notes/init.lua @@ -0,0 +1,239 @@ +-- This module is responsible for CRUD operations for the draft notes in the discussion tree. +-- That includes things like editing existing draft notes in the tree, and +-- and deleting them. Normal notes and comments are managed separately, +-- under lua/gitlab/actions/discussions/init.lua +local winbar = require("gitlab.actions.discussions.winbar") +local diagnostics = require("gitlab.indicators.diagnostics") +local common = require("gitlab.actions.common") +local discussion_tree = require("gitlab.actions.discussions.tree") +local job = require("gitlab.job") +local NuiTree = require("nui.tree") +local List = require("gitlab.utils.list") +local u = require("gitlab.utils") +local state = require("gitlab.state") + +local M = {} + +---@class AddDraftNoteOpts table +---@field draft_note DraftNote +---@field unlinked boolean + +---Adds a draft note to the draft notes state, then rebuilds the view +---@param opts AddDraftNoteOpts +M.add_draft_note = function(opts) + local new_draft_notes = u.ensure_table(state.DRAFT_NOTES) + table.insert(new_draft_notes, opts.draft_note) + state.DRAFT_NOTES = new_draft_notes + local discussions = require("gitlab.actions.discussions") + if opts.unlinked then + discussions.rebuild_unlinked_discussion_tree() + else + discussions.rebuild_discussion_tree() + end + winbar.update_winbar() +end + +---Tells whether a draft note was left on a particular diff or is an unlinked note +---@param note DraftNote +M.has_position = function(note) + return note.position.new_path ~= nil or note.position.old_path ~= nil +end + +---Returns a list of nodes to add to the discussion tree. Can filter and return only unlinked (note) nodes. +---@param unlinked boolean +---@return NuiTree.Node[] +M.add_draft_notes_to_table = function(unlinked) + local draft_notes = List.new(state.DRAFT_NOTES) + + local draft_note_nodes = draft_notes + ---@param note DraftNote + :filter(function(note) + if unlinked then + return not M.has_position(note) + end + return M.has_position(note) + end) + ---@param note DraftNote + :map(function(note) + local _, root_text, root_text_nodes = discussion_tree.build_note(note) + return NuiTree.Node({ + range = (type(note.position) == "table" and note.position.line_range or nil), + text = root_text, + type = "note", + is_root = true, + is_draft = true, + id = note.id, + root_note_id = note.id, + file_name = (type(note.position) == "table" and note.position.new_path or nil), + new_line = (type(note.position) == "table" and note.position.new_line or nil), + old_line = (type(note.position) == "table" and note.position.old_line or nil), + resolvable = false, + resolved = false, + url = state.INFO.web_url .. "#note_" .. note.id, + }, root_text_nodes) + end) + + return draft_note_nodes + + -- TODO: Combine draft_notes and normal discussion nodes in the complex discussion + -- tree. The code for that feature is a clusterfuck so this is difficult + -- if state.settings.discussion_tree.tree_type == "simple" then + -- return draft_note_nodes + -- end +end + +---Send edits will actually send the edits to Gitlab and refresh the draft_notes tree +M.send_edits = function(note_id) + return function(text) + local all_notes = List.new(state.DRAFT_NOTES) + local the_note = all_notes:find(function(note) + return note.id == note_id + end) + local body = { note = text, position = the_note.position } + job.run_job(string.format("/mr/draft_notes/%d", note_id), "PATCH", body, function(data) + u.notify(data.message, vim.log.levels.INFO) + local has_position = false + local new_draft_notes = all_notes:map(function(note) + if note.id == note_id then + has_position = M.has_position(note) + note.note = text + end + return note + end) + state.DRAFT_NOTES = new_draft_notes + local discussions = require("gitlab.actions.discussions") + if has_position then + discussions.rebuild_discussion_tree() + else + discussions.rebuild_unlinked_discussion_tree() + end + winbar.update_winbar() + end) + end +end + +-- This function will actually send the deletion to Gitlab when you make a selection, and re-render the tree +M.send_deletion = function(tree) + local current_node = tree:get_node() + local note_node = common.get_note_node(tree, current_node) + local root_node = common.get_root_node(tree, current_node) + + if note_node == nil or root_node == nil then + u.notify("Could not get note or root node", vim.log.levels.ERROR) + return + end + + ---@type integer + local note_id = note_node.is_root and root_node.id or note_node.id + + job.run_job(string.format("/mr/draft_notes/%d", note_id), "DELETE", nil, function(data) + u.notify(data.message, vim.log.levels.INFO) + + local has_position = false + local new_draft_notes = List.new(state.DRAFT_NOTES):filter(function(note) + if note.id ~= note_id then + return true + else + has_position = M.has_position(note) + return false + end + end) + + state.DRAFT_NOTES = new_draft_notes + local discussions = require("gitlab.actions.discussions") + if has_position then + discussions.rebuild_discussion_tree() + else + discussions.rebuild_unlinked_discussion_tree() + end + + if state.settings.discussion_signs.enabled and state.DISCUSSION_DATA then + diagnostics.refresh_diagnostics() + end + + winbar.update_winbar() + common.add_empty_titles() + end) +end + +-- This function will trigger a popup prompting you to publish the current draft comment +M.publish_draft = function(tree) + vim.ui.select({ "Confirm", "Cancel" }, { + prompt = "Publish current draft comment?", + }, function(choice) + if choice == "Confirm" then + M.confirm_publish_draft(tree) + end + end) +end + +-- This function will trigger a popup prompting you to publish all draft notes +M.publish_all_drafts = function() + vim.ui.select({ "Confirm", "Cancel" }, { + prompt = "Publish all drafts?", + }, function(choice) + if choice == "Confirm" then + M.confirm_publish_all_drafts() + end + end) +end + +---Publishes all draft notes and comments. Re-renders all discussion views. +M.confirm_publish_all_drafts = function() + local body = { publish_all = true } + job.run_job("/mr/draft_notes/publish", "POST", body, function(data) + u.notify(data.message, vim.log.levels.INFO) + state.DRAFT_NOTES = {} + local discussions = require("gitlab.actions.discussions") + discussions.refresh(function() + discussions.rebuild_discussion_tree() + discussions.rebuild_unlinked_discussion_tree() + winbar.update_winbar() + end) + end) +end + +---Publishes the current draft note that is being hovered over in the tree, +---and then makes an API call to refresh the relevant data for that tree +---and re-render it. +---@param tree NuiTree +M.confirm_publish_draft = function(tree) + local current_node = tree:get_node() + local note_node = common.get_note_node(tree, current_node) + local root_node = common.get_root_node(tree, current_node) + + if note_node == nil or root_node == nil then + u.notify("Could not get note or root node", vim.log.levels.ERROR) + return + end + + ---@type integer + local note_id = note_node.is_root and root_node.id or note_node.id + local body = { note = note_id, publish_all = false } + job.run_job("/mr/draft_notes/publish", "POST", body, function(data) + u.notify(data.message, vim.log.levels.INFO) + + local has_position = false + local new_draft_notes = List.new(state.DRAFT_NOTES):filter(function(note) + if note.id ~= note_id then + return true + else + has_position = M.has_position(note) + return false + end + end) + + state.DRAFT_NOTES = new_draft_notes + local discussions = require("gitlab.actions.discussions") + discussions.refresh(function() + if has_position then + discussions.rebuild_discussion_tree() + else + discussions.rebuild_unlinked_discussion_tree() + end + winbar.update_winbar() + end) + end) +end + +return M diff --git a/lua/gitlab/actions/merge_requests.lua b/lua/gitlab/actions/merge_requests.lua new file mode 100644 index 0000000..127d380 --- /dev/null +++ b/lua/gitlab/actions/merge_requests.lua @@ -0,0 +1,56 @@ +local state = require("gitlab.state") +local reviewer = require("gitlab.reviewer") +local git = require("gitlab.git") +local u = require("gitlab.utils") +local M = {} + +---@class SwitchOpts +---@field open_reviewer boolean + +---Opens up a select menu that lets you choose a different merge request. +---@param opts SwitchOpts|nil +M.choose_merge_request = function(opts) + local has_clean_tree, clean_tree_err = git.has_clean_tree() + if clean_tree_err ~= nil then + return + elseif has_clean_tree ~= "" then + u.notify("Your local branch has changes, please stash or commit and push", vim.log.levels.ERROR) + return + end + + if opts == nil then + opts = state.settings.choose_merge_request + end + + vim.ui.select(state.MERGE_REQUESTS, { + prompt = "Choose Merge Request", + format_item = function(mr) + return string.format("%s (%s)", mr.title, mr.author.name) + end, + }, function(choice) + if not choice then + return + end + + if reviewer.is_open then + reviewer.close() + end + + vim.schedule(function() + local _, branch_switch_err = git.switch_branch(choice.source_branch) + if branch_switch_err ~= nil then + return + end + + vim.schedule(function() + require("gitlab.server").restart(function() + if opts.open_reviewer then + require("gitlab").review() + end + end) + end) + end) + end) +end + +return M diff --git a/lua/gitlab/actions/pipeline.lua b/lua/gitlab/actions/pipeline.lua index 045fa73..bddc194 100644 --- a/lua/gitlab/actions/pipeline.lua +++ b/lua/gitlab/actions/pipeline.lua @@ -143,10 +143,7 @@ M.see_logs = function() return end - local lines = {} - for line in u.split_by_new_lines(file) do - table.insert(lines, line) - end + local lines = u.lines_into_table(file) if #lines == 0 then u.notify("Log trace lines could not be parsed", vim.log.levels.ERROR) diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index a3747f9..5d35dc1 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -3,7 +3,9 @@ -- send edits to the description back to Gitlab local Layout = require("nui.layout") local Popup = require("nui.popup") +local git = require("gitlab.git") local job = require("gitlab.job") +local common = require("gitlab.actions.common") local u = require("gitlab.utils") local List = require("gitlab.utils.list") local state = require("gitlab.state") @@ -28,7 +30,7 @@ M.summary = function() end local title = state.INFO.title - local description_lines = M.build_description_lines() + local description_lines = common.build_content(state.INFO.description) local info_lines = state.settings.info.enabled and M.build_info_lines() or { "" } local layout, title_popup, description_popup, info_popup = M.create_layout(info_lines) @@ -69,22 +71,8 @@ M.summary = function() 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() - local description_lines = {} - - local description = state.INFO.description - for line in u.split_by_new_lines(description) do - table.insert(description_lines, line) - end - -- TODO: @harrisoncramer Not sure whether the following line should be here at all. It definitely - -- didn't belong into the for loop, since it inserted an empty line after each line. But maybe - -- there is a purpose for an empty line at the end of the buffer? - table.insert(description_lines, "") - - return description_lines + git.current_branch_up_to_date_on_remote(vim.log.levels.WARN) end -- Builds a lua list of strings that contain metadata about the current MR. Only builds the diff --git a/lua/gitlab/async.lua b/lua/gitlab/async.lua index 0570d23..ebb1f7a 100644 --- a/lua/gitlab/async.lua +++ b/lua/gitlab/async.lua @@ -36,8 +36,9 @@ function async:fetch(dependencies, i, argTable) end -- Call the API, set the data, and then call the next API - job.run_job(dependency.endpoint, "GET", dependency.body, function(data) - state[dependency.state] = data[dependency.key] + local body = dependency.body and dependency.body() or nil + job.run_job(dependency.endpoint, dependency.method or "GET", body, function(data) + state[dependency.state] = dependency.key and data[dependency.key] or data self:fetch(dependencies, i + 1, argTable) end) end diff --git a/lua/gitlab/colors.lua b/lua/gitlab/colors.lua index 44198c6..6baac09 100644 --- a/lua/gitlab/colors.lua +++ b/lua/gitlab/colors.lua @@ -12,3 +12,4 @@ vim.api.nvim_set_hl(0, "GitlabDirectoryIcon", u.get_colors_for_group(discussion. vim.api.nvim_set_hl(0, "GitlabFileName", u.get_colors_for_group(discussion.file_name)) vim.api.nvim_set_hl(0, "GitlabResolved", u.get_colors_for_group(discussion.resolved)) vim.api.nvim_set_hl(0, "GitlabUnresolved", u.get_colors_for_group(discussion.unresolved)) +vim.api.nvim_set_hl(0, "GitlabDraft", u.get_colors_for_group(discussion.draft)) diff --git a/lua/gitlab/emoji.lua b/lua/gitlab/emoji.lua index 0d778a9..e1c9cf5 100644 --- a/lua/gitlab/emoji.lua +++ b/lua/gitlab/emoji.lua @@ -1,4 +1,5 @@ local u = require("gitlab.utils") +local common = require("gitlab.actions.common") local state = require("gitlab.state") local M = { @@ -70,15 +71,15 @@ M.init_popup = function(tree, bufnr) vim.api.nvim_create_autocmd({ "CursorHold" }, { callback = function() local node = tree:get_node() - if node == nil or not require("gitlab.actions.discussions").is_node_note(node) then + if node == nil or not common.is_node_note(node) then return end - local note_node = require("gitlab.actions.discussions").get_note_node(tree, node) - local root_node = require("gitlab.actions.discussions").get_root_node(tree, node) + local note_node = common.get_note_node(tree, node) + local root_node = common.get_root_node(tree, node) local note_id_str = tostring(note_node.is_root and root_node.root_note_id or note_node.id) + local emojis = state.DISCUSSION_DATA.emojis - local emojis = require("gitlab.actions.discussions").emojis local note_emojis = emojis[note_id_str] if note_emojis == nil then return diff --git a/lua/gitlab/git.lua b/lua/gitlab/git.lua index 8d48fb4..503564e 100644 --- a/lua/gitlab/git.lua +++ b/lua/gitlab/git.lua @@ -1,11 +1,122 @@ +local List = require("gitlab.utils.list") + local M = {} -M.has_clean_tree = function() - return vim.fn.trim(vim.fn.system({ "git", "status", "--short", "--untracked-files=no" })) == "" +---Runs a system command, captures the output (if it exists) and handles errors +---@param command table +---@return string|nil, string|nil +local run_system = function(command) + local u = require("gitlab.utils") + local result = vim.fn.trim(vim.fn.system(command)) + if vim.v.shell_error ~= 0 then + u.notify(result, vim.log.levels.ERROR) + return nil, result + end + return result, nil end +---Returns all branches for the current repository +---@return string|nil, string|nil +M.branches = function() + return run_system({ "git", "branch" }) +end + +---Checks whether the tree has any changes that haven't been pushed to the remote +---@return string|nil, string|nil +M.has_clean_tree = function() + return run_system({ "git", "status", "--short", "--untracked-files=no" }) +end + +---Gets the base directory of the current project +---@return string|nil, string|nil M.base_dir = function() - return vim.fn.trim(vim.fn.system({ "git", "rev-parse", "--show-toplevel" })) + return run_system({ "git", "rev-parse", "--show-toplevel" }) +end + +---Switches the current project to the given branch +---@return string|nil, string|nil +M.switch_branch = function(branch) + return run_system({ "git", "checkout", "-q", branch }) +end + +---Return the name of the current branch +---@return string|nil, string|nil +M.get_current_branch = function() + return run_system({ "git", "branch", "--show-current" }) +end + +---Return the list of possible merge targets. +---@return table|nil +M.get_all_merge_targets = function() + local current_branch, err = M.get_current_branch() + if not current_branch or err ~= nil then + return + end + return List.new(M.get_all_remote_branches()):filter(function(branch) + return branch ~= current_branch + end) +end + +---Return the list of names of all remote-tracking branches or an empty list. +---@return table, string|nil +M.get_all_remote_branches = function() + local all_branches, err = M.branches() + if err ~= nil then + return {}, err + end + if all_branches == nil then + return {}, "Something went wrong getting branches for this repository" + end + + local u = require("gitlab.utils") + local lines = u.lines_into_table(all_branches) + return List.new(lines) + :map(function(line) + -- Trim "origin/" + return line:match("origin/(%S+)") + end) + :filter(function(branch) + -- Don't include the HEAD pointer + return not branch:match("^HEAD$") + end) +end + +---Return whether something +---@param current_branch string +---@return string|nil, string|nil +M.contains_branch = function(current_branch) + return run_system({ "git", "branch", "-r", "--contains", current_branch }) +end + +---Returns true if `branch` is up-to-date on remote, false otherwise. +---@param log_level integer +---@return boolean|nil +M.current_branch_up_to_date_on_remote = function(log_level) + local current_branch = M.get_current_branch() + local handle = io.popen("git branch -r --contains " .. current_branch .. " 2>&1") + if not handle then + require("gitlab.utils").notify("Error running 'git branch' command.", vim.log.levels.ERROR) + return nil + end + + local remote_branches_with_current_head = {} + for line in handle:lines() do + table.insert(remote_branches_with_current_head, line) + end + handle:close() + + local current_head_on_remote = List.new(remote_branches_with_current_head):filter(function(line) + return line == " origin/" .. current_branch + end) + local remote_up_to_date = #current_head_on_remote == 1 + + if not remote_up_to_date then + require("gitlab.utils").notify( + "You have local commits that are not on origin. Have you forgotten to push?", + log_level + ) + end + return remote_up_to_date end return M diff --git a/lua/gitlab/hunks/init.lua b/lua/gitlab/hunks.lua similarity index 100% rename from lua/gitlab/hunks/init.lua rename to lua/gitlab/hunks.lua diff --git a/lua/gitlab/indicators/common.lua b/lua/gitlab/indicators/common.lua index b09a66b..e68ee48 100644 --- a/lua/gitlab/indicators/common.lua +++ b/lua/gitlab/indicators/common.lua @@ -5,30 +5,57 @@ local List = require("gitlab.utils.list") local M = {} +---@class NoteWithValues +---@field position NotePosition +---@field resolvable boolean|nil +---@field resolved boolean|nil +---@field created_at string|nil + +---@param note NoteWithValues +---@param file string +---@return boolean +local filter_discussions_and_notes = function(note, file) + ---Do not include unlinked notes + return note.position ~= nil + and (note.position.new_path == file or note.position.old_path == file) + ---Skip resolved discussions if user wants to + and not (state.settings.discussion_signs.skip_resolved_discussion and note.resolvable and note.resolved) + ---Skip discussions from old revisions + and not ( + state.settings.discussion_signs.skip_old_revision_discussion + and u.from_iso_format_date_to_timestamp(note.created_at) + <= u.from_iso_format_date_to_timestamp(state.MR_REVISIONS[1].created_at) + ) +end + ---Filter all discussions which are relevant for currently visible signs and diagnostics. ----@return Discussion[] -M.filter_placeable_discussions = function(all_discussions) - if type(all_discussions) ~= "table" then - return {} +---@return Discussion|DraftNote[] +M.filter_placeable_discussions = function() + local discussions = u.ensure_table(state.DISCUSSION_DATA and state.DISCUSSION_DATA.discussions or {}) + if type(discussions) ~= "table" then + discussions = {} end + + local draft_notes = u.ensure_table(state.DRAFT_NOTES) + if type(draft_notes) ~= "table" then + draft_notes = {} + end + local file = reviewer.get_current_file() if not file then return {} end - return List.new(all_discussions):filter(function(discussion) + + local filtered_discussions = List.new(discussions):filter(function(discussion) local first_note = discussion.notes[1] - return type(first_note.position) == "table" - --Do not include unlinked notes - and (first_note.position.new_path == file or first_note.position.old_path == file) - --Skip resolved discussions if user wants to - and not (state.settings.discussion_signs.skip_resolved_discussion and first_note.resolvable and first_note.resolved) - --Skip discussions from old revisions - and not ( - state.settings.discussion_signs.skip_old_revision_discussion - and u.from_iso_format_date_to_timestamp(first_note.created_at) - <= u.from_iso_format_date_to_timestamp(state.MR_REVISIONS[1].created_at) - ) + return type(first_note.position) == "table" and filter_discussions_and_notes(first_note, file) end) + + local filtered_draft_notes = List.new(draft_notes):filter(function(note) + return filter_discussions_and_notes(note, file) + end) + + return u.join(filtered_discussions, filtered_draft_notes) end M.parse_line_code = function(line_code) @@ -37,24 +64,24 @@ M.parse_line_code = function(line_code) return tonumber(old_line), tonumber(new_line) end ----@param discussion Discussion +---@param d_or_n Discussion|DraftNote ---@return boolean -M.is_old_sha = function(discussion) - local first_note = discussion.notes[1] +M.is_old_sha = function(d_or_n) + local first_note = M.get_first_note(d_or_n) return first_note.position.old_line ~= nil end ----@param discussion Discussion +---@param discussion Discussion|DraftNote ---@return boolean M.is_new_sha = function(discussion) return not M.is_old_sha(discussion) end ----@param discussion Discussion +---@param d_or_n Discussion|DraftNote ---@return boolean -M.is_single_line = function(discussion) - local first_note = discussion.notes[1] - local line_range = first_note.position.line_range +M.is_single_line = function(d_or_n) + local first_note = M.get_first_note(d_or_n) + local line_range = first_note.position and first_note.position.line_range return line_range == nil end @@ -64,10 +91,10 @@ M.is_multi_line = function(discussion) return not M.is_single_line(discussion) end ----@param discussion Discussion ----@return Note -M.get_first_note = function(discussion) - return discussion.notes[1] +---@param d_or_n Discussion|DraftNote +---@return Note|DraftNote +M.get_first_note = function(d_or_n) + return d_or_n.notes and d_or_n.notes[1] or d_or_n end return M diff --git a/lua/gitlab/indicators/diagnostics.lua b/lua/gitlab/indicators/diagnostics.lua index 67b6d41..f263df7 100644 --- a/lua/gitlab/indicators/diagnostics.lua +++ b/lua/gitlab/indicators/diagnostics.lua @@ -1,7 +1,7 @@ local u = require("gitlab.utils") local diffview_lib = require("diffview.lib") -local discussion_tree = require("gitlab.actions.discussions.tree") -local common = require("gitlab.indicators.common") +local indicators_common = require("gitlab.indicators.common") +local actions_common = require("gitlab.actions.common") local List = require("gitlab.utils.list") local state = require("gitlab.state") local discussion_sign_name = "gitlab_discussion" @@ -24,19 +24,23 @@ local display_opts = { ---Takes some range information and data about a discussion ---and creates a diagnostic to be placed in the reviewer ---@param range_info table ----@param discussion Discussion +---@param d_or_n Discussion|DraftNote ---@return Diagnostic -local function create_diagnostic(range_info, discussion) - local message = "" - for _, note in ipairs(discussion.notes) do - message = message .. discussion_tree.build_note_header(note) .. "\n" .. note.body .. "\n" +local function create_diagnostic(range_info, d_or_n) + local first_note = indicators_common.get_first_note(d_or_n) + local header = actions_common.build_note_header(first_note) + local message = header + if d_or_n.notes then + for _, note in ipairs(d_or_n.notes or {}) do + message = message .. actions_common.build_note_header(note) .. "\n" .. note.body .. "\n" + end end local diagnostic = { message = message, col = 0, severity = state.settings.discussion_signs.severity, - user_data = { discussion_id = discussion.id, header = discussion_tree.build_note_header(discussion.notes[1]) }, + user_data = { discussion_id = d_or_n.id, header = header }, source = "gitlab", code = "gitlab.nvim", } @@ -44,38 +48,37 @@ local function create_diagnostic(range_info, discussion) end ---Creates a single line diagnostic ----@param discussion Discussion +---@param d_or_n Discussion|DraftNote ---@return Diagnostic -local create_single_line_diagnostic = function(discussion) - local first_note = discussion.notes[1] - local linnr = (common.is_new_sha(discussion) and first_note.position.new_line or first_note.position.old_line) or 1 +local create_single_line_diagnostic = function(d_or_n) + local linnr = actions_common.get_line_number(d_or_n.id) return create_diagnostic({ lnum = linnr - 1, - }, discussion) + }, d_or_n) end ---Creates a mutli-line line diagnostic ----@param discussion Discussion +---@param d_or_n Discussion|DraftNote ---@return Diagnostic -local create_multiline_diagnostic = function(discussion) - local first_note = discussion.notes[1] +local create_multiline_diagnostic = function(d_or_n) + local first_note = indicators_common.get_first_note(d_or_n) local line_range = first_note.position.line_range if line_range == nil then error("Parsing multi-line comment but note does not contain line range") end - local start_old_line, start_new_line = common.parse_line_code(line_range.start.line_code) + local start_old_line, start_new_line = indicators_common.parse_line_code(line_range.start.line_code) - if common.is_new_sha(discussion) then + if indicators_common.is_new_sha(d_or_n) then return create_diagnostic({ lnum = start_new_line - 1, end_lnum = first_note.position.new_line - 1, - }, discussion) + }, d_or_n) else return create_diagnostic({ lnum = start_old_line - 1, end_lnum = first_note.position.old_line - 1, - }, discussion) + }, d_or_n) end end @@ -106,12 +109,11 @@ local set_diagnostics_in_old_sha = function(namespace, diagnostics, opts) end ---Refresh the diagnostics for the currently reviewed file ----@param discussions Discussion[] -M.refresh_diagnostics = function(discussions) +M.refresh_diagnostics = function() local ok, err = pcall(function() require("gitlab.indicators.signs").clear_signs() M.clear_diagnostics() - local filtered_discussions = common.filter_placeable_discussions(discussions) + local filtered_discussions = indicators_common.filter_placeable_discussions() if filtered_discussions == nil then return end @@ -133,9 +135,9 @@ end ---@param discussions Discussion[] ---@return DiagnosticTable[] M.parse_new_diagnostics = function(discussions) - local new_diagnostics = List.new(discussions):filter(common.is_new_sha) - local single_line = new_diagnostics:filter(common.is_single_line):map(create_single_line_diagnostic) - local multi_line = new_diagnostics:filter(common.is_multi_line):map(create_multiline_diagnostic) + local new_diagnostics = List.new(discussions):filter(indicators_common.is_new_sha) + local single_line = new_diagnostics:filter(indicators_common.is_single_line):map(create_single_line_diagnostic) + local multi_line = new_diagnostics:filter(indicators_common.is_multi_line):map(create_multiline_diagnostic) return u.combine(single_line, multi_line) end @@ -144,9 +146,9 @@ end ---@param discussions Discussion[] ---@return DiagnosticTable[] M.parse_old_diagnostics = function(discussions) - local old_diagnostics = List.new(discussions):filter(common.is_old_sha) - local single_line = old_diagnostics:filter(common.is_single_line):map(create_single_line_diagnostic) - local multi_line = old_diagnostics:filter(common.is_multi_line):map(create_multiline_diagnostic) + local old_diagnostics = List.new(discussions):filter(indicators_common.is_old_sha) + local single_line = old_diagnostics:filter(indicators_common.is_single_line):map(create_single_line_diagnostic) + local multi_line = old_diagnostics:filter(indicators_common.is_multi_line):map(create_multiline_diagnostic) return u.combine(single_line, multi_line) end diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index 874d850..1c9bc8e 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -6,6 +6,7 @@ local emoji = require("gitlab.emoji") local state = require("gitlab.state") local reviewer = require("gitlab.reviewer") local discussions = require("gitlab.actions.discussions") +local merge_requests = require("gitlab.actions.merge_requests") local merge = require("gitlab.actions.merge") local summary = require("gitlab.actions.summary") local data = require("gitlab.actions.data") @@ -14,6 +15,7 @@ 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 draft_notes = require("gitlab.actions.draft_notes") local labels = require("gitlab.actions.labels") local user = state.dependencies.user @@ -22,6 +24,9 @@ local labels_dep = state.dependencies.labels local project_members = state.dependencies.project_members local latest_pipeline = state.dependencies.latest_pipeline local revisions = state.dependencies.revisions +local merge_requests_dep = state.dependencies.merge_requests +local draft_notes_dep = state.dependencies.draft_notes +local discussion_data = state.dependencies.discussion_data return { setup = function(args) @@ -63,15 +68,20 @@ return { pipeline = async.sequence({ latest_pipeline }, pipeline.open), merge = async.sequence({ u.merge(info, { refresh = true }) }, merge.merge), -- Discussion Tree Actions 🌴 - toggle_discussions = async.sequence({ info, user }, discussions.toggle), - edit_comment = async.sequence({ info }, discussions.edit_comment), - delete_comment = async.sequence({ info }, discussions.delete_comment), + toggle_discussions = async.sequence({ + info, + user, + draft_notes_dep, + discussion_data, + }, discussions.toggle), toggle_resolved = async.sequence({ info }, discussions.toggle_discussion_resolved), + publish_all_drafts = draft_notes.publish_all_drafts, reply = async.sequence({ info }, discussions.reply), -- Other functions 🤷 state = state, data = data.data, print_settings = state.print_settings, + choose_merge_request = async.sequence({ merge_requests_dep }, merge_requests.choose_merge_request), open_in_browser = async.sequence({ info }, function() local web_url = u.get_web_url() if web_url ~= nil then diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index 1aa6f97..a3ba61f 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -12,6 +12,7 @@ local async = require("diffview.async") local diffview_lib = require("diffview.lib") local M = { + is_open = false, bufnr = nil, tabnr = nil, stored_win = nil, @@ -41,12 +42,17 @@ M.open = function() end local diffview_open_command = "DiffviewOpen" - local has_clean_tree = git.has_clean_tree() + local has_clean_tree, err = git.has_clean_tree() + if err ~= nil then + return + end if state.settings.reviewer_settings.diffview.imply_local and has_clean_tree then diffview_open_command = diffview_open_command .. " --imply-local" end vim.api.nvim_command(string.format("%s %s..%s", diffview_open_command, diff_refs.base_sha, diff_refs.head_sha)) + + M.is_open = true M.tabnr = vim.api.nvim_get_current_tabpage() if state.settings.reviewer_settings.diffview.imply_local and not has_clean_tree then @@ -74,14 +80,17 @@ M.open = function() end end require("diffview.config").user_emitter:on("view_closed", function(_, ...) + M.is_open = false on_diffview_closed(...) end) if state.settings.discussion_tree.auto_open then local discussions = require("gitlab.actions.discussions") discussions.close() - discussions.toggle() + require("gitlab").toggle_discussions() -- Fetches data and opens discussions end + + git.current_branch_up_to_date_on_remote(vim.log.levels.WARN) end -- Closes the reviewer and cleans up @@ -91,7 +100,7 @@ M.close = function() discussions.close() end --- Jumps to the location provided in the reviewer window +--- Jumps to the location provided in the reviewer window ---@param file_name string ---@param line_number number ---@param new_buffer boolean @@ -172,6 +181,7 @@ M.get_reviewer_data = function() local old_line = vim.api.nvim_win_get_cursor(old_win)[1] local is_current_sha_focused = M.is_current_sha_focused() + local modification_type = hunks.get_modification_type(old_line, new_line, current_file, is_current_sha_focused) if modification_type == nil then u.notify("Error getting modification type", vim.log.levels.ERROR) @@ -206,9 +216,7 @@ M.is_current_sha_focused = function() local layout = view.cur_layout local b_win = u.get_window_id_by_buffer_id(layout.b.file.bufnr) local a_win = u.get_window_id_by_buffer_id(layout.a.file.bufnr) - local current_win = vim.fn.win_getid() - - -- Handle cases where user navigates tabs in the middle of making a comment + local current_win = require("gitlab.actions.comment").current_win if a_win ~= current_win and b_win ~= current_win then current_win = M.stored_win M.stored_win = nil @@ -220,7 +228,7 @@ end ---@return string|nil M.get_current_file = function() local view = diffview_lib.get_current_view() - if not view then + if not view or not view.panel or not view.panel.cur_file then return end return view.panel.cur_file.path diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 609d802..c58db7a 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -3,15 +3,51 @@ -- This module is also responsible for ensuring that the state of the plugin -- is valid via dependencies +local git = require("gitlab.git") local u = require("gitlab.utils") local M = {} M.emoji_map = nil +---Returns a gitlab token, and a gitlab URL. Used to connect to gitlab. +---@return string|nil, string|nil, string|nil +M.default_auth_provider = function() + local base_path, err = M.settings.config_path, nil + if base_path == nil then + base_path, err = git.base_dir() + end + + if err ~= nil then + return "", "" + end + + local config_file_path = base_path .. M.settings.file_separator .. ".gitlab.nvim" + local config_file_content = u.read_file(config_file_path, { remove_newlines = true }) + + local file_properties = {} + if config_file_content ~= nil then + local file = assert(io.open(config_file_path, "r")) + for line in file:lines() do + for key, value in string.gmatch(line, "(.-)=(.-)$") do + file_properties[key] = value + end + end + end + + local auth_token = file_properties.auth_token or os.getenv("GITLAB_TOKEN") + local gitlab_url = file_properties.gitlab_url or os.getenv("GITLAB_URL") + + return auth_token, gitlab_url, err +end + -- These are the default settings for the plugin M.settings = { + auth_provider = M.default_auth_provider, port = nil, -- choose random port - debug = { go_request = false, go_response = false }, + debug = { + go_request = false, + go_response = false, + }, log_path = (vim.fn.stdpath("cache") .. "/gitlab.nvim.log"), config_path = nil, reviewer = "diffview", @@ -56,6 +92,7 @@ M.settings = { delete_comment = "dd", open_in_browser = "b", copy_node_url = "u", + publish_draft = "P", reply = "r", toggle_node = "t", add_emoji = "Ea", @@ -72,24 +109,8 @@ M.settings = { unresolved = "-", tree_type = "simple", toggle_tree_type = "i", - ---@param t WinbarTable - winbar = function(t) - local discussions_content = t.resolvable_discussions ~= 0 - and string.format("Discussions (%d/%d)", t.resolved_discussions, t.resolvable_discussions) - or "Discussions" - local notes_content = t.resolvable_notes ~= 0 - and string.format("Notes (%d/%d)", t.resolved_notes, t.resolvable_notes) - or "Notes" - if t.name == "Discussions" then - notes_content = "%#Comment#" .. notes_content - discussions_content = "%#Text#" .. discussions_content - else - discussions_content = "%#Comment#" .. discussions_content - notes_content = "%#Text#" .. notes_content - end - local help = "%#Comment#%=Help: " .. t.help_keymap:gsub(" ", "") .. " " - return " " .. discussions_content .. " %#Comment#| " .. notes_content .. help - end, + toggle_draft_mode = "D", + draft_mode = false, }, create_mr = { target = nil, @@ -101,6 +122,9 @@ M.settings = { border = "rounded", }, }, + choose_merge_request = { + open_reviewer = true, + }, info = { enabled = true, horizontal = false, @@ -156,6 +180,7 @@ M.settings = { file_name = "Normal", resolved = "DiagnosticSignOk", unresolved = "DiagnosticSignWarn", + draft = "DiffviewNonText", }, }, } @@ -218,32 +243,13 @@ M.setPluginConfiguration = function() return true end - local base_path - if M.settings.config_path ~= nil then - base_path = M.settings.config_path - else - base_path = vim.fn.trim(vim.fn.system({ "git", "rev-parse", "--show-toplevel" })) - if vim.v.shell_error ~= 0 then - u.notify(string.format("Could not get base directory: %s", base_path), vim.log.levels.ERROR) - return false - end + local token, url, err = M.settings.auth_provider() + if err ~= nil then + return end - local config_file_path = base_path .. M.settings.file_separator .. ".gitlab.nvim" - local config_file_content = u.read_file(config_file_path, { remove_newlines = true }) - - local file_properties = {} - if config_file_content ~= nil then - local file = assert(io.open(config_file_path, "r")) - for line in file:lines() do - for key, value in string.gmatch(line, "(.-)=(.-)$") do - file_properties[key] = value - end - end - end - - M.settings.auth_token = file_properties.auth_token or os.getenv("GITLAB_TOKEN") - M.settings.gitlab_url = u.trim_slash(file_properties.gitlab_url or os.getenv("GITLAB_URL") or "https://gitlab.com") + M.settings.auth_token = token + M.settings.gitlab_url = u.trim_slash(url or "https://gitlab.com") if M.settings.auth_token == nil then vim.notify( @@ -322,17 +328,65 @@ 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 = { - user = { endpoint = "/users/me", key = "user", state = "USER", refresh = false }, - info = { endpoint = "/mr/info", key = "info", state = "INFO", refresh = false }, - latest_pipeline = { endpoint = "/pipeline", key = "latest_pipeline", state = "PIPELINE", refresh = true }, - labels = { endpoint = "/mr/label", key = "labels", state = "LABELS", refresh = false }, - revisions = { endpoint = "/mr/revisions", key = "Revisions", state = "MR_REVISIONS", refresh = false }, + user = { + endpoint = "/users/me", + key = "user", + state = "USER", + refresh = false, + }, + info = { + endpoint = "/mr/info", + key = "info", + state = "INFO", + refresh = false, + }, + latest_pipeline = { + endpoint = "/pipeline", + key = "latest_pipeline", + state = "PIPELINE", + refresh = true, + }, + labels = { + endpoint = "/mr/label", + key = "labels", + state = "LABELS", + refresh = false, + }, + revisions = { + endpoint = "/mr/revisions", + key = "Revisions", + state = "MR_REVISIONS", + refresh = false, + }, + draft_notes = { + endpoint = "/mr/draft_notes/", + key = "draft_notes", + state = "DRAFT_NOTES", + refresh = false, + }, project_members = { endpoint = "/project/members", key = "ProjectMembers", state = "PROJECT_MEMBERS", refresh = false, }, + merge_requests = { + endpoint = "/merge_requests", + key = "merge_requests", + state = "MERGE_REQUESTS", + refresh = false, + }, + discussion_data = { + endpoint = "/mr/discussions/list", + state = "DISCUSSION_DATA", + refresh = false, + method = "POST", + body = function() + return { + blacklist = M.settings.discussion_tree.blacklist, + } + end, + }, } -- This function clears out all of the previously fetched data. It's used diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index d33caf9..dd9a465 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -1,3 +1,4 @@ +local git = require("gitlab.git") local List = require("gitlab.utils.list") local has_devicons, devicons = pcall(require, "nvim-web-devicons") local M = {} @@ -202,6 +203,17 @@ M.split_by_new_lines = function(s) return s:gmatch("(.-)\n") -- Match 0 or more (as few as possible) characters followed by a new line. end +---Takes a string of lines and returns a table of lines +---@param s string The string to parse +---@return table +M.lines_into_table = function(s) + local lines = {} + for line in M.split_by_new_lines(s) do + table.insert(lines, line) + end + return lines +end + -- Reverses the order of elements in a list ---@param list table The list to reverse ---@return table @@ -493,7 +505,7 @@ M.create_popup_state = function(title, settings, width, height, zindex) end ---Create view_opts for Box popups used inside popup Layouts ----@param title string The string to appear on top of the popup +---@param title string|nil The string to appear on top of the popup ---@param enter boolean Whether the pop should be focused after creation ---@return table M.create_box_popup_state = function(title, enter) @@ -656,52 +668,10 @@ M.make_comma_separated_readable = function(str) return string.gsub(str, ",", ", ") end ----Return the name of the current branch ----@return string|nil -M.get_current_branch = function() - local handle = io.popen("git branch --show-current 2>&1") - if handle then - return handle:read() - else - M.notify("Error running 'git branch' command.", vim.log.levels.ERROR) - end -end - ----Return the list of names of all remote-tracking branches -M.get_all_merge_targets = function() - local handle = io.popen("git branch -r 2>&1") - if not handle then - M.notify("Error running 'git branch' command.", vim.log.levels.ERROR) - return - end - - local current_branch = M.get_current_branch() - if not current_branch then - return - end - - local lines = {} - for line in handle:lines() do - table.insert(lines, line) - end - handle:close() - - -- Trim "origin/" and don't include the HEAD pointer - local branches = List.new(lines) - :map(function(line) - return line:match("origin/(%S+)") - end) - :filter(function(branch) - return not branch:match("^HEAD$") and branch ~= current_branch - end) - - return branches -end - ---Select a git branch and perform callback with the branch as an argument ---@param cb function The callback to perform with the selected branch M.select_target_branch = function(cb) - local all_branch_names = M.get_all_merge_targets() + local all_branch_names = git.get_all_merge_targets() if not all_branch_names then return end @@ -738,6 +708,20 @@ M.open_in_browser = function(url) end end +---Combines two tables +---@param t1 table +---@param t2 table +---@return table +M.join = function(t1, t2) + local res = {} + for _, val in ipairs(t1) do + table.insert(res, val) + end + for _, val in ipairs(t2) do + table.insert(res, val) + end + return res +end ---Trims the trailing slash from a URL ---@param s string ---@return string @@ -745,4 +729,11 @@ M.trim_slash = function(s) return (s:gsub("/+$", "")) end +M.ensure_table = function(data) + if data == vim.NIL or data == nil then + return {} + end + return data +end + return M diff --git a/lua/gitlab/utils/list.lua b/lua/gitlab/utils/list.lua index 71db002..ea54b90 100644 --- a/lua/gitlab/utils/list.lua +++ b/lua/gitlab/utils/list.lua @@ -21,12 +21,12 @@ end ---Filters a given list ---@generic T ----@param func fun(v: T):boolean +---@param func fun(v: T, i: integer):boolean ---@return List @Returns a new list of elements for which func returns true function List:filter(func) local result = List.new() - for _, v in ipairs(self) do - if func(v) == true then + for i, v in ipairs(self) do + if func(v, i) == true then table.insert(result, v) end end @@ -63,6 +63,19 @@ function List:slice(first, last, step) return sliced end +---Returns true if any of the elements can satisfy the callback +---@generic T +---@param func fun(v: T, i: integer):boolean +---@return List @Returns a boolean +function List:includes(func) + for i, v in ipairs(self) do + if func(v, i) == true then + return true + end + end + return false +end + function List:values() local result = {} for _, v in ipairs(self) do