diff --git a/README.md b/README.md index fda1751..0b81127 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,9 @@ return { "harrisoncramer/gitlab.nvim", dependencies = { "MunifTanjim/nui.nvim", - "nvim-lua/plenary.nvim" + "nvim-lua/plenary.nvim", + "stevearc/dressing.nvim" -- Recommended but not required. Better UI for pickers. + enabled = true, }, build = function () require("gitlab").build() end, -- Builds the Go binary config = function() @@ -143,6 +145,24 @@ The `comment` command will open up a NUI popover that will allow you to create a require("gitlab").create_comment() ``` +The `add_reviewer` and `delete_reviewer` commands, as well as the `add_assignee` and `delete_assignee` functions, will let you choose from a list of users who are availble in the current project: + +```lua +require("gitlab").add_reviewer() +require("gitlab").delete_reviewer() +require("gitlab").add_assignee() +require("gitlab").delete_assignee() +``` + +These commands use Neovim's built in picker, which is much nicer if you install dressing. If you use Dressing, please enable it: + +```lua +require("dressing").setup({ + input = { + enabled = true + } +}) + ### Discussions Gitlab groups threads of notes together into "disucssions." To get a list of all the discussions for the current MR, use the `list_discussions` command. This command will open up a split view of all the comments on the current merge request. You can jump to the comment location by using the `o` key in the tree buffer, and you can reply to a thread by using the `r` keybinding in the tree buffer: @@ -170,6 +190,10 @@ vim.keymap.set("n", "glA", gitlab.approve) vim.keymap.set("n", "glR", gitlab.revoke) vim.keymap.set("n", "glc", gitlab.create_comment) vim.keymap.set("n", "gld", gitlab.list_discussions) +vim.keymap.set("n", "glaa", gitlab.add_assignee) +vim.keymap.set("n", "glad", gitlab.delete_assignee) +vim.keymap.set("n", "glra", gitlab.add_reviewer) +vim.keymap.set("n", "glrd", gitlab.delete_reviewer) ``` ## Troubleshooting diff --git a/cmd/assignee.go b/cmd/assignee.go new file mode 100644 index 0000000..646f6f2 --- /dev/null +++ b/cmd/assignee.go @@ -0,0 +1,70 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/xanzy/go-gitlab" +) + +type AssigneeUpdateRequest struct { + Ids []int `json:"ids"` +} + +type AssigneeUpdateResponse struct { + SuccessResponse + Assignees []*gitlab.BasicUser `json:"assignees"` +} + +type AssigneesRequestResponse struct { + SuccessResponse + Assignees []int `json:"assignees"` +} + +func AssigneesHandler(w http.ResponseWriter, r *http.Request) { + c := r.Context().Value("client").(Client) + w.Header().Set("Content-Type", "application/json") + + body, err := io.ReadAll(r.Body) + if err != nil { + c.handleError(w, err, "Could not read request body", http.StatusBadRequest) + return + } + + defer r.Body.Close() + var assigneeUpdateRequest AssigneeUpdateRequest + err = json.Unmarshal(body, &assigneeUpdateRequest) + + if err != nil { + c.handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) + return + } + + mr, res, err := c.git.MergeRequests.UpdateMergeRequest(c.projectId, c.mergeId, &gitlab.UpdateMergeRequestOptions{ + AssigneeIDs: &assigneeUpdateRequest.Ids, + }) + + if err != nil { + c.handleError(w, err, "Could not modify merge request assignees", http.StatusBadRequest) + return + } + + if res.StatusCode != http.StatusOK { + c.handleError(w, err, "Could not modify merge request assignees", http.StatusBadRequest) + return + } + + w.WriteHeader(http.StatusOK) + + response := AssigneeUpdateResponse{ + SuccessResponse: SuccessResponse{ + Message: "Assignees updated", + Status: http.StatusOK, + }, + Assignees: mr.Assignees, + } + + json.NewEncoder(w).Encode(response) + +} diff --git a/cmd/update.go b/cmd/description.go similarity index 67% rename from cmd/update.go rename to cmd/description.go index 9dfc30e..3ebcbe9 100644 --- a/cmd/update.go +++ b/cmd/description.go @@ -9,16 +9,16 @@ import ( "github.com/xanzy/go-gitlab" ) -type UpdateRequest struct { +type DescriptionUpdateRequest struct { Description string `json:"description"` } -type UpdateResponse struct { +type DescriptionUpdateResponse struct { SuccessResponse MergeRequest *gitlab.MergeRequest `json:"mr"` } -func UpdateHandler(w http.ResponseWriter, r *http.Request) { +func DescriptionHandler(w http.ResponseWriter, r *http.Request) { c := r.Context().Value("client").(Client) w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodPut { @@ -33,31 +33,31 @@ func UpdateHandler(w http.ResponseWriter, r *http.Request) { } defer r.Body.Close() - var updateRequest UpdateRequest - err = json.Unmarshal(body, &updateRequest) + var DescriptionUpdateRequest DescriptionUpdateRequest + err = json.Unmarshal(body, &DescriptionUpdateRequest) if err != nil { c.handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) return } - mr, res, err := c.git.MergeRequests.UpdateMergeRequest(c.projectId, c.mergeId, &gitlab.UpdateMergeRequestOptions{Description: &updateRequest.Description}) + mr, res, err := c.git.MergeRequests.UpdateMergeRequest(c.projectId, c.mergeId, &gitlab.UpdateMergeRequestOptions{Description: &DescriptionUpdateRequest.Description}) if err != nil { - c.handleError(w, err, "Could not edit merge request", http.StatusBadRequest) + c.handleError(w, err, "Could not edit merge request description", http.StatusBadRequest) return } if res.StatusCode != http.StatusOK { - c.handleError(w, err, "Could not edit merge request", http.StatusBadRequest) + c.handleError(w, err, "Could not edit merge request description", http.StatusBadRequest) return } w.WriteHeader(http.StatusOK) - response := UpdateResponse{ + response := DescriptionUpdateResponse{ SuccessResponse: SuccessResponse{ - Message: "Merge request updated", + Message: "Description updated", Status: http.StatusOK, }, MergeRequest: mr, diff --git a/cmd/main.go b/cmd/main.go index cf25c1a..e81cc44 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -29,13 +29,16 @@ func main() { } m := http.NewServeMux() - m.Handle("/mr", withGitlabContext(http.HandlerFunc(UpdateHandler), c)) + m.Handle("/mr/description", withGitlabContext(http.HandlerFunc(DescriptionHandler), c)) + m.Handle("/mr/reviewer", withGitlabContext(http.HandlerFunc(ReviewersHandler), c)) + m.Handle("/mr/assignee", withGitlabContext(http.HandlerFunc(AssigneesHandler), c)) m.Handle("/approve", withGitlabContext(http.HandlerFunc(ApproveHandler), c)) m.Handle("/revoke", withGitlabContext(http.HandlerFunc(RevokeHandler), c)) m.Handle("/info", withGitlabContext(http.HandlerFunc(InfoHandler), c)) m.Handle("/discussions", withGitlabContext(http.HandlerFunc(ListDiscussionsHandler), c)) m.Handle("/comment", withGitlabContext(http.HandlerFunc(CommentHandler), c)) m.Handle("/reply", withGitlabContext(http.HandlerFunc(ReplyHandler), c)) + m.Handle("/members", withGitlabContext(http.HandlerFunc(ProjectMembersHandler), c)) port := fmt.Sprintf(":%s", os.Args[3]) server := &http.Server{ diff --git a/cmd/members.go b/cmd/members.go new file mode 100644 index 0000000..c5c3634 --- /dev/null +++ b/cmd/members.go @@ -0,0 +1,43 @@ +package main + +import ( + "encoding/json" + "net/http" + + "github.com/xanzy/go-gitlab" +) + +type ProjectMembersResponse struct { + SuccessResponse + ProjectMembers []*gitlab.ProjectMember +} + +func ProjectMembersHandler(w http.ResponseWriter, r *http.Request) { + c := r.Context().Value("client").(Client) + w.Header().Set("Content-Type", "application/json") + + projectMemberOptions := gitlab.ListProjectMembersOptions{ + ListOptions: gitlab.ListOptions{ + PerPage: 100, + }, + } + + projectMembers, res, err := c.git.ProjectMembers.ListAllProjectMembers(c.projectId, &projectMemberOptions) + if err != nil { + c.handleError(w, err, "Could not fetch project users", res.StatusCode) + } + + w.WriteHeader(http.StatusOK) + + response := ProjectMembersResponse{ + SuccessResponse: SuccessResponse{ + Status: http.StatusOK, + Message: "Project users fetched successfully", + }, + ProjectMembers: projectMembers, + } + + json.NewEncoder(w).Encode(response) + + return +} diff --git a/cmd/reviewer.go b/cmd/reviewer.go new file mode 100644 index 0000000..39d9b62 --- /dev/null +++ b/cmd/reviewer.go @@ -0,0 +1,70 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/xanzy/go-gitlab" +) + +type ReviewerUpdateRequest struct { + Ids []int `json:"ids"` +} + +type ReviewerUpdateResponse struct { + SuccessResponse + Reviewers []*gitlab.BasicUser `json:"reviewers"` +} + +type ReviewersRequestResponse struct { + SuccessResponse + Reviewers []int `json:"reviewers"` +} + +func ReviewersHandler(w http.ResponseWriter, r *http.Request) { + c := r.Context().Value("client").(Client) + w.Header().Set("Content-Type", "application/json") + + body, err := io.ReadAll(r.Body) + if err != nil { + c.handleError(w, err, "Could not read request body", http.StatusBadRequest) + return + } + + defer r.Body.Close() + var reviewerUpdateRequest ReviewerUpdateRequest + err = json.Unmarshal(body, &reviewerUpdateRequest) + + if err != nil { + c.handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) + return + } + + mr, res, err := c.git.MergeRequests.UpdateMergeRequest(c.projectId, c.mergeId, &gitlab.UpdateMergeRequestOptions{ + ReviewerIDs: &reviewerUpdateRequest.Ids, + }) + + if err != nil { + c.handleError(w, err, "Could not modify merge request reviewers", http.StatusBadRequest) + return + } + + if res.StatusCode != http.StatusOK { + c.handleError(w, err, "Could not modify merge request reviewers", http.StatusBadRequest) + return + } + + w.WriteHeader(http.StatusOK) + + response := ReviewerUpdateResponse{ + SuccessResponse: SuccessResponse{ + Message: "Reviewers updated", + Status: http.StatusOK, + }, + Reviewers: mr.Reviewers, + } + + json.NewEncoder(w).Encode(response) + +} diff --git a/lua/gitlab/assignees_and_reviewers.lua b/lua/gitlab/assignees_and_reviewers.lua new file mode 100644 index 0000000..af7951e --- /dev/null +++ b/lua/gitlab/assignees_and_reviewers.lua @@ -0,0 +1,71 @@ +local u = require("gitlab.utils") +local job = require("gitlab.job") +local state = require("gitlab.state") +local M = {} + +M.add_assignee = function() + M.add_popup('assignee') +end + +M.delete_assignee = function() + M.delete_popup('assignee') +end + +M.add_reviewer = function() + M.add_popup('reviewer') +end + +M.delete_reviewer = function() + M.delete_popup('reviewer') +end + +M.add_popup = function(type) + local plural = type .. 's' + local current = state.INFO[plural] + local eligible = M.filter_eligible(state.PROJECT_MEMBERS, current) + vim.ui.select(eligible, { + prompt = 'Choose ' .. type .. ' to add', + format_item = function(user) + return user.username .. " (" .. user.name .. ")" + end + }, function(choice) + if not choice then return end + local current_ids = u.extract(current, 'id') + table.insert(current_ids, choice.id) + local json = vim.json.encode({ ids = current_ids }) + job.run_job("mr/" .. type, "PUT", json, function(data) + vim.notify(data.message, vim.log.levels.INFO) + state.INFO[plural] = data[plural] + end) + end) +end + +M.delete_popup = function(type) + local plural = type .. 's' + local current = state.INFO[plural] + vim.ui.select(current, { + prompt = 'Choose ' .. type .. ' to delete', + format_item = function(user) + return user.username .. " (" .. user.name .. ")" + end + }, function(choice) + if not choice then return end + local ids = u.extract(M.filter_eligible(current, { choice }), 'id') + local json = vim.json.encode({ ids = ids }) + job.run_job("mr/" .. type, "PUT", json, function(data) + vim.notify(data.message, vim.log.levels.INFO) + state.INFO[plural] = data[plural] + end) + end) +end + +M.filter_eligible = function(current, to_remove) + local ids = u.extract(to_remove, 'id') + local res = {} + for _, member in ipairs(current) do + if not u.contains(ids, member.id) then table.insert(res, member) end + end + return res +end + +return M diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index 5838821..845cd5d 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -1,13 +1,18 @@ -local state = require("gitlab.state") -local discussions = require("gitlab.discussions") -local summary = require("gitlab.summary") -local keymaps = require("gitlab.keymaps") -local comment = require("gitlab.comment") -local job = require("gitlab.job") -local u = require("gitlab.utils") +local state = require("gitlab.state") +local discussions = require("gitlab.discussions") +local summary = require("gitlab.summary") +local assignees_and_reviewers = require("gitlab.assignees_and_reviewers") +local keymaps = require("gitlab.keymaps") +local comment = require("gitlab.comment") +local job = require("gitlab.job") +local u = require("gitlab.utils") --- Ensures the plugin's state is initialized prior to running other calls. This state contains the basic information about the current merge request, like description, author, etc -local ensureState = function(callback) +-- Function names prefixed with "ensure" will ensure the plugin's state +-- is initialized prior to running other calls. These functions run +-- API calls if the state isn't initialized, which will set state containing +-- information that's necessary for other API calls, like description, +-- author, reviewer, etc. +local ensureState = function(callback) return function() if type(state.INFO) ~= "table" then job.run_job("info", "GET", nil, function(data) @@ -20,20 +25,37 @@ local ensureState = function(callback) end end +local ensureProjectMembers = function(callback) + return function() + if type(state.PROJECT_MEMBERS) ~= "table" then + job.run_job("members", "GET", nil, function(data) + state.PROJECT_MEMBERS = data.ProjectMembers + callback() + end) + else + callback() + end + end +end + -- Root Module Scope -local M = {} -M.summary = ensureState(summary.summary) -M.approve = ensureState(job.approve) -M.revoke = ensureState(job.revoke) -M.create_comment = ensureState(comment.create_comment) -M.list_discussions = ensureState(discussions.list_discussions) -M.edit_comment = ensureState(comment.edit_comment) -M.delete_comment = ensureState(comment.delete_comment) -M.reply = ensureState(discussions.reply) -M.state = state +local M = {} +M.summary = ensureState(summary.summary) +M.approve = ensureState(job.approve) +M.revoke = ensureState(job.revoke) +M.create_comment = ensureState(comment.create_comment) +M.list_discussions = ensureState(discussions.list_discussions) +M.edit_comment = ensureState(comment.edit_comment) +M.delete_comment = ensureState(comment.delete_comment) +M.add_reviewer = ensureProjectMembers(ensureState(assignees_and_reviewers.add_reviewer)) +M.delete_reviewer = ensureProjectMembers(ensureState(assignees_and_reviewers.delete_reviewer)) +M.add_assignee = ensureProjectMembers(ensureState(assignees_and_reviewers.add_assignee)) +M.delete_assignee = ensureProjectMembers(ensureState(assignees_and_reviewers.delete_assignee)) +M.reply = ensureState(discussions.reply) +M.state = state -- Builds the binary (if not built); starts the Go server; sets the keymaps -M.setup = function(args) +M.setup = function(args) if args == nil then args = {} end local file_path = u.current_file_path() local parent_dir = vim.fn.fnamemodify(file_path, ":h:h:h:h") @@ -75,7 +97,7 @@ M.setup = function(args) end -- Builds the Go binary -M.build = function() +M.build = function() local command = string.format("cd %s && make", state.BIN_PATH) local installCode = os.execute(command .. "> /dev/null") if installCode ~= 0 then @@ -87,7 +109,7 @@ end -- Initializes state for the project based on the arguments -- provided in the `.gitlab.nvim` file per project, and the args provided in the setup function -M.setPluginConfiguration = function(args) +M.setPluginConfiguration = function(args) local config_file_path = vim.fn.getcwd() .. "/.gitlab.nvim" local config_file_content = u.read_file(config_file_path) if config_file_content == nil then diff --git a/lua/gitlab/summary.lua b/lua/gitlab/summary.lua index 59cddfc..31d1feb 100644 --- a/lua/gitlab/summary.lua +++ b/lua/gitlab/summary.lua @@ -27,7 +27,7 @@ end M.edit_description = function(text) local jsonTable = { description = text } local json = vim.json.encode(jsonTable) - job.run_job("mr", "PUT", json, function(data) + job.run_job("mr/description", "PUT", json, function(data) vim.notify(data.message, vim.log.levels.INFO) state.INFO.description = data.mr.description end) diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index 5f9d062..b780fc7 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -244,15 +244,6 @@ local current_file_path = function() return vim.fn.fnamemodify(path, ':p') end --- Function to join two tables -local function join_tables(table1, table2) - for _, value in ipairs(table2) do - table.insert(table1, value) - end - - return table1 -end - local random = math.random local function uuid() local template = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx' @@ -266,6 +257,36 @@ local attach_uuid = function(str) return { text = str, id = uuid() } end +local join_tables = function(table1, table2) + for _, value in ipairs(table2) do + table.insert(table1, value) + end + + return table1 +end + +local contains = function(array, search_value) + for _, value in ipairs(array) do + if value == search_value then + return true + end + end + return false +end + +local extract = function(t, property) + local resultTable = {} + for _, value in ipairs(t) do + if value[property] then + table.insert(resultTable, value[property]) + end + end + return resultTable +end + + +M.extract = extract +M.contains = contains M.attach_uuid = attach_uuid M.join_tables = join_tables M.get_relative_file_path = get_relative_file_path