Feat: Add and Remove Labels from an MR (#159)

This MR adds the ability to add or remove labels to a merge request. These labels are visible in the summary panel and are colored the same way as they would be in the Gitlab UI.

This is a MINOR release.
This commit is contained in:
Harrison (Harry) Cramer
2024-01-13 10:37:05 -05:00
committed by GitHub
parent 67f09e559a
commit 50e06ceff6
12 changed files with 306 additions and 3 deletions

View File

@@ -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)

View File

@@ -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,
}
}

130
cmd/label.go Normal file
View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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),

View File

@@ -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",

View File

@@ -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 = {}