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:
Harrison (Harry) Cramer
2023-08-27 17:26:54 -04:00
committed by GitHub
parent ed67a03f8f
commit 19468a3d2d
19 changed files with 1273 additions and 967 deletions

View 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

View File

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

View 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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

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

View File

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

View File

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