diff --git a/README.md b/README.md index 844f6fe..7d82d82 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,8 @@ vim.keymap.set("n", "gln", gitlab.create_note) vim.keymap.set("n", "gld", gitlab.toggle_discussions) vim.keymap.set("n", "glaa", gitlab.add_assignee) vim.keymap.set("n", "glad", gitlab.delete_assignee) +vim.keymap.set("n", "glla", gitlab.add_label) +vim.keymap.set("n", "glld", gitlab.delete_label) vim.keymap.set("n", "glra", gitlab.add_reviewer) vim.keymap.set("n", "glrd", gitlab.delete_reviewer) vim.keymap.set("n", "glp", gitlab.pipeline) diff --git a/cmd/client.go b/cmd/client.go index 5b65f8e..cab6c01 100644 --- a/cmd/client.go +++ b/cmd/client.go @@ -32,6 +32,7 @@ type Client struct { *gitlab.ProjectMembersService *gitlab.JobsService *gitlab.PipelinesService + *gitlab.LabelsService } /* initGitlabClient parses and validates the project settings and initializes the Gitlab client. */ @@ -87,6 +88,7 @@ func initGitlabClient() (error, *Client) { ProjectMembersService: client.ProjectMembers, JobsService: client.Jobs, PipelinesService: client.Pipelines, + LabelsService: client.Labels, } } diff --git a/cmd/label.go b/cmd/label.go new file mode 100644 index 0000000..9c185f1 --- /dev/null +++ b/cmd/label.go @@ -0,0 +1,130 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/xanzy/go-gitlab" +) + +type LabelUpdateRequest struct { + Labels []string `json:"labels"` +} + +type Label struct { + Name string + Color string +} + +type LabelUpdateResponse struct { + SuccessResponse + Labels gitlab.Labels `json:"labels"` +} + +type LabelsRequestResponse struct { + SuccessResponse + Labels []Label `json:"labels"` +} + +/* labelsHandler adds or removes labels from a merge request, and returns all labels for the current project */ +func (a *api) labelHandler(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + a.getLabels(w, r) + case http.MethodPut: + a.updateLabels(w, r) + default: + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Methods", fmt.Sprintf("%s, %s", http.MethodPut, http.MethodGet)) + handleError(w, InvalidRequestError{}, "Expected GET or PUT", http.StatusMethodNotAllowed) + } +} + +func (a *api) getLabels(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + labels, res, err := a.client.ListLabels(a.projectInfo.ProjectId, &gitlab.ListLabelsOptions{}) + + if err != nil { + handleError(w, err, "Could not modify merge request labels", http.StatusInternalServerError) + return + } + + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/mr/label"}, "Could not modify merge request labels", res.StatusCode) + return + } + + /* Hacky, but convert them to the correct response */ + convertedLabels := make([]Label, len(labels)) + for i, labelPtr := range labels { + convertedLabels[i] = Label{ + Name: labelPtr.Name, + Color: labelPtr.Color, + } + } + + w.WriteHeader(http.StatusOK) + response := LabelsRequestResponse{ + SuccessResponse: SuccessResponse{ + Message: "Labels updated", + Status: http.StatusOK, + }, + Labels: convertedLabels, + } + + err = json.NewEncoder(w).Encode(response) + if err != nil { + handleError(w, err, "Could not encode response", http.StatusInternalServerError) + } + +} + +func (a *api) updateLabels(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + 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 labelUpdateRequest LabelUpdateRequest + err = json.Unmarshal(body, &labelUpdateRequest) + + if err != nil { + handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) + return + } + + var labels = gitlab.Labels(labelUpdateRequest.Labels) + mr, res, err := a.client.UpdateMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &gitlab.UpdateMergeRequestOptions{ + Labels: &labels, + }) + + if err != nil { + handleError(w, err, "Could not modify merge request labels", http.StatusInternalServerError) + return + } + + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/mr/label"}, "Could not modify merge request labels", res.StatusCode) + return + } + + w.WriteHeader(http.StatusOK) + response := LabelUpdateResponse{ + SuccessResponse: SuccessResponse{ + Message: "Labels updated", + Status: http.StatusOK, + }, + Labels: mr.Labels, + } + + err = json.NewEncoder(w).Encode(response) + if err != nil { + handleError(w, err, "Could not encode response", http.StatusInternalServerError) + } +} diff --git a/cmd/server.go b/cmd/server.go index c91c1bc..bd09405 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -122,6 +122,7 @@ func createRouterAndApi(client ClientInterface, optFuncs ...optFunc) (*http.Serv m.HandleFunc("/mr/reviewer", a.withMr(a.reviewersHandler)) m.HandleFunc("/mr/revisions", a.withMr(a.revisionsHandler)) m.HandleFunc("/mr/reply", a.withMr(a.replyHandler)) + m.HandleFunc("/mr/label", a.withMr(a.labelHandler)) m.HandleFunc("/mr/revoke", a.withMr(a.revokeHandler)) m.HandleFunc("/attachment", a.attachmentHandler) diff --git a/cmd/test.go b/cmd/test.go index a9923cf..0376b0b 100644 --- a/cmd/test.go +++ b/cmd/test.go @@ -36,6 +36,7 @@ type fakeClient struct { 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) 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) } type Author struct { @@ -120,6 +121,10 @@ func (f fakeClient) GetTraceFile(pid interface{}, jobID int, options ...gitlab.R return f.getTraceFile(pid, jobID, options...) } +func (f fakeClient) ListLabels(pid interface{}, opt *gitlab.ListLabelsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Label, *gitlab.Response, error) { + return f.listLabels(pid, opt, options...) +} + /* This middleware function needs to return an ID for the rest of the handlers */ func (f fakeClient) ListProjectMergeRequests(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) { return []*gitlab.MergeRequest{{ID: 1}}, &gitlab.Response{}, nil diff --git a/cmd/types.go b/cmd/types.go index 195d342..235fa27 100644 --- a/cmd/types.go +++ b/cmd/types.go @@ -54,4 +54,5 @@ type ClientInterface interface { 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) 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) } diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index bc7c675..c7ae3fb 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -12,6 +12,7 @@ Table of Contents *gitlab.nvim.table-of-contents* - The Summary view |gitlab.nvim.the-summary-view| - Reviewing an MR |gitlab.nvim.reviewing-an-mr| - Discussions and Notes |gitlab.nvim.discussions-and-notes| + - Labels |gitlab.nvim.labels| - Signs and diagnostics |gitlab.nvim.signs-and-diagnostics| - Uploading Files |gitlab.nvim.uploading-files| - MR Approvals |gitlab.nvim.mr-approvals| @@ -187,6 +188,7 @@ you call this function with no values the defaults will be used: "reviewers", "branch", "pipeline", + "labels", }, }, discussion_sign_and_diagnostic = { @@ -328,6 +330,15 @@ delete/edit/reply are available on the note tree. require("gitlab").create_note() < +LABELS *gitlab.nvim.labels* + +You can add or remove labels from the current MR. +>lua + require("gitlab").add_label() + require("gitlab").delete_label() + +These labels will be visible in the summary panel, as long as you provide the +"fields" string in your setup function under the `setting.info.fields` block. SIGNS AND DIAGNOSTICS *gitlab.nvim.signs-and-diagnostics* @@ -681,6 +692,18 @@ Opens up a select menu for adding a reviewer for the current merge request. >lua require("gitlab").add_reviewer() +gitlab.add_label() *gitlab.nvim.add_label* + +Opens up a select menu for adding a label to the current merge request. +>lua + require("gitlab").add_label() + +gitlab.delete_label() *gitlab.nvim.delete_label* + +Opens up a select menu for removing an existing label from the current merge request. +>lua + require("gitlab").delete_label() + gitlab.delete_reviewer() *gitlab.nvim.delete_reviewer* Opens up a select menu for removing an existing reviewer for the current merge request. diff --git a/lua/gitlab/actions/labels.lua b/lua/gitlab/actions/labels.lua new file mode 100644 index 0000000..0ef1c26 --- /dev/null +++ b/lua/gitlab/actions/labels.lua @@ -0,0 +1,83 @@ +-- This module is responsible for the creation, deletion, +-- and assignment and removeal of labels. +local u = require("gitlab.utils") +local job = require("gitlab.job") +local state = require("gitlab.state") +local M = {} + +M.add_label = function() + M.add_popup("label") +end + +M.delete_label = function() + M.delete_popup("label") +end + +local refresh_label_state = function(labels) + local new_labels = "" + for _, label in ipairs(labels) do + new_labels = new_labels .. "," .. label + end + state.INFO.labels = new_labels +end + +local get_current_labels = function() + local label_string = state.INFO.labels + local current_labels = {} + for value in label_string:gmatch("[^,]+") do + table.insert(current_labels, value) + end + return current_labels +end + +local get_all_labels = function() + local labels = {} + for _, label in ipairs(state.LABELS) do -- How can we use the colors?? + table.insert(labels, label.Name) + end + return labels +end + +M.add_popup = function(type) + local all_labels = get_all_labels() + local current_labels = get_current_labels() + local unused_labels = u.difference(all_labels, current_labels) + vim.ui.select(unused_labels, { + prompt = "Choose label to add", + }, function(choice) + if not choice then + return + end + local label_string = state.INFO.labels + local new_labels = {} + for value in label_string:gmatch("[^,]+") do + table.insert(new_labels, value) + end + + table.insert(new_labels, choice) + local body = { labels = new_labels } + job.run_job("/mr/" .. type, "PUT", body, function(data) + u.notify(data.message, vim.log.levels.INFO) + refresh_label_state(data.labels) + end) + end) +end + +M.delete_popup = function(type) + local current_labels = get_current_labels() + vim.ui.select(current_labels, { + prompt = "Choose label to delete", + }, function(choice) + if not choice then + return + end + local filtered_labels = u.filter(current_labels, choice) + local body = { labels = filtered_labels } + job.run_job("/mr/" .. type, "PUT", body, function(data) + u.notify(data.message, vim.log.levels.INFO) + refresh_label_state(data.labels) + end) + end) +end + +return M diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index 1cf5ac2..448c214 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -89,6 +89,8 @@ M.summary = function() vim.api.nvim_set_option_value("readonly", false, { buf = info_popup.bufnr }) end + M.color_labels(info_popup.bufnr) -- Color labels in details popup + state.set_popup_keymaps( description_popup, M.edit_summary, @@ -128,8 +130,9 @@ M.build_info_lines = function() assignees = { title = "Assignees", content = u.make_readable_list(info.assignees, "name") }, reviewers = { title = "Reviewers", content = u.make_readable_list(info.reviewers, "name") }, branch = { title = "Branch", content = info.source_branch }, + labels = { title = "Labels", content = u.make_comma_separated_readable(info.labels) }, pipeline = { - title = "Pipeline Status:", + title = "Pipeline Status", content = function() return pipeline.get_pipeline_status() end, @@ -230,8 +233,25 @@ M.create_layout = function(info_lines) }, internal_layout) layout:mount() - return layout, title_popup, description_popup, details_popup end +M.color_labels = function(bufnr) + local label_namespace = vim.api.nvim_create_namespace("Labels") + for i, v in ipairs(state.settings.info.fields) do + if v == "labels" then + local line_content = u.get_line_content(bufnr, i) + vim.print(line_content) + for j, label in ipairs(state.LABELS) do + local start_idx, end_idx = line_content:find(label.Name) + if start_idx ~= nil and end_idx ~= nil then + vim.cmd("highlight " .. "label" .. j .. " guifg=white") + vim.api.nvim_set_hl(0, ("label" .. j), { fg = label.Color }) + vim.api.nvim_buf_add_highlight(bufnr, label_namespace, ("label" .. j), i - 1, start_idx - 1, end_idx) + end + end + end + end +end + return M diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index 7e92f58..c521c5f 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -11,8 +11,10 @@ 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 labels = require("gitlab.actions.labels") local info = state.dependencies.info +local labels_dep = state.dependencies.labels local project_members = state.dependencies.project_members local revisions = state.dependencies.revisions @@ -28,11 +30,13 @@ return { discussions.initialize_discussions() -- place signs / diagnostics for discussions in reviewer end, -- Global Actions 🌎 - summary = async.sequence({ u.merge(info, { refresh = true }) }, summary.summary), + summary = async.sequence({ u.merge(info, { refresh = true }), labels_dep }, summary.summary), approve = async.sequence({ info }, approvals.approve), revoke = async.sequence({ info }, approvals.revoke), add_reviewer = async.sequence({ info, project_members }, assignees_and_reviewers.add_reviewer), delete_reviewer = async.sequence({ info, project_members }, assignees_and_reviewers.delete_reviewer), + add_label = async.sequence({ info, labels_dep }, labels.add_label), + delete_label = async.sequence({ info, labels_dep }, labels.delete_label), add_assignee = async.sequence({ info, project_members }, assignees_and_reviewers.add_assignee), delete_assignee = async.sequence({ info, project_members }, assignees_and_reviewers.delete_assignee), create_comment = async.sequence({ info, revisions }, comment.create_comment), diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 764f753..0af2ada 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -90,6 +90,7 @@ M.settings = { "reviewers", "branch", "pipeline", + "labels", }, }, discussion_sign_and_diagnostic = { @@ -293,6 +294,7 @@ end -- adding a reviewer) requires some initial state. M.dependencies = { info = { endpoint = "/mr/info", key = "info", state = "INFO", refresh = false }, + labels = { endpoint = "/mr/label", key = "labels", state = "LABELS", refresh = false }, revisions = { endpoint = "/mr/revisions", key = "Revisions", state = "MR_REVISIONS", refresh = false }, project_members = { endpoint = "/project/members", diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index add310b..b137c76 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -28,6 +28,16 @@ M.get_last_word = function(sentence, divider) return words[#words] or "" end +M.filter = function(input_table, value_to_remove) + local resultTable = {} + for _, v in ipairs(input_table) do + if v ~= value_to_remove then + table.insert(resultTable, v) + end + end + return resultTable +end + ---Merges two deeply nested tables together, overriding values from the first with conflicts ---@param defaults table The first table ---@param overrides table The second table @@ -328,6 +338,22 @@ M.format_date = function(date_string) end end +M.difference = function(a, b) + local set_b = {} + for _, val in ipairs(b) do + set_b[val] = true + end + + local not_included = {} + for _, val in ipairs(a) do + if not set_b[val] then + table.insert(not_included, val) + end + end + + return not_included +end + M.jump_to_file = function(filename, line_number) if line_number == nil then line_number = 1 @@ -637,6 +663,10 @@ M.get_icon = function(filename) end end +M.make_comma_separated_readable = function(str) + return string.gsub(str, ",", ", ") +end + ---@param remote? boolean M.get_all_git_branches = function(remote) local branches = {}