Files
gitlab.nvim/lua/gitlab/actions/summary.lua
2026-02-27 17:44:59 +01:00

352 lines
13 KiB
Lua
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- This module is responsible for the MR description
-- This lets the user open the description in a popup and
-- 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 popup = require("gitlab.popup")
local state = require("gitlab.state")
local miscellaneous = require("gitlab.actions.miscellaneous")
-- No-break space used in summary details to make matching different parts of the line more robust
local nbsp = " "
local M = {
layout_visible = false,
layout = nil,
layout_buf = nil,
title_bufnr = nil,
description_bufnr = nil,
}
-- The function will render a popup containing the MR title and MR description, and optionally,
-- any additional metadata that the user wants. The title and description are editable and
-- can be changed via the local action keybinding, which also closes the popup
M.summary = function()
if M.layout_visible then
M.layout:unmount()
M.layout_visible = false
return
end
local title = state.INFO.title
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)
layout:mount()
local popups = {
title_popup,
description_popup,
info_popup,
}
M.layout = layout
M.info_popup = info_popup
M.title_popup = title_popup
M.description_popup = description_popup
M.layout_buf = layout.bufnr
M.layout_visible = true
local function exit()
layout:unmount()
M.layout_visible = false
end
vim.schedule(function()
vim.api.nvim_buf_set_lines(description_popup.bufnr, 0, -1, false, description_lines)
vim.api.nvim_buf_set_lines(title_popup.bufnr, 0, -1, false, { title })
if info_popup then
M.update_details_popup(info_popup.bufnr, info_lines)
end
popup.set_popup_keymaps(
description_popup,
M.edit_summary,
miscellaneous.attach_file,
{ cb = exit, action_before_close = true, action_before_exit = true, save_to_temp_register = true }
)
popup.set_popup_keymaps(
title_popup,
M.edit_summary,
nil,
{ cb = exit, action_before_close = true, action_before_exit = true }
)
popup.set_popup_keymaps(
info_popup,
M.edit_summary,
nil,
{ cb = exit, action_before_close = true, action_before_exit = true }
)
popup.set_cycle_popups_keymaps(popups)
vim.api.nvim_set_current_buf(description_popup.bufnr)
end)
git.check_current_branch_up_to_date_on_remote(vim.log.levels.WARN)
git.check_mr_in_good_condition()
end
M.update_summary_details = function()
if not M.info_popup or not M.info_popup.bufnr then
return
end
local details_lines = state.settings.info.enabled and M.build_info_lines() or { "" }
local internal_layout = M.create_internal_layout(details_lines, M.title_popup, M.description_popup, M.info_popup)
M.layout:update(M.get_outer_layout_config(), internal_layout)
M.update_details_popup(M.info_popup.bufnr, details_lines)
end
M.update_details_popup = function(bufnr, info_lines)
u.switch_can_edit_buf(bufnr, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, info_lines)
u.switch_can_edit_buf(bufnr, false)
M.color_details(bufnr) -- Color values in details popup
end
---Return the mergeability checks statuses and descriptions
---@return string[]
local make_mergeability_checks = function()
local lines = {}
for _, check in ipairs(state.MERGEABILITY.mergeability_checks) do
local status = state.settings.mergeability_checks.statuses[check.status]
if status == nil then
u.notify(string.format("Unknown mergeability check status: %s", check.status), vim.log.levels.ERROR)
end
if status then
local description = state.settings.mergeability_checks.checks[check.identifier]
if description == nil then
u.notify(string.format("Unknown mergeability check identifier: %s", check.identifier), vim.log.levels.ERROR)
end
if description then
table.insert(lines, status .. " " .. description)
end
end
end
return lines
end
-- Builds a lua list of strings that contain metadata about the current MR. Only builds the
-- lines that users include in their state.settings.info.fields list.
M.build_info_lines = function()
local info = state.INFO
local options = {
author = { title = "Author", content = "@" .. info.author.username .. " (" .. info.author.name .. ")" },
created_at = { title = "Created", content = u.format_to_local(info.created_at, vim.fn.strftime("%z")) },
updated_at = { title = "Updated", content = u.time_since(info.updated_at) },
detailed_merge_status = { title = "Status", content = info.detailed_merge_status },
draft = { title = "Draft", content = (info.draft and "Yes" or "No") },
conflicts = { title = "Merge Conflicts", content = (info.has_conflicts and "Yes" or "No") },
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 = table.concat(info.labels, ", ") },
target_branch = { title = "Target Branch", content = info.target_branch },
delete_branch = {
title = "Delete Source Branch",
content = (info.force_remove_source_branch and "Yes" or "No"),
},
squash = { title = "Squash Commits", content = (info.squash and "Yes" or "No") },
pipeline = {
title = "Pipeline Status",
content = function()
local pipeline = info.head_pipeline ~= vim.NIL and info.head_pipeline or info.pipeline
if type(pipeline) ~= "table" or (type(pipeline) == "table" and u.table_size(pipeline) == 0) then
return ""
end
return pipeline.status
end,
},
web_url = { title = "MR URL", content = info.web_url },
mergeability_checks = { title = "Mergeability checks", content = make_mergeability_checks },
}
local longest_used = ""
for _, v in ipairs(state.settings.info.fields) do
if v == "merge_status" then
v = "detailed_merge_status"
end -- merge_status was deprecated, see https://gitlab.com/gitlab-org/gitlab/-/issues/3169#note_1162532204
if options[v] == nil then
u.notify(string.format("Invalid field in settings.info.fields: '%s'", v), vim.log.levels.ERROR)
else
local title = options[v].title
if vim.fn.strcharlen(title) > vim.fn.strcharlen(longest_used) then
longest_used = title
end
end
end
local function row_offset(row)
local offset = vim.fn.strcharlen(longest_used) - vim.fn.strcharlen(row)
return string.rep(nbsp, offset + 3)
end
local result = {}
for _, v in ipairs(state.settings.info.fields) do
if v == "merge_status" then
v = "detailed_merge_status"
end
local row = options[v]
local title_prefix = "* " .. row.title .. row_offset(row.title)
local content = type(row.content) == "function" and row.content() or row.content
if type(content) == "table" then
-- Multi-line content
local padding = string.rep(nbsp, vim.fn.strcharlen(title_prefix)) -- no-break space
for i, line in ipairs(#content > 0 and content or { "" }) do
table.insert(result, (i == 1 and title_prefix or padding) .. line)
end
else
-- Single-line content
table.insert(result, title_prefix .. (content or ""))
end
end
return result
end
-- This function will PUT the new description to the Go server
M.edit_summary = function()
local description = u.get_buffer_text(M.description_bufnr)
local title = u.get_buffer_text(M.title_bufnr):gsub("\n", " ")
local body = { title = title, description = description }
job.run_job("/mr/summary", "PUT", body, function(data)
u.notify(data.message, vim.log.levels.INFO)
state.INFO.description = data.mr.description
state.INFO.title = data.mr.title
end)
end
---Create the Summary layout and individual popups that make up the Layout.
---@return NuiLayout, NuiPopup, NuiPopup, NuiPopup
M.create_layout = function(info_lines)
local settings = u.merge(state.settings.popup, state.settings.popup.summary or {})
local title_popup = Popup(popup.create_box_popup_state(nil, false, settings))
M.title_bufnr = title_popup.bufnr
local description_popup = Popup(popup.create_popup_state("Description", settings))
M.description_bufnr = description_popup.bufnr
local details_popup
if state.settings.info.enabled then
details_popup = Popup(popup.create_box_popup_state("Details", false, settings))
end
local internal_layout = M.create_internal_layout(info_lines, title_popup, description_popup, details_popup)
local layout = Layout(M.get_outer_layout_config(), internal_layout)
popup.set_up_autocommands(description_popup, layout, vim.api.nvim_get_current_win())
return layout, title_popup, description_popup, details_popup
end
---Create the internal layout of the Summary and individual popups that make up the Layout.
---@param info_lines string[] Table of strings that make up the details content
---@param title_popup NuiPopup
---@param description_popup NuiPopup
---@param details_popup NuiPopup
---@return NuiLayout.Box
M.create_internal_layout = function(info_lines, title_popup, description_popup, details_popup)
local internal_layout
if state.settings.info.enabled then
if state.settings.info.horizontal then
local longest_line = u.get_longest_string(info_lines)
internal_layout = Layout.Box({
Layout.Box(title_popup, { size = 3 }),
Layout.Box({
Layout.Box(details_popup, { size = longest_line + 3 }),
Layout.Box(description_popup, { grow = 1 }),
}, { dir = "row", size = "95%" }),
}, { dir = "col" })
else
internal_layout = Layout.Box({
Layout.Box(title_popup, { size = 3 }),
Layout.Box(description_popup, { grow = 1 }),
Layout.Box(details_popup, { size = #info_lines + 3 }),
}, { dir = "col" })
end
else
internal_layout = Layout.Box({
Layout.Box(title_popup, { size = 3 }),
Layout.Box(description_popup, { grow = 1 }),
}, { dir = "col" })
end
return internal_layout
end
---Create the config for the outer Layout of the Summary
---@return nui_layout_options
M.get_outer_layout_config = function()
local settings = u.merge(state.settings.popup, state.settings.popup.summary or {})
return {
position = settings.position,
relative = "editor",
size = {
width = settings.width,
height = settings.height,
},
}
end
M.color_details = function(bufnr)
local details_namespace = vim.api.nvim_create_namespace("Details")
for i, line in ipairs(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)) do
if line:match("^* Labels") then
for j, label in ipairs(state.LABELS) do
local start_idx, end_idx = line:find(label.Name, 1, true)
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.hl.range(bufnr, details_namespace, ("label" .. j), { i - 1, start_idx - 1 }, { i - 1, end_idx })
end
end
elseif line:match("^* Status") then
local status = line:match("[^" .. nbsp .. "]-$")
local hl = ({
blocked_status = "DiagnosticError",
broken_status = "DiagnosticError",
checking = "DiagnosticInfo",
ci_must_pass = "DiagnosticWarn",
ci_still_running = "DiagnosticInfo",
discussions_not_resolved = "DiagnosticWarn",
draft_status = "Comment",
external_status_checks = "DiagnosticHint",
mergeable = "DiagnosticOK",
not_approved = "DiagnosticWarn",
not_open = "NonText",
policies_denied = "DiagnosticError",
unchecked = "NonText",
})[status] or "Normal"
local start_idx, end_idx = line:find("[^" .. nbsp .. "]-$")
vim.hl.range(bufnr, details_namespace, hl, { i - 1, start_idx - 1 }, { i - 1, end_idx })
elseif line:match("^* Branch") or line:match("^* Target Branch") then
local start_idx, end_idx = line:find("[^" .. nbsp .. "]-$")
vim.hl.range(bufnr, details_namespace, "Title", { i - 1, start_idx - 1 }, { i - 1, end_idx })
elseif line:match("^* Pipeline") then
local status = line:match("[^" .. nbsp .. "]-$")
local hl = ({
canceled = "DiagnosticWarn",
created = "DiagnosticInfo",
failed = "DiagnosticError",
manual = "DiagnosticHint",
pending = "DiagnosticWarn",
running = "DiagnosticInfo",
skipped = "Comment",
success = "DiagnosticOK",
unknown = "NonText",
})[status] or "Normal"
local start_idx, end_idx = line:find("[^" .. nbsp .. "]-$")
vim.hl.range(bufnr, details_namespace, hl, { i - 1, start_idx - 1 }, { i - 1, end_idx })
elseif line:match(nbsp .. "No$") or line:match(nbsp .. "Yes$") then
local start_idx, end_idx = line:find("[^" .. nbsp .. "]-$")
vim.api.nvim_set_hl(0, "boolean", { link = "Constant" })
vim.hl.range(bufnr, details_namespace, "boolean", { i - 1, start_idx - 1 }, { i - 1, end_idx })
end
end
end
return M