diff --git a/README.md b/README.md index b819d1f8..4eb50f9c 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,10 @@ require("gitlab").setup({ }, help = "g?", -- Opens a help popup for local keymaps when a relevant view is focused (popup, discussion panel, etc) popup = { -- The popup for comment creation, editing, and replying + keymaps = { + next_field = "", -- Cycle to the next field. Accepts count. + prev_field = "", -- Cycle to the previous field. Accepts count. + }, perform_action = "s", -- Once in normal mode, does action (like saving comment or editing description, etc) perform_linewise_action = "l", -- Once in normal mode, does the linewise action (see logs for this job, etc) width = "40%", diff --git a/cmd/comment.go b/cmd/comment.go index 3b300ba4..879344f9 100644 --- a/cmd/comment.go +++ b/cmd/comment.go @@ -222,7 +222,7 @@ func (a *api) editComment(w http.ResponseWriter, r *http.Request) { } options := gitlab.UpdateMergeRequestDiscussionNoteOptions{} - options.Body = gitlab.String(editCommentRequest.Comment) + options.Body = gitlab.Ptr(editCommentRequest.Comment) note, res, err := a.client.UpdateMergeRequestDiscussionNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, editCommentRequest.DiscussionId, editCommentRequest.NoteId, &options) diff --git a/cmd/git.go b/cmd/git.go index c66f144c..2fc89f6b 100644 --- a/cmd/git.go +++ b/cmd/git.go @@ -8,10 +8,11 @@ import ( ) type GitProjectInfo struct { - RemoteUrl string - Namespace string - ProjectName string - BranchName string + RemoteUrl string + Namespace string + ProjectName string + BranchName string + GetLatestCommitOnRemote func(a *api) (string, error) } /* @@ -108,3 +109,18 @@ func RefreshProjectInfo() error { return nil } + +/* +The GetLatestCommitOnRemote function is attached during the createRouterAndApi call, since it needs to be called every time to get the latest commit. +*/ +func GetLatestCommitOnRemote(a *api) (string, error) { + cmd := exec.Command("git", "log", "-1", "--format=%H", fmt.Sprintf("origin/%s", a.gitInfo.BranchName)) + + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("Failed to run `git log -1 --format=%%H " + fmt.Sprintf("origin/%s", a.gitInfo.BranchName)) + } + + commit := strings.TrimSpace(string(out)) + return commit, nil +} diff --git a/cmd/git_test.go b/cmd/git_test.go index 5ec04f28..d2f263bd 100644 --- a/cmd/git_test.go +++ b/cmd/git_test.go @@ -169,8 +169,17 @@ func TestExtractGitInfo_Success(t *testing.T) { if err != nil { t.Errorf("No error was expected, got %s", err) } - if actual != tC.expected { - t.Errorf("\nExpected: %s\nActual: %s", tC.expected, actual) + if actual.RemoteUrl != tC.expected.RemoteUrl { + t.Errorf("\nExpected Remote URL: %s\nActual: %s", tC.expected.RemoteUrl, actual.RemoteUrl) + } + if actual.BranchName != tC.expected.BranchName { + t.Errorf("\nExpected Branch Name: %s\nActual: %s", tC.expected.BranchName, actual.BranchName) + } + if actual.ProjectName != tC.expected.ProjectName { + t.Errorf("\nExpected Project Name: %s\nActual: %s", tC.expected.ProjectName, actual.ProjectName) + } + if actual.Namespace != tC.expected.Namespace { + t.Errorf("\nExpected Namespace: %s\nActual: %s", tC.expected.Namespace, actual.Namespace) } }) } diff --git a/cmd/pipeline.go b/cmd/pipeline.go index e14e3c81..877904cf 100644 --- a/cmd/pipeline.go +++ b/cmd/pipeline.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "errors" "fmt" "net/http" "strconv" @@ -16,8 +17,8 @@ type RetriggerPipelineResponse struct { } type PipelineWithJobs struct { - Jobs []*gitlab.Job `json:"jobs"` - LatestPipeline *gitlab.Pipeline `json:"latest_pipeline"` + Jobs []*gitlab.Job `json:"jobs"` + LatestPipeline *gitlab.PipelineInfo `json:"latest_pipeline"` } type GetPipelineAndJobsResponse struct { @@ -42,25 +43,51 @@ func (a *api) pipelineHandler(w http.ResponseWriter, r *http.Request) { } } +/* Gets the latest pipeline for a given commit, returns an error if there is no pipeline */ +func (a *api) GetLastPipeline(commit string) (*gitlab.PipelineInfo, error) { + + l := &gitlab.ListProjectPipelinesOptions{ + SHA: gitlab.Ptr(commit), + Sort: gitlab.Ptr("desc"), + } + + l.Page = 1 + l.PerPage = 1 + + pipes, _, err := a.client.ListProjectPipelines(a.projectInfo.ProjectId, l) + + if err != nil { + return nil, err + } + + if len(pipes) == 0 { + return nil, errors.New("No pipeline running or available for commit " + commit) + } + + return pipes[0], nil +} + +/* Gets the latest pipeline and job information for the current branch */ func (a *api) GetPipelineAndJobs(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - pipeline, res, err := a.client.GetLatestPipeline(a.projectInfo.ProjectId, &gitlab.GetLatestPipelineOptions{ - Ref: &a.gitInfo.BranchName, - }) + commit, err := a.gitInfo.GetLatestCommitOnRemote(a) if err != nil { - handleError(w, err, fmt.Sprintf("Gitlab failed to get latest pipeline for %s branch", a.gitInfo.BranchName), http.StatusInternalServerError) + fmt.Println(err) + handleError(w, err, "Error getting commit on remote branch", http.StatusInternalServerError) return } - if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/pipeline"}, fmt.Sprintf("Could not get latest pipeline for %s branch", a.gitInfo.BranchName), res.StatusCode) + pipeline, err := a.GetLastPipeline(commit) + + if err != nil { + handleError(w, err, fmt.Sprintf("Gitlab failed to get latest pipeline for %s branch", a.gitInfo.BranchName), http.StatusInternalServerError) return } if pipeline == nil { - handleError(w, GenericError{endpoint: "/pipeline"}, fmt.Sprintf("No pipeline found for %s branch", a.gitInfo.BranchName), res.StatusCode) + handleError(w, GenericError{endpoint: "/pipeline"}, fmt.Sprintf("No pipeline found for %s branch", a.gitInfo.BranchName), http.StatusInternalServerError) return } diff --git a/cmd/pipeline_test.go b/cmd/pipeline_test.go index 5f940d33..2ef44821 100644 --- a/cmd/pipeline_test.go +++ b/cmd/pipeline_test.go @@ -32,17 +32,27 @@ func retryPipelineBuildNon200(pid interface{}, pipeline int, options ...gitlab.R return nil, makeResponse(http.StatusSeeOther), nil } -func getLatestPipeline200(pid interface{}, opts *gitlab.GetLatestPipelineOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) { - return &gitlab.Pipeline{ID: 1}, makeResponse(http.StatusOK), nil +func listProjectPipelines(pid interface{}, opt *gitlab.ListProjectPipelinesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.PipelineInfo, *gitlab.Response, error) { + return []*gitlab.PipelineInfo{ + {ID: 12345}, + }, makeResponse(http.StatusOK), nil +} + +func withGitInfo(a *api) error { + a.gitInfo.GetLatestCommitOnRemote = func(a *api) (string, error) { + return "123abc", nil + } + a.gitInfo.BranchName = "some-feature" + return nil } func TestPipelineHandler(t *testing.T) { t.Run("Gets all pipeline jobs", func(t *testing.T) { request := makeRequest(t, http.MethodGet, "/pipeline", nil) server, _ := createRouterAndApi(fakeClient{ - listPipelineJobs: listPipelineJobs, - getLatestPipeline: getLatestPipeline200, - }) + listPipelineJobs: listPipelineJobs, + listProjectPipelines: listProjectPipelines, + }, withGitInfo) data := serveRequest(t, server, request, GetPipelineAndJobsResponse{}) assert(t, data.SuccessResponse.Message, "Pipeline retrieved") assert(t, data.SuccessResponse.Status, http.StatusOK) @@ -51,9 +61,9 @@ func TestPipelineHandler(t *testing.T) { t.Run("Disallows non-GET, non-POST methods", func(t *testing.T) { request := makeRequest(t, http.MethodPatch, "/pipeline", nil) server, _ := createRouterAndApi(fakeClient{ - listPipelineJobs: listPipelineJobs, - getLatestPipeline: getLatestPipeline200, - }) + listPipelineJobs: listPipelineJobs, + listProjectPipelines: listProjectPipelines, + }, withGitInfo) data := serveRequest(t, server, request, ErrorResponse{}) checkBadMethod(t, *data, http.MethodGet, http.MethodPost) }) @@ -61,9 +71,9 @@ func TestPipelineHandler(t *testing.T) { t.Run("Handles errors from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodGet, "/pipeline", nil) server, _ := createRouterAndApi(fakeClient{ - listPipelineJobs: listPipelineJobsErr, - getLatestPipeline: getLatestPipeline200, - }) + listPipelineJobs: listPipelineJobsErr, + listProjectPipelines: listProjectPipelines, + }, withGitInfo) data := serveRequest(t, server, request, ErrorResponse{}) checkErrorFromGitlab(t, *data, "Could not get pipeline jobs") }) @@ -71,9 +81,9 @@ func TestPipelineHandler(t *testing.T) { t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodGet, "/pipeline", nil) server, _ := createRouterAndApi(fakeClient{ - listPipelineJobs: listPipelineJobsNon200, - getLatestPipeline: getLatestPipeline200, - }) + listPipelineJobs: listPipelineJobsNon200, + listProjectPipelines: listProjectPipelines, + }, withGitInfo) data := serveRequest(t, server, request, ErrorResponse{}) checkNon200(t, *data, "Could not get pipeline jobs", "/pipeline") }) @@ -81,9 +91,9 @@ func TestPipelineHandler(t *testing.T) { t.Run("Handles errors from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/pipeline/trigger/1", nil) server, _ := createRouterAndApi(fakeClient{ - retryPipelineBuild: retryPipelineBuildErr, - getLatestPipeline: getLatestPipeline200, - }) + retryPipelineBuild: retryPipelineBuildErr, + listProjectPipelines: listProjectPipelines, + }, withGitInfo) data := serveRequest(t, server, request, ErrorResponse{}) checkErrorFromGitlab(t, *data, "Could not retrigger pipeline") }) @@ -91,9 +101,9 @@ func TestPipelineHandler(t *testing.T) { t.Run("Retriggers pipeline", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/pipeline/trigger/1", nil) server, _ := createRouterAndApi(fakeClient{ - retryPipelineBuild: retryPipelineBuild, - getLatestPipeline: getLatestPipeline200, - }) + retryPipelineBuild: retryPipelineBuild, + listProjectPipelines: listProjectPipelines, + }, withGitInfo) data := serveRequest(t, server, request, GetPipelineAndJobsResponse{}) assert(t, data.SuccessResponse.Message, "Pipeline retriggered") assert(t, data.SuccessResponse.Status, http.StatusOK) @@ -102,9 +112,9 @@ func TestPipelineHandler(t *testing.T) { t.Run("Handles non-200s from Gitlab client on retrigger", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/pipeline/trigger/1", nil) server, _ := createRouterAndApi(fakeClient{ - retryPipelineBuild: retryPipelineBuildNon200, - getLatestPipeline: getLatestPipeline200, - }) + retryPipelineBuild: retryPipelineBuildNon200, + listProjectPipelines: listProjectPipelines, + }, withGitInfo) data := serveRequest(t, server, request, ErrorResponse{}) checkNon200(t, *data, "Could not retrigger pipeline", "/pipeline") }) diff --git a/cmd/reply.go b/cmd/reply.go index e2998b92..556184d8 100644 --- a/cmd/reply.go +++ b/cmd/reply.go @@ -45,7 +45,7 @@ func (a *api) replyHandler(w http.ResponseWriter, r *http.Request) { now := time.Now() options := gitlab.AddMergeRequestDiscussionNoteOptions{ - Body: gitlab.String(replyRequest.Reply), + Body: gitlab.Ptr(replyRequest.Reply), CreatedAt: &now, } diff --git a/cmd/server.go b/cmd/server.go index ae2a8a0b..c38d78a3 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -35,8 +35,11 @@ func startServer(client *Client, projectInfo *ProjectInfo, gitInfo GitProjectInf func(a *api) error { err := attachEmojisToApi(a) return err + }, + func(a *api) error { + a.gitInfo.GetLatestCommitOnRemote = GetLatestCommitOnRemote + return nil }) - l := createListener() server := &http.Server{Handler: m} @@ -191,8 +194,8 @@ func (a *api) withMr(f func(w http.ResponseWriter, r *http.Request)) func(http.R } options := gitlab.ListProjectMergeRequestsOptions{ - Scope: gitlab.String("all"), - State: gitlab.String("opened"), + Scope: gitlab.Ptr("all"), + State: gitlab.Ptr("opened"), SourceBranch: &a.gitInfo.BranchName, } diff --git a/cmd/test.go b/cmd/test.go index 085009b1..29430a2d 100644 --- a/cmd/test.go +++ b/cmd/test.go @@ -35,7 +35,7 @@ type fakeClient struct { listAllProjectMembers func(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error) retryPipelineBuild func(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) listPipelineJobs func(pid interface{}, pipelineID int, opts *gitlab.ListJobsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Job, *gitlab.Response, error) - getLatestPipeline func(pid interface{}, opt *gitlab.GetLatestPipelineOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) + listProjectPipelines func(pid interface{}, opt *gitlab.ListProjectPipelinesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.PipelineInfo, *gitlab.Response, error) getTraceFile func(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) listLabels func(pid interface{}, opt *gitlab.ListLabelsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Label, *gitlab.Response, error) listMergeRequestAwardEmojiOnNote func(pid interface{}, mergeRequestIID, noteID int, opt *gitlab.ListAwardEmojiOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.AwardEmoji, *gitlab.Response, error) @@ -121,8 +121,8 @@ func (f fakeClient) ListPipelineJobs(pid interface{}, pipelineID int, opts *gitl return f.listPipelineJobs(pid, pipelineID, opts, options...) } -func (f fakeClient) GetLatestPipeline(pid interface{}, opts *gitlab.GetLatestPipelineOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) { - return f.getLatestPipeline(pid, opts, options...) +func (f fakeClient) ListProjectPipelines(pid interface{}, opt *gitlab.ListProjectPipelinesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.PipelineInfo, *gitlab.Response, error) { + return f.listProjectPipelines(pid, opt, options...) } func (f fakeClient) GetTraceFile(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) { diff --git a/cmd/types.go b/cmd/types.go index 871112a6..9434577b 100644 --- a/cmd/types.go +++ b/cmd/types.go @@ -53,7 +53,7 @@ type ClientInterface interface { 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) ListPipelineJobs(pid interface{}, pipelineID int, opts *gitlab.ListJobsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Job, *gitlab.Response, error) - GetLatestPipeline(pid interface{}, opt *gitlab.GetLatestPipelineOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) + ListProjectPipelines(pid interface{}, opt *gitlab.ListProjectPipelinesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.PipelineInfo, *gitlab.Response, error) GetTraceFile(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) ListLabels(pid interface{}, opt *gitlab.ListLabelsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Label, *gitlab.Response, error) ListMergeRequestAwardEmojiOnNote(pid interface{}, mergeRequestIID int, noteID int, opt *gitlab.ListAwardEmojiOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.AwardEmoji, *gitlab.Response, error) diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index d766f0ff..dad3cde6 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -141,6 +141,10 @@ you call this function with no values the defaults will be used: }, help = "g?", -- Opens a help popup for local keymaps when a relevant view is focused (popup, discussion panel, etc) popup = { -- The popup for comment creation, editing, and replying + keymaps = { + next_field = "", -- Cycle to the next field. Accepts |count|. + prev_field = "", -- Cycle to the previous field. Accepts |count|. + }, perform_action = "s", -- Once in normal mode, does action (like saving comment or editing description, etc) perform_linewise_action = "l", -- Once in normal mode, does the linewise action (see logs for this job, etc) width = "40%", @@ -522,6 +526,7 @@ in normal mode): 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) < TROUBLESHOOTING *gitlab.nvim.troubleshooting* @@ -633,7 +638,7 @@ After the comment is typed, submit it to Gitlab via the |settings.popup.perform_ keybinding, by default |l| *gitlab.nvim.create_mr* -create_mr({opts}) ~ +gitlab.create_mr({opts}) ~ Starts the process of creating an MR for the currently checked out branch. >lua @@ -662,6 +667,15 @@ Starts the process of creating an MR for the currently checked out branch. • {squash}: (bool) If true, the commits will be marked for squashing. +After selecting all necessary details, you'll be presented with a confirmation +window. You can cycle through the individual fields with the keymaps defined +in `settings.popup.keymaps.next_field` and `settings.popup.keymaps.prev_field`. +Both keymaps accept a count, i.g., 2 goes to the 2nd next field. +In the "Delete source branch", "Squash commits", and "Target branch" fields, +you can use the `settings.popup.perform_linewise_action` keymap to either +toggle the Boolean value or to select a new target branch, respectively. +Use the `settings.popup.perform_action` keymap to POST the MR to Gitlab. + *gitlab.nvim.move_to_discussion_tree_from_diagnostic* gitlab.move_to_discussion_tree_from_diagnostic() ~ @@ -681,7 +695,7 @@ tied to specific changes in an MR. require("gitlab").create_note() After the comment is typed, submit it to Gitlab via the `settings.popup.perform_action` -keybinding, by default |l| +keybinding, by default |s|. *gitlab.nvim.toggle_discussions* gitlab.toggle_discussions() ~ @@ -753,6 +767,13 @@ gitlab.open_in_browser() ~ Opens the current MR in your default web browser. >lua require("gitlab").open_in_browser() +< + *gitlab.nvim.copy_mr_url* +gitlab.copy_mr_url() ~ + +Copies the URL of the current MR to system clipboard. +>lua + require("gitlab").copy_mr_url() < *gitlab.nvim.merge* gitlab.merge({opts}) ~ diff --git a/lua/gitlab/actions/create_mr.lua b/lua/gitlab/actions/create_mr.lua index 7c7ea2b4..41b68f27 100644 --- a/lua/gitlab/actions/create_mr.lua +++ b/lua/gitlab/actions/create_mr.lua @@ -74,14 +74,10 @@ M.pick_target = function(mr) return end - local all_branch_names = u.get_all_git_branches(true) - vim.ui.select(all_branch_names, { - prompt = "Choose target branch for merge", - }, function(choice) - if choice then - mr.target = choice - M.pick_template(mr) - end + -- Select target branch interactively if it hasn't been selected by other means + u.select_target_branch(function(target) + mr.target = target + M.pick_template(mr) end) end @@ -177,6 +173,14 @@ M.open_confirmation_popup = function(mr) local layout, title_popup, description_popup, target_popup, delete_branch_popup, squash_popup = M.create_layout() + local popups = { + title_popup, + description_popup, + delete_branch_popup, + squash_popup, + target_popup, + } + M.layout = layout M.layout_buf = layout.bufnr M.layout_visible = true @@ -209,6 +213,10 @@ M.open_confirmation_popup = function(mr) vim.api.nvim_buf_set_lines(M.delete_branch_bufnr, 0, -1, false, { u.bool_to_string(delete_branch) }) vim.api.nvim_buf_set_lines(M.squash_bufnr, 0, -1, false, { u.bool_to_string(squash) }) + u.switch_can_edit_buf(M.delete_branch_bufnr, false) + u.switch_can_edit_buf(M.squash_bufnr, false) + u.switch_can_edit_buf(M.target_bufnr, false) + local popup_opts = { cb = exit, action_before_close = true, @@ -217,9 +225,10 @@ M.open_confirmation_popup = function(mr) state.set_popup_keymaps(description_popup, M.create_mr, miscellaneous.attach_file, popup_opts) state.set_popup_keymaps(title_popup, M.create_mr, nil, popup_opts) - state.set_popup_keymaps(target_popup, M.create_mr, nil, popup_opts) - state.set_popup_keymaps(delete_branch_popup, M.create_mr, nil, popup_opts) - state.set_popup_keymaps(squash_popup, M.create_mr, nil, popup_opts) + state.set_popup_keymaps(target_popup, M.create_mr, M.select_new_target, popup_opts) + state.set_popup_keymaps(delete_branch_popup, M.create_mr, miscellaneous.toggle_bool, popup_opts) + state.set_popup_keymaps(squash_popup, M.create_mr, miscellaneous.toggle_bool, popup_opts) + miscellaneous.set_cycle_popups_keymaps(popups) vim.api.nvim_set_current_buf(M.description_bufnr) end) @@ -237,6 +246,18 @@ M.build_description_lines = function(template_content) 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() + u.select_target_branch(function(target) + vim.schedule(function() + u.switch_can_edit_buf(bufnr, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { target }) + u.switch_can_edit_buf(bufnr, false) + end) + end) +end + ---This function will POST the new MR to create it M.create_mr = function() local description = u.get_buffer_text(M.description_bufnr) diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index c7f411ee..67f26c64 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -432,7 +432,12 @@ M.jump_to_reviewer = function(tree) u.notify("Could not get discussion node", vim.log.levels.ERROR) return end - reviewer.jump(root_node.file_name, get_new_line(root_node), get_old_line(root_node)) + 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 @@ -973,20 +978,18 @@ end ---@param tree NuiTree M.open_in_browser = function(tree) local url = M.get_url(tree) - if url == nil then - return + if url ~= nil then + u.open_in_browser(url) end - u.open_in_browser(url) end ---@param tree NuiTree M.copy_node_url = function(tree) local url = M.get_url(tree) - if url == nil then - return + if url ~= nil then + vim.fn.setreg("+", url) + u.notify("Copied '" .. url .. "' to clipboard", vim.log.levels.INFO) end - u.notify("Copied '" .. url .. "' to clipboard", vim.log.levels.INFO) - vim.fn.setreg("+", url) end M.add_emoji_to_note = function(tree, unlinked) diff --git a/lua/gitlab/actions/miscellaneous.lua b/lua/gitlab/actions/miscellaneous.lua index b6cc086a..868b4be5 100644 --- a/lua/gitlab/actions/miscellaneous.lua +++ b/lua/gitlab/actions/miscellaneous.lua @@ -38,4 +38,58 @@ M.editable_popup_opts = { save_to_temp_register = true, } +-- Get the index of the next popup when cycling forward +local function next_index(i, n, count) + count = count > 0 and count or 1 + for _ = 1, count do + if i < n then + i = i + 1 + elseif i == n then + i = 1 + end + end + return i +end + +---Get the index of the previous popup when cycling backward +---@param i integer The current index +---@param n integer The total number of popups +---@param count integer The count used with the keymap (replaced with 1 if no count was given) +local function prev_index(i, n, count) + count = count > 0 and count or 1 + for _ = 1, count do + if i > 1 then + i = i - 1 + elseif i == 1 then + i = n + end + end + return i +end + +---Setup keymaps for cycling popups. The keymap accepts count. +---@param popups table Table of Popups +M.set_cycle_popups_keymaps = function(popups) + local number_of_popups = #popups + for i, popup in ipairs(popups) do + popup:map("n", state.settings.popup.keymaps.next_field, function() + vim.api.nvim_set_current_win(popups[next_index(i, number_of_popups, vim.v.count)].winid) + end, { desc = "Go to next field (accepts count)" }) + popup:map("n", state.settings.popup.keymaps.prev_field, function() + vim.api.nvim_set_current_win(popups[prev_index(i, number_of_popups, vim.v.count)].winid) + end, { desc = "Go to previous field (accepts count)" }) + end +end + +---Toggle the value in a "Boolean buffer" +M.toggle_bool = function() + local bufnr = vim.api.nvim_get_current_buf() + local current_val = u.get_buffer_text(bufnr) + vim.schedule(function() + u.switch_can_edit_buf(bufnr, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { u.toggle_string_bool(current_val) }) + u.switch_can_edit_buf(bufnr, false) + end) +end + return M diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index 14af9e46..a3747f95 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -32,6 +32,11 @@ M.summary = function() 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) + local popups = { + title_popup, + description_popup, + info_popup, + } M.layout = layout M.layout_buf = layout.bufnr @@ -48,12 +53,10 @@ M.summary = function() if info_popup then vim.api.nvim_buf_set_lines(info_popup.bufnr, 0, -1, false, info_lines) - vim.api.nvim_set_option_value("modifiable", false, { buf = info_popup.bufnr }) - vim.api.nvim_set_option_value("readonly", false, { buf = info_popup.bufnr }) + u.switch_can_edit_buf(info_popup.bufnr, false) + M.color_details(info_popup.bufnr) -- Color values in details popup end - M.color_details(info_popup.bufnr) -- Color values in details popup - state.set_popup_keymaps( description_popup, M.edit_summary, @@ -61,6 +64,8 @@ M.summary = function() { cb = exit, action_before_close = true } ) state.set_popup_keymaps(title_popup, M.edit_summary, nil, { cb = exit, action_before_close = true }) + state.set_popup_keymaps(info_popup, M.edit_summary, nil, { cb = exit, action_before_close = true }) + miscellaneous.set_cycle_popups_keymaps(popups) vim.api.nvim_set_current_buf(description_popup.bufnr) end) diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index 1e9add6c..874d8502 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -73,10 +73,16 @@ return { data = data.data, print_settings = state.print_settings, open_in_browser = async.sequence({ info }, function() - if state.INFO.web_url == nil then - u.notify("Could not get Gitlab URL", vim.log.levels.ERROR) - return + local web_url = u.get_web_url() + if web_url ~= nil then + u.open_in_browser(web_url) + end + end), + copy_mr_url = async.sequence({ info }, function() + local web_url = u.get_web_url() + if web_url ~= nil then + vim.fn.setreg("+", web_url) + u.notify("Copied '" .. web_url .. "' to clipboard", vim.log.levels.INFO) end - u.open_in_browser(state.INFO.web_url) end), } diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index 32c86023..1aa6f971 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -93,9 +93,9 @@ end -- Jumps to the location provided in the reviewer window ---@param file_name string ----@param new_line number|nil ----@param old_line number|nil -M.jump = function(file_name, new_line, old_line) +---@param line_number number +---@param new_buffer boolean +M.jump = function(file_name, line_number, new_buffer) if M.tabnr == nil then u.notify("Can't jump to Diffvew. Is it open?", vim.log.levels.ERROR) return @@ -115,13 +115,19 @@ M.jump = function(file_name, new_line, old_line) async.await(view:set_file(file)) local layout = view.cur_layout - if old_line == nil then + local number_of_lines + if new_buffer then layout.b:focus() - vim.api.nvim_win_set_cursor(0, { new_line, 0 }) + number_of_lines = u.get_buffer_length(layout.b.file.bufnr) else layout.a:focus() - vim.api.nvim_win_set_cursor(0, { old_line, 0 }) + number_of_lines = u.get_buffer_length(layout.a.file.bufnr) end + if line_number > number_of_lines then + u.notify("Diagnostic position outside buffer. Jumping to last line instead.", vim.log.levels.WARN) + line_number = number_of_lines + end + vim.api.nvim_win_set_cursor(0, { line_number, 0 }) end ---Get the data from diffview, such as line information and file name. May be used by diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index c8b90096..609d802b 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -26,6 +26,10 @@ M.settings = { attachment_dir = "", help = "g?", popup = { + keymaps = { + next_field = "", + prev_field = "", + }, perform_action = "s", perform_linewise_action = "l", width = "40%", diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index 45b7e150..d33caf98 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -347,6 +347,11 @@ M.get_buffer_text = function(bufnr) return text end +---Returns the number of lines in the buffer. Returns 1 even for empty buffers. +M.get_buffer_length = function(bufnr) + return #vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) +end + ---Convert string to corresponding boolean ---@param str string ---@return boolean @@ -371,6 +376,27 @@ M.bool_to_string = function(bool) return "false" end +---Toggle boolean value +---@param bool string +---@return string +M.toggle_string_bool = function(bool) + local string_bools = { + ["true"] = "false", + ["True"] = "False", + ["TRUE"] = "FALSE", + ["false"] = "true", + ["False"] = "True", + ["FALSE"] = "TRUE", + } + bool = bool:gsub("^%s+", ""):gsub("%s+$", "") + local toggled = string_bools[bool] + if toggled == nil then + M.notify(("Cannot toggle value '%s'"):format(bool), vim.log.levels.ERROR) + return bool + end + return toggled +end + M.string_starts = function(str, start) return str:sub(1, #start) == start end @@ -630,37 +656,77 @@ M.make_comma_separated_readable = function(str) return string.gsub(str, ",", ", ") end ----@param remote? boolean -M.get_all_git_branches = function(remote) - local branches = {} - - local handle = remote == true and io.popen("git branch -r 2>&1") or io.popen("git branch 2>&1") - +---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 - for line in handle:lines() do - local branch - if remote then - for res in line:gmatch("origin/([^\n]+)") do - branch = res -- Trim /origin - end - else - branch = line:gsub("^%s*%*?%s*", "") -- Trim leading whitespace and the "* " marker for the current branch - end - table.insert(branches, branch) - end - handle:close() + 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() + if not all_branch_names then + return + end + vim.ui.select(all_branch_names, { + prompt = "Choose target branch for merge", + }, function(choice) + if choice then + cb(choice) + end + end) +end + M.basename = function(str) local name = string.gsub(str, "(.*/)(.*)", "%2") return name end +M.get_web_url = function() + local web_url = require("gitlab.state").INFO.web_url + if web_url ~= nil then + return web_url + end + M.notify("Could not get Gitlab URL", vim.log.levels.ERROR) +end + ---@param url string? M.open_in_browser = function(url) if vim.fn.has("mac") == 1 then diff --git a/makefile b/makefile index f077924c..626ad683 100644 --- a/makefile +++ b/makefile @@ -7,7 +7,7 @@ compile: @cd cmd && go build -o bin && mv bin ../bin ## test: run golang project tests test: - @cd cmd && go test -v + @cd cmd && go test .PHONY: help all: help