diff --git a/README.md b/README.md index 088cf45..986f4f0 100644 --- a/README.md +++ b/README.md @@ -28,20 +28,16 @@ See help for default config. Requirements: - nvim 0.12+ - ["gh" (GitHub CLI)](https://cli.github.com/) -- (Optional) [Diffview.nvim](https://github.com/sindrets/diffview.nvim) -- (Optional) To override UI select for fzf-lua or telescope, use: - ``` - vim.cmd('FzfLua register_ui_select') - ``` ## How it works 1. Show `gh` output in a `:terminal` buffer. 2. Set keymaps on the buffer. -3. (TODO) Fetch the git data into `.git` (without doing a checkout). -4. (TODO) When viewing the diff, user can navigate to the git object (file) +3. PR diff comments are loaded in a 'scrollbind' split window. +4. (TODO) Fetch the git data into `.git` (without doing a checkout). +5. (TODO) When viewing the diff, user can navigate to the git object (file) without doing a checkout. -5. (TODO) PR comments will display on relevant git objects. +6. (TODO) PR comments will display on relevant git objects. ## Development diff --git a/doc/guh.txt b/doc/guh.txt index 714cb3d..c8a32bd 100644 --- a/doc/guh.txt +++ b/doc/guh.txt @@ -16,9 +16,6 @@ REQUIREMENTS *guh-requirements* - nvim 0.12+ - ["gh" (GitHub CLI)](https://cli.github.com/) -- (Optional) [Diffview.nvim](https://github.com/sindrets/diffview.nvim) -- (Optional) To override UI select for fzf-lua or telescope: > - vim.cmd('FzfLua register_ui_select') < ============================================================================== INSTALLATION *guh-install* @@ -108,7 +105,7 @@ The "checkout" workflow: COMMANDS *guh-commands* *:Guh* -:Guh [pr-or-issue] +:Guh [pr_or_issue] Views (or refreshes) the given PR or issue, or prompts to select a PR. > @@ -134,40 +131,10 @@ COMMANDS *guh-commands* through `html_comments_command`. You can disable this functionality by setting `html_comments_command` as `false`. - *:GuhCheckout* -:GuhCheckout [prnum] - - Performs a git checkout of a PR selected from a list or provided - as a command arg. > - - :GuhCheckout 123 -< - - *:GuhApprove* -:GuhApprove Approves the selected PR. - - *:GuhRequestChanges* -:GuhRequestChanges - Request changes on PR. - - *:GuhMerge* -:GuhMerge This command merges selected PR. Approved and non-approved PRs - use different options when running `gh pr merge` command. Check - `gh pr merge -h` for available options and use them in config's - `merge` section if defaults are not working for you. - *:GuhLoadComments* :GuhLoadComments - TODO: i think this loads "new" comments or something..? - - NOTE: You must checkout the PR branch using git or via - `:GuhCheckout`. - - Loads PR comments of the current PR checked out `:GuhCheckout`. - Only non-outdated review comments are loaded, PR comments are - not loaded. Comments are loaded to quickfix list and to buffer - diagnostics on buffer load. Navigate quickfix list using `cnext` - and `cprev`. + Loads the current PR diff comments in a vertical 'scrollbind' + split window. *:GuhDiff* :GuhDiff Loads PR diff that you can review. Shows diff of selected PR. If @@ -181,18 +148,6 @@ COMMANDS *guh-commands* - `cA` to approve PR - *:GuhDiffview* -:GuhDiffview Shows the diff of the selected PR, or [prnum], using - [Diffview.nvim](https://github.com/sindrets/diffview.nvim). - - However note that this command shows diff between what's in main - branch and PR branch. This might be different from what's shown - in GitHub. E.g. if there are changes in your main branch and - your branch does not have changes from main branch, then you - will be shown that some things are missing in your branch. - - Requires gh 2.64.0+ (see https://github.com/cli/cli/pull/9938) - *:GuhComment* :GuhComment With bang "!", comments on PR at top level. Without bang, comments on the current diff line or range. > @@ -205,18 +160,6 @@ COMMANDS *guh-commands* reply to thread. If there is no comment on line then new conversation is started. - *:GuhCommentEdit* -:GuhCommentEdit - Updates selected comment. - - *:GuhCommentDelete* -:GuhCommentDelete - Deletes selected comment. - - *:GuhWeb* -:GuhWeb Opens the converation at cursor in the GitHub web UI in your - browser. - ------------------------------------------------------------------------------ vim:tw=78:ts=8:ft=help:norl: diff --git a/lua/guh/comments.lua b/lua/guh/comments.lua index c6171a0..fae0439 100644 --- a/lua/guh/comments.lua +++ b/lua/guh/comments.lua @@ -1,6 +1,7 @@ local config = require('guh.config') local gh = require('guh.gh') -local utils = require('guh.utils') +local state = require('guh.state') +local util = require('guh.util') local M = {} @@ -8,61 +9,187 @@ local severity = vim.diagnostic.severity local function load_comments_to_quickfix_list(comments_list) local qf_entries = {} - local filenames = {} - for fn in pairs(comments_list) do - table.insert(filenames, fn) - end - table.sort(filenames) - for _, filename in pairs(filenames) do - local comments_in_file = comments_list[filename] + for filename, comments_in_file in vim.spairs(comments_list) do table.sort(comments_in_file, function(a, b) return a.line < b.line end) for _, comment in pairs(comments_in_file) do - if #comment.comments > 0 then + local cs = comment.comments + if #cs > 0 then table.insert(qf_entries, { filename = filename, lnum = comment.line, - text = comment.content, + -- text = comment.content, + text = cs[1].body .. (cs[2] and (' (+%s more)'):format(vim.tbl_count(cs)) or ''), }) end end end if #qf_entries > 0 then - vim.fn.setqflist(qf_entries, 'r') - vim.cmd('cfirst') + vim.fn.setqflist(qf_entries, 'u') else - utils.notify('No GH comments loaded.') + util.notify('No GH comments loaded.') end end ----@param prnum integer -function M.load_comments(prnum, bufnr) - local progress = utils.new_progress_report('Loading comments', vim.fn.bufnr()) - assert(type(prnum) == 'number') - gh.get_pr_info(prnum, function(pr) - if not pr then - utils.notify(('PR #%s not found'):format(prnum), vim.log.levels.ERROR) - progress('failed') - return - else - vim.schedule_wrap(function() - gh.load_comments(pr.number, function(comments_list) - vim.schedule(function() - load_comments_to_quickfix_list(comments_list) +--- Loads comments for a given diff in a vertical window, where each comment is vertically aligned +--- with the diff line that it annotates. +local function show_comments_in_scrollbind_win(id, diff_win, comments_list) + local diff_buf = vim.api.nvim_win_get_buf(diff_win) + local diff_lines = vim.api.nvim_buf_get_lines(diff_buf, 0, -1, false) - progress('success') - end) - end) - end) + if not state.try_show('comments', id) then + vim.cmd [[botright vertical split]] + end + local buf = state.init_buf('comments', id) + + local win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(win, buf) + + -- match window options of diff + vim.wo.wrap = true + vim.wo.number = false + vim.wo.relativenumber = false + vim.wo.list = false + vim.wo.scrollbind = true + + -- also scrollbind diff window + local prev = diff_win + local cur = vim.api.nvim_get_current_win() + vim.api.nvim_set_current_win(prev) + vim.wo.scrollbind = true + vim.api.nvim_set_current_win(cur) + + --------------------------------------------------------------------------- + -- Step 1: Parse diff → map each *visible line* to its file + "new" line num + --------------------------------------------------------------------------- + local file = nil + local new_line = 0 + local hunk_start = 0 + local line_map = {} ---@type table + for i, l in ipairs(diff_lines) do + local plusfile = l:match('^%+%+%+ b/(.+)$') + if plusfile then + file = plusfile + new_line = 0 + hunk_start = 0 end - end) + + local hunk_new = l:match('^@@ [^+]+%+(%d+)') + if hunk_new then + new_line = tonumber(hunk_new) + hunk_start = i + elseif file then + local c = l:sub(1, 1) + if c == '+' or c == ' ' then + line_map[i] = { file = file, new_line = new_line } + new_line = new_line + 1 + elseif c == '-' then + line_map[i] = { file = file, new_line = nil } + end + end + end + + --------------------------------------------------------------------------- + -- Step 2: Build text lines for the comment buffer + --------------------------------------------------------------------------- + local lines = {} + for i = 1, #diff_lines do + lines[i] = '' + end + + local function normalize_diff_path(p) + p = p:gsub('^b/', '') -- remove Git diff prefix + p = p:gsub('^a/', '') + return p + end + + for filename, comments_for_file in pairs(comments_list) do + local normalized_filename = normalize_diff_path(filename) + for _, comment in ipairs(comments_for_file) do + local gh_line = comment.line + local idx + -- for i, m in pairs(line_map) do + -- if i < 10 then + -- vim.print({ i = i, file = m.file, old = m.old_line, new = m.new_line }) + -- else + -- break + -- end + -- end + for i, m in pairs(line_map) do + local normalized_mfile = normalize_diff_path(m.file) + if normalized_mfile == normalized_filename and m.new_line == gh_line then + idx = i + break + end + end + if idx then + local body + if comment.body then + body = comment.body + elseif comment.comments then + body = table.concat( + vim.tbl_map(function(c) + return c.body or '' + end, comment.comments), + '\n' + ) + end + if body and body ~= '' then + local author = comment.user or comment.comments[1].user + local date = comment.updated_at or comment.comments[1].updated_at + local prefix = ('%s %s `%s:%d`\n'):format(author, date, filename, gh_line) + lines[idx] = (lines[idx] ~= '' and lines[idx] .. '\n' or '') .. prefix .. body + end + end + end + end + + --------------------------------------------------------------------------- + -- Step 3: Write to buffer + --------------------------------------------------------------------------- + vim.bo[buf].modifiable = true + + -- Flatten multiline entries into individual lines + local out = {} + for _, v in ipairs(lines) do + if v == '' then + table.insert(out, '') + else + for sub in v:gmatch('[^\n]+') do + table.insert(out, sub) + end + end + end + -- error(vim.inspect(buf)) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, out) + + vim.cmd [[wincmd p]] -- Return to diff window. + util.show_info_overlay(buf, 'Empty line = no comment on that diff line') + + -- vim.bo[buf].modifiable = false + -- vim.bo[buf].readonly = true + vim.bo[buf].filetype = 'markdown' +end + +---@param prnum integer +function M.load_comments(prnum) + local progress = util.new_progress_report('Loading comments', vim.fn.bufnr()) + assert(type(prnum) == 'number') + gh.load_comments( + prnum, + vim.schedule_wrap(function(comments_list) + load_comments_to_quickfix_list(comments_list) + show_comments_in_scrollbind_win(prnum, vim.api.nvim_get_current_win(), comments_list) + progress('success') + end) + ) end M.update_comment = function(opts) - utils.notify('TODO') + util.notify('TODO') end -- TODO: fix this, code is outdated after big refactor. @@ -71,7 +198,7 @@ M.load_comments_into_diagnostics = function(bufnr, filename, comments_list) vim.schedule(function() config.log('load_comments_into_diagnostics:', filename) if not comments_list or comments_list[filename] == nil then - utils.notify(('comments_list[%s] is empty'):format(filename)) + util.notify(('comments_list[%s] is empty'):format(filename)) else local diagnostics = {} for _, comment in pairs(comments_list[filename]) do @@ -92,4 +219,179 @@ M.load_comments_into_diagnostics = function(bufnr, filename, comments_list) end) end +--- Prepare info for commenting on a range in the current diff. +--- This does not make a network request; it just returns metadata. +--- +--- @param line1 integer 1-indexed start line +--- @param line2 integer 1-indexed end line (inclusive) +--- @return table|nil info { buf, pr_id, file, start_line, end_line } +function M.prepare_to_comment(line1, line2) + local buf = vim.api.nvim_get_current_buf() + local prnum = assert(vim.b.guh.id) + if not prnum then + vim.notify('Not a PR diff buffer', vim.log.levels.WARN) + return nil + end + + line1 = math.max(1, line1) + line2 = math.max(line1, line2 or line1) + local lines = vim.api.nvim_buf_get_lines(buf, line1 - 1, line2, false) + if vim.tbl_isempty(lines) then + vim.notify('Empty selection', vim.log.levels.WARN) + return nil + end + + --------------------------------------------------------------------------- + -- Step 1: Determine the file path at the start of the selection + --------------------------------------------------------------------------- + local file + for i = line1, 1, -1 do + local l = vim.api.nvim_buf_get_lines(buf, i - 1, i, false)[1] + local m = l and l:match('^%+%+%+ b/(.+)$') + if m then + file = m + break + end + end + if not file then + vim.notify('Could not determine file from diff', vim.log.levels.WARN) + return nil + end + + --------------------------------------------------------------------------- + -- Step 2: Validate that the range does not cross into another file section + --------------------------------------------------------------------------- + for i = line1, line2 do + local l = vim.api.nvim_buf_get_lines(buf, i - 1, i, false)[1] + if l and l:match('^%+%+%+ b/(.+)$') and not l:match('^%+%+%+ b/' .. vim.pesc(file) .. '$') then + vim.notify('Cannot comment across multiple files in a diff', vim.log.levels.ERROR) + return nil + end + end + + --------------------------------------------------------------------------- + -- Step 3: Find nearest hunk header (if any) + --------------------------------------------------------------------------- + local hunk_start, new_start + for i = line1, 1, -1 do + local l = vim.api.nvim_buf_get_lines(buf, i - 1, i, false)[1] + local start_new = l and l:match('^@@ [^+]+%+(%d+)') + if start_new then + hunk_start = i + new_start = tonumber(start_new) + break + end + end + + -- No hunk found → treat as file-level comment + if not new_start then + return { + buf = buf, + pr_id = tonumber(prnum), + file = file, + line_start = nil, + line_end = nil, + } + end + + --------------------------------------------------------------------------- + -- Step 4: Compute new-file line numbers for range + --------------------------------------------------------------------------- + local function compute_new_line(idx) + local line_num = new_start + for i = hunk_start + 1, idx - 1 do + local l = vim.api.nvim_buf_get_lines(buf, i - 1, i, false)[1] + local c = l:sub(1, 1) + if c ~= '-' then + line_num = line_num + 1 + end + end + return line_num + end + + local line_start = compute_new_line(line1) + local line_end = compute_new_line(line2) + + --------------------------------------------------------------------------- + -- Step 5: Return structured info + --------------------------------------------------------------------------- + return { + buf = buf, + pr_id = tonumber(prnum), + file = file, + -- GH expects 0-indexed lines, end-EXclusive. + start_line = line_start, + end_line = line_end, + } +end + +--- Posts a file comment on the line at cursor. +--- +--- @param line1 integer 1-indexed line +--- @param line2 integer 1-indexed line +function M.do_comment(line1, line2) + local info = M.prepare_to_comment(line1, line2) + if not info then + return + end + + gh.get_pr_info(info.pr_id, function(pr) + if not pr then + return util.notify(('PR #%s not found'):format(info.pr_id), vim.log.levels.ERROR) + end + vim.schedule(function() + M.edit_comment(info.pr_id, { '' }, config.s.keymaps.comment.send_comment, function(input) + local progress = util.new_progress_report('Sending comment...', vim.api.nvim_get_current_buf()) + gh.new_comment(pr, input, info.file, info.start_line, info.end_line, function(resp) + if resp['errors'] == nil then + progress('success', nil, 'Comment sent.') + M.load_comments(info.pr_id) -- Reload comments. + else + progress('failed', nil, 'Failed to send comment.') + end + end) + end) + end) + end) +end + +function M.edit_comment(prnum, content, keymap, callback) + if not state.try_show('comment', prnum) then + vim.cmd [[ + split + ]] + end + local buf = state.init_buf('comment', prnum) + vim._with({ buf = buf }, function() + vim.cmd[[set wrap breakindent nonumber norelativenumber nolist]] + end) + + local infomsg = ('Type your comment, then press %s to post it.'):format(keymap) + util.show_info_overlay(buf, infomsg) + + vim.bo[buf].buftype = 'nofile' + vim.bo[buf].filetype = 'markdown' + vim.bo[buf].modifiable = true + + vim.api.nvim_buf_set_lines(buf, 0, -1, false, content) + vim.cmd [[normal! G]] + + local function capture_input_and_close() + local input_lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + local input = table.concat(input_lines, '\n') + + vim.cmd('bdelete') + callback(input) + end + + util.buf_keymap(buf, 'n', keymap, '', capture_input_and_close) + vim.api.nvim_create_autocmd('InsertEnter', { + once = true, + buffer = buf, + callback = function() + util.show_info_overlay(buf, nil) -- Clear overlay. + end, + }) +end + return M diff --git a/lua/guh/config.lua b/lua/guh/config.lua index 525035b..c2e0398 100644 --- a/lua/guh/config.lua +++ b/lua/guh/config.lua @@ -19,7 +19,7 @@ M.s = { request_changes = 'cR', }, comment = { - send_comment = 'c', + send_comment = 'ZZ', }, pr = { approve = 'cA', diff --git a/lua/guh/gh.lua b/lua/guh/gh.lua index 5594e8b..52e180f 100644 --- a/lua/guh/gh.lua +++ b/lua/guh/gh.lua @@ -1,6 +1,7 @@ local async = require('async') local config = require('guh.config') -local utils = require('guh.utils') +local state = require('guh.state') +local util = require('guh.util') require('guh.types') @@ -49,40 +50,61 @@ local function parse_or_default(str, default) end --- Gets details for one "thing" from `gh` and parses the JSON response into an object. -local function get_info(cmd, cb) - vim.schedule_wrap(utils.system_str)(cmd, function(result, stderr) +--- +--- @param cmd string gh command +--- @param b_field string b:guh field to check for (and store) cached data. +local function get_info(cmd, b_field, cb) + -- Use b:guh cache on the current buffer, if available. + local b_guh = vim.b.guh + if b_guh and b_guh[b_field] then + cb(b_guh[b_field]) + return + end + + local buf = vim.api.nvim_get_current_buf() + vim.schedule_wrap(util.system_str)(cmd, function(result, stderr) if result == nil then cb(nil) return elseif stderr:match('Unknown JSON field') then error(('Unknown JSON field: %s'):format(stderr)) - cb(parse_or_default(result, nil)) + local r = parse_or_default(result, nil) + ---@diagnostic disable-next-line: missing-fields + state.set_b_guh(buf, { [b_field] = r }) + cb(r) end config.log('get_info resp', result) - cb(parse_or_default(result, nil)) + local r = parse_or_default(result, nil) + ---@diagnostic disable-next-line: missing-fields + state.set_b_guh(buf, { [b_field] = r }) + cb(r) end) end +--- Gets PR data from b:guh, or requests it from the API. +--- --- @param prnum string|number PR number, or empty for "current PR" --- @param cb fun(pr?: PullRequest) function M.get_pr_info(prnum, cb) local cmd = f('gh pr view %s --json %s', prnum, table.concat(pr_fields, ',')) - get_info(cmd, cb) + get_info(cmd, 'pr_data', cb) end --- @param issue_num string|number Issue number --- @param cb fun(issue?: Issue) function M.get_issue(issue_num, cb) local cmd = f('gh issue view %s --json %s', issue_num, table.concat(issue_fields, ',')) - get_info(cmd, cb) + get_info(cmd, 'issue_data', cb) end function M.get_repo(cb) - local progress = utils.new_progress_report('Loading...') + local progress = util.new_progress_report('Loading...', 0) progress('running') - utils.system_str('gh repo view --json nameWithOwner -q .nameWithOwner', function(result) - if result ~= nil then + util.system_str('gh repo view --json nameWithOwner -q .nameWithOwner', function(result) + if result == nil then + progress('failed') + else cb(vim.split(result, '\n')[1]) progress('success') end @@ -95,12 +117,14 @@ local function format_comment(comment) '✍️ %s at %s:\n%s\n\n', comment.user, comment.updated_at, - string.gsub(comment.body, '\r', '') + comment.body ) end +--- Builds a markdown view of all comments associated with a diff-line. +--- --- @param comments Comment[] -function prepare_content(comments) +local function prepare_content(comments) local lines = {} if #comments > 0 and comments[1].start_line ~= vim.NIL and comments[1].start_line ~= comments[1].line then table.insert(lines, ('📓 Comment on lines %d to %d\n\n'):format(comments[1].start_line, comments[1].line)) @@ -117,23 +141,22 @@ function prepare_content(comments) return table.concat(lines, '') end ---- @return Comment: extracted gh comment +--- Marshalls an API comment to local `Comment` type. +--- +--- @return Comment extracted gh comment local function convert_comment(comment) - return { - id = comment.id, - url = comment.html_url, - path = comment.path, - line = comment.line, - start_line = comment.start_line, - user = comment.user.login, - body = comment.body, - updated_at = comment.updated_at, - diff_hunk = comment.diff_hunk, - } + local extended = vim.tbl_extend('force', {}, comment) + -- Aliases + extended.url = comment.html_url + -- XXX override + extended.user = comment.user.login + -- Remove CR chars. + extended.body = string.gsub(comment.body, '\r', '') + return extended end local function group_comments(gh_comments, cb) - utils.get_git_root(function(git_root) + util.get_git_root(function(git_root) --- @type table local comment_groups = {} local base = {} @@ -161,11 +184,11 @@ local function group_comments(gh_comments, cb) comments = comments, } - local full_path = git_root .. '/' .. comments[1].path - if result[full_path] == nil then - result[full_path] = { grouped_comments } + local filepath = comments[1].path -- Relative file path as given in the unified diff. + if result[filepath] == nil then + result[filepath] = { grouped_comments } else - table.insert(result[full_path], grouped_comments) + table.insert(result[filepath], grouped_comments) end end @@ -173,26 +196,25 @@ local function group_comments(gh_comments, cb) end) end +--- @param type 'pulls'|'issues' local function load_comments(type, number, cb) + local log_type = type == 'pulls' and 'pr' or 'issue' M.get_repo(function(repo) config.log('repo', repo) - utils.system_str(f('gh api repos/%s/%s/%d/comments', repo, type, number), function(comments_json) + util.system_str(f('gh api repos/%s/%s/%d/comments', repo, type, number), function(comments_json) local comments = parse_or_default(comments_json, {}) - config.log(('%s comments'):format(type), comments) + config.log(('%s comments'):format(log_type), comments) local function is_valid_comment(comment) return comment.line ~= vim.NIL end - comments = utils.filter_array(comments, is_valid_comment) - config.log(('Valid %s comments count'):format(type), #comments) - config.log(('%s comments'):format(type), comments) - - group_comments(comments, function(grouped_comments) - config.log(('Valid %s comments groups count:'):format(type), #grouped_comments) - config.log(('grouped %s comments'):format(type), grouped_comments) + comments = util.filter_array(comments, is_valid_comment) + config.log(('%s comments (total: %s)'):format(log_type, vim.tbl_count(comments)), comments) - cb(grouped_comments) + group_comments(comments, function(grouped) + config.log(('grouped %s comments (total: %s)'):format(log_type, vim.tbl_count(grouped)), grouped) + cb(grouped) end) end) end) @@ -223,7 +245,7 @@ function M.reply_to_comment(pr_number, body, reply_to, cb) } config.log('reply_to_comment request', request) - utils.system(request, function(result) + util.system(request, function(result) local resp = parse_or_default(result, { errors = {} }) config.log('reply_to_comment resp', resp) @@ -261,7 +283,7 @@ function M.new_comment(pr, body, path, start_line, line, cb) config.log('new_comment request', request) - utils.system(request, function(result) + util.system(request, function(result) local resp = parse_or_default(result, { errors = {} }) config.log('new_comment resp', resp) cb(resp) @@ -281,7 +303,7 @@ function M.new_pr_comment(pr, body, cb) config.log('new_pr_comment request', request) - local result = utils.system(request, function(result) + local result = util.system(request, function(result) config.log('new_pr_comment resp', result) cb(result) end) @@ -300,7 +322,7 @@ function M.update_comment(comment_id, body, cb) } config.log('update_comment request', request) - utils.system(request, function(result) + util.system(request, function(result) local resp = parse_or_default(result, { errors = {} }) config.log('update_comment resp', resp) cb(resp) @@ -319,7 +341,7 @@ function M.delete_comment(comment_id, cb) } config.log('delete_comment request', request) - utils.system(request, function(resp) + util.system(request, function(resp) config.log('delete_comment resp', resp) cb(resp) end) @@ -329,7 +351,7 @@ end --- @param cb fun(prs: PullRequest[]) function M.get_pr_list(cb) local cmd = 'gh pr list --json ' .. table.concat(pr_fields, ',') - utils.system_str(cmd, function(resp, stderr) + util.system_str(cmd, function(resp, stderr) config.log('get_pr_list resp', resp) local prefix = 'Unknown JSON field' if string.sub(stderr, 1, #prefix) == prefix then @@ -340,7 +362,7 @@ function M.get_pr_list(cb) return v ~= 'baseRefOid' end) :totable() - utils.system_str('gh pr list --json ' .. table.concat(fields, ','), function(resp2) + util.system_str('gh pr list --json ' .. table.concat(fields, ','), function(resp2) config.log('get_pr_list resp', resp2) cb(parse_or_default(resp2, {})) end) @@ -353,11 +375,11 @@ end --- @param pr PullRequest function M.checkout_pr(pr, cb) local branch = ('pr%s-%s'):format(pr.number, pr.author.login):gsub(' ', '_') - utils.system_str(f('gh pr checkout --force --branch %s %d', branch, pr.number), cb) + util.system_str(f('gh pr checkout --force --branch %s %d', branch, pr.number), cb) end function M.approve_pr(number, cb) - utils.system_str(f('gh pr review %s -a', number), cb) + util.system_str(f('gh pr review %s -a', number), cb) end function M.request_changes_pr(number, body, cb) @@ -373,22 +395,22 @@ function M.request_changes_pr(number, body, cb) config.log('request_changes_pr request', request) - local result = utils.system(request, function(result) + local result = util.system(request, function(result) config.log('request_changes_pr resp', result) cb(result) end) end function M.get_pr_diff(number, cb) - utils.system_str(f('gh pr diff %s', number), cb) + util.system_str(f('gh pr diff %s', number), cb) end function M.merge_pr(number, options, cb) - utils.system_str(f('gh pr merge %s %s', number, options), cb) + util.system_str(f('gh pr merge %s %s', number, options), cb) end function M.get_user(cb) - utils.system_str('gh api user -q .login', function(result) + util.system_str('gh api user -q .login', function(result) if result ~= nil then cb(vim.split(result, '\n')[1]) end diff --git a/lua/guh/pr_commands.lua b/lua/guh/pr_commands.lua index 7cd34bc..2858b99 100644 --- a/lua/guh/pr_commands.lua +++ b/lua/guh/pr_commands.lua @@ -1,23 +1,24 @@ +local comments = require('guh.comments') local config = require('guh.config') local gh = require('guh.gh') local state = require('guh.state') -local utils = require('guh.utils') +local util = require('guh.util') local M = {} --- @param buf integer local function set_pr_view_keymaps(buf) - utils.buf_keymap(buf, 'n', config.s.keymaps.pr.approve, 'Approve PR', M.approve_pr) - utils.buf_keymap(buf, 'n', config.s.keymaps.pr.request_changes, 'Request PR changes', M.request_changes_pr) - utils.buf_keymap(buf, 'n', config.s.keymaps.pr.merge, 'Merge PR in remote repo', M.merge_pr) - utils.buf_keymap(buf, 'n', config.s.keymaps.pr.comment, 'Comment on PR or diff', M.comment) - utils.buf_keymap(buf, 'x', 'c', 'Comment on PR or diff', M.comment) - utils.buf_keymap(buf, 'n', config.s.keymaps.pr.diff, 'View the PR diff', ':GuhDiff') + util.buf_keymap(buf, 'n', config.s.keymaps.pr.approve, 'Approve PR', M.approve_pr) + util.buf_keymap(buf, 'n', config.s.keymaps.pr.request_changes, 'Request PR changes', M.request_changes_pr) + util.buf_keymap(buf, 'n', config.s.keymaps.pr.merge, 'Merge PR in remote repo', M.merge_pr) + util.buf_keymap(buf, 'n', config.s.keymaps.pr.comment, 'Comment on PR or diff', M.comment) + util.buf_keymap(buf, 'x', 'c', 'Comment on PR or diff', M.comment) + util.buf_keymap(buf, 'n', config.s.keymaps.pr.diff, 'View the PR diff', ':GuhDiff') end --- @param buf integer local function set_issue_view_keymaps(buf) - utils.buf_keymap(buf, 'n', config.s.keymaps.pr.comment, 'Comment on issue', ':GuhComment') + util.buf_keymap(buf, 'n', config.s.keymaps.pr.comment, 'Comment on issue', ':GuhComment') end --- Shows... @@ -38,7 +39,7 @@ function M.select(opts) return not not repo end) if not repo then - utils.notify('Failed to get repo info', vim.log.levels.ERROR) + util.notify('Failed to get repo info', vim.log.levels.ERROR) return end local test_cmd = vim.system({ 'gh', 'api', ('repos/%s/pulls/%s'):format(repo, num) }, { text = true }):wait() @@ -54,225 +55,69 @@ end --- Performs checkout. Shows PR info. function M.checkout(opts) - utils.notify('TODO') + util.notify('TODO') end function M.approve_pr() - utils.notify('TODO') + util.notify('TODO') end function M.request_changes_pr() - utils.notify('TODO') + util.notify('TODO') end function M.merge_pr() - utils.notify('TODO') + util.notify('TODO') end function M.load_comments(opts) - local prnum = opts.args and tonumber(opts.args) or (vim.b.guh or {}).id + local prnum = opts and opts.args and tonumber(opts.args) or (vim.b.guh or {}).id if not prnum then - utils.notify('No PR number provided', vim.log.levels.ERROR) + util.notify('No PR number provided', vim.log.levels.ERROR) return end - require('guh.comments').load_comments(prnum) + comments.load_comments(prnum) end function M.show_status() - local buf = state.get_buf('status', 'all') - state.show_buf(buf) - state.set_b_guh(buf, { - id = 0, - feat = 'status', - }) - utils.run_term_cmd(buf, 'status', 'all', { 'gh', 'status' }) + local buf = state.init_buf('status', 'all') + util.run_term_cmd(buf, 'status', 'all', { 'gh', 'status' }) end --- @param id integer function M.show_issue(id) - local buf = state.get_buf('issue', id) - state.show_buf(buf) - state.set_b_guh(buf, { - id = id, - feat = 'issue', - }) - utils.run_term_cmd(buf, 'issue', id, { 'gh', 'issue', 'view', tostring(id) }) + local buf = state.init_buf('issue', id) + util.run_term_cmd(buf, 'issue', id, { 'gh', 'issue', 'view', tostring(id) }) set_issue_view_keymaps(buf) end function M.show_pr(id) - local buf = state.get_buf('pr', id) - state.show_buf(buf) - state.set_b_guh(buf, { - id = id, - feat = 'pr', - }) - utils.run_term_cmd(buf, 'pr', id, { 'gh', 'pr', 'view', '--comments', tostring(id) }) + local buf = state.init_buf('pr', id) + util.run_term_cmd(buf, 'pr', id, { 'gh', 'pr', 'view', '--comments', tostring(id) }) set_pr_view_keymaps(buf) end function M.show_pr_diff(opts) local id = assert(opts and opts.args and tonumber(opts.args) or tonumber(opts) or (vim.b.guh or {}).id) - local buf = state.get_buf('diff', id) - state.show_buf(buf) - state.set_b_guh(buf, { - id = id, - feat = 'diff', - }) - utils.run_term_cmd(buf, 'diff', id, { 'gh', 'pr', 'diff', tostring(id) }) + local buf = state.init_buf('diff', id) + util.run_term_cmd(buf, 'diff', id, { 'gh', 'pr', 'diff', tostring(id) }, function() + M.load_comments() + end) set_pr_view_keymaps(buf) end ---- Comment on a PR (bang "!") or a diff line/range. +--- Comment on a diff line/range, or PR overview (bang "!"). M.comment = function(args) assert(args and args.line1 and args.line2) if args.bang and args.range then - return utils.notify('Cannot use bang and range together.', vim.log.levels.ERROR) + return util.notify('Cannot use bang and range together.', vim.log.levels.ERROR) end if args.bang then - return utils.notify(':GuhComment! (bang) not implemented yet', vim.log.levels.ERROR) + return util.notify(':GuhComment! (bang) not implemented yet', vim.log.levels.ERROR) else - M.do_comment(args.line1, args.line2) - end -end - ---- Prepare info for commenting on a range in the current diff. ---- This does not make a network request; it just returns metadata. ---- ---- @param line1 integer 1-indexed start line ---- @param line2 integer 1-indexed end line (inclusive) ---- @return table|nil info { buf, pr_id, file, start_line, end_line } -function M.prepare_to_comment(line1, line2) - local buf = vim.api.nvim_get_current_buf() - local prnum = assert(vim.b.guh.id) - if not prnum then - vim.notify('Not a PR diff buffer', vim.log.levels.WARN) - return nil - end - - line1 = math.max(1, line1) - line2 = math.max(line1, line2 or line1) - local lines = vim.api.nvim_buf_get_lines(buf, line1 - 1, line2, false) - if vim.tbl_isempty(lines) then - vim.notify('Empty selection', vim.log.levels.WARN) - return nil - end - - --------------------------------------------------------------------------- - -- Step 1: Determine the file path at the start of the selection - --------------------------------------------------------------------------- - local file - for i = line1, 1, -1 do - local l = vim.api.nvim_buf_get_lines(buf, i - 1, i, false)[1] - local m = l and l:match('^%+%+%+ b/(.+)$') - if m then - file = m - break - end - end - if not file then - vim.notify('Could not determine file from diff', vim.log.levels.WARN) - return nil - end - - --------------------------------------------------------------------------- - -- Step 2: Validate that the range does not cross into another file section - --------------------------------------------------------------------------- - for i = line1, line2 do - local l = vim.api.nvim_buf_get_lines(buf, i - 1, i, false)[1] - if l and l:match('^%+%+%+ b/(.+)$') and not l:match('^%+%+%+ b/' .. vim.pesc(file) .. '$') then - vim.notify('Cannot comment across multiple files in a diff', vim.log.levels.ERROR) - return nil - end - end - - --------------------------------------------------------------------------- - -- Step 3: Find nearest hunk header (if any) - --------------------------------------------------------------------------- - local hunk_start, new_start - for i = line1, 1, -1 do - local l = vim.api.nvim_buf_get_lines(buf, i - 1, i, false)[1] - local start_new = l and l:match('^@@ [^+]+%+(%d+)') - if start_new then - hunk_start = i - new_start = tonumber(start_new) - break - end - end - - -- No hunk found → treat as file-level comment - if not new_start then - return { - buf = buf, - pr_id = tonumber(prnum), - file = file, - line_start = nil, - line_end = nil, - } + comments.do_comment(args.line1, args.line2) end - - --------------------------------------------------------------------------- - -- Step 4: Compute new-file line numbers for range - --------------------------------------------------------------------------- - local function compute_new_line(idx) - local line_num = new_start - for i = hunk_start + 1, idx - 1 do - local l = vim.api.nvim_buf_get_lines(buf, i - 1, i, false)[1] - local c = l:sub(1, 1) - if c ~= '-' then - line_num = line_num + 1 - end - end - return line_num - end - - local line_start = compute_new_line(line1) - local line_end = compute_new_line(line2) - - --------------------------------------------------------------------------- - -- Step 5: Return structured info - --------------------------------------------------------------------------- - return { - buf = buf, - pr_id = tonumber(prnum), - file = file, - -- GH expects 0-indexed lines, end-EXclusive. - start_line = line_start, - end_line = line_end, - } -end - ---- Posts a file comment on the line at cursor. ---- ---- @param line1 integer 1-indexed line ---- @param line2 integer 1-indexed line -function M.do_comment(line1, line2) - local info = M.prepare_to_comment(line1, line2) - if not info then - return - end - - gh.get_pr_info(info.pr_id, function(pr) - if not pr then - return utils.notify(('PR #%s not found'):format(prnum), vim.log.levels.ERROR) - end - vim.schedule(function() - local prompt = '' - utils.edit_comment(info.pr_id, prompt, { prompt, '' }, config.s.keymaps.comment.send_comment, function(input) - local progress = utils.new_progress_report('Sending comment...', vim.api.nvim_get_current_buf()) - gh.new_comment(pr, input, info.file, info.start_line, info.end_line, function(resp) - if resp['errors'] == nil then - progress('success', nil, 'Comment sent.') - -- TODO this is broken. ignore it for now. - -- comments.load_comments_on_current_buffer() - else - progress('failed', nil, 'Failed to send comment.') - end - end) - end) - end) - end) end return M diff --git a/lua/guh/state.lua b/lua/guh/state.lua index 95768b2..e9c26d9 100644 --- a/lua/guh/state.lua +++ b/lua/guh/state.lua @@ -7,6 +7,8 @@ local bufs = { ---@type table comment = {}, ---@type table + comments = {}, + ---@type table diff = {}, ---@type table pr = {}, @@ -18,7 +20,7 @@ local bufs = { --- Gets the existing buf or creates a new one, for the given PR + feature. --- @param feat Feat ---- @param pr_or_issue string|number PR or issue number or 'none' for special cases (e.g. status). +--- @param pr_or_issue string|number PR or issue number or "all" for special cases (e.g. status). function M.get_buf(feat, pr_or_issue) local pr_or_issue_str = tostring(pr_or_issue) local b = bufs[feat][pr_or_issue_str] @@ -43,6 +45,22 @@ function M.show_buf(buf) end end +--- Tries to resolve the feat+id buffer and navigate to it, else returns false. +--- +--- @param feat Feat +--- @param pr_or_issue string|number PR or issue number or "all" for special cases (e.g. status). +--- @return boolean true if the feat+id buffer exists and was focused, else false. +function M.try_show(feat, pr_or_issue) + local buf = M.get_buf(feat, pr_or_issue) + local wins = vim.fn.win_findbuf(buf) + if #wins > 0 then + -- Already displayed elsewhere, focus it. + vim.api.nvim_set_current_win(wins[1]) + return true + end + return false +end + --- Sets the `b:guh` buffer-local dict. `bufstate` is merged with existing state, if any. --- @param buf integer --- @param bufstate BufState @@ -55,6 +73,24 @@ function M.set_b_guh(buf, bufstate) end end +--- @param feat Feat +--- @param pr_or_issue string|number PR or issue number or "all" for special cases (e.g. status). +--- @param bufstate table? +function M.init_buf(feat, pr_or_issue, bufstate) + bufstate = bufstate or {} + local buf = M.get_buf(feat, pr_or_issue) + M.show_buf(buf) + if not bufstate.id then + bufstate['id'] = pr_or_issue == 'all' and 0 or assert(tonumber(pr_or_issue)) + end + if not bufstate.feat then + bufstate.feat = feat + end + M.set_b_guh(buf, bufstate) + M.set_buf_name(buf, feat, pr_or_issue) + return buf +end + local function get_buf_name(feat, id) return ('guh://%s/%s'):format(feat, id) end @@ -62,6 +98,7 @@ end --- Sets the buffer name to "guh://…/…" format. function M.set_buf_name(buf, feat, id) local bufname = get_buf_name(feat, id) + local prev_altbuf = vim.fn.bufnr('#') -- NOTE: This leaves orphan "term://~/…:/usr/local/bin/gh" buffers. -- Fixed upstream: https://github.com/neovim/neovim/pull/35951 @@ -71,31 +108,18 @@ function M.set_buf_name(buf, feat, id) -- end) -- XXX fucking hack because Vim creates new buffer after (re)naming it. - local unwanted_alt_buf = vim.fn.bufnr('#') - if unwanted_alt_buf > 0 and unwanted_alt_buf ~= buf then - vim.api.nvim_buf_delete(unwanted_alt_buf, {}) + local unwanted_altbuf = vim.fn.bufnr('#') + if prev_altbuf ~= unwanted_altbuf and unwanted_altbuf > 0 and unwanted_altbuf ~= buf then + vim.api.nvim_buf_delete(unwanted_altbuf, {}) end end -function M.try_set_buf_name(buf, feat, id) - local bufname = get_buf_name(feat, id) - local foundbuf = vim.fn.bufnr(bufname) - if foundbuf > 0 and buf ~= foundbuf then - M.show_buf(foundbuf) - return foundbuf - end - M.set_buf_name(buf, feat, id) - M.on_win_open() - M.show_buf(buf) - return buf -end - -M.on_win_open = function() - vim.cmd [[ - vertical topleft split - set wrap breakindent nonumber norelativenumber nolist - ]] -end +-- M.on_win_open = function() +-- vim.cmd [[ +-- vertical topleft split +-- set wrap breakindent nonumber norelativenumber nolist +-- ]] +-- end M.bufs = bufs diff --git a/lua/guh/types.lua b/lua/guh/types.lua index f02adb0..73796d1 100644 --- a/lua/guh/types.lua +++ b/lua/guh/types.lua @@ -1,9 +1,10 @@ ---- @alias Feat 'diff'|'pr'|'issue'|'comment'|'status' +--- @alias Feat 'diff'|'pr'|'issue'|'comment'|'comments'|'status' --- @class BufState --- Buffer-local b:guh dict. --- @field id integer PR or issue number --- @field feat Feat Feature name +--- @field pr_data PullRequest --- @class Comment --- @field body string diff --git a/lua/guh/utils.lua b/lua/guh/util.lua similarity index 69% rename from lua/guh/utils.lua rename to lua/guh/util.lua index b0df862..837d810 100644 --- a/lua/guh/utils.lua +++ b/lua/guh/util.lua @@ -21,7 +21,7 @@ end function M.system(cmd, cb) vim.system(cmd, { text = true }, function(result) if type(cb) == 'function' then - cb(result.stdout) + vim.schedule_wrap(cb)(result.stdout) end end) end @@ -81,7 +81,7 @@ function M.new_progress_report(action, buf) local msg = done and '' or ('%s %s'):format(action, (fmt or ''):format(...)) progress.id = vim.api.nvim_echo({ { msg } }, status ~= 'running', progress) - if buf then + if buf and vim.api.nvim_buf_is_valid(buf) then vim.bo[buf].busy = math.max(0, vim.bo[buf].busy - 1) end end) @@ -113,34 +113,13 @@ function M.buf_keymap(buf, mode, lhs, desc, rhs) end end -function M.edit_comment(prnum, prompt, content, key_binding, callback) - local buf = state.get_buf('comment', prnum) - state.try_set_buf_name(buf, 'comment', prnum) - vim.bo[buf].buftype = 'nofile' - vim.bo[buf].filetype = 'markdown' - vim.bo[buf].modifiable = true - - vim.api.nvim_buf_set_lines(buf, 0, -1, false, content) - vim.cmd [[normal! G]] - - local function capture_input_and_close() - local input_lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - if prompt ~= nil and input_lines[1] == prompt then - table.remove(input_lines, 1) - end - local input = table.concat(input_lines, '\n') - - vim.cmd('bdelete') - callback(input) - end - - M.buf_keymap(buf, 'n', key_binding, '', capture_input_and_close) - M.buf_keymap(buf, 'i', key_binding, '', capture_input_and_close) -end - --- Overwrites the current :terminal buffer with the given cmd. +--- @param buf integer +--- @param feat Feat +--- @param id any --- @param cmd string[] -function M.run_term_cmd(buf, feat, id, cmd) +--- @param on_done? fun() +function M.run_term_cmd(buf, feat, id, cmd, on_done) local progress = M.new_progress_report('Loading...', buf) progress('running') vim.schedule(function() @@ -153,10 +132,57 @@ function M.run_term_cmd(buf, feat, id, cmd) term = true, on_exit = function() state.set_buf_name(buf, feat, id) + if on_done then + on_done() + end progress('success') end, }) end) end +local overlay_win = -1 +local overlay_buf = -1 + +--- Shows an info overlay message in the given buffer. +--- Only one overlay is allowed globally. +--- Pass `msg=nil` to delete the current overlay. +--- +--- @param buf integer +--- @param msg string? Message, or nil to delete the current overlay. +function M.show_info_overlay(buf, msg) + local win = (vim.fn.win_findbuf(buf) or {})[1] + local winvalid = vim.api.nvim_win_is_valid + if not win then + return -- Buffer not currently visible in any window. + end + -- vim.api.nvim_buf_clear_namespace(buf, overlay_ns, 0, -1) + if not msg then + if winvalid(overlay_win) then + vim.api.nvim_win_close(overlay_win, true) + end + return -- If msg=nil, only clear the overlay. + end + + -- Scratch buffer + overlay_buf = vim.api.nvim_buf_is_valid(overlay_buf) and overlay_buf or vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(overlay_buf, 0, -1, false, { msg }) + + local winconfig = { + focusable = false, + hide = false, + relative = 'win', -- Anchor to window + win = win, + row = 0, + col = 2, + width = math.max(1, vim.api.nvim_win_get_width(win) - 2), + height = 1, + style = 'minimal', + border = 'none', + } + overlay_win = winvalid(overlay_win) and overlay_win or vim.api.nvim_open_win(overlay_buf, false, winconfig) + vim.api.nvim_win_set_config(overlay_win, winconfig) + vim.wo[overlay_win].winhighlight = 'Normal:Comment' +end + return M diff --git a/test/gh_spec.lua b/test/gh_spec.lua index 0193c45..aa7361e 100644 --- a/test/gh_spec.lua +++ b/test/gh_spec.lua @@ -8,11 +8,11 @@ local test_cwd = assert(os.getenv('TEST_CWD')) local screen before_each(function() - n.clear{ + n.clear { args = { '-c', - ("cd '%s'"):format(test_cwd) - } + ("cd '%s'"):format(test_cwd), + }, } n.exec [[ set laststatus=2 @@ -21,7 +21,7 @@ before_each(function() n.exec_lua(function(cwd_) vim.cmd.cd(cwd_) -- TODO: do this in global test setup - vim.opt.runtimepath:append{ + vim.opt.runtimepath:append { cwd_, -- vim.fn.getcwd() .. '/test/functional/guh.nvim/', } @@ -34,12 +34,12 @@ describe('guh.gh', function() n.exec_lua(function() local async = require('async') local gh = require('guh.gh') - local utils = require('guh.utils') - local system_str_async = async.wrap(2, utils.system_str) + local util = require('guh.util') + local system_str_async = async.wrap(2, util.system_str) local get_pr_info_async = async.wrap(2, gh.get_pr_info) local function test_get_pr_info() - return async.run(function() + return async.run(vim.schedule_wrap(function() local result = assert(system_str_async('gh pr list --json number')) local pr_num = assert(vim.json.decode(result)[1].number, 'failed to get a repo issue') @@ -48,7 +48,7 @@ describe('guh.gh', function() assert(type(pr.number) == 'number', 'pr.number not number') assert(type(pr.title) == 'string', 'pr.title not string') assert(type(pr.author) == 'table', 'pr.author not table') - end) + end)) end local task = test_get_pr_info() task:wait(5000) @@ -59,12 +59,12 @@ describe('guh.gh', function() n.exec_lua(function() local async = require('async') local gh = require('guh.gh') - local utils = require('guh.utils') - local system_str_async = async.wrap(2, utils.system_str) + local util = require('guh.util') + local system_str_async = async.wrap(2, util.system_str) local get_issue_async = async.wrap(2, gh.get_issue) local function test_get_issue() - return async.run(function() + return async.run(vim.schedule_wrap(function() local result = system_str_async('gh issue list --json number') local issue_num = assert(vim.json.decode(assert(result))[1].number, 'failed to get a repo issue') @@ -73,7 +73,7 @@ describe('guh.gh', function() assert(type(issue.number) == 'number', 'issue.number not number') assert(type(issue.title) == 'string', 'issue.title not string') assert(type(issue.author) == 'table', 'issue.author not table') - end) + end)) end local task = test_get_issue() @@ -93,7 +93,39 @@ describe('guh.gh', function() local comments = load_comments_async(pr_id) assert(comments) assert(vim.tbl_count(comments) > 0) - -- error(vim.tbl_keys(comments)[1]) + + -- Check structure: comments is table + for path, grouped_comments in pairs(comments) do + assert(type(path) == 'string', 'path should be string') + assert(type(grouped_comments) == 'table', 'grouped_comments should be table') + assert(#grouped_comments > 0, 'grouped_comments should not be empty') + for _, grouped in ipairs(grouped_comments) do + assert(grouped.id, 'grouped.id missing') + assert(type(grouped.line) == 'number', 'grouped.line should be number') + assert( + type(grouped.start_line) == 'number' or grouped.start_line == vim.NIL, + 'grouped.start_line should be number or nil' + ) + assert(type(grouped.url) == 'string', 'grouped.url should be string') + assert(type(grouped.content) == 'string', 'grouped.content should be string') + assert(type(grouped.comments) == 'table', 'grouped.comments should be table') + assert(#grouped.comments > 0, 'grouped.comments should not be empty') + for _, comment in ipairs(grouped.comments) do + assert(comment.id, 'comment.id missing') + assert(type(comment.url) == 'string', 'comment.url should be string') + assert(type(comment.path) == 'string', 'comment.path should be string') + assert(type(comment.line) == 'number', 'comment.line should be number') + assert( + type(comment.start_line) == 'number' or comment.start_line == vim.NIL, + 'comment.start_line should be number or nil' + ) + assert(type(comment.user) == 'string', 'comment.user should be string') + assert(type(comment.body) == 'string', 'comment.body should be string') + assert(type(comment.updated_at) == 'string', 'comment.updated_at should be string') + assert(type(comment.diff_hunk) == 'string', 'comment.diff_hunk should be string') + end + end + end end) end @@ -104,45 +136,32 @@ describe('guh.gh', function() end) describe('comments', function() - pending('load_comments', function() + it('load_comments', function() n.exec_lua(function() - local async = require('async') - -- Tests real comments response Github. -- Calls load_comments() and asserts that some comments were loaded into quickfix. local function test_load_comments() - return async.run(function() - -- local result = system_str_async('gh pr list --json number') - -- local prs = assert(vim.json.decode(assert(result)), 'failed to get PRs') - -- assert(#prs > 0, 'no PRs found') - local pr_num = 2 - - require('guh.comments').load_comments(pr_num) - - -- Wait for quickfix to be populated or timeout - local ok = vim.wait(5000, function() - local qf = vim.fn.getqflist() - return #qf > 0 - end) - - if ok then - local qf = vim.fn.getqflist() - assert(#qf > 0, 'quickfix list is empty') - -- Check that entries have filename, lnum, text - for _, entry in ipairs(qf) do - assert(entry.filename, 'entry missing filename') - assert(entry.lnum > 0, 'entry lnum not positive') - assert(entry.text, 'entry missing text') - end - else - error('No comments found for PR ' .. pr_num) - end + -- local result = system_str_async('gh pr list --json number') + -- local prs = assert(vim.json.decode(assert(result)), 'failed to get PRs') + -- assert(#prs > 0, 'no PRs found') + local pr_num = 2 + require('guh.comments').load_comments(pr_num, nil) + + -- Wait for quickfix to be populated or timeout + local ok, qf = vim.wait(5000, function() + local qf = vim.fn.getqflist() + return #qf > 0, qf end) + + assert(ok and #qf > 0, 'load_comments did not set quickfix list') + -- Check that entries have filename, lnum, text + for _, entry in ipairs(qf) do + assert(entry.lnum > 0, ('entry lnum not positive: %s'):format(vim.inspect(entry))) + assert(entry.text, ('entry missing text: %s'):format(vim.inspect(entry))) + end end - local task = test_load_comments() - local ok, err = task:wait(5000) - assert(ok, err or 'test_load_comments failed') + test_load_comments() end) end) end) @@ -150,7 +169,7 @@ end) describe('features', function() it('prepare_to_comment (hardcoded diff)', function() n.exec_lua(function() - local pr_commands = require('guh.pr_commands') + local comments = require('guh.comments') local state = require('guh.state') local pr_id = 42 @@ -175,7 +194,7 @@ describe('features', function() }) vim.api.nvim_win_set_cursor(0, { 9, 0 }) -- on "+ comment = 'cc'," - local info = pr_commands.prepare_to_comment(9, 9) + local info = comments.prepare_to_comment(9, 9) assert(info) assert('lua/guh/config.lua' == info.file) assert(16 == info.start_line, info.start_line) @@ -187,19 +206,20 @@ describe('features', function() end) describe('commands', function() - it(':GuhDiff', function() + it(':GuhDiff loads PR diff + comments split window', function() n.command('GuhDiff 1') screen:expect { + timeout = 10000, attr_ids = {}, -- Don't care about colors. grid = [[ - ^diff --git {MATCH:a/.* b/.* +}| + ^diff --git {MATCH:a/.* b/.*}│ Empty line = no comment {MATCH:.*}| index {MATCH:.*}| --- {MATCH:.*}| +++ {MATCH:.*}| @@ {MATCH:.*} @@ {MATCH:.*}| {MATCH:.*}|*3 - {MATCH:guh://diff/1 .*}| + {MATCH:guh://diff/1 .* guh://comments/1 +}| {MATCH:.*}| ]], }