feat(gh): create review comments in GitHub PR diff, on diff lines. Closes #2446

This commit is contained in:
Folke Lemaitre 2025-11-06 22:04:44 +01:00
parent 42374e9a6d
commit 85bf3f0123
No known key found for this signature in database
GPG key ID: 9B52594D560070AB
12 changed files with 111 additions and 25 deletions

View file

@ -28,6 +28,24 @@ local M = {}
---@field type? "pr" | "issue"
---@field enabled? fun(item: snacks.picker.gh.Item): boolean
---@param item snacks.picker.gh.Item
---@param ctx snacks.gh.action.ctx
local function update_main(item, ctx)
local gh = { repo = item.repo, number = item.number, type = item.type }
if ctx.main and vim.api.nvim_win_is_valid(ctx.main) then
local buf = vim.api.nvim_win_get_buf(ctx.main)
if vim.deep_equal(vim.b[buf].snacks_gh or {}, gh) then
return ctx.main, buf
end
end
local win = vim.api.nvim_get_current_win()
local buf = vim.api.nvim_win_get_buf(win)
if vim.deep_equal(vim.b[buf].snacks_gh or {}, gh) then
ctx.main = win
return ctx.main, buf
end
end
---@class snacks.gh.actions: {[string]:snacks.gh.Action}
M.actions = setmetatable({}, {
__index = function(_, key)
@ -80,7 +98,7 @@ M.actions.gh_actions = {
if ctx.action and ctx.action.cmd then
return Snacks.picker.actions.jump(ctx.picker, item, ctx.action)
end
ctx.main = ctx.main or ctx.picker and ctx.picker.main or nil
update_main(item, ctx)
local actions = M.get_actions(item)
actions.gh_actions = nil -- remove this action
actions.gh_perform_action = nil -- remove this action
@ -103,9 +121,10 @@ M.actions.gh_actions = {
end
ctx.action = action
if ctx.picker then
ctx.picker.visual = ctx.picker.visual or picker.visual or nil
ctx.picker:focus()
end
ctx.main = ctx.main or picker and picker.main or nil
update_main(item, ctx)
it.action.action(item, ctx)
picker:close()
end,
@ -242,25 +261,55 @@ M.actions.gh_comment = {
title = "Comment on {type} #{number}",
icon = "",
action = function(item, ctx)
item = item.gh_item or item -- unwrap from
local current_win = vim.api.nvim_get_current_win()
local win = vim.w[current_win].snacks_picker_preview and current_win or ctx.main or current_win
local buf = vim.api.nvim_win_get_buf(win)
local action = vim.deepcopy(M.cli_actions.gh_comment)
if vim.b[buf].snacks_meta then
local lino = vim.api.nvim_win_get_cursor(win)[1]
local meta = vim.b[buf].snacks_meta or {}
for _, c in ipairs(meta) do
if c.line == lino and c.comment_id then
local win, buf = update_main(item, ctx)
if win and buf then
local meta = Snacks.picker.highlight.meta(buf)
if meta then
---@type {comment_id?: number, diff?: snacks.diff.Meta}?
local m = meta[vim.api.nvim_win_get_cursor(win)[1]]
if m and m.comment_id then
action.title = "Reply to comment on {type} #{number}"
action.api = {
endpoint = "/repos/{repo}/pulls/{number}/comments",
input = { in_reply_to = m.comment_id },
}
elseif m and m.diff then
local visual = ctx.picker and ctx.picker.visual or Snacks.picker.util.visual()
visual = visual and visual.buf == buf and visual or nil
local line = m.diff.line ---@type number
local start_line ---@type number?
if visual then
local line_diff = vim.tbl_get(meta, visual.end_pos[1], "diff") or m.diff --[[@as {file: string, line: number, side: string}]]
local start_diff = vim.tbl_get(meta, visual.pos[1], "diff") or m.diff --[[@as {file: string, line: number, side: string}]]
if line_diff.file ~= start_diff.file then
Snacks.notify.error("Cannot add comment: visual selection spans multiple files")
return
end
line, start_line = line_diff.line, start_diff.line
start_line, line = math.min(start_line or line, line), math.max(start_line or line, line)
end
start_line = start_line ~= line and start_line or nil
if start_line then
action.title = ("Comment on lines %s%d to %s%d"):format(
m.diff.side:sub(1, 1):upper(),
start_line or line,
m.diff.side:sub(1, 1):upper(),
line
)
else
action.title = ("Comment on line %s%d"):format(m.diff.side:sub(1, 1):upper(), line)
end
action.api = {
endpoint = "/repos/{repo}/pulls/{number}/comments",
input = {
in_reply_to = c.comment_id,
commit_id = item.headRefOid, -- or item.headCommit.oid depending on your data structure
path = m.diff.file,
side = m.diff.side:upper(), -- "RIGHT" or "LEFT" (uppercase)
line = line,
start_line = start_line,
},
}
break
end
end
end

View file

@ -56,6 +56,7 @@ local config = {
"baseRefName",
"deletions",
"headRefName",
"headRefOid",
"mergedAt",
"statusCheckRollup",
"reviews",

View file

@ -32,7 +32,7 @@ function M.new(buf, item)
vim.b[buf].snacks_gh = {
repo = item.repo,
type = item.type,
number = item.number,
number = tonumber(item.number) or item.number,
}
self:bo()
self:wo()

View file

@ -133,6 +133,7 @@
---@field statusCheckRollup? snacks.gh.Check[]
---@field baseRefName? string
---@field headRefName? string
---@field headRefOid? string
---@field isDraft? boolean
---@field reviews? snacks.gh.Review[]
---@field reviewThreads? snacks.gh.review.Thread[]

View file

@ -2,7 +2,7 @@ local M = {}
---@alias snacks.picker.format.resolve fun(max_width:number):snacks.picker.Highlight[]
---@alias snacks.picker.Extmark vim.api.keyset.set_extmark|{col:number, row?:number, field?:string}
---@alias snacks.picker.Meta {line?:number, [string]:any}
---@alias snacks.picker.Meta {[string]:any}
---@alias snacks.picker.Text {[1]:string, [2]:(string|string[])?, virtual?:boolean, field?:string, resolve?:snacks.picker.format.resolve, inline?:boolean}
---@alias snacks.picker.Highlight snacks.picker.Text|snacks.picker.Extmark|{meta?:snacks.picker.Meta}
---@alias snacks.picker.format fun(item:snacks.picker.Item, picker:snacks.Picker):snacks.picker.Highlight[]

View file

@ -282,11 +282,12 @@ M.gh_diff = {
group = true,
finder = "gh_diff",
format = "git_status",
preview = "diff",
preview = "gh_preview_diff",
win = {
preview = {
keys = {
["a"] = { "gh_comment", mode = { "n", "x" } },
["<cr>"] = { "gh_actions", mode = { "n", "x" } },
},
},
},

View file

@ -32,7 +32,6 @@ end
---@param action snacks.picker.Action.spec
---@param ref snacks.Picker.ref
---@param name? string
---@return snacks.win.Action?
function M.wrap(action, ref, name)
local picker = ref()
if not picker then
@ -40,6 +39,7 @@ function M.wrap(action, ref, name)
end
action = M.resolve(action, picker, name)
action.name = name
---@type snacks.win.Action
return {
name = name,
action = function()

View file

@ -188,7 +188,6 @@ local function fancy_diff(diff, ft, ctx)
local buf = ctx.preview:scratch()
ctx.preview.win:map()
require("snacks.picker.util.diff").render(buf, ns, diff, {
ft = ft,
annotations = ctx.item.annotations or ctx.picker.opts.annotations,
})
Snacks.util.wo(ctx.win, ctx.picker.opts.previewers.diff.wo or {})

View file

@ -15,10 +15,15 @@ M.actions = setmetatable({}, {
local action = {
desc = Actions.actions[k].desc,
action = function(picker, item, action)
local items = picker:selected({ fallback = true })
if item.gh_item then
item = item.gh_item
items = { item }
end
---@diagnostic disable-next-line: param-type-mismatch
return Actions.actions[k].action(item, {
picker = picker,
items = picker:selected({ fallback = true }),
items = items,
action = action,
})
end,
@ -307,6 +312,19 @@ function M.format(item, picker)
return ret
end
---@param ctx snacks.picker.preview.ctx
function M.preview_diff(ctx)
Snacks.picker.preview.diff(ctx)
local item = ctx.item.gh_item ---@type snacks.picker.gh.Item?
if item then
vim.b[ctx.buf].snacks_gh = {
repo = item.repo,
type = item.type,
number = item.number,
}
end
end
---@param ctx snacks.picker.preview.ctx
function M.preview(ctx)
local config = require("snacks.gh").config()

View file

@ -3,7 +3,6 @@ local M = {}
---@class snacks.diff.Config
---@field max_hunk_lines? number only show last N lines of each hunk (used by GitHub PRs)
---@field hunk_header? boolean whether to show hunk header (default: true)
---@field ft? "diff" | "git"
---@field annotations? snacks.diff.Annotation[]
---@class snacks.diff.Annotation
@ -14,6 +13,11 @@ local M = {}
---@field line number
---@field text snacks.picker.Highlight[][]
---@class snacks.diff.Meta
---@field side "left" | "right"
---@field file string
---@field line number
---@class snacks.diff.ctx
---@field diff snacks.picker.Diff
---@field opts snacks.diff.Config

View file

@ -2,7 +2,7 @@
local M = {}
---@class (private) vim.var_accessor
---@field snacks_meta? snacks.picker.Meta[]
---@field snacks_meta? table<number,snacks.picker.Meta>
M.langs = {} ---@type table<string, boolean>
M._scratch = {} ---@type table<string, number>
@ -471,6 +471,19 @@ function M.fix_offset(hl, offset, start_idx)
return hl
end
--- tables with number as keys are stored in vim.b as an array,
--- so we need to filter out vim.NIL
---@param buf number
function M.meta(buf)
local ret = {} ---@type table<number, snacks.picker.Meta>
for k, v in pairs(vim.b[buf].snacks_meta or {}) do
if v ~= vim.NIL then
ret[k] = v
end
end
return not vim.tbl_isempty(ret) and ret or nil
end
---@param dst snacks.picker.Highlight[]
---@param src snacks.picker.Highlight[]
function M.extend(dst, src)
@ -492,7 +505,7 @@ function M.render(buf, ns, lines, opts)
vim.api.nvim_buf_clear_namespace(buf, ns, 0, -1)
end
local meta = {} ---@type snacks.picker.Meta[]
local meta = {} ---@type table<number, snacks.picker.Meta>
local changed = #lines ~= #old_lines
local offset = opts.append and vim.api.nvim_buf_line_count(buf) or 0
@ -504,8 +517,7 @@ function M.render(buf, ns, lines, opts)
changed = true
end
if line_meta then
line_meta.line = offset + l
meta[#meta + 1] = line_meta
meta[offset + l] = line_meta
end
for _, extmark in ipairs(extmarks) do
local e = vim.deepcopy(extmark)

View file

@ -196,6 +196,7 @@ function M.visual()
local text = table.concat(lines, "\n")
---@class snacks.picker.Visual
local ret = {
buf = vim.api.nvim_get_current_buf(),
pos = pos,
end_pos = end_pos,
text = text,