feat(gh): you can now use Snacks.picker.gh_actions() directly to see actions for the checked out PR

This commit is contained in:
Folke Lemaitre 2025-11-02 12:31:29 +01:00
parent 07c569dfd5
commit d0d10f6d13
No known key found for this signature in database
GPG key ID: 9B52594D560070AB
7 changed files with 214 additions and 52 deletions

View file

@ -80,12 +80,11 @@ M.actions.gh_actions = {
return Snacks.picker.actions.jump(ctx.picker, item, ctx.action)
end
local actions = M.get_actions(item)
actions.gh_actions = nil -- remove self to avoid recursion
Snacks.picker.pick({
source = "gh_actions",
actions.gh_actions = nil -- remove this action
actions.gh_perform_action = nil -- remove this action
Snacks.picker.gh_actions({
item = item,
layout = {
preset = "select",
layout = { max_width = 80 },
config = function(layout)
-- Fit list height to number of items, up to 10
for _, box in ipairs(layout.layout) do
@ -95,35 +94,6 @@ M.actions.gh_actions = {
end
end,
},
main = { current = true },
title = ("Actions for %s #%d"):format(item.type, item.number),
finder = function()
local items = {} ---@type snacks.picker.finder.Item[]
for name, action in pairs(actions) do
---@class snacks.picker.gh.Action: snacks.picker.finder.Item
items[#items + 1] = {
text = Snacks.picker.util.text(action, { "name", "desc" }),
file = item.uri,
name = name,
item = item,
desc = action.desc or name,
action = action,
}
end
table.sort(items, function(a, b)
local pa = a.action.priority or 0
local pb = b.action.priority or 0
if pa ~= pb then
return pa > pb
end
return a.desc < b.desc
end)
for i, it in ipairs(items) do
it.text = ("%d. %s"):format(i, it.text)
end
return items
end,
format = "gh_format_action",
---@param it snacks.picker.gh.Action
confirm = function(picker, it, action)
if not it then
@ -140,6 +110,16 @@ M.actions.gh_actions = {
end,
}
M.actions.gh_perform_action = {
action = function(item, ctx)
if not item then
return
end
item.action.action(item.item, ctx)
ctx.picker:close()
end,
}
M.actions.gh_browse = {
desc = "Open in web browser",
title = "Open {type} #{number} in web browser",

View file

@ -6,6 +6,7 @@ local M = {}
---@type table<string, snacks.picker.gh.Item>
local cache = setmetatable({}, { __mode = "v" })
local pr_cache = {} ---@type table<string, snacks.picker.gh.Item>
---@type table<string, snacks.gh.api.Config|{}>
local config = {
@ -304,10 +305,10 @@ function M.list(what, cb, opts)
})
end
---@param item snacks.gh.api.View
---@param cb fun(item?: snacks.picker.gh.Item, updated?: boolean)
---@param item snacks.gh.api.View
---@param opts? { fields?: string[], force?: boolean }
function M.view(item, cb, opts)
function M.view(cb, item, opts)
opts = opts or {}
local api_opts = get_opts(item.type, "view")
if opts.fields then
@ -455,4 +456,83 @@ function M.comments(item, cb)
})
end
function M.get_branch()
local branch = vim.fn.system({ "git", "branch", "--show-current" }):gsub("\n", "")
-- Get all config in one call
local git_config = vim.fn
.system({
"git",
"config",
"--get-regexp",
("^(branch\\.%s\\.|remote\\.)"):format(branch),
})
:gsub("\n$", "")
local cfg = {} ---@type table<string, string>
for _, line in ipairs(vim.split(git_config, "\n")) do
local key, value = line:match("^([^%s]+)%s+(.+)$")
if key then
cfg[key] = value
end
end
-- Extract values
local remote = cfg[("branch.%s.remote"):format(branch)] or ""
local merge = cfg[("branch.%s.merge"):format(branch)] or ""
local origin_url = cfg["remote.origin.url"] or ""
-- Get fork URL (either named remote or direct URL)
local url = (remote:match("^https://") or remote:match("^git@")) and remote
or cfg[("remote.%s.url"):format(remote)]
or remote
-- Parse author from fork URL
local author ---@type string?
if url ~= "" then
---@type string?
local owner_repo = url:match("github%.com[:/](.+/.+)%.git") or url:match("github%.com[:/](.+/.+)$")
author = owner_repo and owner_repo:match("^([^/]+)/") or nil
end
-- Parse repo from origin
local repo = origin_url:match("github%.com[:/](.+/.+)%.git") or origin_url:match("github%.com[:/](.+/.+)$")
-- Parse head from merge ref
local head = merge:match("^refs/heads/(.+)$") or branch
-- Get base branch (default branch from origin)
local base = vim.fn.system({ "git", "symbolic-ref", "refs/remotes/origin/HEAD" }):gsub("\n", "")
base = base:match("refs/remotes/origin/(.+)") or "main"
---@type snacks.gh.api.Branch
return {
url = url,
author = author,
repo = repo,
branch = branch,
head = head,
base = base,
}
end
---@param cb fun(item?: snacks.picker.gh.Item)
function M.current_pr(cb)
local branch = M.get_branch()
local key = string.format("%s:%s/%s->%s", branch.author or "", branch.repo or "", branch.head, branch.base)
if pr_cache[key] then
return cb(pr_cache[key])
end
return M.list("pr", function(items)
pr_cache[key] = items and items[1] or nil
cb(pr_cache[key])
end, {
author = branch.author,
head = branch.head,
base = branch.base,
repo = branch.repo,
limit = 1,
})
end
return M

View file

@ -98,7 +98,7 @@ function M:render(opts)
self:wo()
local spinner ---@type snacks.util.Spinner?
local proc = Api.view(self.item, function(it, updated)
local proc = Api.view(function(it, updated)
vim.schedule(function()
if not self:valid() then
return
@ -112,7 +112,7 @@ function M:render(opts)
self:keys()
end
end)
end, { force = opts.force })
end, self.item, { force = opts.force })
-- initial render (is partial if proc is running)
if Item.is(self.item) then

View file

@ -175,3 +175,11 @@
---@field closed? number
---@field merged? number
---@field draft? boolean
---@class snacks.gh.api.Branch
---@field url string URL of the remote branch
---@field author string owner of the remote branch
---@field repo string owner/name format
---@field branch string local branch name
---@field base string branch we want to merge into
---@field head string branch we want to merge from

View file

@ -309,6 +309,20 @@ M.gh_labels = {
format = "gh_format_label",
}
---@class snacks.picker.gh.actions.Config: snacks.picker.Config
---@field number number issue or PR number
---@field repo string GitHub repository (owner/repo). Defaults to current git repo
---@field type "issue" | "pr"
---@field item? snacks.picker.gh.Item
M.gh_actions = {
layout = { preset = "select", layout = { max_width = 50 } },
title = " Actions",
main = { current = true },
finder = "gh_get_actions",
format = "gh_format_action",
confirm = "gh_perform_action",
}
--- Git arguments are use like this:
--- * git [<cmd_args>] <cmd> [<args>]
--- * cmd may be `status`, `log`, `diff`, etc.

View file

@ -1,5 +1,5 @@
local Actions = require("snacks.gh.actions")
local Api = require("snacks.gh.api")
local Actions = require("snacks.gh.actions").actions
local M = {}
@ -8,15 +8,15 @@ M.actions = setmetatable({}, {
if type(k) ~= "string" then
return
end
if not Actions[k] then
if not Actions.actions[k] then
return nil
end
---@type snacks.picker.Action
local action = {
desc = Actions[k].desc,
desc = Actions.actions[k].desc,
action = function(picker, item, action)
---@diagnostic disable-next-line: param-type-mismatch
return Actions[k].action(item, {
return Actions.actions[k].action(item, {
picker = picker,
items = picker:selected({ fallback = true }),
action = action,
@ -44,7 +44,7 @@ function M.gh(opts, ctx)
end
end
---@param opts snacks.picker.Config
---@param opts snacks.picker.gh.issue.Config
---@type snacks.picker.finder
function M.issue(opts, ctx)
return M.gh(
@ -55,7 +55,7 @@ function M.issue(opts, ctx)
)
end
---@param opts snacks.picker.Config
---@param opts snacks.picker.gh.pr.Config
---@type snacks.picker.finder
function M.pr(opts, ctx)
return M.gh(
@ -66,6 +66,81 @@ function M.pr(opts, ctx)
)
end
---@param opts snacks.picker.gh.actions.Config
---@type snacks.picker.finder
function M.get_actions(opts, ctx)
opts = opts or {}
local proc ---@type snacks.spawn.Proc?
if not opts.item and not opts.number then
proc = Api.current_pr(function(pr)
opts.item = pr
end)
end
---@async
return function(cb)
if proc then
proc:wait()
end
local item = opts.item
if not item then
local required = { "type", "repo", "number" }
local missing = vim.tbl_filter(function(field)
return opts[field] == nil
end, required) ---@type string[]
if #missing > 0 then
Snacks.notify.error({
"Missing required options for `Snacks.picker.gh_actions()`:",
"- `" .. table.concat(missing, ", ") .. "`",
"",
"Either provide the fields, or run in a git repo with a **current PR**.",
}, { title = "Snacks Picker GH Actions" })
return
end
item = Api.get({ type = opts.type or "pr", repo = opts.repo, number = opts.number })
proc = Api.view(function(it)
item = it
end, item)
if proc then
proc:wait()
end
if not item then
Snacks.notify.error("snacks.picker.gh.get_actions: Failed to get item")
return
end
end
local actions = Actions.get_actions(item)
actions.gh_actions = nil -- remove this action
actions.gh_perform_action = nil -- remove this action
local items = {} ---@type snacks.picker.finder.Item[]
for name, action in pairs(actions) do
---@class snacks.picker.gh.Action: snacks.picker.finder.Item
items[#items + 1] = {
text = Snacks.picker.util.text(action, { "name", "desc" }),
file = item.uri,
name = name,
item = item,
desc = action.desc or name,
action = action,
}
end
table.sort(items, function(a, b)
local pa = a.action.priority or 0
local pb = b.action.priority or 0
if pa ~= pb then
return pa > pb
end
return a.desc < b.desc
end)
for i, it in ipairs(items) do
it.text = ("%d. %s"):format(i, it.text)
cb(it)
end
end
end
---@param opts snacks.picker.gh.diff.Config
---@type snacks.picker.finder
function M.diff(opts, ctx)

View file

@ -105,8 +105,20 @@ function Proc:debug(opts)
return Snacks.debug.cmd(opts)
end
function Proc:setup_async()
self.async = Async.running()
if self.async then
self.async:on("abort", function()
if self:running() then
self:kill()
end
end)
end
end
---@async
function Proc:wait()
self:setup_async()
assert(self.async, "Not in an async context")
assert(self.async == Async.running(), "Not in the current async context")
while not self.did_exit or self:running() do
@ -120,14 +132,7 @@ function Proc:run()
return self:on_exit()
end
self.async = Async.running()
if self.async then
self.async:on("abort", function()
if self:running() then
self:kill()
end
end)
end
self:setup_async()
self.stdout = assert(uv.new_pipe())
self.stderr = assert(uv.new_pipe())