BREAKING CHANGE: Delta Pager + Large Refactor (#43)
BREAKING CHANGE: This MR addresses an underlying issue with the original implementation in regards to detecting line numbers for comments. As such, this is a major breaking change. The setup function signature has changed, please review the `README.md` for the new arguments. The delta pager has also been added as a dependency: https://github.com/dandavison/delta There will be future work to implement a native solution for parsing changes and line numbers.
This commit is contained in:
committed by
GitHub
parent
ed67a03f8f
commit
19468a3d2d
13
lua/gitlab/actions/approvals.lua
Normal file
13
lua/gitlab/actions/approvals.lua
Normal file
@@ -0,0 +1,13 @@
|
||||
local job = require("gitlab.job")
|
||||
|
||||
local M = {}
|
||||
|
||||
M.approve = function()
|
||||
job.run_job("/approve", "POST")
|
||||
end
|
||||
|
||||
M.revoke = function()
|
||||
job.run_job("/revoke", "POST")
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,3 +1,5 @@
|
||||
-- This module is responsible for the assignment of reviewers
|
||||
-- and assignees in Gitlab, those who must review an MR.
|
||||
local u = require("gitlab.utils")
|
||||
local job = require("gitlab.job")
|
||||
local state = require("gitlab.state")
|
||||
@@ -33,7 +35,7 @@ M.add_popup = function(type)
|
||||
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)
|
||||
job.run_job("/mr/" .. type, "PUT", json, function(data)
|
||||
vim.notify(data.message, vim.log.levels.INFO)
|
||||
state.INFO[plural] = data[plural]
|
||||
end)
|
||||
@@ -52,7 +54,7 @@ M.delete_popup = function(type)
|
||||
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)
|
||||
job.run_job("/mr/" .. type, "PUT", json, function(data)
|
||||
vim.notify(data.message, vim.log.levels.INFO)
|
||||
state.INFO[plural] = data[plural]
|
||||
end)
|
||||
64
lua/gitlab/actions/comment.lua
Normal file
64
lua/gitlab/actions/comment.lua
Normal file
@@ -0,0 +1,64 @@
|
||||
-- This module is responsible for creating new comments
|
||||
-- in the reviewer's buffer. The reviewer will pass back
|
||||
-- to this module the data required to make the API calls
|
||||
local Popup = require("nui.popup")
|
||||
local state = require("gitlab.state")
|
||||
local job = require("gitlab.job")
|
||||
local u = require("gitlab.utils")
|
||||
local discussions = require("gitlab.actions.discussions")
|
||||
local reviewer = require("gitlab.reviewer")
|
||||
local M = {}
|
||||
|
||||
local comment_popup = Popup(u.create_popup_state("Comment", "40%", "60%"))
|
||||
|
||||
-- This function will open a comment popup in order to create a comment on the changed/updated line in the current MR
|
||||
M.create_comment = function()
|
||||
comment_popup:mount()
|
||||
state.set_popup_keymaps(comment_popup, M.confirm_create_comment)
|
||||
end
|
||||
|
||||
-- This function (settings.popup.perform_action) will send the comment to the Go server
|
||||
M.confirm_create_comment = function(text)
|
||||
local file_name, line_numbers, error = reviewer.get_location()
|
||||
|
||||
if error then
|
||||
vim.notify(error, vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
if file_name == nil then
|
||||
vim.notify("Reviewer did not provide file name", vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
if line_numbers == nil then
|
||||
vim.notify("Reviewer did not provide line numbers of change", vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
if text == nil then
|
||||
vim.notify("Reviewer did not provide text of change", vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
local revision = state.MR_REVISIONS[1]
|
||||
local jsonTable = {
|
||||
comment = text,
|
||||
file_name = file_name,
|
||||
old_line = line_numbers.old_line,
|
||||
new_line = line_numbers.new_line,
|
||||
base_commit_sha = revision.base_commit_sha,
|
||||
start_commit_sha = revision.start_commit_sha,
|
||||
head_commit_sha = revision.head_commit_sha,
|
||||
type = "modification"
|
||||
}
|
||||
|
||||
local json = vim.json.encode(jsonTable)
|
||||
|
||||
job.run_job("/comment", "POST", json, function(data)
|
||||
vim.notify("Comment created")
|
||||
discussions.refresh_tree()
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
475
lua/gitlab/actions/discussions.lua
Normal file
475
lua/gitlab/actions/discussions.lua
Normal file
@@ -0,0 +1,475 @@
|
||||
-- This module is responsible for the discussion tree. That includes things like
|
||||
-- editing existing notes in the tree, replying to notes in the tree,
|
||||
-- and marking discussions as resolved/unresolved.
|
||||
local Popup = require("nui.popup")
|
||||
local Menu = require("nui.menu")
|
||||
local NuiTree = require("nui.tree")
|
||||
local NuiSplit = require("nui.split")
|
||||
local job = require("gitlab.job")
|
||||
local u = require("gitlab.utils")
|
||||
local state = require("gitlab.state")
|
||||
local reviewer = require("gitlab.reviewer")
|
||||
|
||||
local edit_popup = Popup(u.create_popup_state("Edit Comment", "80%", "80%"))
|
||||
local reply_popup = Popup(u.create_popup_state("Reply", "80%", "80%"))
|
||||
|
||||
local M = {
|
||||
split_visible = false,
|
||||
split = nil,
|
||||
split_buf = nil,
|
||||
tree = nil
|
||||
}
|
||||
|
||||
M.set_tree_keymaps = function()
|
||||
vim.keymap.set('n', state.settings.discussion_tree.jump_to_file, M.jump_to_file, { buffer = true })
|
||||
vim.keymap.set('n', state.settings.discussion_tree.jump_to_reviewer, M.jump_to_reviewer, { buffer = true })
|
||||
vim.keymap.set('n', state.settings.discussion_tree.edit_comment, M.edit_comment, { buffer = true })
|
||||
vim.keymap.set('n', state.settings.discussion_tree.delete_comment, M.delete_comment, { buffer = true })
|
||||
vim.keymap.set('n', state.settings.discussion_tree.toggle_resolved, M.toggle_resolved, { buffer = true })
|
||||
vim.keymap.set('n', state.settings.discussion_tree.toggle_node, M.toggle_node, { buffer = true })
|
||||
vim.keymap.set('n', state.settings.discussion_tree.reply, M.reply, { buffer = true })
|
||||
end
|
||||
|
||||
-- Opens the discussion tree, sets the keybindings,
|
||||
M.toggle = function()
|
||||
if M.split_visible then
|
||||
M.split:hide()
|
||||
M.split_visible = false
|
||||
return
|
||||
end
|
||||
|
||||
if M.split then
|
||||
M.split:show()
|
||||
M.split_visible = true
|
||||
return
|
||||
end
|
||||
|
||||
local split = NuiSplit({
|
||||
buf_options = { modifiable = false },
|
||||
relative = state.settings.discussion_tree.relative,
|
||||
position = state.settings.discussion_tree.position,
|
||||
size = state.settings.discussion_tree.size,
|
||||
})
|
||||
|
||||
split:mount()
|
||||
M.split = split
|
||||
M.split_visible = true
|
||||
M.split_buf = split.bufnr
|
||||
state.discussion_buf = split.bufnr
|
||||
|
||||
vim.api.nvim_create_autocmd({ "QuitPre", "BufDelete", "BufUnload" }, {
|
||||
buffer = split.bufnr,
|
||||
callback = function()
|
||||
M.split = nil
|
||||
M.split_visible = false
|
||||
M.split_buf = nil
|
||||
end,
|
||||
desc = "Handles users who close the split in non-keybinding fashion",
|
||||
})
|
||||
|
||||
local buf = M.split.bufnr
|
||||
|
||||
job.run_job("/discussions", "GET", nil, function(data)
|
||||
if type(data.discussions) ~= "table" then
|
||||
vim.notify("No discussions for this MR", vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
|
||||
local tree_nodes = M.add_discussions_to_table(data.discussions)
|
||||
|
||||
M.tree = NuiTree({ nodes = tree_nodes, bufnr = buf })
|
||||
M.set_tree_keymaps()
|
||||
|
||||
M.tree:render()
|
||||
vim.api.nvim_buf_set_option(buf, 'filetype', 'markdown')
|
||||
end)
|
||||
end
|
||||
|
||||
-- The reply popup will mount in a window when you trigger it (settings.discussion_tree.reply) when hovering over a node in the discussion tree.
|
||||
M.reply = function()
|
||||
local node = M.tree:get_node()
|
||||
local discussion_node = M.get_root_node(node)
|
||||
local id = tostring(discussion_node.id)
|
||||
reply_popup:mount()
|
||||
state.set_popup_keymaps(reply_popup, M.send_reply(id))
|
||||
end
|
||||
|
||||
-- This function will send the reply to the Go API
|
||||
M.send_reply = function(discussion_id)
|
||||
print(discussion_id)
|
||||
return function(text)
|
||||
local jsonTable = { discussion_id = discussion_id, reply = text }
|
||||
local json = vim.json.encode(jsonTable)
|
||||
job.run_job("/reply", "POST", json, function(data)
|
||||
M.add_note_to_tree(data.note, discussion_id)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
-- This function (settings.discussion_tree.delete_comment) will trigger a popup prompting you to delete the current comment
|
||||
M.delete_comment = function()
|
||||
local menu = Menu({
|
||||
position = "50%",
|
||||
size = {
|
||||
width = 25,
|
||||
},
|
||||
border = {
|
||||
style = "single",
|
||||
text = {
|
||||
top = "Delete Comment?",
|
||||
top_align = "center",
|
||||
},
|
||||
},
|
||||
win_options = {
|
||||
winhighlight = "Normal:Normal,FloatBorder:Normal",
|
||||
},
|
||||
}, {
|
||||
lines = {
|
||||
Menu.item("Confirm"),
|
||||
Menu.item("Cancel"),
|
||||
},
|
||||
max_width = 20,
|
||||
keymap = {
|
||||
focus_next = state.settings.dialogue.focus_next,
|
||||
focus_prev = state.settings.dialogue.focus_prev,
|
||||
close = state.settings.dialogue.close,
|
||||
submit = state.settings.dialogue.submit,
|
||||
},
|
||||
on_submit = M.send_deletion
|
||||
})
|
||||
menu:mount()
|
||||
end
|
||||
|
||||
-- This function will actually send the deletion to Gitlab
|
||||
-- when you make a selection
|
||||
M.send_deletion = function(item)
|
||||
if item.text == "Confirm" then
|
||||
local current_node = M.tree:get_node()
|
||||
|
||||
local note_node = M.get_note_node(current_node)
|
||||
local root_node = M.get_root_node(current_node)
|
||||
local note_id = note_node.is_root and root_node.root_note_id or note_node.id
|
||||
|
||||
local jsonTable = { discussion_id = root_node.id, note_id = note_id }
|
||||
local json = vim.json.encode(jsonTable)
|
||||
|
||||
job.run_job("/comment", "DELETE", json, function(data)
|
||||
vim.notify(data.message, vim.log.levels.INFO)
|
||||
if not note_node.is_root then
|
||||
M.tree:remove_node("-" .. note_id)
|
||||
M.tree:render()
|
||||
else
|
||||
-- We are removing the root node of the discussion,
|
||||
-- we need to move all the children around, the easiest way
|
||||
-- to do this is to just re-render the whole tree 🤷
|
||||
M.refresh_tree()
|
||||
note_node:expand()
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
-- This function (settings.discussion_tree.edit_comment) will open the edit popup for the current comment in the discussion tree
|
||||
M.edit_comment = function()
|
||||
local current_node = M.tree:get_node()
|
||||
local note_node = M.get_note_node(current_node)
|
||||
local root_node = M.get_root_node(current_node)
|
||||
|
||||
edit_popup:mount()
|
||||
|
||||
local lines = {} -- Gather all lines from immediate children that aren't note nodes
|
||||
local children_ids = note_node:get_child_ids()
|
||||
for _, child_id in ipairs(children_ids) do
|
||||
local child_node = M.tree:get_node(child_id)
|
||||
if (not child_node:has_children()) then
|
||||
local line = M.tree:get_node(child_id).text
|
||||
table.insert(lines, line)
|
||||
end
|
||||
end
|
||||
|
||||
local currentBuffer = vim.api.nvim_get_current_buf()
|
||||
vim.api.nvim_buf_set_lines(currentBuffer, 0, -1, false, lines)
|
||||
state.set_popup_keymaps(edit_popup, M.send_edits(tostring(root_node.id), note_node.root_note_id or note_node.id))
|
||||
end
|
||||
|
||||
-- This function sends the edited comment to the Go server
|
||||
M.send_edits = function(discussion_id, note_id)
|
||||
return function(text)
|
||||
local json_table = {
|
||||
discussion_id = discussion_id,
|
||||
note_id = note_id,
|
||||
comment = text
|
||||
}
|
||||
local json = vim.json.encode(json_table)
|
||||
job.run_job("/comment", "PATCH", json, function(data)
|
||||
vim.notify(data.message, vim.log.levels.INFO)
|
||||
M.redraw_text(text)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
-- This comment (settings.discussion_tree.toggle_resolved) will toggle the resolved status of the current discussion and send the change to the Go server
|
||||
M.toggle_resolved = function()
|
||||
local note = M.tree:get_node()
|
||||
if not note or not note.resolvable then return end
|
||||
|
||||
local json_table = {
|
||||
discussion_id = note.id,
|
||||
note_id = note.root_note_id,
|
||||
resolved = not note.resolved,
|
||||
}
|
||||
|
||||
local json = vim.json.encode(json_table)
|
||||
job.run_job("/comment", "PATCH", json, function(data)
|
||||
vim.notify(data.message, vim.log.levels.INFO)
|
||||
M.redraw_resolved_status(note, not note.resolved)
|
||||
end)
|
||||
end
|
||||
|
||||
-- This function (settings.discussion_tree.jump_to_reviewer) will jump the cursor to the reviewer's location associated with the note. The implementation depends on the reviewer
|
||||
M.jump_to_reviewer = function()
|
||||
local file_name, new_line, old_line, error = M.get_note_location()
|
||||
if error ~= nil then
|
||||
vim.notify(error, vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
reviewer.jump(file_name, new_line, old_line)
|
||||
end
|
||||
|
||||
-- This function (settings.discussion_tree.jump_to_file) will jump to the file changed in a new tab
|
||||
M.jump_to_file = function()
|
||||
local file_name, new_line, old_line, error = M.get_note_location()
|
||||
if error ~= nil then
|
||||
vim.notify(error, vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
vim.cmd.tabnew()
|
||||
u.jump_to_file(file_name, (new_line or old_line))
|
||||
end
|
||||
|
||||
-- This function (settings.discussion_tree.toggle_node) expands/collapses the current node and its children
|
||||
M.toggle_node = function()
|
||||
local node = M.tree:get_node()
|
||||
if node == nil then return end
|
||||
local children = node:get_child_ids()
|
||||
if node == nil then return end
|
||||
if node:is_expanded() then
|
||||
node:collapse()
|
||||
for _, child in ipairs(children) do
|
||||
M.tree:get_node(child):collapse()
|
||||
end
|
||||
else
|
||||
for _, child in ipairs(children) do
|
||||
M.tree:get_node(child):expand()
|
||||
end
|
||||
node:expand()
|
||||
end
|
||||
|
||||
M.tree:render()
|
||||
end
|
||||
|
||||
|
||||
--
|
||||
-- 🌲 Helper Functions
|
||||
--
|
||||
|
||||
M.redraw_resolved_status = function(note, mark_resolved)
|
||||
local current_text = M.tree.nodes.by_id["-" .. note.id].text
|
||||
local target = mark_resolved and 'resolved' or 'unresolved'
|
||||
local current = mark_resolved and 'unresolved' or 'resolved'
|
||||
|
||||
local function set_property(key, val)
|
||||
M.tree.nodes.by_id["-" .. note.id][key] = val
|
||||
end
|
||||
|
||||
local has_symbol = function(s)
|
||||
return state.settings.discussion_tree[s] ~= nil and state.settings.discussion_tree[s] ~= ''
|
||||
end
|
||||
|
||||
set_property('resolved', mark_resolved)
|
||||
|
||||
if not has_symbol(current) and not has_symbol(target) then return end
|
||||
|
||||
if not has_symbol(current) and has_symbol(target) then
|
||||
set_property('text', (current_text .. " " .. state.settings.discussion_tree[target]))
|
||||
elseif has_symbol(current) and not has_symbol(target) then
|
||||
set_property('text', u.remove_last_chunk(current_text))
|
||||
else
|
||||
set_property('text', (u.remove_last_chunk(current_text) .. " " .. state.settings.discussion_tree[target]))
|
||||
end
|
||||
|
||||
M.tree:render()
|
||||
end
|
||||
|
||||
M.redraw_text = function(text)
|
||||
local current_node = M.tree:get_node()
|
||||
local note_node = M.get_note_node(current_node)
|
||||
|
||||
local childrenIds = note_node:get_child_ids()
|
||||
for _, value in ipairs(childrenIds) do
|
||||
M.tree:remove_node(value)
|
||||
end
|
||||
|
||||
local newNoteTextNodes = {}
|
||||
for bodyLine in text:gmatch("[^\n]+") do
|
||||
table.insert(newNoteTextNodes, NuiTree.Node({ text = bodyLine, is_body = true }, {}))
|
||||
end
|
||||
|
||||
M.tree:set_nodes(newNoteTextNodes, "-" .. note_node.id)
|
||||
M.tree:render()
|
||||
end
|
||||
|
||||
M.get_root_node = function(node)
|
||||
if (not node.is_root) then
|
||||
local parent_id = node:get_parent_id()
|
||||
return M.get_root_node(M.tree:get_node(parent_id))
|
||||
else
|
||||
return node
|
||||
end
|
||||
end
|
||||
|
||||
M.get_note_node = function(node)
|
||||
if (not node.is_note) then
|
||||
local parent_id = node:get_parent_id()
|
||||
if parent_id == nil then return node end
|
||||
return M.get_note_node(M.tree:get_node(parent_id))
|
||||
else
|
||||
return node
|
||||
end
|
||||
end
|
||||
|
||||
local attach_uuid = function(str)
|
||||
return { text = str, id = u.uuid() }
|
||||
end
|
||||
|
||||
M.build_note_body = function(note, resolve_info)
|
||||
local text_nodes = {}
|
||||
for bodyLine in note.body:gmatch("[^\n]+") do
|
||||
local line = attach_uuid(bodyLine)
|
||||
table.insert(text_nodes, NuiTree.Node({
|
||||
new_line = note.position.new_line,
|
||||
old_line = note.position.old_line,
|
||||
text = line.text,
|
||||
id = line.id,
|
||||
is_body = true
|
||||
}, {}))
|
||||
end
|
||||
|
||||
local resolve_symbol = ''
|
||||
if resolve_info ~= nil and resolve_info.resolvable then
|
||||
resolve_symbol = resolve_info.resolved and state.settings.discussion_tree.resolved or
|
||||
state.settings.discussion_tree.unresolved
|
||||
end
|
||||
|
||||
local noteHeader = "@" .. note.author.username .. " " .. u.format_date(note.created_at) .. " " .. resolve_symbol
|
||||
|
||||
return noteHeader, text_nodes
|
||||
end
|
||||
|
||||
M.build_note = function(note, resolve_info)
|
||||
local text, text_nodes = M.build_note_body(note, resolve_info)
|
||||
local note_node = NuiTree.Node({
|
||||
text = text,
|
||||
id = note.id,
|
||||
file_name = note.position.new_path,
|
||||
new_line = note.position.new_line,
|
||||
old_line = note.position.old_line,
|
||||
is_note = true,
|
||||
}, text_nodes)
|
||||
|
||||
return note_node, text, text_nodes
|
||||
end
|
||||
|
||||
M.add_note_to_tree = function(note, discussion_id)
|
||||
local note_node = M.build_note(note)
|
||||
note_node:expand()
|
||||
M.tree:add_node(note_node, discussion_id and ("-" .. discussion_id) or nil)
|
||||
M.tree:render()
|
||||
vim.notify("Sent reply!", vim.log.levels.INFO)
|
||||
end
|
||||
|
||||
M.refresh_tree = function()
|
||||
job.run_job("/discussions", "GET", nil, function(data)
|
||||
if type(data.discussions) ~= "table" then
|
||||
vim.notify("No discussions for this MR")
|
||||
return
|
||||
end
|
||||
|
||||
if not M.split_buf or (vim.fn.bufwinid(M.split_buf) == -1) then return end
|
||||
|
||||
vim.api.nvim_buf_set_option(M.split_buf, 'modifiable', true)
|
||||
vim.api.nvim_buf_set_option(M.split_buf, 'readonly', false)
|
||||
vim.api.nvim_buf_set_lines(M.split_buf, 0, -1, false, {})
|
||||
vim.api.nvim_buf_set_option(M.split_buf, 'readonly', true)
|
||||
vim.api.nvim_buf_set_option(M.split_buf, 'modifiable', false)
|
||||
|
||||
local tree_nodes = M.add_discussions_to_table(data.discussions)
|
||||
M.tree = NuiTree({ nodes = tree_nodes, bufnr = M.split_buf })
|
||||
M.set_tree_keymaps()
|
||||
M.tree:render()
|
||||
vim.api.nvim_buf_set_option(M.split_buf, 'filetype', 'markdown')
|
||||
end)
|
||||
end
|
||||
|
||||
M.add_discussions_to_table = function(discussions)
|
||||
local t = {}
|
||||
for _, discussion in ipairs(discussions) do
|
||||
local discussion_children = {}
|
||||
|
||||
-- These properties are filled in by the first note
|
||||
local root_text = ''
|
||||
local root_note_id = ''
|
||||
local root_file_name = ''
|
||||
local root_id = 0
|
||||
local root_text_nodes = {}
|
||||
local resolvable = false
|
||||
local resolved = false
|
||||
local root_new_line = nil
|
||||
local root_old_line = nil
|
||||
|
||||
for j, note in ipairs(discussion.notes) do
|
||||
if j == 1 then
|
||||
__, root_text, root_text_nodes = M.build_note(note, { resolved = note.resolved, resolvable = note.resolvable })
|
||||
root_file_name = note.position.new_path
|
||||
root_new_line = note.position.new_line
|
||||
root_old_line = note.position.old_line
|
||||
root_id = discussion.id
|
||||
root_note_id = note.id
|
||||
resolvable = note.resolvable
|
||||
resolved = note.resolved
|
||||
else -- Otherwise insert it as a child node...
|
||||
local note_node = M.build_note(note)
|
||||
table.insert(discussion_children, note_node)
|
||||
end
|
||||
end
|
||||
|
||||
-- Creates the first node in the discussion, and attaches children
|
||||
local body = u.join_tables(root_text_nodes, discussion_children)
|
||||
local root_node = NuiTree.Node({
|
||||
text = root_text,
|
||||
is_note = true,
|
||||
is_root = true,
|
||||
id = root_id,
|
||||
root_note_id = root_note_id,
|
||||
file_name = root_file_name,
|
||||
new_line = root_new_line,
|
||||
old_line = root_old_line,
|
||||
resolvable = resolvable,
|
||||
resolved = resolved
|
||||
}, body)
|
||||
|
||||
table.insert(t, root_node)
|
||||
end
|
||||
|
||||
return t
|
||||
end
|
||||
|
||||
M.get_note_location = function()
|
||||
local node = M.tree:get_node()
|
||||
if node == nil then return nil, nil, nil, "Could not get node" end
|
||||
local discussion_node = M.get_root_node(node)
|
||||
if discussion_node == nil then return nil, nil, nil, "Could not get discussion node" end
|
||||
return discussion_node.file_name, discussion_node.new_line, discussion_node.old_line
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,12 +1,15 @@
|
||||
-- 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 Popup = require("nui.popup")
|
||||
local job = require("gitlab.job")
|
||||
local state = require("gitlab.state")
|
||||
local Popup = require("nui.popup")
|
||||
local u = require("gitlab.utils")
|
||||
local keymaps = require("gitlab.keymaps")
|
||||
local descriptionPopup = Popup(u.create_popup_state("Loading Description...", "80%", "80%"))
|
||||
local M = {}
|
||||
|
||||
-- The MR description will mount in a popup when this funciton is called
|
||||
local descriptionPopup = Popup(u.create_popup_state("Loading Description...", "80%", "80%"))
|
||||
|
||||
-- The function will render the MR description in a popup
|
||||
M.summary = function()
|
||||
descriptionPopup:mount()
|
||||
local currentBuffer = vim.api.nvim_get_current_buf()
|
||||
@@ -20,7 +23,7 @@ M.summary = function()
|
||||
vim.schedule(function()
|
||||
vim.api.nvim_buf_set_lines(currentBuffer, 0, -1, false, lines)
|
||||
descriptionPopup.border:set_text("top", title, "center")
|
||||
keymaps.set_popup_keymaps(descriptionPopup, M.edit_description)
|
||||
state.set_popup_keymaps(descriptionPopup, M.edit_description)
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -28,7 +31,7 @@ end
|
||||
M.edit_description = function(text)
|
||||
local jsonTable = { description = text }
|
||||
local json = vim.json.encode(jsonTable)
|
||||
job.run_job("mr/description", "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)
|
||||
67
lua/gitlab/async.lua
Normal file
67
lua/gitlab/async.lua
Normal file
@@ -0,0 +1,67 @@
|
||||
-- This module is responsible for calling APIs in sequence. It provides
|
||||
-- an abstraction around the APIs that lets us ensure state.
|
||||
local server = require("gitlab.server")
|
||||
local job = require("gitlab.job")
|
||||
local state = require("gitlab.state")
|
||||
|
||||
local M = {}
|
||||
|
||||
Async = {
|
||||
cb = nil
|
||||
}
|
||||
|
||||
function Async:new(o)
|
||||
o = o or {}
|
||||
setmetatable(o, self)
|
||||
self.__index = self
|
||||
return o
|
||||
end
|
||||
|
||||
function Async:init(cb)
|
||||
self.cb = cb
|
||||
end
|
||||
|
||||
function Async:fetch(dependencies, i)
|
||||
if i > #dependencies then
|
||||
self:cb()
|
||||
return
|
||||
end
|
||||
|
||||
local dependency = dependencies[i]
|
||||
|
||||
-- Do not call endpoint unless refresh is required
|
||||
if state[dependency.state] ~= nil and not dependency.refresh then
|
||||
self:fetch(dependencies, i + 1)
|
||||
return
|
||||
end
|
||||
|
||||
job.run_job(dependency.endpoint, "GET", dependency.body, function(data)
|
||||
state[dependency.state] = data[dependency.key]
|
||||
self:fetch(dependencies, i + 1)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Will call APIs in sequence and set global state
|
||||
M.sequence = function(dependencies, cb)
|
||||
return function()
|
||||
local handler = Async:new()
|
||||
handler:init(cb)
|
||||
|
||||
if not state.is_gitlab_project then
|
||||
vim.notify("The gitlab.nvim state was not set. Do you have a .gitlab.nvim file configured?", vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
if state.go_server_running then
|
||||
handler:fetch(dependencies, 1)
|
||||
return
|
||||
end
|
||||
|
||||
server.start_server(function()
|
||||
state.go_server_running = true
|
||||
handler:fetch(dependencies, 1)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,250 +0,0 @@
|
||||
local Menu = require("nui.menu")
|
||||
local NuiTree = require("nui.tree")
|
||||
local Popup = require("nui.popup")
|
||||
local job = require("gitlab.job")
|
||||
local state = require("gitlab.state")
|
||||
local u = require("gitlab.utils")
|
||||
local discussions = require("gitlab.discussions")
|
||||
local keymaps = require("gitlab.keymaps")
|
||||
local M = {}
|
||||
|
||||
local comment_popup = Popup(u.create_popup_state("Comment", "40%", "60%"))
|
||||
local edit_popup = Popup(u.create_popup_state("Edit Comment", "80%", "80%"))
|
||||
|
||||
-- This function will open a comment popup in order to create a comment on the changed/updated line in the current MR
|
||||
M.create_comment = function()
|
||||
comment_popup:mount()
|
||||
keymaps.set_popup_keymaps(comment_popup, M.confirm_create_comment)
|
||||
end
|
||||
|
||||
-- This function (keymaps.popup.perform_action) will send the comment to the Go server
|
||||
M.confirm_create_comment = function(text)
|
||||
local relative_file_path = u.get_relative_file_path()
|
||||
local current_line_number = u.get_current_line_number()
|
||||
if relative_file_path == nil then return end
|
||||
|
||||
-- If leaving a comment on a deleted line, get hash value + proper filename
|
||||
local sha = ""
|
||||
local is_base_file = relative_file_path:find(".git")
|
||||
if is_base_file then -- We are looking at a deletion.
|
||||
local _, path = u.split_diff_view_filename(relative_file_path)
|
||||
relative_file_path = path
|
||||
sha = M.find_deletion_commit(path)
|
||||
if sha == "" then
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
-- TODO: How can we know whether to specify that the comment is on a line that has been modified,
|
||||
-- added, or deleted? Additionally, how will we know which line number to send?
|
||||
-- We need an intelligent way of getting this information so that we can send it to the comment
|
||||
-- creation endpoint, relates to Issue #25: https://github.com/harrisoncramer/gitlab.nvim/issues/25
|
||||
|
||||
local revision = state.MR_REVISIONS[1]
|
||||
local jsonTable = {
|
||||
comment = text,
|
||||
file_name = relative_file_path,
|
||||
line_number = current_line_number,
|
||||
base_commit_sha = revision.base_commit_sha,
|
||||
start_commit_sha = revision.start_commit_sha,
|
||||
head_commit_sha = revision.head_commit_sha,
|
||||
type = "modification"
|
||||
}
|
||||
|
||||
local json = vim.json.encode(jsonTable)
|
||||
|
||||
job.run_job("comment", "POST", json, function(data)
|
||||
vim.notify("Comment created")
|
||||
discussions.refresh_tree()
|
||||
end)
|
||||
end
|
||||
|
||||
-- This function (keymaps.discussion_tree.delete_comment) will trigger a popup prompting you to delete the current comment
|
||||
M.delete_comment = function()
|
||||
local menu = Menu({
|
||||
position = "50%",
|
||||
size = {
|
||||
width = 25,
|
||||
},
|
||||
border = {
|
||||
style = "single",
|
||||
text = {
|
||||
top = "Delete Comment?",
|
||||
top_align = "center",
|
||||
},
|
||||
},
|
||||
win_options = {
|
||||
winhighlight = "Normal:Normal,FloatBorder:Normal",
|
||||
},
|
||||
}, {
|
||||
lines = {
|
||||
Menu.item("Confirm"),
|
||||
Menu.item("Cancel"),
|
||||
},
|
||||
max_width = 20,
|
||||
keymap = {
|
||||
focus_next = state.keymaps.dialogue.focus_next,
|
||||
focus_prev = state.keymaps.dialogue.focus_prev,
|
||||
close = state.keymaps.dialogue.close,
|
||||
submit = state.keymaps.dialogue.submit,
|
||||
},
|
||||
on_submit = M.send_deletion
|
||||
})
|
||||
menu:mount()
|
||||
end
|
||||
|
||||
-- This function will actually send the deletion to Gitlab
|
||||
-- when you make a selection
|
||||
M.send_deletion = function(item)
|
||||
if item.text == "Confirm" then
|
||||
local current_node = state.tree:get_node()
|
||||
|
||||
local note_node = discussions.get_note_node(current_node)
|
||||
local root_node = discussions.get_root_node(current_node)
|
||||
local note_id = note_node.is_root and root_node.root_note_id or note_node.id
|
||||
|
||||
local jsonTable = { discussion_id = root_node.id, note_id = note_id }
|
||||
local json = vim.json.encode(jsonTable)
|
||||
|
||||
job.run_job("comment", "DELETE", json, function(data)
|
||||
vim.notify(data.message, vim.log.levels.INFO)
|
||||
if not note_node.is_root then
|
||||
state.tree:remove_node("-" .. note_id)
|
||||
state.tree:render()
|
||||
else
|
||||
-- We are removing the root node of the discussion,
|
||||
-- we need to move all the children around, the easiest way
|
||||
-- to do this is to just re-render the whole tree 🤷
|
||||
discussions.refresh_tree()
|
||||
note_node:expand()
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
-- This function (keymaps.discussion_tree.edit_comment) will open the edit popup for the current comment in the discussion tree
|
||||
M.edit_comment = function()
|
||||
local current_node = state.tree:get_node()
|
||||
local note_node = discussions.get_note_node(current_node)
|
||||
local root_node = discussions.get_root_node(current_node)
|
||||
|
||||
edit_popup:mount()
|
||||
|
||||
local lines = {} -- Gather all lines from immediate children that aren't note nodes
|
||||
local children_ids = note_node:get_child_ids()
|
||||
for _, child_id in ipairs(children_ids) do
|
||||
local child_node = state.tree:get_node(child_id)
|
||||
if (not child_node:has_children()) then
|
||||
local line = state.tree:get_node(child_id).text
|
||||
table.insert(lines, line)
|
||||
end
|
||||
end
|
||||
|
||||
local currentBuffer = vim.api.nvim_get_current_buf()
|
||||
vim.api.nvim_buf_set_lines(currentBuffer, 0, -1, false, lines)
|
||||
keymaps.set_popup_keymaps(edit_popup, M.send_edits(tostring(root_node.id), note_node.root_note_id or note_node.id))
|
||||
end
|
||||
|
||||
-- This function sends the edited comment to the Go server
|
||||
M.send_edits = function(discussion_id, note_id)
|
||||
return function(text)
|
||||
local json_table = {
|
||||
discussion_id = discussion_id,
|
||||
note_id = note_id,
|
||||
comment = text
|
||||
}
|
||||
local json = vim.json.encode(json_table)
|
||||
job.run_job("comment", "PATCH", json, function(data)
|
||||
vim.notify(data.message, vim.log.levels.INFO)
|
||||
M.redraw_text(text)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
-- This comment (keymaps.discussion_tree.toggle_resolved) will toggle the resolved status of the current discussion and send the change to the Go server
|
||||
M.toggle_resolved = function()
|
||||
local note = state.tree:get_node()
|
||||
if not note.resolvable then return end
|
||||
|
||||
local json_table = {
|
||||
discussion_id = note.id,
|
||||
note_id = note.root_note_id,
|
||||
resolved = not note.resolved,
|
||||
}
|
||||
|
||||
local json = vim.json.encode(json_table)
|
||||
job.run_job("comment", "PATCH", json, function(data)
|
||||
vim.notify(data.message, vim.log.levels.INFO)
|
||||
M.update_resolved_status(note, not note.resolved)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Helpers
|
||||
M.find_deletion_commit = function(file)
|
||||
local current_line = vim.api.nvim_get_current_line()
|
||||
local command = string.format("git log -S '%s' %s", current_line, file)
|
||||
local handle = io.popen(command)
|
||||
local output = handle:read("*line")
|
||||
if output == nil then
|
||||
vim.notify("Error reading SHA of deletion commit", vim.log.levels.ERROR)
|
||||
return ""
|
||||
end
|
||||
handle:close()
|
||||
local words = {}
|
||||
for word in output:gmatch("%S+") do
|
||||
table.insert(words, word)
|
||||
end
|
||||
|
||||
return words[2]
|
||||
end
|
||||
|
||||
M.update_resolved_status = function(note, mark_resolved)
|
||||
local current_text = state.tree.nodes.by_id["-" .. note.id].text
|
||||
local target = mark_resolved and 'resolved' or 'unresolved'
|
||||
local current = mark_resolved and 'unresolved' or 'resolved'
|
||||
|
||||
local function set_property(key, val)
|
||||
state.tree.nodes.by_id["-" .. note.id][key] = val
|
||||
end
|
||||
|
||||
local has_symbol = function(s)
|
||||
return state.SYMBOLS[s] ~= nil and state.SYMBOLS[s] ~= ''
|
||||
end
|
||||
|
||||
set_property('resolved', mark_resolved)
|
||||
|
||||
if not has_symbol(current) and not has_symbol(target) then return end
|
||||
|
||||
if not has_symbol(current) and has_symbol(target) then
|
||||
set_property('text', (current_text .. " " .. state.SYMBOLS[target]))
|
||||
elseif has_symbol(current) and not has_symbol(target) then
|
||||
set_property('text', u.remove_last_chunk(current_text))
|
||||
else
|
||||
set_property('text', (u.remove_last_chunk(current_text) .. " " .. state.SYMBOLS[target]))
|
||||
end
|
||||
|
||||
state.tree:render()
|
||||
end
|
||||
|
||||
M.redraw_text = function(text)
|
||||
local current_node = state.tree:get_node()
|
||||
local note_node = discussions.get_note_node(current_node)
|
||||
|
||||
local childrenIds = note_node:get_child_ids()
|
||||
for _, value in ipairs(childrenIds) do
|
||||
state.tree:remove_node(value)
|
||||
end
|
||||
|
||||
local newNoteTextNodes = {}
|
||||
for bodyLine in text:gmatch("[^\n]+") do
|
||||
table.insert(newNoteTextNodes, NuiTree.Node({ text = bodyLine, is_body = true }, {}))
|
||||
end
|
||||
|
||||
state.tree:set_nodes(newNoteTextNodes, "-" .. note_node.id)
|
||||
|
||||
state.tree:render()
|
||||
local buf = vim.api.nvim_get_current_buf()
|
||||
u.darken_metadata(buf, '')
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,265 +0,0 @@
|
||||
local u = require("gitlab.utils")
|
||||
local NuiTree = require("nui.tree")
|
||||
local NuiSplit = require("nui.split")
|
||||
local job = require("gitlab.job")
|
||||
local state = require("gitlab.state")
|
||||
local Popup = require("nui.popup")
|
||||
local keymaps = require("gitlab.keymaps")
|
||||
|
||||
local M = {}
|
||||
|
||||
-- Places all of the discussions into a readable tree
|
||||
-- in a split window
|
||||
M.list_discussions = function()
|
||||
job.run_job("discussions", "GET", nil, function(data)
|
||||
if type(data.discussions) ~= "table" then
|
||||
vim.notify("No discussions for this MR")
|
||||
return
|
||||
end
|
||||
|
||||
local splitState = state.DISCUSSION.SPLIT
|
||||
splitState.buf_options = { modifiable = false }
|
||||
local split = NuiSplit(splitState)
|
||||
split:mount()
|
||||
|
||||
local buf = split.bufnr
|
||||
state.SPLIT_BUF = buf
|
||||
|
||||
local tree_nodes = M.add_discussions_to_table(data.discussions)
|
||||
|
||||
state.tree = NuiTree({ nodes = tree_nodes, bufnr = buf })
|
||||
M.set_tree_keymaps(buf)
|
||||
|
||||
state.tree:render()
|
||||
vim.api.nvim_buf_set_option(buf, 'filetype', 'markdown')
|
||||
u.darken_metadata(buf, '')
|
||||
end)
|
||||
end
|
||||
|
||||
-- The reply popup will mount in a window when you trigger it (keymaps.discussion_tree.reply_to_comment) when hovering over a node in the discussion tree.
|
||||
local replyPopup = Popup(u.create_popup_state("Reply", "80%", "80%"))
|
||||
M.reply = function(discussion_id)
|
||||
replyPopup:mount()
|
||||
keymaps.set_popup_keymaps(replyPopup, M.send_reply(discussion_id))
|
||||
end
|
||||
|
||||
-- This function will send the reply to the Go API
|
||||
M.send_reply = function(discussion_id)
|
||||
return function(text)
|
||||
local jsonTable = { discussion_id = discussion_id, reply = text }
|
||||
local json = vim.json.encode(jsonTable)
|
||||
job.run_job("reply", "POST", json, function(data)
|
||||
M.add_note_to_tree(data.note, discussion_id)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
-- This function (keymaps.discussion_tree.jump_to_location) will
|
||||
-- jump you to the file and line where the comment was left
|
||||
M.jump_to_file = function()
|
||||
local node = state.tree:get_node()
|
||||
if node == nil then return end
|
||||
|
||||
local wins = vim.api.nvim_list_wins()
|
||||
local discussion_win = vim.api.nvim_get_current_win()
|
||||
for _, winId in ipairs(wins) do
|
||||
if winId ~= discussion_win then
|
||||
vim.api.nvim_set_current_win(winId)
|
||||
end
|
||||
end
|
||||
|
||||
local discussion_node = M.get_root_node(node)
|
||||
u.jump_to_file(discussion_node.file_name, discussion_node.line_number)
|
||||
end
|
||||
|
||||
M.set_tree_keymaps = function(buf)
|
||||
vim.keymap.set('n', state.keymaps.discussion_tree.jump_to_location, function()
|
||||
M.jump_to_file()
|
||||
end, { buffer = true })
|
||||
|
||||
vim.keymap.set('n', state.keymaps.discussion_tree.edit_comment, function()
|
||||
require("gitlab.comment").edit_comment()
|
||||
end, { buffer = true })
|
||||
|
||||
vim.keymap.set('n', state.keymaps.discussion_tree.delete_comment, function()
|
||||
require("gitlab.comment").delete_comment()
|
||||
end, { buffer = true })
|
||||
|
||||
vim.keymap.set('n', state.keymaps.discussion_tree.toggle_resolved, function()
|
||||
require("gitlab.comment").toggle_resolved()
|
||||
end, { buffer = true })
|
||||
|
||||
-- Expands/collapses the current node
|
||||
vim.keymap.set('n', state.keymaps.discussion_tree.toggle_node, function()
|
||||
local node = state.tree:get_node()
|
||||
if node == nil then return end
|
||||
local children = node:get_child_ids()
|
||||
if node == nil then return end
|
||||
if node:is_expanded() then
|
||||
node:collapse()
|
||||
for _, child in ipairs(children) do
|
||||
state.tree:get_node(child):collapse()
|
||||
end
|
||||
else
|
||||
for _, child in ipairs(children) do
|
||||
state.tree:get_node(child):expand()
|
||||
end
|
||||
node:expand()
|
||||
end
|
||||
|
||||
state.tree:render()
|
||||
u.darken_metadata(buf, '')
|
||||
end,
|
||||
{ buffer = true })
|
||||
|
||||
vim.keymap.set('n', 'r', function()
|
||||
local node = state.tree:get_node()
|
||||
if node == nil then return end
|
||||
local discussion_node = M.get_root_node(node)
|
||||
M.reply(tostring(discussion_node.id))
|
||||
end, { buffer = true })
|
||||
end
|
||||
|
||||
--
|
||||
-- 🌲 Helper Functions
|
||||
--
|
||||
|
||||
M.get_root_node = function(node)
|
||||
if (not node.is_root) then
|
||||
local parent_id = node:get_parent_id()
|
||||
return M.get_root_node(state.tree:get_node(parent_id))
|
||||
else
|
||||
return node
|
||||
end
|
||||
end
|
||||
|
||||
M.get_note_node = function(node)
|
||||
if (not node.is_note) then
|
||||
local parent_id = node:get_parent_id()
|
||||
if parent_id == nil then return node end
|
||||
return M.get_note_node(state.tree:get_node(parent_id))
|
||||
else
|
||||
return node
|
||||
end
|
||||
end
|
||||
|
||||
M.build_note_body = function(note, resolve_info)
|
||||
local text_nodes = {}
|
||||
for bodyLine in note.body:gmatch("[^\n]+") do
|
||||
local line = u.attach_uuid(bodyLine)
|
||||
table.insert(text_nodes, NuiTree.Node({
|
||||
text = line.text,
|
||||
id = line.id,
|
||||
is_body = true
|
||||
}, {}))
|
||||
end
|
||||
|
||||
local resolve_symbol = ''
|
||||
if resolve_info ~= nil and resolve_info.resolvable then
|
||||
resolve_symbol = resolve_info.resolved and state.SYMBOLS.resolved or state.SYMBOLS.unresolved
|
||||
end
|
||||
|
||||
local noteHeader = "@" .. note.author.username .. " " .. u.format_date(note.created_at) .. " " .. resolve_symbol
|
||||
|
||||
return noteHeader, text_nodes
|
||||
end
|
||||
|
||||
M.build_note = function(note, resolve_info)
|
||||
local text, text_nodes = M.build_note_body(note, resolve_info)
|
||||
local line_number = note.position.new_line or note.position.old_line
|
||||
local note_node = NuiTree.Node({
|
||||
text = text,
|
||||
id = note.id,
|
||||
file_name = note.position.new_path,
|
||||
line_number = line_number,
|
||||
is_note = true,
|
||||
}, text_nodes)
|
||||
|
||||
return note_node, text, text_nodes
|
||||
end
|
||||
|
||||
M.add_note_to_tree = function(note, discussion_id)
|
||||
local note_node = M.build_note(note)
|
||||
note_node:expand()
|
||||
state.tree:add_node(note_node, discussion_id and ("-" .. discussion_id) or nil)
|
||||
state.tree:render()
|
||||
local buf = vim.api.nvim_get_current_buf()
|
||||
u.darken_metadata(buf, '')
|
||||
vim.notify("Sent reply!", vim.log.levels.INFO)
|
||||
end
|
||||
|
||||
M.refresh_tree = function()
|
||||
job.run_job("discussions", "GET", nil, function(data)
|
||||
if type(data.discussions) ~= "table" then
|
||||
vim.notify("No discussions for this MR")
|
||||
return
|
||||
end
|
||||
|
||||
if not state.SPLIT_BUF or (vim.fn.bufwinid(state.SPLIT_BUF) == -1) then return end
|
||||
|
||||
vim.api.nvim_buf_set_option(state.SPLIT_BUF, 'modifiable', true)
|
||||
vim.api.nvim_buf_set_option(state.SPLIT_BUF, 'readonly', false)
|
||||
vim.api.nvim_buf_set_lines(state.SPLIT_BUF, 0, -1, false, {})
|
||||
vim.api.nvim_buf_set_option(state.SPLIT_BUF, 'readonly', true)
|
||||
vim.api.nvim_buf_set_option(state.SPLIT_BUF, 'modifiable', false)
|
||||
|
||||
local tree_nodes = M.add_discussions_to_table(data.discussions)
|
||||
state.tree = NuiTree({ nodes = tree_nodes, bufnr = state.SPLIT_BUF })
|
||||
M.set_tree_keymaps(state.SPLIT_BUF)
|
||||
state.tree:render()
|
||||
vim.api.nvim_buf_set_option(state.SPLIT_BUF, 'filetype', 'markdown')
|
||||
u.darken_metadata(state.SPLIT_BUF, '')
|
||||
end)
|
||||
end
|
||||
|
||||
M.add_discussions_to_table = function(discussions)
|
||||
local t = {}
|
||||
for _, discussion in ipairs(discussions) do
|
||||
local discussion_children = {}
|
||||
|
||||
-- These properties are filled in by the first note
|
||||
local root_text = ''
|
||||
local root_note_id = ''
|
||||
local root_line_number = 0
|
||||
local root_file_name = ''
|
||||
local root_id = 0
|
||||
local root_text_nodes = {}
|
||||
local resolvable = false
|
||||
local resolved = false
|
||||
|
||||
for j, note in ipairs(discussion.notes) do
|
||||
if j == 1 then
|
||||
__, root_text, root_text_nodes = M.build_note(note, { resolved = note.resolved, resolvable = note.resolvable })
|
||||
root_file_name = note.position.new_path
|
||||
root_line_number = note.position.new_line or note.position.old_line
|
||||
root_id = discussion.id
|
||||
root_note_id = note.id
|
||||
resolvable = note.resolvable
|
||||
resolved = note.resolved
|
||||
else -- Otherwise insert it as a child node...
|
||||
local note_node = M.build_note(note)
|
||||
table.insert(discussion_children, note_node)
|
||||
end
|
||||
end
|
||||
|
||||
-- Creates the first node in the discussion, and attaches children
|
||||
local body = u.join_tables(root_text_nodes, discussion_children)
|
||||
local root_node = NuiTree.Node({
|
||||
text = root_text,
|
||||
is_note = true,
|
||||
is_root = true,
|
||||
id = root_id,
|
||||
root_note_id = root_note_id,
|
||||
file_name = root_file_name,
|
||||
line_number = root_line_number,
|
||||
resolvable = resolvable,
|
||||
resolved = resolved
|
||||
}, body)
|
||||
|
||||
table.insert(t, root_node)
|
||||
end
|
||||
|
||||
return t
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,200 +1,42 @@
|
||||
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")
|
||||
local async = require("gitlab.async")
|
||||
local server = require("gitlab.server")
|
||||
local state = require("gitlab.state")
|
||||
local reviewer = require("gitlab.reviewer")
|
||||
local discussions = require("gitlab.actions.discussions")
|
||||
local summary = require("gitlab.actions.summary")
|
||||
local assignees_and_reviewers = require("gitlab.actions.assignees_and_reviewers")
|
||||
local comment = require("gitlab.actions.comment")
|
||||
local approvals = require("gitlab.actions.approvals")
|
||||
|
||||
local M = {}
|
||||
M.args = nil
|
||||
local info = state.dependencies.info
|
||||
local project_members = state.dependencies.project_members
|
||||
local revisions = state.dependencies.revisions
|
||||
|
||||
-- Builds the binary (if not built) and sets the plugin arguments
|
||||
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")
|
||||
state.BIN_PATH = parent_dir
|
||||
state.BIN = parent_dir .. "/bin"
|
||||
|
||||
local binary_exists = vim.loop.fs_stat(state.BIN)
|
||||
if binary_exists == nil then M.build() end
|
||||
|
||||
if not M.setPluginConfiguration(args) then return end -- Return if not a valid gitlab project
|
||||
M.args = args -- The ensureState function won't start without args
|
||||
end
|
||||
|
||||
-- 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.
|
||||
M.ensureState = function(callback)
|
||||
return function()
|
||||
if not M.args then
|
||||
vim.notify("The gitlab.nvim state was not set. Do you have a .gitlab.nvim file configured?", vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
if M.go_server_running then
|
||||
callback()
|
||||
return
|
||||
end
|
||||
|
||||
-- Once the Go binary has go_server_running, call the info endpoint to set global state
|
||||
M.start_server(function()
|
||||
keymaps.set_keymap_keys(M.args.keymaps)
|
||||
M.go_server_running = true
|
||||
job.run_job("info", "GET", nil, function(data)
|
||||
state.INFO = data.info
|
||||
callback()
|
||||
end)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
-- This will start the Go server and call the callback provided
|
||||
M.go_server_running = false
|
||||
M.start_server = function(callback)
|
||||
local command = state.BIN
|
||||
.. " "
|
||||
.. state.PROJECT_ID
|
||||
.. " "
|
||||
.. state.GITLAB_URL
|
||||
.. " "
|
||||
.. state.PORT
|
||||
.. " "
|
||||
.. state.AUTH_TOKEN
|
||||
.. " "
|
||||
.. state.LOG_PATH
|
||||
|
||||
vim.fn.jobstart(command, {
|
||||
on_stdout = function(job_id)
|
||||
if job_id <= 0 then
|
||||
vim.notify("Could not start gitlab.nvim binary", vim.log.levels.ERROR)
|
||||
elseif callback ~= nil then
|
||||
callback()
|
||||
end
|
||||
end,
|
||||
on_stderr = function(_, errors)
|
||||
local err_msg = ''
|
||||
for _, err in ipairs(errors) do
|
||||
if err ~= "" and err ~= nil then
|
||||
err_msg = err_msg .. err .. "\n"
|
||||
end
|
||||
end
|
||||
vim.notify(err_msg, vim.log.levels.ERROR)
|
||||
end
|
||||
})
|
||||
end
|
||||
|
||||
|
||||
M.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
|
||||
|
||||
M.ensureRevisions = function(callback)
|
||||
return function()
|
||||
if type(state.MR_REVISIONS) ~= "table" then
|
||||
job.run_job("mr/revisions", "GET", nil, function(data)
|
||||
state.MR_REVISIONS = data.Revisions
|
||||
callback()
|
||||
end)
|
||||
else
|
||||
callback()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Builds the Go binary
|
||||
M.build = function()
|
||||
local command = string.format("cd %s && make", state.BIN_PATH)
|
||||
local installCode = os.execute(command .. "> /dev/null")
|
||||
if installCode ~= 0 then
|
||||
vim.notify("Could not install gitlab.nvim!", vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
return true
|
||||
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)
|
||||
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
|
||||
return false
|
||||
end
|
||||
|
||||
local file = assert(io.open(config_file_path, "r"))
|
||||
local properties = {}
|
||||
for line in file:lines() do
|
||||
for key, value in string.gmatch(line, "(.-)=(.-)$") do
|
||||
properties[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
state.PROJECT_ID = properties.project_id
|
||||
state.AUTH_TOKEN = properties.auth_token or os.getenv("GITLAB_TOKEN")
|
||||
state.GITLAB_URL = properties.gitlab_url or "https://gitlab.com"
|
||||
|
||||
if state.AUTH_TOKEN == nil then
|
||||
error("Missing authentication token for Gitlab")
|
||||
end
|
||||
|
||||
if state.PROJECT_ID == nil then
|
||||
error("Missing project ID in .gitlab.nvim file.")
|
||||
end
|
||||
|
||||
if type(tonumber(state.PROJECT_ID)) ~= "number" then
|
||||
error("The .gitlab.nvim project file's 'project_id' must be number")
|
||||
end
|
||||
|
||||
-- Configuration for the plugin, such as port of server, layout, etc
|
||||
state.PORT = args.port or 21036
|
||||
state.LOG_PATH = args.log_path or (vim.fn.stdpath("cache") .. "/gitlab.nvim.log")
|
||||
state.DISCUSSION = {
|
||||
SPLIT = {
|
||||
relative = args.keymaps and args.keymaps.discussion_tree and args.keymaps.discussion_tree.relative or "editor",
|
||||
position = args.keymaps and args.keymaps.discussion_tree and args.keymaps.discussion_tree.position or "left",
|
||||
size = args.keymaps and args.keymaps.discussion_tree and args.keymaps.discussion_tree.size or "20%",
|
||||
}
|
||||
}
|
||||
|
||||
state.SYMBOLS = {
|
||||
resolved = (args.symbols and args.symbols.resolved or '✓'),
|
||||
unresolved = (args.symbols and args.symbols.unresolved or '')
|
||||
}
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
-- Root Module Scope
|
||||
-- These functions are exposed when you call require("gitlab").some_function() from Neovim
|
||||
-- and are bound to keymaps provided in the setup function
|
||||
M.summary = M.ensureState(summary.summary)
|
||||
M.approve = M.ensureState(function() job.run_job("approve", "POST") end)
|
||||
M.revoke = M.ensureState(function() job.run_job("revoke", "POST") end)
|
||||
M.list_discussions = M.ensureState(discussions.list_discussions)
|
||||
M.create_comment = M.ensureState(M.ensureRevisions(comment.create_comment))
|
||||
M.edit_comment = M.ensureState(comment.edit_comment)
|
||||
M.delete_comment = M.ensureState(comment.delete_comment)
|
||||
M.toggle_resolved = M.ensureState(comment.toggle_resolved)
|
||||
M.reply = M.ensureState(discussions.reply)
|
||||
M.add_reviewer = M.ensureState(M.ensureProjectMembers(assignees_and_reviewers.add_reviewer))
|
||||
M.delete_reviewer = M.ensureState(M.ensureProjectMembers(assignees_and_reviewers.delete_reviewer))
|
||||
M.add_assignee = M.ensureState(M.ensureProjectMembers(assignees_and_reviewers.add_assignee))
|
||||
M.delete_assignee = M.ensureState(M.ensureProjectMembers(assignees_and_reviewers.delete_assignee))
|
||||
M.state = state
|
||||
|
||||
return M
|
||||
return {
|
||||
setup = function(args)
|
||||
server.build() -- Builds the Go binary if it doesn't exist
|
||||
state.setPluginConfiguration() -- Sets configuration from `.gitlab.nvim` file
|
||||
state.merge_settings(args) -- Sets keymaps and other settings from setup function
|
||||
reviewer.init() -- Picks and initializes reviewer (default is Delta)
|
||||
end,
|
||||
-- Global Actions 🌎
|
||||
summary = async.sequence({ info }, 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_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),
|
||||
review = async.sequence({ u.merge(info, { refresh = true }) }, function() reviewer.open() end),
|
||||
-- Discussion Tree Actions 🌴
|
||||
toggle_discussions = async.sequence({ info }, discussions.toggle),
|
||||
edit_comment = async.sequence({ info }, discussions.edit_comment),
|
||||
delete_comment = async.sequence({ info }, discussions.delete_comment),
|
||||
toggle_resolved = async.sequence({ info }, discussions.toggle_resolved),
|
||||
reply = async.sequence({ info }, discussions.reply),
|
||||
-- Other functions 🤷
|
||||
state = state,
|
||||
print_settings = state.print_settings,
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
local Job = require("plenary.job")
|
||||
local state = require("gitlab.state")
|
||||
local M = {}
|
||||
|
||||
-- This function is responsible for making API calls to the Go server and
|
||||
-- This module is responsible for making API calls to the Go server and
|
||||
-- running the callbacks associated with those jobs when the JSON is returned
|
||||
M.run_job = function(endpoint, method, body, callback)
|
||||
local args = { "-s", "-X", (method or "POST"), string.format("localhost:%s/", state.PORT) .. endpoint }
|
||||
local Job = require("plenary.job")
|
||||
local M = {}
|
||||
|
||||
M.run_job = function(endpoint, method, body, callback)
|
||||
local state = require("gitlab.state")
|
||||
local args = { "-s", "-X", (method or "POST"), string.format("localhost:%s", state.settings.port) .. endpoint }
|
||||
|
||||
if body ~= nil then
|
||||
table.insert(args, 1, "-d")
|
||||
@@ -21,7 +21,13 @@ M.run_job = function(endpoint, method, body, callback)
|
||||
on_stdout = function(_, output)
|
||||
vim.defer_fn(function()
|
||||
local data_ok, data = pcall(vim.json.decode, output)
|
||||
if data_ok and data ~= nil then
|
||||
if not data_ok then
|
||||
local msg = string.format("Failed to parse JSON from %s endpoint", endpoint)
|
||||
if (type(output) == "string") then msg = string.format(msg .. ", got: '%s'", output) end
|
||||
vim.notify(string.format(msg, endpoint, output), vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
if data ~= nil then
|
||||
local status = (tonumber(data.status) >= 200 and tonumber(data.status) < 300) and "success" or "error"
|
||||
if status == "success" and callback ~= nil then
|
||||
callback(data)
|
||||
@@ -39,7 +45,14 @@ M.run_job = function(endpoint, method, body, callback)
|
||||
vim.defer_fn(function()
|
||||
vim.notify("Could not run command!", vim.log.levels.ERROR)
|
||||
end, 0)
|
||||
end
|
||||
end,
|
||||
on_exit = function(msg, status)
|
||||
vim.defer_fn(function()
|
||||
if status ~= 0 then
|
||||
vim.notify(string.format("Go server exited with non-zero code: %d", status), vim.log.levels.ERROR)
|
||||
end
|
||||
end, 0)
|
||||
end,
|
||||
}):start()
|
||||
end
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
local u = require("gitlab.utils")
|
||||
local state = require("gitlab.state")
|
||||
local M = {}
|
||||
|
||||
-- Sets the keymaps for the popup window that's used for replies, the summary, etc
|
||||
M.set_popup_keymaps = function(popup, action)
|
||||
vim.keymap.set('n', state.keymaps.popup.exit, function() u.exit(popup) end, { buffer = true })
|
||||
if action ~= nil then
|
||||
vim.keymap.set('n', state.keymaps.popup.perform_action, function()
|
||||
local text = u.get_buffer_text(popup.bufnr)
|
||||
popup:unmount()
|
||||
action(text)
|
||||
end, { buffer = true })
|
||||
end
|
||||
end
|
||||
|
||||
M.set_keymap_keys = function(keyTable)
|
||||
if keyTable == nil then return end
|
||||
state.keymaps = u.merge_tables(state.keymaps, keyTable)
|
||||
end
|
||||
|
||||
return M
|
||||
202
lua/gitlab/reviewer/delta.lua
Normal file
202
lua/gitlab/reviewer/delta.lua
Normal file
@@ -0,0 +1,202 @@
|
||||
-- This Module contains all of the code specific to the Delta reviewer.
|
||||
local state = require("gitlab.state")
|
||||
local u = require("gitlab.utils")
|
||||
|
||||
local M = {
|
||||
bufnr = nil
|
||||
}
|
||||
|
||||
-- Public Functions
|
||||
-- These functions are exposed externally and are used
|
||||
-- when the reviewer is consumed by other code. They must follow the specification
|
||||
-- outlined in the reviewer/init.lua file
|
||||
M.open = function()
|
||||
local current_buf = vim.api.nvim_get_current_buf()
|
||||
if current_buf == state.discussion_buf then
|
||||
vim.api.nvim_command("wincmd w")
|
||||
end
|
||||
|
||||
vim.cmd.enew()
|
||||
if M.bufnr ~= nil then
|
||||
vim.api.nvim_set_current_buf(M.bufnr)
|
||||
return
|
||||
end
|
||||
|
||||
local term_command_template =
|
||||
"GIT_PAGER='delta --hunk-header-style omit --line-numbers --paging never --file-added-label %s --file-removed-label %s --file-modified-label %s' git diff %s...HEAD"
|
||||
|
||||
local term_command = string.format(term_command_template,
|
||||
state.settings.review_pane.delta.added_file,
|
||||
state.settings.review_pane.delta.removed_file,
|
||||
state.settings.review_pane.delta.modified_file,
|
||||
state.INFO.target_branch)
|
||||
|
||||
vim.fn.termopen(term_command) -- Calls delta and sends the output to the currently blank buffer
|
||||
M.bufnr = vim.api.nvim_get_current_buf()
|
||||
end
|
||||
|
||||
M.jump = function(file_name, new_line, old_line)
|
||||
local linnr, error = M.get_jump_location(file_name, new_line, old_line)
|
||||
if error ~= nil then
|
||||
vim.notify(error, vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
vim.api.nvim_command("wincmd w")
|
||||
u.jump_to_buffer(M.bufnr, linnr)
|
||||
end
|
||||
|
||||
M.get_location = function()
|
||||
if M.bufnr == nil then return nil, nil, "Delta reviewer must be initialized first" end
|
||||
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
if bufnr ~= M.bufnr then return nil, nil, "Line location can only be determined within reviewer window" end
|
||||
|
||||
local line_num = u.get_current_line_number()
|
||||
local file_name = M.get_file_from_review_buffer(u.get_current_line_number())
|
||||
|
||||
local range, error = M.get_review_buffer_range(file_name)
|
||||
|
||||
if error ~= nil then return nil, nil, error end
|
||||
if range == nil then return nil, nil, "Review buffer range could not be identified" end
|
||||
|
||||
-- In case the comment is left on a line without change information, we
|
||||
-- iterate backward until we find it within the range of the changes
|
||||
local current_line_changes = nil
|
||||
local num = line_num
|
||||
while range ~= nil and num >= range[1] and current_line_changes == nil do
|
||||
local content = u.get_line_content(M.bufnr, num)
|
||||
local change_nums = M.get_change_nums(content)
|
||||
current_line_changes = change_nums
|
||||
num = num - 1
|
||||
end
|
||||
|
||||
if current_line_changes == nil then
|
||||
return nil, nil, "Could not find current line change information"
|
||||
end
|
||||
|
||||
local new_line_num = line_num + 1
|
||||
local next_line_changes = nil
|
||||
while range ~= nil and new_line_num <= range[2] and next_line_changes == nil do
|
||||
local content = u.get_line_content(M.bufnr, new_line_num)
|
||||
local change_nums = M.get_change_nums(content)
|
||||
next_line_changes = change_nums
|
||||
new_line_num = new_line_num + 1
|
||||
end
|
||||
|
||||
if next_line_changes == nil then
|
||||
return nil, nil, "Could not find next line change information"
|
||||
end
|
||||
|
||||
-- This is actually a modified line if these conditions are met
|
||||
if (current_line_changes.old_line and not current_line_changes.new_line and not next_line_changes.old_line and next_line_changes.new_line) then
|
||||
do
|
||||
current_line_changes = {
|
||||
old_line = current_line_changes.old,
|
||||
new_line = next_line_changes.new_line
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
return file_name, current_line_changes
|
||||
end
|
||||
|
||||
-- Helper Functions 🤝
|
||||
-- These functions are not exported and should be private
|
||||
-- to the delta reviewer, they are used to support the public functions
|
||||
M.get_jump_location = function(file_name, new_line, old_line)
|
||||
local range, error = M.get_review_buffer_range(file_name)
|
||||
if error ~= nil then return nil, error end
|
||||
if range == nil then return nil, "Review buffer range could not be identified" end
|
||||
|
||||
local linnr = nil
|
||||
|
||||
local lines = M.get_review_buffer_lines(range)
|
||||
for _, line in ipairs(lines) do
|
||||
local line_data = M.get_change_nums(line.line_content)
|
||||
if old_line == line_data.old_line and new_line == line_data.new_line then
|
||||
linnr = line.line_number
|
||||
break
|
||||
end
|
||||
end
|
||||
if linnr == nil then return nil, "Could not find matching line" end
|
||||
return linnr, nil
|
||||
end
|
||||
|
||||
M.get_file_from_review_buffer = function(linenr)
|
||||
for i = linenr, 0, -1 do
|
||||
local line_content = u.get_line_content(M.bufnr, i)
|
||||
if M.starts_with_file_symbol(line_content) then
|
||||
local file_name = u.get_last_chunk(line_content)
|
||||
return file_name
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
M.get_change_nums = function(line)
|
||||
local data, _ = line:match("(.-)" .. "│" .. "(.*)")
|
||||
local line_data = {}
|
||||
if data == nil then return nil end
|
||||
|
||||
if data ~= nil then
|
||||
local old_line = u.trim(u.get_first_chunk(data, "[^" .. "⋮" .. "]+"))
|
||||
local new_line = u.trim(u.get_last_chunk(data, "[^" .. "⋮" .. "]+"))
|
||||
line_data.new_line = tonumber(new_line)
|
||||
line_data.old_line = tonumber(old_line)
|
||||
end
|
||||
|
||||
if line_data.new_line == nil and line_data.old_line == nil then return nil end
|
||||
|
||||
return line_data
|
||||
end
|
||||
|
||||
|
||||
M.get_review_buffer_range = function(file_name)
|
||||
if M.bufnr == nil then return nil, "Delta reviewer must be initialized first" end
|
||||
local lines = vim.api.nvim_buf_get_lines(M.bufnr, 0, -1, false)
|
||||
local start = nil
|
||||
local stop = nil
|
||||
|
||||
for i, line in ipairs(lines) do
|
||||
if start ~= nil and stop ~= nil then return { start, stop } end
|
||||
if M.starts_with_file_symbol(line) then
|
||||
-- Check if the file name matches the node name
|
||||
local delta_file_name = u.get_last_chunk(line)
|
||||
if file_name == delta_file_name then
|
||||
start = i
|
||||
elseif start ~= nil then
|
||||
stop = i
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- We've reached the end of the file, set "stop" in case we already found start
|
||||
stop = #lines
|
||||
if start ~= nil and stop ~= nil then return { start, stop } end
|
||||
end
|
||||
|
||||
M.starts_with_file_symbol = function(line)
|
||||
for _, substring in ipairs({
|
||||
state.settings.review_pane.delta.added_file,
|
||||
state.settings.review_pane.delta.removed_file,
|
||||
state.settings.review_pane.delta.modified_file,
|
||||
}) do
|
||||
if string.sub(line, 1, string.len(substring)) == substring then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
M.get_review_buffer_lines = function(review_buffer_range)
|
||||
local lines = {}
|
||||
for i = review_buffer_range[1], review_buffer_range[2], 1 do
|
||||
local line_content = vim.api.nvim_buf_get_lines(M.bufnr, i - 1, i, false)[1]
|
||||
if string.find(line_content, "⋮") then
|
||||
table.insert(lines, { line_content = line_content, line_number = i })
|
||||
end
|
||||
end
|
||||
return lines
|
||||
end
|
||||
|
||||
return M
|
||||
36
lua/gitlab/reviewer/init.lua
Normal file
36
lua/gitlab/reviewer/init.lua
Normal file
@@ -0,0 +1,36 @@
|
||||
-- This Module will pick the reviewer set in the user's
|
||||
-- settings and then map all of it's functions
|
||||
local state = require("gitlab.state")
|
||||
local delta = require("gitlab.reviewer.delta")
|
||||
|
||||
local M = {
|
||||
reviewer = nil,
|
||||
}
|
||||
|
||||
local reviewer_map = {
|
||||
delta = delta
|
||||
}
|
||||
|
||||
M.init = function()
|
||||
local reviewer = reviewer_map[state.settings.reviewer]
|
||||
if reviewer == nil then
|
||||
vim.notify(string.format("gitlab.nvim could not find reviewer %s", state.settings.reviewer), vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
M.open = reviewer.open
|
||||
-- Opens the reviewer window
|
||||
|
||||
M.jump = reviewer.jump
|
||||
-- Jumps to the location provided in the reviewer window
|
||||
-- Parameters:
|
||||
-- • {file_name} The name of the file to jump to
|
||||
-- • {new_line} The new_line of the change
|
||||
-- • {interval} The old_lien of the change
|
||||
|
||||
M.get_location = reviewer.get_location
|
||||
-- Returns the current location (based on cursor) from the reviewer window
|
||||
end
|
||||
|
||||
|
||||
return M
|
||||
69
lua/gitlab/server.lua
Normal file
69
lua/gitlab/server.lua
Normal file
@@ -0,0 +1,69 @@
|
||||
-- This module contains the logic responsible for building and starting
|
||||
-- the Golang server. The Go server is responsible for making API calls
|
||||
-- to Gitlab and returning the data
|
||||
local job = require("gitlab.job")
|
||||
local state = require("gitlab.state")
|
||||
local u = require("gitlab.utils")
|
||||
local M = {}
|
||||
|
||||
-- Starts the Go server and call the callback provided
|
||||
M.start_server = function(callback)
|
||||
local command = state.settings.bin
|
||||
.. " "
|
||||
.. state.settings.project_id
|
||||
.. " "
|
||||
.. state.settings.gitlab_url
|
||||
.. " "
|
||||
.. state.settings.port
|
||||
.. " "
|
||||
.. state.settings.auth_token
|
||||
.. " "
|
||||
.. state.settings.log_path
|
||||
|
||||
vim.fn.jobstart(command, {
|
||||
on_stdout = function(job_id)
|
||||
if job_id <= 0 then
|
||||
vim.notify("Could not start gitlab.nvim binary", vim.log.levels.ERROR)
|
||||
else
|
||||
callback()
|
||||
end
|
||||
end,
|
||||
on_stderr = function(_, errors)
|
||||
local err_msg = ''
|
||||
for _, err in ipairs(errors) do
|
||||
if err ~= "" and err ~= nil then
|
||||
err_msg = err_msg .. err .. "\n"
|
||||
end
|
||||
end
|
||||
|
||||
if err_msg ~= '' then vim.notify(err_msg, vim.log.levels.ERROR) end
|
||||
end
|
||||
})
|
||||
end
|
||||
|
||||
|
||||
-- Builds the Go binary
|
||||
M.build = function()
|
||||
if not u.has_delta() then
|
||||
vim.notify("Please install delta to use gitlab.nvim!", vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
local file_path = u.current_file_path()
|
||||
local parent_dir = vim.fn.fnamemodify(file_path, ":h:h:h:h")
|
||||
state.settings.bin_path = parent_dir
|
||||
state.settings.bin = parent_dir .. "/bin"
|
||||
|
||||
local binary_exists = vim.loop.fs_stat(state.settings.bin)
|
||||
if binary_exists ~= nil then return end
|
||||
|
||||
local command = string.format("cd %s && make", state.settings.bin_path)
|
||||
local installCode = os.execute(command .. "> /dev/null")
|
||||
if installCode ~= 0 then
|
||||
vim.notify("Could not install gitlab.nvim!", vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,18 +1,37 @@
|
||||
local M = {}
|
||||
-- This module is responsible for holding and setting shared state between
|
||||
-- modules, such as keybinding data and other settings and configuration.
|
||||
-- This module is also responsible for ensuring that the state of the plugin
|
||||
-- is valid via dependencies
|
||||
|
||||
-- These are the default keymaps for the plugin
|
||||
M.keymaps = {
|
||||
local u = require("gitlab.utils")
|
||||
local M = {}
|
||||
|
||||
-- These are the default settings for the plugin
|
||||
M.settings = {
|
||||
port = 21036,
|
||||
log_path = (vim.fn.stdpath("cache") .. "/gitlab.nvim.log"),
|
||||
popup = {
|
||||
exit = "<Esc>",
|
||||
perform_action = "<leader>s",
|
||||
},
|
||||
discussion_tree = {
|
||||
jump_to_location = "o",
|
||||
toggle = "<leader>d",
|
||||
jump_to_file = "o",
|
||||
edit_comment = "e",
|
||||
delete_comment = "dd",
|
||||
reply_to_comment = "r",
|
||||
reply = "r",
|
||||
toggle_node = "t",
|
||||
toggle_resolved = "p"
|
||||
toggle_resolved = "p",
|
||||
relative = "editor",
|
||||
position = "left",
|
||||
size = "20%",
|
||||
resolved = '✓',
|
||||
unresolved = ''
|
||||
},
|
||||
review_pane = {
|
||||
added_file = "",
|
||||
modified_file = "",
|
||||
removed_file = "",
|
||||
},
|
||||
dialogue = {
|
||||
focus_next = { "j", "<Down>", "<Tab>" },
|
||||
@@ -20,9 +39,84 @@ M.keymaps = {
|
||||
close = { "<Esc>", "<C-c>" },
|
||||
submit = { "<CR>", "<Space>" },
|
||||
},
|
||||
review = {
|
||||
toggle = "<leader>glt"
|
||||
}
|
||||
go_server_running = false,
|
||||
is_gitlab_project = false,
|
||||
}
|
||||
|
||||
-- Merges user settings into the default settings, overriding them
|
||||
M.merge_settings = function(args)
|
||||
if args == nil then return end
|
||||
M.settings = u.merge(M.settings, args)
|
||||
end
|
||||
|
||||
M.print_settings = function()
|
||||
u.P(M.settings)
|
||||
end
|
||||
|
||||
-- Merges `.gitlab.nvim` settings into the state module
|
||||
M.setPluginConfiguration = function()
|
||||
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
|
||||
return false
|
||||
end
|
||||
|
||||
M.is_gitlab_project = true
|
||||
|
||||
local file = assert(io.open(config_file_path, "r"))
|
||||
local properties = {}
|
||||
for line in file:lines() do
|
||||
for key, value in string.gmatch(line, "(.-)=(.-)$") do
|
||||
properties[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
M.settings.project_id = properties.project_id
|
||||
M.settings.auth_token = properties.auth_token or os.getenv("GITLAB_TOKEN")
|
||||
M.settings.gitlab_url = properties.gitlab_url or "https://gitlab.com"
|
||||
|
||||
if M.settings.auth_token == nil then
|
||||
error("Missing authentication token for Gitlab")
|
||||
end
|
||||
|
||||
if M.settings.project_id == nil then
|
||||
error("Missing project ID in .gitlab.nvim file.")
|
||||
end
|
||||
|
||||
if type(tonumber(M.settings.project_id)) ~= "number" then
|
||||
error("The .gitlab.nvim project file's 'project_id' must be number")
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
local function exit(popup)
|
||||
popup:unmount()
|
||||
end
|
||||
|
||||
-- These keymaps are buffer specific and are set dynamically when popups mount
|
||||
M.set_popup_keymaps = function(popup, action)
|
||||
vim.keymap.set('n', M.settings.popup.exit, function() exit(popup) end, { buffer = true })
|
||||
if action ~= nil then
|
||||
vim.keymap.set('n', M.settings.popup.perform_action, function()
|
||||
local text = u.get_buffer_text(popup.bufnr)
|
||||
exit(popup)
|
||||
action(text)
|
||||
end, { buffer = true })
|
||||
end
|
||||
end
|
||||
|
||||
-- Dependencies
|
||||
-- These tables are passed to the async.sequence function, which calls them in sequence
|
||||
-- before calling an action. They are used to set global state that's required
|
||||
-- for each of the actions to occur. This is necessary because some Gitlab behaviors (like
|
||||
-- adding a reviewer) requires some initial state.
|
||||
M.dependencies = {
|
||||
info = { endpoint = "/info", key = "info", state = "INFO" },
|
||||
revisions = { endpoint = "/mr/revisions", key = "Revisions", state = "MR_REVISIONS" },
|
||||
project_members = { endpoint = "/members", key = "ProjectMembers", state = "PROJECT_MEMBERS" }
|
||||
}
|
||||
|
||||
|
||||
|
||||
return M
|
||||
|
||||
@@ -1,29 +1,14 @@
|
||||
local state = require("gitlab.state")
|
||||
local M = {}
|
||||
|
||||
local function get_git_root()
|
||||
local output = vim.fn.system('git rev-parse --show-toplevel 2>/dev/null')
|
||||
if vim.v.shell_error == 0 then
|
||||
return vim.fn.substitute(output, '\n', '', '')
|
||||
else
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
local function get_relative_file_path()
|
||||
local git_root = get_git_root()
|
||||
if git_root ~= nil then
|
||||
local current_file = vim.fn.expand('%:p')
|
||||
return vim.fn.substitute(current_file, git_root .. '/', '', '')
|
||||
else
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
local get_current_line_number = function()
|
||||
M.get_current_line_number = function()
|
||||
return vim.api.nvim_call_function('line', { '.' })
|
||||
end
|
||||
|
||||
function P(...)
|
||||
M.has_delta = function()
|
||||
return vim.fn.executable("delta") == 1
|
||||
end
|
||||
|
||||
M.P = function(...)
|
||||
local objects = {}
|
||||
for i = 1, select("#", ...) do
|
||||
local v = select(i, ...)
|
||||
@@ -34,21 +19,21 @@ function P(...)
|
||||
return ...
|
||||
end
|
||||
|
||||
local function get_buffer_text(bufnr)
|
||||
M.get_buffer_text = function(bufnr)
|
||||
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||
local text = table.concat(lines, "\n")
|
||||
return text
|
||||
end
|
||||
|
||||
local string_starts = function(str, start)
|
||||
M.string_starts = function(str, start)
|
||||
return str:sub(1, #start) == start
|
||||
end
|
||||
|
||||
local press_enter = function()
|
||||
M.press_enter = function()
|
||||
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("<CR>", false, true, true), "n", false)
|
||||
end
|
||||
|
||||
local format_date = function(date_string)
|
||||
M.format_date = function(date_string)
|
||||
local date_table = os.date("!*t")
|
||||
local year, month, day, hour, min, sec = date_string:match("(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)")
|
||||
local date = os.time({ year = year, month = month, day = day, hour = hour, min = min, sec = sec })
|
||||
@@ -78,20 +63,11 @@ local format_date = function(date_string)
|
||||
end
|
||||
end
|
||||
|
||||
local add_comment_sign = function(line_number)
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
vim.cmd("sign define piet text= texthl=Substitute")
|
||||
vim.fn.sign_place(0, "piet", "piet", bufnr, { lnum = line_number })
|
||||
end
|
||||
|
||||
local function jump_to_file(filename, line_number)
|
||||
M.jump_to_file = function(filename, line_number)
|
||||
if line_number == nil then line_number = 1 end
|
||||
vim.api.nvim_command("wincmd l")
|
||||
local bufnr = vim.fn.bufnr(filename)
|
||||
if bufnr ~= -1 then
|
||||
-- Buffer is already open, switch to it
|
||||
vim.cmd("buffer " .. bufnr)
|
||||
vim.api.nvim_win_set_cursor(0, { line_number, 0 })
|
||||
M.jump_to_buffer(bufnr, line_number)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -100,43 +76,12 @@ local function jump_to_file(filename, line_number)
|
||||
vim.api.nvim_win_set_cursor(0, { line_number, 0 })
|
||||
end
|
||||
|
||||
local function find_value_by_id(tbl, id)
|
||||
for i = 1, #tbl do
|
||||
if tbl[i].id == tonumber(id) then
|
||||
return tbl[i]
|
||||
end
|
||||
end
|
||||
return nil
|
||||
M.jump_to_buffer = function(bufnr, line_number)
|
||||
vim.cmd("buffer " .. bufnr)
|
||||
vim.api.nvim_win_set_cursor(0, { line_number, 0 })
|
||||
end
|
||||
|
||||
vim.cmd("highlight Gray guifg=#888888")
|
||||
local function darken_metadata(bufnr, regex)
|
||||
local num_lines = vim.api.nvim_buf_line_count(bufnr)
|
||||
for i = 0, num_lines - 1 do
|
||||
local line = vim.api.nvim_buf_get_lines(bufnr, i, i + 1, false)[1]
|
||||
if string.match(line, regex) then
|
||||
vim.api.nvim_buf_add_highlight(bufnr, -1, 'Gray', i, 0, -1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function print_success(_, line)
|
||||
if line ~= nil and line ~= "" then
|
||||
vim.notify(line, vim.log.levels.INFO)
|
||||
end
|
||||
end
|
||||
|
||||
local function print_error(_, line)
|
||||
if line ~= nil and line ~= "" then
|
||||
vim.notify(line, vim.log.levels.ERROR)
|
||||
end
|
||||
end
|
||||
|
||||
local function exit(popup)
|
||||
popup:unmount()
|
||||
end
|
||||
|
||||
local create_popup_state = function(title, width, height)
|
||||
M.create_popup_state = function(title, width, height)
|
||||
return {
|
||||
buf_options = {
|
||||
filetype = 'markdown'
|
||||
@@ -158,13 +103,20 @@ local create_popup_state = function(title, width, height)
|
||||
}
|
||||
end
|
||||
|
||||
local M = {}
|
||||
M.merge_tables = function(defaults, overrides)
|
||||
M.merge = function(defaults, overrides)
|
||||
local result = {}
|
||||
|
||||
for key, value in pairs(defaults) do
|
||||
if type(value) == "table" then
|
||||
result[key] = M.merge_tables(value, overrides[key] or {})
|
||||
result[key] = M.merge(value, overrides[key] or {})
|
||||
else
|
||||
result[key] = overrides[key] or value
|
||||
end
|
||||
end
|
||||
|
||||
for key, value in pairs(overrides) do
|
||||
if type(value) == "table" then
|
||||
result[key] = M.merge(value, overrides[key] or {})
|
||||
else
|
||||
result[key] = overrides[key] or value
|
||||
end
|
||||
@@ -173,7 +125,23 @@ M.merge_tables = function(defaults, overrides)
|
||||
return result
|
||||
end
|
||||
|
||||
local read_file = function(file_path)
|
||||
M.join = function(tbl, separator)
|
||||
separator = separator or " "
|
||||
|
||||
local result = ""
|
||||
for _, value in pairs(tbl) do
|
||||
result = result .. tostring(value) .. separator
|
||||
end
|
||||
|
||||
-- Remove the trailing separator
|
||||
if separator ~= "" then
|
||||
result = result:sub(1, - #separator - 1)
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
M.read_file = function(file_path)
|
||||
local file = io.open(file_path, "r")
|
||||
if file == nil then
|
||||
return nil
|
||||
@@ -184,22 +152,13 @@ local read_file = function(file_path)
|
||||
return file_contents
|
||||
end
|
||||
|
||||
local split_diff_view_filename = function(filename)
|
||||
local hash, path = filename:match("://%.git/(/?[0-9a-f]+)(/.*)$")
|
||||
if hash and path then
|
||||
path = path:gsub("%.git/", ""):gsub("^/", "")
|
||||
hash = hash:gsub("^/", "")
|
||||
end
|
||||
return hash, path
|
||||
end
|
||||
|
||||
local current_file_path = function()
|
||||
M.current_file_path = function()
|
||||
local path = debug.getinfo(1, 'S').source:sub(2)
|
||||
return vim.fn.fnamemodify(path, ':p')
|
||||
end
|
||||
|
||||
local random = math.random
|
||||
local function uuid()
|
||||
M.uuid = function()
|
||||
local template = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'
|
||||
return string.gsub(template, '[xy]', function(c)
|
||||
local v = (c == 'x') and random(0, 0xf) or random(8, 0xb)
|
||||
@@ -207,11 +166,7 @@ local function uuid()
|
||||
end)
|
||||
end
|
||||
|
||||
local attach_uuid = function(str)
|
||||
return { text = str, id = uuid() }
|
||||
end
|
||||
|
||||
local join_tables = function(table1, table2)
|
||||
M.join_tables = function(table1, table2)
|
||||
for _, value in ipairs(table2) do
|
||||
table.insert(table1, value)
|
||||
end
|
||||
@@ -219,7 +174,7 @@ local join_tables = function(table1, table2)
|
||||
return table1
|
||||
end
|
||||
|
||||
local contains = function(array, search_value)
|
||||
M.contains = function(array, search_value)
|
||||
for _, value in ipairs(array) do
|
||||
if value == search_value then
|
||||
return true
|
||||
@@ -228,7 +183,7 @@ local contains = function(array, search_value)
|
||||
return false
|
||||
end
|
||||
|
||||
local extract = function(t, property)
|
||||
M.extract = function(t, property)
|
||||
local resultTable = {}
|
||||
for _, value in ipairs(t) do
|
||||
if value[property] then
|
||||
@@ -238,7 +193,7 @@ local extract = function(t, property)
|
||||
return resultTable
|
||||
end
|
||||
|
||||
local remove_last_chunk = function(sentence)
|
||||
M.remove_last_chunk = function(sentence)
|
||||
local words = {}
|
||||
for word in sentence:gmatch("%S+") do
|
||||
table.insert(words, word)
|
||||
@@ -248,27 +203,45 @@ local remove_last_chunk = function(sentence)
|
||||
return sentence_without_last
|
||||
end
|
||||
|
||||
M.remove_last_chunk = remove_last_chunk
|
||||
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
|
||||
M.get_current_line_number = get_current_line_number
|
||||
M.get_buffer_text = get_buffer_text
|
||||
M.press_enter = press_enter
|
||||
M.string_starts = string_starts
|
||||
M.format_date = format_date
|
||||
M.add_comment_sign = add_comment_sign
|
||||
M.jump_to_file = jump_to_file
|
||||
M.find_value_by_id = find_value_by_id
|
||||
M.darken_metadata = darken_metadata
|
||||
M.print_success = print_success
|
||||
M.print_error = print_error
|
||||
M.create_popup_state = create_popup_state
|
||||
M.exit = exit
|
||||
M.read_file = read_file
|
||||
M.split_diff_view_filename = split_diff_view_filename
|
||||
M.current_file_path = current_file_path
|
||||
M.P = P
|
||||
M.get_first_chunk = function(sentence, divider)
|
||||
local words = {}
|
||||
for word in sentence:gmatch(divider or "%S+") do
|
||||
table.insert(words, word)
|
||||
end
|
||||
return words[1]
|
||||
end
|
||||
|
||||
M.get_last_chunk = function(sentence, divider)
|
||||
local words = {}
|
||||
for word in sentence:gmatch(divider or "%S+") do
|
||||
table.insert(words, word)
|
||||
end
|
||||
return words[#words]
|
||||
end
|
||||
|
||||
M.trim = function(s)
|
||||
return s:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
end
|
||||
|
||||
M.get_line_content = function(bufnr, start)
|
||||
local current_buffer = vim.api.nvim_get_current_buf()
|
||||
local lines = vim.api.nvim_buf_get_lines(
|
||||
bufnr ~= nil and bufnr or current_buffer,
|
||||
start - 1,
|
||||
start,
|
||||
false)
|
||||
|
||||
for _, line in ipairs(lines) do
|
||||
return line
|
||||
end
|
||||
end
|
||||
|
||||
M.get_win_from_buf = function(bufnr)
|
||||
for _, win in ipairs(vim.api.nvim_list_wins()) do
|
||||
if vim.fn.winbufnr(win) == bufnr then
|
||||
return win
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
Reference in New Issue
Block a user