diff --git a/.busted b/.busted index 62db5054..4ccaa997 100644 --- a/.busted +++ b/.busted @@ -2,10 +2,10 @@ return { _all = { pattern = "_spec", lpath = "lua/?.lua;lua/?/init.lua", - ROOT = {"lua/gitlab"}, + ROOT = { "tests/spec" }, }, default = { - verbose = true + verbose = true, }, tests = { verbose = true, diff --git a/.github/workflows/lua-tests.yaml b/.github/workflows/lua-tests.yaml deleted file mode 100644 index 1ae2601f..00000000 --- a/.github/workflows/lua-tests.yaml +++ /dev/null @@ -1,15 +0,0 @@ -name: Lua Tests -on: - pull_request: - branches: - - main -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v3 - - name: Run Busted - uses: lunarmodules/busted@v2.2.0 - # with: - # args: --run=api diff --git a/.github/workflows/lua.yaml b/.github/workflows/lua.yaml index dea054a1..12d5b785 100644 --- a/.github/workflows/lua.yaml +++ b/.github/workflows/lua.yaml @@ -5,7 +5,7 @@ on: - main jobs: lua_lint: - name: Lint Lua 💅 + name: Lint Lua 💅 runs-on: ubuntu-latest steps: - name: Checkout @@ -15,7 +15,7 @@ jobs: with: args: --globals vim --no-max-line-length -- . lua_format: - name: Formatting Lua 💅 + name: Formatting Lua 💅 runs-on: ubuntu-latest steps: - name: Checkout @@ -27,11 +27,29 @@ jobs: version: latest args: --check . lua_test: - name: Test Lua 🧪 - needs: [lua_format,lua_lint] + name: Run tests 🧪 + strategy: + matrix: + nvim_version: [stable, nightly] + runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 - - name: Run Busted - uses: lunarmodules/busted@v2.2.0 + uses: actions/checkout@v4 + - name: Install neovim + uses: rhysd/action-setup-vim@v1 + id: vim + with: + neovim: true + version: ${{ matrix.nvim_version }} + - name: Install luajit + uses: leafo/gh-actions-lua@v10 + with: + luaVersion: "luajit-2.1.0-beta3" + - name: Install luarocks + uses: leafo/gh-actions-luarocks@v4 + - name: Run tests + shell: bash + run: | + chmod +x lua-test.sh + ./lua-test.sh diff --git a/.gitignore b/.gitignore index 021a0b7f..58519d8c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ bin /luarocks /lua_modules /.luarocks +*.rockspec +tests/plugins +!tests/plugins/.placeholder +luacov.* diff --git a/.luacov b/.luacov new file mode 100644 index 00000000..d4c4e11e --- /dev/null +++ b/.luacov @@ -0,0 +1,3 @@ +include = { + "lua/gitlab/" +} diff --git a/README.md b/README.md index c47d97fd..cfe3e585 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ return { "nvim-lua/plenary.nvim", "sindrets/diffview.nvim", "stevearc/dressing.nvim", -- Recommended but not required. Better UI for pickers. + "nvim-tree/nvim-web-devicons" -- Recommended but not required. Icons in discussion tree. enabled = true, }, build = function () require("gitlab.server").build(true) end, -- Builds the Go binary @@ -104,7 +105,6 @@ gitlab_url=https://my-personal-gitlab-instance.com/ The plugin will look for the `.gitlab.nvim` file in the root of the current project by default. However, you may provide a custom path to the configuration file via the `config_path` option. This must be an absolute path to the directory that holds your `.gitlab.nvim` file. - ## Configuring the Plugin Here is the default setup function. All of these values are optional, and if you call this function with no values the defaults will be used: @@ -135,6 +135,7 @@ require("gitlab").setup({ relative = "editor", -- Position of tree split relative to "editor" or "window" resolved = '✓', -- Symbol to show next to resolved discussions unresolved = '✖', -- Symbol to show next to unresolved discussions + tree_type = "simple", -- Type of discussion tree - "simple" means just list of discussions, "by_file_name" means file tree with discussions under file }, info = { -- Show additional fields in the summary pane enabled = true, @@ -195,10 +196,13 @@ require("gitlab").setup({ }, colors = { discussion_tree = { - username = 'Keyword', -- The highlight group used, for instance 'DiagnosticSignWarn' - date = 'Comment', - chevron = 'Comment', - } + username = "Keyword", + date = "Comment", + chevron = "DiffviewNonText", + directory = "Directory", + directory_icon = "DiffviewFolderSign", + file_name = "Normal", + } } }) ``` diff --git a/lua-coverage.sh b/lua-coverage.sh new file mode 100755 index 00000000..89be690d --- /dev/null +++ b/lua-coverage.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# +# Process generated luacov stats file into coverage report for gitlab.nvim. +# +set -e + +if ! [[ -f luacov.stats.out ]]; then + echo "You need to first run \`./lua-test.sh --coverage\`" + exit 1 +fi + +eval "$(luarocks path)" +luacov "$@" diff --git a/lua-test.sh b/lua-test.sh new file mode 100755 index 00000000..63513751 --- /dev/null +++ b/lua-test.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# +# Setup and run tests for lua part of gitlab.nvim. +# +# In order to run tests you need to have `luarocks` and `git` installed. This script will check if +# environment is already setup, if not it will initialize current directory with `luarocks`, +# install `busted` framework and download plugin dependencies. +# +# +set -e + +LUA_VERSION="5.1" +PLUGINS_FOLDER="tests/plugins" +PLUGINS=( + "https://github.com/MunifTanjim/nui.nvim" + "https://github.com/nvim-lua/plenary.nvim" + "https://github.com/sindrets/diffview.nvim" +) + +if ! command -v luarocks > /dev/null 2>&1; then + echo "You need to have luarocks installed in order to run tests." + exit 1 +fi + +if ! command -v git > /dev/null 2>&1; then + echo "You need to have git installed in order to run tests." + exit 1 +fi + +if ! luarocks --lua-version=$LUA_VERSION which busted > /dev/null 2>&1; then + echo "Installing busted." + luarocks init + luarocks config --scope project lua_version "$LUA_VERSION" + luarocks install --lua-version="$LUA_VERSION" busted +fi + +for arg in "$@"; do +if [[ $arg =~ "--coverage" ]] && ! luarocks --lua-version=$LUA_VERSION which luacov > /dev/null 2>&1; then + luarocks install --lua-version="$LUA_VERSION" luacov + # lcov reporter for luacov - lcov format is supported by `nvim-coverage` + luarocks install --lua-version="$LUA_VERSION" luacov-reporter-lcov +fi +done + +for plugin in "${PLUGINS[@]}"; do + plugin_name=${plugin##*/} + plugin_folder="$PLUGINS_FOLDER/$plugin_name" + + # Check if plugin was already downloaded + if [[ -d "$plugin_folder/.git" ]]; then + # We could also try to pull here but I am not sure if that wouldn't slow down tests too much. + continue + fi + + git clone --depth 1 "$plugin" "$plugin_folder" + +done + +nvim -u NONE -U NONE -N -i NONE -l tests/init.lua "$@" diff --git a/lua/gitlab/actions/discussions/annotations.lua b/lua/gitlab/actions/discussions/annotations.lua new file mode 100644 index 00000000..fcfa3e77 --- /dev/null +++ b/lua/gitlab/actions/discussions/annotations.lua @@ -0,0 +1,66 @@ +---@meta diagnostics + +---@class Author +---@field id integer +---@field username string +---@field email string +---@field name string +---@field state string +---@field avatar_url string +---@field web_url string + +---@class LinePosition +---@field line_code string +---@field type string + +---@class GitlabLineRange +---@field start LinePosition +---@field end LinePosition + +---@class NotePosition +---@field base_sha string +---@field start_sha string +---@field head_sha string +---@field position_type string +---@field new_path string? +---@field new_line integer? +---@field old_path string? +---@field old_line integer? +---@field line_range GitlabLineRange? + +---@class Note +---@field id integer +---@field type string +---@field body string +---@field attachment string +---@field title string +---@field file_name string +---@field author Author +---@field system boolean +---@field expires_at string? +---@field updated_at string? +---@field created_at string? +---@field noteable_id integer +---@field noteable_type string +---@field commit_id string +---@field position NotePosition +---@field resolvable boolean +---@field resolved boolean +---@field resolved_by Author +---@field resolved_at string? +---@field noteable_iid integer + +---@class UnlinkedNote: Note +---@field position nil + +---@class Discussion +---@field id string +---@field individual_note boolean +---@field notes Note[] + +---@class UnlinkedDiscussion: Discussion +---@field notes UnlinkedNote[] + +---@class DiscussionData +---@field discussions Discussion[] +---@field unlinked_discussions UnlinkedDiscussion[] diff --git a/lua/gitlab/actions/discussions.lua b/lua/gitlab/actions/discussions/init.lua similarity index 82% rename from lua/gitlab/actions/discussions.lua rename to lua/gitlab/actions/discussions/init.lua index 2d95817c..f104a00d 100644 --- a/lua/gitlab/actions/discussions.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -4,12 +4,14 @@ local Split = require("nui.split") local Popup = require("nui.popup") local NuiTree = require("nui.tree") +local NuiLine = require("nui.line") local Layout = require("nui.layout") local job = require("gitlab.job") local u = require("gitlab.utils") local state = require("gitlab.state") local reviewer = require("gitlab.reviewer") local miscellaneous = require("gitlab.actions.miscellaneous") +local discussions_tree = require("gitlab.actions.discussions.tree") local edit_popup = Popup(u.create_popup_state("Edit Comment", "80%", "80%")) local reply_popup = Popup(u.create_popup_state("Reply", "80%", "80%")) @@ -32,79 +34,16 @@ local M = { discussion_tree = nil, } ----@class Author ----@field id integer ----@field username string ----@field email string ----@field name string ----@field state string ----@field avatar_url string ----@field web_url string - ----@class LinePosition ----@field line_code string ----@field type string - ----@class GitlabLineRange ----@field start LinePosition ----@field end LinePosition - ----@class NotePosition ----@field base_sha string ----@field start_sha string ----@field head_sha string ----@field position_type string ----@field new_path string? ----@field new_line integer? ----@field old_path string? ----@field old_line integer? ----@field line_range GitlabLineRange? - ----@class Note ----@field id integer ----@field type string ----@field body string ----@field attachment string ----@field title string ----@field file_name string ----@field author Author ----@field system boolean ----@field expires_at string? ----@field updated_at string? ----@field created_at string? ----@field noteable_id integer ----@field noteable_type string ----@field commit_id string ----@field position NotePosition ----@field resolvable boolean ----@field resolved boolean ----@field resolved_by Author ----@field resolved_at string? ----@field noteable_iid integer - ----@class UnlinkedNote: Note ----@field position nil - ----@class Discussion ----@field id string ----@field individual_note boolean ----@field notes Note[] - ----@class UnlinkedDiscussion: Discussion ----@field notes UnlinkedNote[] - ----@class DiscussionData ----@field discussions Discussion[] ----@field unlinked_discussions UnlinkedDiscussion[] - ---Load the discussion data, storage them in M.discussions and M.unlinked_discussions and call ---callback with data ----@param callback fun(data: DiscussionData): nil +---@param callback (fun(data: DiscussionData): nil)? M.load_discussions = function(callback) job.run_job("/discussions/list", "POST", { blacklist = state.settings.discussion_tree.blacklist }, function(data) M.discussions = data.discussions M.unlinked_discussions = data.unlinked_discussions - callback(data) + if type(callback) == "function" then + callback(data) + end end) end @@ -145,7 +84,8 @@ M.filter_discussions_for_signs_and_diagnostics = function() --Skip discussions from old revisions and not ( state.settings.discussion_sign_and_diagnostic.skip_old_revision_discussion - and first_note.position.base_sha ~= state.MR_REVISIONS[1].base_sha + and u.from_iso_format_date_to_timestamp(first_note.created_at) + <= u.from_iso_format_date_to_timestamp(state.MR_REVISIONS[1].created_at) ) then table.insert(discussions, discussion) @@ -253,6 +193,13 @@ M.refresh_signs = function() reviewer.place_sign(new_signs, "new") end +---Build note header from note. +---@param note Note +---@return string +M.build_note_header = function(note) + return "@" .. note.author.username .. " " .. u.time_since(note.created_at) +end + ---Refresh the diagnostics for the currently reviewed file M.refresh_diagnostics = function() -- Keep in mind that diagnostic line numbers use 0-based indexing while line numbers use @@ -537,6 +484,7 @@ M.send_reply = function(tree, discussion_id) job.run_job("/reply", "POST", body, function(data) u.notify("Sent reply!", vim.log.levels.INFO) M.add_reply_to_tree(tree, data.note, discussion_id) + M.load_discussions() end) end end @@ -561,7 +509,7 @@ M.send_deletion = function(tree, unlinked) local root_node = M.get_root_node(tree, current_node) local note_id = note_node.is_root and root_node.root_note_id or note_node.id - local body = { discussion_id = root_node.id, note_id = note_id } + local body = { discussion_id = root_node.id, note_id = tonumber(note_id) } job.run_job("/comment", "DELETE", body, function(data) u.notify(data.message, vim.log.levels.INFO) @@ -608,11 +556,14 @@ M.edit_comment = function(tree, unlinked) 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, unlinked) + M.send_edits(tostring(root_node.id), tonumber(note_node.root_note_id or note_node.id), unlinked) ) end --- This function sends the edited comment to the Go server +---This function sends the edited comment to the Go server +---@param discussion_id string +---@param note_id integer +---@param unlinked boolean M.send_edits = function(discussion_id, note_id, unlinked) return function(text) local body = { @@ -623,10 +574,10 @@ M.send_edits = function(discussion_id, note_id, unlinked) job.run_job("/comment", "PATCH", body, function(data) u.notify(data.message, vim.log.levels.INFO) if unlinked then - M.unlinked_discussions = M.replace_text(M.unlinked_discussions, discussion_id, note_id, text) + M.replace_text(M.unlinked_discussions, discussion_id, note_id, text) M.rebuild_unlinked_discussion_tree() else - M.discussions = M.replace_text(M.discussions, discussion_id, note_id, text) + M.replace_text(M.discussions, discussion_id, note_id, text) M.rebuild_discussion_tree() end end) @@ -684,12 +635,16 @@ M.toggle_node = function(tree) end if node:is_expanded() then node:collapse() - for _, child in ipairs(children) do - tree:get_node(child):collapse() + if M.is_node_note(node) then + for _, child in ipairs(children) do + tree:get_node(child):collapse() + end end else - for _, child in ipairs(children) do - tree:get_node(child):expand() + if M.is_node_note(node) then + for _, child in ipairs(children) do + tree:get_node(child):expand() + end end node:expand() end @@ -700,12 +655,48 @@ end -- -- 🌲 Helper Functions -- +---Inspired by default func https://github.com/MunifTanjim/nui.nvim/blob/main/lua/nui/tree/util.lua#L38 +local function nui_tree_prepare_node(node) + if not node.text then + error("missing node.text") + end + + local texts = node.text + + if type(node.text) ~= "table" or node.text.content then + texts = { node.text } + end + + local lines = {} + + for i, text in ipairs(texts) do + local line = NuiLine() + + line:append(string.rep(" ", node._depth - 1)) + + if i == 1 and node:has_children() then + line:append(node:is_expanded() and " " or " ") + if node.icon then + line:append(node.icon .. " ", node.icon_hl) + end + else + line:append(" ") + end + + line:append(text, node.text_hl) + + table.insert(lines, line) + end + + return lines +end M.rebuild_discussion_tree = function() M.switch_can_edit_bufs(true) vim.api.nvim_buf_set_lines(M.linked_section.bufnr, 0, -1, false, {}) - local discussion_tree_nodes = M.add_discussions_to_table(M.discussions) - local discussion_tree = NuiTree({ nodes = discussion_tree_nodes, bufnr = M.linked_section.bufnr }) + local discussion_tree_nodes = discussions_tree.add_discussions_to_table(M.discussions, false) + local discussion_tree = + NuiTree({ nodes = discussion_tree_nodes, bufnr = M.linked_section.bufnr, prepare_node = nui_tree_prepare_node }) discussion_tree:render() M.set_tree_keymaps(discussion_tree, M.linked_section.bufnr, false) M.discussion_tree = discussion_tree @@ -716,8 +707,12 @@ end M.rebuild_unlinked_discussion_tree = function() M.switch_can_edit_bufs(true) vim.api.nvim_buf_set_lines(M.unlinked_section.bufnr, 0, -1, false, {}) - local unlinked_discussion_tree_nodes = M.add_discussions_to_table(M.unlinked_discussions) - local unlinked_discussion_tree = NuiTree({ nodes = unlinked_discussion_tree_nodes, bufnr = M.unlinked_section.bufnr }) + local unlinked_discussion_tree_nodes = discussions_tree.add_discussions_to_table(M.unlinked_discussions, true) + local unlinked_discussion_tree = NuiTree({ + nodes = unlinked_discussion_tree_nodes, + bufnr = M.unlinked_section.bufnr, + prepare_node = nui_tree_prepare_node, + }) unlinked_discussion_tree:render() M.set_tree_keymaps(unlinked_discussion_tree, M.unlinked_section.bufnr, true) M.unlinked_discussion_tree = unlinked_discussion_tree @@ -793,29 +788,59 @@ M.add_empty_titles = function(args) end end +---Check if type of node is note or note body +---@param node NuiTree.Node? +---@return boolean +M.is_node_note = function(node) + if node and (node.type == "note_body" or node.type == "note") then + return true + else + return false + end +end + +---Check if type of current node is note or note body +---@param tree NuiTree +---@return boolean +M.is_current_node_note = function(tree) + return M.is_node_note(tree:get_node()) +end + M.set_tree_keymaps = function(tree, bufnr, unlinked) vim.keymap.set("n", state.settings.discussion_tree.edit_comment, function() - M.edit_comment(tree, unlinked) + if M.is_current_node_note(tree) then + M.edit_comment(tree, unlinked) + end end, { buffer = bufnr }) vim.keymap.set("n", state.settings.discussion_tree.delete_comment, function() - M.delete_comment(tree, unlinked) + if M.is_current_node_note(tree) then + M.delete_comment(tree, unlinked) + end end, { buffer = bufnr }) vim.keymap.set("n", state.settings.discussion_tree.toggle_resolved, function() - M.toggle_discussion_resolved(tree) + if M.is_current_node_note(tree) then + M.toggle_discussion_resolved(tree) + end end, { buffer = bufnr }) vim.keymap.set("n", state.settings.discussion_tree.toggle_node, function() M.toggle_node(tree) end, { buffer = bufnr }) vim.keymap.set("n", state.settings.discussion_tree.reply, function() - M.reply(tree) + if M.is_current_node_note(tree) then + M.reply(tree) + end end, { buffer = bufnr }) if not unlinked then vim.keymap.set("n", state.settings.discussion_tree.jump_to_file, function() - M.jump_to_file(tree) + if M.is_current_node_note(tree) then + M.jump_to_file(tree) + end end, { buffer = bufnr }) vim.keymap.set("n", state.settings.discussion_tree.jump_to_reviewer, function() - M.jump_to_reviewer(tree) + if M.is_current_node_note(tree) then + M.jump_to_reviewer(tree) + end end, { buffer = bufnr }) end end @@ -850,153 +875,68 @@ M.redraw_resolved_status = function(tree, note, mark_resolved) tree:render() end +---Replace text in discussion after note update. +---@param data Discussion[]|UnlinkedDiscussion[] +---@param discussion_id string +---@param note_id integer +---@param text string M.replace_text = function(data, discussion_id, note_id, text) for i, discussion in ipairs(data) do if discussion.id == discussion_id then for j, note in ipairs(discussion.notes) do if note.id == note_id then data[i].notes[j].body = text - return data end end end end end +---Get root node +---@param tree NuiTree +---@param node NuiTree.Node? +---@return NuiTree.Node? M.get_root_node = function(tree, node) - if not node.is_root then + if not node then + return nil + end + if node.type == "note_body" or node.type == "note" and not node.is_root then local parent_id = node:get_parent_id() return M.get_root_node(tree, tree:get_node(parent_id)) - else + elseif node.is_root then return node end end +---Get note node +---@param tree NuiTree +---@param node NuiTree.Node? +---@return NuiTree.Node? M.get_note_node = function(tree, node) - if not node.is_note then + if not node then + return nil + end + + if node.type == "note_body" then local parent_id = node:get_parent_id() if parent_id == nil then return node end return M.get_note_node(tree, tree:get_node(parent_id)) - else + elseif node.type == "note" then return node end end -local attach_uuid = function(str) - return { text = str, id = u.uuid() } -end - ----Build note header from note. ----@param note Note ----@return string -M.build_note_header = function(note) - return "@" .. note.author.username .. " " .. u.time_since(note.created_at) -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 = (type(note.position) == "table" and note.position.new_line), - old_line = (type(note.position) == "table" and 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 = M.build_note_header(note) .. " " .. 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 = (type(note.position) == "table" and note.position.new_path), - new_line = (type(note.position) == "table" and note.position.new_line), - old_line = (type(note.position) == "table" and note.position.old_line), - is_note = true, - }, text_nodes) - - return note_node, text, text_nodes -end - M.add_reply_to_tree = function(tree, note, discussion_id) - local note_node = M.build_note(note) + local note_node = discussions_tree.build_note(note) note_node:expand() tree:add_node(note_node, discussion_id and ("-" .. discussion_id) or nil) tree:render() end -M.add_discussions_to_table = function(items) - local t = {} - for _, discussion in ipairs(items) 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 = (type(note.position) == "table" and note.position.new_path) - root_new_line = (type(note.position) == "table" and note.position.new_line) - root_old_line = (type(note.position) == "table" and 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.spread(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 - +---Get note location +---@param tree NuiTree M.get_note_location = function(tree) local node = tree:get_node() if node == nil then diff --git a/lua/gitlab/actions/discussions/tree.lua b/lua/gitlab/actions/discussions/tree.lua new file mode 100644 index 00000000..acf7d1cd --- /dev/null +++ b/lua/gitlab/actions/discussions/tree.lua @@ -0,0 +1,279 @@ +local state = require("gitlab.state") +local u = require("gitlab.utils") +local NuiTree = require("nui.tree") + +local M = {} + +local attach_uuid = function(str) + return { text = str, id = u.uuid() } +end + +---Create path node +---@param relative_path string +---@param full_path string +---@param child_nodes NuiTree.Node[]? +---@return NuiTree.Node +local function create_path_node(relative_path, full_path, child_nodes) + return NuiTree.Node({ + text = relative_path, + path = full_path, + id = full_path, + type = "path", + icon = " ", + icon_hl = "GitlabDirectoryIcon", + text_hl = "GitlabDirectory", + }, child_nodes or {}) +end + +---Create file name node +---@param file_name string +---@param full_file_path string +---@param child_nodes NuiTree.Node[]? +---@return NuiTree.Node +local function create_file_name_node(file_name, full_file_path, child_nodes) + local icon, icon_hl = u.get_icon(file_name) + return NuiTree.Node({ + text = file_name, + file_name = full_file_path, + id = full_file_path, + type = "file_name", + icon = icon, + icon_hl = icon_hl, + text_hl = "GitlabFileName", + }, child_nodes or {}) +end + +---Sort list of nodes (in place) of type "path" or "file_name" +---@param nodes NuiTree.Node[] +local function sort_nodes(nodes) + table.sort(nodes, function(node1, node2) + if node1.type == "path" and node2.type == "path" then + return node1.path < node2.path + elseif node1.type == "file_name" and node2.type == "file_name" then + return node1.file_name < node2.file_name + elseif node1.type == "path" and node2.type == "file_name" then + return true + else + return false + end + end) +end + +---Merge path nodes which have only single path child +---@param node NuiTree.Node +local function flatten_nodes(node) + if node.type ~= "path" then + return + end + for _, child in ipairs(node.__children) do + flatten_nodes(child) + end + if #node.__children == 1 and node.__children[1].type == "path" then + local child = node.__children[1] + node.__children = child.__children + node.id = child.id + node.path = child.path + node.text = node.text .. u.path_separator .. child.text + end + sort_nodes(node.__children) +end + +---Build note header from note. +---@param note Note +---@return string +local function build_note_header(note) + return "@" .. note.author.username .. " " .. u.time_since(note.created_at) +end + +---Build note node body +---@param note Note +---@param resolve_info table? +---@return string +---@return NuiTree.Node[] +local function build_note_body(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 = (type(note.position) == "table" and note.position.new_line), + old_line = (type(note.position) == "table" and note.position.old_line), + text = line.text, + id = line.id, + type = "note_body", + }, {}) + ) + 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 = build_note_header(note) .. " " .. resolve_symbol + + return noteHeader, text_nodes +end + +---Build note node +---@param note Note +---@param resolve_info table? +---@return NuiTree.Node +---@return string +---@return NuiTree.Node[] +M.build_note = function(note, resolve_info) + local text, text_nodes = build_note_body(note, resolve_info) + local note_node = NuiTree.Node({ + text = text, + id = note.id, + file_name = (type(note.position) == "table" and note.position.new_path), + new_line = (type(note.position) == "table" and note.position.new_line), + old_line = (type(note.position) == "table" and note.position.old_line), + type = "note", + }, text_nodes) + + return note_node, text, text_nodes +end + +---Create nodes for NuiTree from discussions +---@param items Discussion[] +---@param unlinked boolean? False or nil means that discussions are linked to code lines +---@return NuiTree.Node[] +M.add_discussions_to_table = function(items, unlinked) + local t = {} + for _, discussion in ipairs(items) do + local discussion_children = {} + + -- These properties are filled in by the first note + ---@type string? + local root_text = "" + ---@type string? + local root_note_id = "" + ---@type string? + local root_file_name = "" + ---@type string + local root_id + 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 = (type(note.position) == "table" and note.position.new_path or nil) + root_new_line = (type(note.position) == "table" and note.position.new_line or nil) + root_old_line = (type(note.position) == "table" and note.position.old_line or nil) + root_id = discussion.id + root_note_id = tostring(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.spread(root_text_nodes, discussion_children) + local root_node = NuiTree.Node({ + text = root_text, + type = "note", + 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 + if state.settings.discussion_tree.tree_type == "simple" or unlinked == true then + return t + end + + -- Create all the folder and file name nodes. + local discussion_by_file_name = {} + local top_level_path_to_node = {} + for _, node in ipairs(t) do + local path = "" + local parent_node = nil + local path_parts = u.split_path(node.file_name) + local file_name = table.remove(path_parts, #path_parts) + -- Create folders + for i, path_part in ipairs(path_parts) do + path = path ~= nil and path .. u.path_separator .. path_part or path_part + if i == 1 then + if top_level_path_to_node[path] == nil then + parent_node = create_path_node(path_part, path) + top_level_path_to_node[path] = parent_node + table.insert(discussion_by_file_name, parent_node) + end + parent_node = top_level_path_to_node[path] + elseif parent_node then + local child_node = nil + for _, child in ipairs(parent_node.__children) do + if child.path == path then + child_node = child + break + end + end + + if child_node == nil then + child_node = create_path_node(path_part, path) + table.insert(parent_node.__children, child_node) + parent_node:expand() + parent_node = child_node + else + parent_node = child_node + end + end + end + + -- Create file name nodes + if parent_node == nil then + ---Top level file name + if top_level_path_to_node[node.file_name] ~= nil then + table.insert(top_level_path_to_node[node.file_name].__children, node) + else + local file_node = create_file_name_node(file_name, node.file_name, { node }) + file_node:expand() + top_level_path_to_node[node.file_name] = file_node + table.insert(discussion_by_file_name, file_node) + end + else + local child_node = nil + for _, child in ipairs(parent_node.__children) do + if child.file_name == node.file_name then + child_node = child + break + end + end + if child_node == nil then + child_node = create_file_name_node(file_name, node.file_name, { node }) + table.insert(parent_node.__children, child_node) + parent_node:expand() + child_node:expand() + else + table.insert(child_node.__children, node) + end + end + end + + -- Flatten empty folders + for _, node in ipairs(discussion_by_file_name) do + flatten_nodes(node) + end + sort_nodes(discussion_by_file_name) + + return discussion_by_file_name +end + +return M diff --git a/lua/gitlab/actions/miscellaneous.lua b/lua/gitlab/actions/miscellaneous.lua index 12208c97..ac0edf0b 100644 --- a/lua/gitlab/actions/miscellaneous.lua +++ b/lua/gitlab/actions/miscellaneous.lua @@ -38,7 +38,7 @@ M.attach_file = function() if not choice then return end - local full_path = attachment_dir .. (u.is_windows() and "\\" or "/") .. choice + local full_path = attachment_dir .. u.path_separator .. choice local body = { file_path = full_path, file_name = choice } job.run_job("/mr/attachment", "POST", body, function(data) local markdown = data.markdown diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index 22914bba..a1a4b7ab 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -193,7 +193,6 @@ M.create_layout = function(info_lines) details_popup = Popup(details_popup_settings) if state.settings.info.horizontal then local longest_line = u.get_longest_string(info_lines) - print(longest_line) internal_layout = Layout.Box({ Layout.Box(title_popup, { size = 3 }), Layout.Box({ diff --git a/lua/gitlab/colors.lua b/lua/gitlab/colors.lua index 27a7769f..dc3b996f 100644 --- a/lua/gitlab/colors.lua +++ b/lua/gitlab/colors.lua @@ -7,3 +7,6 @@ local discussion = colors.discussion_tree vim.api.nvim_set_hl(0, "GitlabUsername", u.get_colors_for_group(discussion.username)) vim.api.nvim_set_hl(0, "GitlabDate", u.get_colors_for_group(discussion.date)) vim.api.nvim_set_hl(0, "GitlabChevron", u.get_colors_for_group(discussion.chevron)) +vim.api.nvim_set_hl(0, "GitlabDirectory", u.get_colors_for_group(discussion.directory)) +vim.api.nvim_set_hl(0, "GitlabDirectoryIcon", u.get_colors_for_group(discussion.directory_icon)) +vim.api.nvim_set_hl(0, "GitlabFileName", u.get_colors_for_group(discussion.file_name)) diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index bda39e3b..3efb444d 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -33,6 +33,7 @@ M.settings = { size = "20%", resolved = "✓", unresolved = "", + tree_type = "simple", }, info = { enabled = true, @@ -97,7 +98,10 @@ M.settings = { discussion_tree = { username = "Keyword", date = "Comment", - chevron = "Comment", + chevron = "DiffviewNonText", + directory = "Directory", + directory_icon = "DiffviewFolderSign", + file_name = "Normal", }, }, } diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index 98552a05..2763b507 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -1,3 +1,4 @@ +local has_devicons, devicons = pcall(require, "nvim-web-devicons") local M = {} ---Pulls out a list of values matching a given key from an array of tables @@ -246,12 +247,83 @@ M.is_windows = function() return false end +---Path separator based on current OS. +---@type string +M.path_separator = M.is_windows() and "\\" or "/" + +---Split path by OS path separator. +---@param path string +---@return string[] +M.split_path = function(path) + local path_parts = {} + for part in string.gmatch(path, "([^" .. M.path_separator .. "]+)") do + table.insert(path_parts, part) + end + return path_parts +end + +M.P = function(...) + local objects = {} + for i = 1, select("#", ...) do + local v = select(i, ...) + table.insert(objects, vim.inspect(v)) + end + + print(table.concat(objects, "\n")) + return ... +end + 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 +M.string_starts = function(str, start) + return str:sub(1, #start) == start +end + +M.press_enter = function() + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", false, true, true), "n", false) +end + +---Return timestamp from ISO 8601 formatted date string. +---@param date_string string ISO 8601 formatted date string +---@return integer timestamp +M.from_iso_format_date_to_timestamp = function(date_string) + local year, month, day, hour, min, sec = date_string:match("(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)") + return os.time({ year = year, month = month, day = day, hour = hour, min = min, sec = sec }) +end + +M.format_date = function(date_string) + local date_table = os.date("!*t") + local date = M.from_iso_format_date_to_timestamp(date_string) + + local current_date = os.time({ + year = date_table.year, + month = date_table.month, + day = date_table.day, + hour = date_table.hour, + min = date_table.min, + sec = date_table.sec, + }) + + local time_diff = current_date - date + + if time_diff < 60 then + return pluralize(time_diff, "second") + elseif time_diff < 3600 then + return pluralize(math.floor(time_diff / 60), "minute") + elseif time_diff < 86400 then + return pluralize(math.floor(time_diff / 3600), "hour") + elseif time_diff < 2592000 then + return pluralize(math.floor(time_diff / 86400), "day") + else + local formatted_date = os.date("%A, %B %e", date) + return formatted_date + end +end + M.jump_to_file = function(filename, line_number) if line_number == nil then line_number = 1 @@ -362,7 +434,7 @@ M.list_files_in_folder = function(folder_path) local files = {} if folder ~= nil then for _, file in ipairs(folder) do - local file_path = folder_path .. (M.is_windows() and "\\" or "/") .. file + local file_path = folder_path .. M.path_separator .. file local timestamp = vim.fn.getftime(file_path) table.insert(files, { name = file, timestamp = timestamp }) end @@ -528,4 +600,21 @@ M.get_visual_selection_boundaries = function() return start_line, end_line end +---Get icon for filename if nvim-web-devicons plugin is available otherwise return empty string +---@return string? +---@return string? +M.get_icon = function(filename) + if has_devicons then + local extension = vim.fn.fnamemodify(filename, ":e") + local icon, icon_hl = devicons.get_icon(filename, extension, { default = true }) + if icon ~= nil then + return icon .. " ", icon_hl + else + return nil, nil + end + else + return nil, nil + end +end + return M diff --git a/tests/init.lua b/tests/init.lua new file mode 100644 index 00000000..293c38b8 --- /dev/null +++ b/tests/init.lua @@ -0,0 +1,40 @@ +---Initialize neovim to use lua modules from luarocks and prepare correct search paths for +---modules, neovim plugins and load busted frameworks. + +local function build_path(modules, extensions) + local path = "" + for _, module_path in ipairs(modules) do + for _, lua_path_extension in ipairs(extensions) do + path = path .. module_path .. lua_path_extension .. ";" + end + end + return path +end + +local plugins_folder = "tests/plugins/*/lua" +local luarocks_cmd = "luarocks config --scope project" + +-- Project path +local modules = { "lua" } +-- External plugins - dependencies +for plugin_path in vim.fn.glob(plugins_folder):gmatch("[^\r\n]+") do + table.insert(modules, plugin_path) +end +-- Lua modules path +table.insert(modules, vim.fn.trim(vim.fn.system(luarocks_cmd .. " deploy_lua_dir"))) + +local lua_path_extensions = { "/?.lua", "/?/init.lua" } +package.path = build_path(modules, lua_path_extensions) .. package.path + +local cmodules = { + vim.fn.trim(vim.fn.system(luarocks_cmd .. " deploy_lib_dir")), +} +local lua_lib_extensions = { "/?.so", "/?/init.so" } +package.cpath = build_path(cmodules, lua_lib_extensions) .. package.cpath + +-- Initialize required plugins which needs it +require("diffview").setup() + +-- Run busted - +require("busted.runner")({ standalone = false }) +os.exit(0) diff --git a/tests/plugins/.placeholder b/tests/plugins/.placeholder new file mode 100644 index 00000000..e69de29b diff --git a/tests/spec/discussions_spec.lua b/tests/spec/discussions_spec.lua new file mode 100644 index 00000000..8f2c0416 --- /dev/null +++ b/tests/spec/discussions_spec.lua @@ -0,0 +1,6 @@ +describe("gitlab/actions/discussions/init.lua", function() + it("Loads package", function() + local utils_ok, _ = pcall(require, "gitlab.actions.discussions") + assert._is_true(utils_ok) + end) +end) diff --git a/tests/spec/discussions_tree_spec.lua b/tests/spec/discussions_tree_spec.lua new file mode 100644 index 00000000..cd47faa3 --- /dev/null +++ b/tests/spec/discussions_tree_spec.lua @@ -0,0 +1,707 @@ +---@class ResultNodeTree +---@field type string +---@field text string +---@field children ResultNodeTree[]? + +---Transform nui nodes to table for easier comparison in tests We could compare directly +---NuiTree.Node but that have a lot of parameters which we don't care about +---@param nodes NuiTree.Node[] +---@param allowed_node_types table +---@return ResultNodeTree +local function tree_nodes_to_table(nodes, allowed_node_types) + local result = {} + for _, node in ipairs(nodes) do + assert._is_true(allowed_node_types[node.type]) + local current = { + type = node.type, + text = node.text, + children = tree_nodes_to_table(node.__children, allowed_node_types), + } + table.insert(result, current) + end + return result +end + +math.randomseed(os.time()) +---Create new discussion node, change ids and path +---@param discussion Discussion +---@param path string +local function copy_discussion_with_new_path(discussion, path) + local new_discussion = vim.fn.deepcopy(discussion) + new_discussion.id = tostring(math.random(1000, 10000000)) + new_discussion.notes[1].id = math.random(1000, 10000000) + new_discussion.notes[1].position.new_path = path + new_discussion.notes[1].position.old_path = path + return new_discussion +end + +describe("gitlab/actions/discussions/tree.lua", function() + it("Loads package", function() + local utils_ok, _ = pcall(require, "gitlab.actions.discussions.tree") + assert._is_true(utils_ok) + end) + describe("add_discussions_to_table", function() + local tree = require("gitlab.actions.discussions.tree") + local state = require("gitlab.state") + local utils = require("gitlab.utils") + local original_time_since = utils.time_since + local discussions + local unlinked_discussions + local spy_time_since + local all_node_types = { note = true, note_body = true, path = true, file_name = true } + + it("Returns empty list with no discussions", function() + assert.are.same(tree.add_discussions_to_table({}), {}) + end) + + after_each(function() + utils.time_since = original_time_since + end) + before_each(function() + spy_time_since = spy.new(function() + return "5 days ago" + end) + utils.time_since = spy_time_since + local author = { + avatar_url = "https://secure.gravatar.com/avatar/a857c8a11e80d5c9116ad6ac4c0fb98a?s=80&d=identicon", + email = "", + id = 12345, + name = "Gitlab Name", + state = "active", + username = "gitlab.username", + web_url = "https://gitlab.com/gitlab.username", + } + local empty_resolved_by = { + avatar_url = "", + email = "", + id = 0, + name = "", + state = "", + username = "", + web_url = "", + } + + discussions = { + { + id = "17c7b7558925d0caa7f73684482x9055977bf454", + individual_note = false, + notes = { + { + attachment = "", + author = author, + body = "Multiline comment", + commit_id = "", + created_at = "2023-10-28T18:27:34.082Z", + expires_at = vim.NIL, + file_name = "", + id = 1624411, + noteable_id = 240727, + noteable_iid = 1, + noteable_type = "MergeRequest", + position = { + base_sha = "d687b5ad4ad5ccd5ae9517efcd103629af1750d6", + head_sha = "18f76ebeb6e8fcd76a80dce5b592a4f133d2ad05", + line_range = { + ["end"] = { + line_code = "8ec9a01bfd10b3191ac6b22252dba2aa95a0579d_18_17", + new_line = 0, + old_line = 0, + type = "new", + }, + start = { + line_code = "8ec9a01bfd10b3191ac6b22252dba2aa95a0579d_18_19", + new_line = 0, + old_line = 0, + type = "new", + }, + }, + new_line = 17, + new_path = "README.md", + old_path = "README.md", + position_type = "text", + start_sha = "d687b5ad4ad5ccd5ae9517efcd103629af1750d6", + }, + resolvable = true, + resolved = false, + resolved_at = vim.NIL, + resolved_by = empty_resolved_by, + system = false, + title = "", + type = "DiffNote", + updated_at = "2023-10-28T18:27:34.082Z", + }, + }, + }, + { + id = "c418928237e9e542b676d25c4211160agcs11733", + individual_note = false, + notes = { + { + attachment = "", + author = author, + body = "test single line comment!", + commit_id = "", + created_at = "2023-10-28T18:26:22.336Z", + expires_at = vim.NIL, + file_name = "", + id = 1624415, + noteable_id = 240727, + noteable_iid = 1, + noteable_type = "MergeRequest", + position = { + base_sha = "d687b5ad4ad5ccd5ae9517efcd103629af1750d6", + head_sha = "18f76ebeb6e8fcd76a80dce5b592a4f133d2ad05", + new_line = 11, + new_path = "folder_1/folder_2/folder_3/file.lua", + old_path = "folder_1/folder_2/folder_3/file.lua", + position_type = "text", + start_sha = "d687b5ad4ad5ccd5ae9517efcd103629af1750d6", + }, + resolvable = true, + resolved = false, + resolved_at = vim.NIL, + resolved_by = empty_resolved_by, + system = false, + title = "", + type = "DiffNote", + updated_at = "2023-10-28T18:26:22.336Z", + }, + }, + }, + } + unlinked_discussions = { + { + id = "16c5b7558923d0caa7f73684481c9055976bf454", + individual_note = false, + notes = { + { + attachment = "", + author = author, + body = "Test just unlinked note", + commit_id = "", + created_at = "2021-05-20T10:10:00.648Z", + expires_at = vim.NIL, + file_name = "", + id = 165260, + noteable_id = 25024, + noteable_iid = 1, + noteable_type = "MergeRequest", + position = vim.NIL, + resolvable = true, + resolved = false, + resolved_at = vim.NIL, + resolved_by = empty_resolved_by, + system = false, + title = "", + type = "DiscussionNote", + updated_at = "2023-11-16T24:15:49.648Z", + }, + }, + }, + { + id = "38bbe42a1bb8f2a014c4fd87d87760772f090a3c", + individual_note = false, + notes = { + { + attachment = "", + author = author, + body = "Other unlinked note", + commit_id = "", + created_at = "2022-10-25T12:20:30.648Z", + expires_at = vim.NIL, + file_name = "", + id = 165260, + noteable_id = 25024, + noteable_iid = 1, + noteable_type = "MergeRequest", + position = vim.NIL, + resolvable = true, + resolved = false, + resolved_at = vim.NIL, + resolved_by = empty_resolved_by, + system = false, + title = "", + type = "DiscussionNote", + updated_at = "2023-11-16T20:15:49.648Z", + }, + { + attachment = "", + author = author, + body = "Response to the unlinked note", + commit_id = "", + created_at = "2023-11-18T20:15:49.648Z", + expires_at = vim.NIL, + file_name = "", + id = 165260, + noteable_id = 25024, + noteable_iid = 1, + noteable_type = "MergeRequest", + position = vim.NIL, + resolvable = true, + resolved = false, + resolved_at = vim.NIL, + resolved_by = empty_resolved_by, + system = false, + title = "", + type = "DiscussionNote", + updated_at = "2023-11-16T20:15:49.648Z", + }, + }, + }, + } + end) + + it("Returns list of note nodes if `tree_type` is `simple`", function() + state.settings.discussion_tree.tree_type = "simple" + local nodes = tree.add_discussions_to_table(discussions) + assert.are.same(tree_nodes_to_table(nodes, { note = true, note_body = true }), { + { + text = "@gitlab.username 5 days ago ", + type = "note", + children = { + { + children = {}, + text = "Multiline comment", + type = "note_body", + }, + }, + }, + { + text = "@gitlab.username 5 days ago ", + type = "note", + children = { + { + text = "test single line comment!", + type = "note_body", + children = {}, + }, + }, + }, + }) + end) + + it("Returns path tree of note nodes if tree_type is `by_file_name`", function() + state.settings.discussion_tree.tree_type = "by_file_name" + local nodes = tree.add_discussions_to_table(discussions) + assert.are.same(tree_nodes_to_table(nodes, all_node_types), { + { + text = "folder_1/folder_2/folder_3", + type = "path", + children = { + { + text = "file.lua", + type = "file_name", + children = { + { + text = "@gitlab.username 5 days ago ", + type = "note", + children = { + { + text = "test single line comment!", + type = "note_body", + children = {}, + }, + }, + }, + }, + }, + }, + }, + { + text = "README.md", + type = "file_name", + children = { + { + text = "@gitlab.username 5 days ago ", + type = "note", + children = { + { + text = "Multiline comment", + type = "note_body", + children = {}, + }, + }, + }, + }, + }, + }) + end) + it("Merges the paths in path tree if there is no file in folder", function() + state.settings.discussion_tree.tree_type = "by_file_name" + local nodes = tree.add_discussions_to_table({ discussions[2] }) + assert.are.same(tree_nodes_to_table(nodes, all_node_types), { + { + text = "folder_1/folder_2/folder_3", + type = "path", + children = { + { + text = "file.lua", + type = "file_name", + children = { + { + text = "@gitlab.username 5 days ago ", + type = "note", + children = { + { + text = "test single line comment!", + type = "note_body", + children = {}, + }, + }, + }, + }, + }, + }, + }, + }) + end) + it("Correctly places files in folders in file tree", function() + state.settings.discussion_tree.tree_type = "by_file_name" + local discussion1 = copy_discussion_with_new_path(discussions[2], "folder_1/first_level.txt") + local discussion2 = copy_discussion_with_new_path(discussions[2], "folder_1/folder_2/second_level.txt") + local expected_result = { + { + text = "folder_1", + type = "path", + children = { + { + text = "folder_2", + type = "path", + children = { + { + text = "folder_3", + type = "path", + children = { + { + text = "file.lua", + type = "file_name", + children = { + { + text = "@gitlab.username 5 days ago ", + type = "note", + children = { + { + text = "test single line comment!", + type = "note_body", + children = {}, + }, + }, + }, + }, + }, + }, + }, + { + text = "second_level.txt", + type = "file_name", + children = { + { + text = "@gitlab.username 5 days ago ", + type = "note", + children = { + { + text = "test single line comment!", + type = "note_body", + children = {}, + }, + }, + }, + }, + }, + }, + }, + { + text = "first_level.txt", + type = "file_name", + children = { + { + text = "@gitlab.username 5 days ago ", + type = "note", + children = { + { + text = "test single line comment!", + type = "note_body", + children = {}, + }, + }, + }, + }, + }, + }, + }, + } + -- Make sure that order of nodes does not change result! + assert.are.same( + tree_nodes_to_table(tree.add_discussions_to_table({ discussions[2], discussion2, discussion1 }), all_node_types), + expected_result + ) + assert.are.same( + tree_nodes_to_table(tree.add_discussions_to_table({ discussion2, discussions[2], discussion1 }), all_node_types), + expected_result + ) + assert.are.same( + tree_nodes_to_table(tree.add_discussions_to_table({ discussion2, discussion1, discussions[2] }), all_node_types), + expected_result + ) + assert.are.same( + tree_nodes_to_table(tree.add_discussions_to_table({ discussion1, discussion2, discussions[2] }), all_node_types), + expected_result + ) + end) + it("Correctly places files with same filenames and different paths", function() + state.settings.discussion_tree.tree_type = "by_file_name" + local discussion1 = copy_discussion_with_new_path(discussions[2], "folder_1/diffent_folder/folder_3/file.lua") + discussion1.notes[1].body = "path: folder_1/diffent_folder/folder_3/file.lua" + local discussion2 = copy_discussion_with_new_path(discussions[2], "another/folder_2/folder_3/file.lua") + discussion2.notes[1].body = "path: another/folder_2/folder_3/file.lua" + local expected_result = { + { + text = "another/folder_2/folder_3", + type = "path", + children = { + { + text = "file.lua", + type = "file_name", + children = { + { + text = "@gitlab.username 5 days ago ", + type = "note", + children = { + { + text = "path: another/folder_2/folder_3/file.lua", + type = "note_body", + children = {}, + }, + }, + }, + }, + }, + }, + }, + { + text = "folder_1", + type = "path", + children = { + { + text = "diffent_folder/folder_3", + type = "path", + children = { + { + text = "file.lua", + type = "file_name", + children = { + { + text = "@gitlab.username 5 days ago ", + type = "note", + children = { + { + text = "path: folder_1/diffent_folder/folder_3/file.lua", + type = "note_body", + children = {}, + }, + }, + }, + }, + }, + }, + }, + { + text = "folder_2/folder_3", + type = "path", + children = { + { + text = "file.lua", + type = "file_name", + children = { + { + text = "@gitlab.username 5 days ago ", + type = "note", + children = { + { + text = "test single line comment!", + type = "note_body", + children = {}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + assert.are.same( + tree_nodes_to_table(tree.add_discussions_to_table({ discussions[2], discussion2, discussion1 }), all_node_types), + expected_result + ) + end) + it("Correctly places multiple notes in same file", function() + state.settings.discussion_tree.tree_type = "by_file_name" + local discussion1 = copy_discussion_with_new_path(discussions[2], "folder_1/folder_2/folder_3/file.lua") + discussion1.notes[1].body = "This is different note!" + local expected_result = { + { + text = "folder_1/folder_2/folder_3", + type = "path", + children = { + { + text = "file.lua", + type = "file_name", + children = { + { + text = "@gitlab.username 5 days ago ", + type = "note", + children = { + { + text = "test single line comment!", + type = "note_body", + children = {}, + }, + }, + }, + { + text = "@gitlab.username 5 days ago ", + type = "note", + children = { + { + text = "This is different note!", + type = "note_body", + children = {}, + }, + }, + }, + }, + }, + }, + }, + } + assert.are.same( + tree_nodes_to_table(tree.add_discussions_to_table({ discussions[2], discussion1 }), all_node_types), + expected_result + ) + end) + it("Correctly places multiple notes in same top level file", function() + state.settings.discussion_tree.tree_type = "by_file_name" + local discussion1 = copy_discussion_with_new_path(discussions[1], "README.md") + discussion1.notes[1].body = "This is different note!" + local expected_result = { + { + text = "README.md", + type = "file_name", + children = { + { + text = "@gitlab.username 5 days ago ", + type = "note", + children = { + { + text = "Multiline comment", + type = "note_body", + children = {}, + }, + }, + }, + { + text = "@gitlab.username 5 days ago ", + type = "note", + children = { + { + text = "This is different note!", + type = "note_body", + children = {}, + }, + }, + }, + }, + }, + } + assert.are.same( + tree_nodes_to_table(tree.add_discussions_to_table({ discussions[1], discussion1 }), all_node_types), + expected_result + ) + end) + + it("Returns list of note nodes for unlinked discussions", function() + state.settings.discussion_tree.tree_type = "simple" + local nodes = tree.add_discussions_to_table(unlinked_discussions, true) + assert.are.same(tree_nodes_to_table(nodes, { note = true, note_body = true }), { + { + text = "@gitlab.username 5 days ago ", + type = "note", + children = { + { + children = {}, + text = "Test just unlinked note", + type = "note_body", + }, + }, + }, + { + text = "@gitlab.username 5 days ago ", + type = "note", + children = { + { + text = "Other unlinked note", + type = "note_body", + children = {}, + }, + { + text = "@gitlab.username 5 days ago ", + type = "note", + children = { + { + children = {}, + text = "Response to the unlinked note", + type = "note_body", + }, + }, + }, + }, + }, + }) + assert.spy(spy_time_since).was.called_with("2021-05-20T10:10:00.648Z") + assert.spy(spy_time_since).was.called_with("2022-10-25T12:20:30.648Z") + assert.spy(spy_time_since).was.called_with("2023-11-18T20:15:49.648Z") + end) + + it("Returns list of note nodes for unlinked discussions even if tree_type is not `simple`", function() + state.settings.discussion_tree.tree_type = "by_file_name" + local nodes = tree.add_discussions_to_table(unlinked_discussions, true) + assert.are.same(tree_nodes_to_table(nodes, { note = true, note_body = true }), { + { + text = "@gitlab.username 5 days ago ", + type = "note", + children = { + { + children = {}, + text = "Test just unlinked note", + type = "note_body", + }, + }, + }, + { + text = "@gitlab.username 5 days ago ", + type = "note", + children = { + { + text = "Other unlinked note", + type = "note_body", + children = {}, + }, + { + text = "@gitlab.username 5 days ago ", + type = "note", + children = { + { + children = {}, + text = "Response to the unlinked note", + type = "note_body", + }, + }, + }, + }, + }, + }) + end) + end) +end) diff --git a/tests/spec/gitlab_spec.lua b/tests/spec/gitlab_spec.lua new file mode 100644 index 00000000..7b83eaeb --- /dev/null +++ b/tests/spec/gitlab_spec.lua @@ -0,0 +1,7 @@ +describe("gitlab", function() + it("Loads module", function() + require("gitlab") + local utils_ok, _ = pcall(require, "gitlab") + assert._is_true(utils_ok) + end) +end) diff --git a/lua/gitlab/spec/util_spec.lua b/tests/spec/util_spec.lua similarity index 100% rename from lua/gitlab/spec/util_spec.lua rename to tests/spec/util_spec.lua