forge.nvim/plugin/forge.lua
Barrett Ruth 2af47b6cf4
feat: add capabilities system and per-forge compatibility
Problem: forge.nvim silently ignored unsupported features on
non-GitHub forges. Codeberg `pr_for_branch_cmd` blocked all PR
creation, CI picker had zero actions, `repo_info` was hardcoded,
and the compose buffer showed draft/reviewer fields that did nothing.

Solution: add `forge.Capabilities` declaration (`draft`, `reviewers`,
`per_pr_checks`, `ci_json`) to each source. Compose buffer hides
unsupported fields. Per-PR checks falls back to repo-wide CI with
a notification. Fix Codeberg `pr_for_branch_cmd` to filter by branch
via jq, implement `repo_info` and `list_runs_json_cmd` via Gitea API,
add `default_branch_cmd` fallback, and add yank notifications for
GitLab/Codeberg.
2026-03-28 14:36:32 -04:00

430 lines
11 KiB
Lua

vim.api.nvim_create_autocmd('FileType', {
pattern = 'qf',
callback = function()
local info = vim.fn.getwininfo(vim.api.nvim_get_current_win())[1]
local items = info.loclist == 1 and vim.fn.getloclist(0) or vim.fn.getqflist()
if #items == 0 then
return
end
local bufname = vim.fn.bufname(items[1].bufnr)
if not bufname:match('^diffs://') then
return
end
vim.fn.matchadd('DiffAdd', [[\v\+\d+]])
vim.fn.matchadd('DiffDelete', [[\v-\d+]])
vim.fn.matchadd('DiffChange', [[\v\s\zsM\ze\s]])
vim.fn.matchadd('diffAdded', [[\v\s\zsA\ze\s]])
vim.fn.matchadd('DiffDelete', [[\v\s\zsD\ze\s]])
vim.fn.matchadd('DiffText', [[\v\s\zsR\ze\s]])
end,
})
local function require_forge_or_warn()
local forge_mod = require('forge')
local f = forge_mod.detect()
if not f then
vim.notify('[forge]: no forge detected', vim.log.levels.WARN)
return nil, forge_mod
end
return f, forge_mod
end
local function require_git_or_warn()
vim.fn.system('git rev-parse --show-toplevel')
if vim.v.shell_error ~= 0 then
vim.notify('[forge]: not a git repository', vim.log.levels.WARN)
return false
end
return true
end
local function parse_flags(args, start)
local flags = {}
local positional = {}
for i = start, #args do
local flag = args[i]:match('^%-%-(.+)$')
if flag then
local fk, fv = flag:match('^(.-)=(.+)$')
if fk then
flags[fk] = fv
else
flags[flag] = true
end
else
table.insert(positional, args[i])
end
end
return flags, positional
end
local function dispatch(args)
local sub = args[1]
if sub == 'pr' then
if not require_git_or_warn() then
return
end
local f, forge_mod = require_forge_or_warn()
if not f then
return
end
local pickers = require('forge.pickers')
if #args == 1 then
pickers.pr('open', f)
return
end
local flags, pos = parse_flags(args, 2)
if flags.state then
pickers.pr(flags.state, f)
return
end
local action = pos[1]
if action == 'create' then
local cf = parse_flags(args, 3)
local opts = {}
if cf.draft then
opts.draft = true
end
if cf.fill then
opts.instant = true
end
if cf.web then
opts.web = true
end
forge_mod.create_pr(opts)
return
end
local num = pos[2]
if not num then
vim.notify('[forge]: missing argument', vim.log.levels.WARN)
return
end
if action == 'checkout' then
pickers.pr_actions(f, num)._by_name.checkout()
elseif action == 'diff' then
pickers.pr_actions(f, num)._by_name.diff()
elseif action == 'worktree' then
pickers.pr_actions(f, num)._by_name.worktree()
elseif action == 'ci' then
if f.capabilities.per_pr_checks then
pickers.checks(f, num)
else
require('forge').log(
('per-%s checks unavailable on %s, showing repo CI'):format(f.labels.pr_one, f.name)
)
pickers.ci(f)
end
elseif action == 'browse' then
f:view_web(f.kinds.pr, num)
elseif action == 'manage' then
pickers.pr_manage(f, num)
else
vim.notify('[forge]: unknown pr action: ' .. action, vim.log.levels.WARN)
end
return
end
if sub == 'issue' then
if not require_git_or_warn() then
return
end
local f = require_forge_or_warn()
if not f then
return
end
local pickers = require('forge.pickers')
if #args == 1 then
pickers.issue('all', f)
return
end
local flags, pos = parse_flags(args, 2)
if flags.state then
pickers.issue(flags.state, f)
return
end
local action = pos[1]
local num = pos[2]
if action == 'browse' then
if not num then
vim.notify('[forge]: missing issue number', vim.log.levels.WARN)
return
end
f:view_web(f.kinds.issue, num)
elseif action == 'close' then
if not num then
vim.notify('[forge]: missing issue number', vim.log.levels.WARN)
return
end
pickers.issue_close(f, num)
elseif action == 'reopen' then
if not num then
vim.notify('[forge]: missing issue number', vim.log.levels.WARN)
return
end
pickers.issue_reopen(f, num)
else
vim.notify('[forge]: unknown issue action: ' .. (action or ''), vim.log.levels.WARN)
end
return
end
if sub == 'ci' then
if not require_git_or_warn() then
return
end
local f = require_forge_or_warn()
if not f then
return
end
local flags = parse_flags(args, 2)
local branch
if not flags.all then
branch = vim.trim(vim.fn.system('git branch --show-current'))
if branch == '' then
branch = nil
end
end
require('forge.pickers').ci(f, branch)
return
end
if sub == 'commit' then
if not require_git_or_warn() then
return
end
local forge_mod = require('forge')
local f = forge_mod.detect()
local pickers = require('forge.pickers')
if #args == 1 then
pickers.commits(f)
return
end
local _, pos = parse_flags(args, 2)
local action = pos[1]
local sha = pos[2]
if not sha then
vim.notify('[forge]: missing commit sha', vim.log.levels.WARN)
return
end
if action == 'checkout' then
forge_mod.log_now('checking out ' .. sha .. '...')
vim.system({ 'git', 'checkout', sha }, { text = true }, function(result)
vim.schedule(function()
if result.code == 0 then
vim.notify(('[forge]: checked out %s (detached)'):format(sha))
else
vim.notify('[forge]: checkout failed', vim.log.levels.ERROR)
end
vim.cmd.redraw()
end)
end)
elseif action == 'diff' then
local review = require('forge.review')
local range = sha .. '^..' .. sha
review.start(range)
local ok, commands = pcall(require, 'diffs.commands')
if ok then
commands.greview(range)
end
forge_mod.log_now('reviewing ' .. sha)
elseif action == 'browse' then
if not f then
vim.notify('[forge]: no forge detected', vim.log.levels.WARN)
return
end
f:browse_commit(sha)
else
vim.notify('[forge]: unknown commit action: ' .. action, vim.log.levels.WARN)
end
return
end
if sub == 'branch' then
if not require_git_or_warn() then
return
end
local forge_mod = require('forge')
local f = forge_mod.detect()
local pickers = require('forge.pickers')
if #args == 1 then
pickers.branches(f)
return
end
local _, pos = parse_flags(args, 2)
local action = pos[1]
local name = pos[2]
if not name then
vim.notify('[forge]: missing branch name', vim.log.levels.WARN)
return
end
if action == 'diff' then
local review = require('forge.review')
review.start(name)
local ok, commands = pcall(require, 'diffs.commands')
if ok then
commands.greview(name)
end
forge_mod.log_now('reviewing ' .. name)
elseif action == 'browse' then
if not f then
vim.notify('[forge]: no forge detected', vim.log.levels.WARN)
return
end
f:browse_branch(name)
else
vim.notify('[forge]: unknown branch action: ' .. action, vim.log.levels.WARN)
end
return
end
if sub == 'worktree' then
if not require_git_or_warn() then
return
end
require('fzf-lua').git_worktrees()
return
end
if sub == 'browse' then
if not require_git_or_warn() then
return
end
local f = require_forge_or_warn()
if not f then
return
end
local flags = parse_flags(args, 2)
if flags.root then
f:browse_root()
elseif flags.commit then
local sha = vim.trim(vim.fn.system('git rev-parse HEAD'))
f:browse_commit(sha)
else
local forge_mod = require('forge')
local loc = forge_mod.file_loc()
local branch = vim.trim(vim.fn.system('git branch --show-current'))
if branch == '' then
vim.notify('[forge]: detached HEAD', vim.log.levels.WARN)
return
end
f:browse(loc, branch)
end
return
end
if sub == 'yank' then
if not require_git_or_warn() then
return
end
local f = require_forge_or_warn()
if not f then
return
end
local forge_mod = require('forge')
local loc = forge_mod.file_loc()
local flags = parse_flags(args, 2)
if flags.commit then
f:yank_commit(loc)
else
f:yank_branch(loc)
end
return
end
if sub == 'review' then
local review = require('forge.review')
if #args < 2 then
vim.notify('[forge]: missing review action (end, toggle)', vim.log.levels.WARN)
return
end
local action = args[2]
if action == 'end' then
review.stop()
elseif action == 'toggle' then
review.toggle()
else
vim.notify('[forge]: unknown review action: ' .. action, vim.log.levels.WARN)
end
return
end
if sub == 'clear' then
require('forge').clear_cache()
vim.notify('[forge]: cache cleared')
return
end
vim.notify('[forge]: unknown command: ' .. sub, vim.log.levels.WARN)
end
local function complete(arglead, cmdline, _)
local words = {}
for word in cmdline:gmatch('%S+') do
table.insert(words, word)
end
local arg_idx = arglead == '' and #words or #words - 1
local subcmds =
{ 'pr', 'issue', 'ci', 'commit', 'branch', 'worktree', 'browse', 'yank', 'review', 'clear' }
local sub_actions = {
pr = { 'checkout', 'diff', 'worktree', 'ci', 'browse', 'manage', 'create', '--state=' },
issue = { 'browse', 'close', 'reopen', '--state=' },
ci = { '--all' },
commit = { 'checkout', 'diff', 'browse' },
branch = { 'diff', 'browse' },
review = { 'end', 'toggle' },
browse = { '--root', '--commit' },
yank = { '--commit' },
}
local flag_values = {
['--state'] = { 'open', 'closed', 'all' },
}
local create_flags = { '--draft', '--fill', '--web' }
local function filter(candidates)
return vim.tbl_filter(function(s)
return s:find(arglead, 1, true) == 1
end, candidates)
end
local flag, value_prefix = arglead:match('^(%-%-[^=]+)=(.*)$')
if flag and flag_values[flag] then
return vim.tbl_map(
function(v)
return flag .. '=' .. v
end,
vim.tbl_filter(function(v)
return v:find(value_prefix, 1, true) == 1
end, flag_values[flag])
)
end
if arg_idx == 1 then
return filter(subcmds)
end
local sub = words[2]
if arg_idx == 2 then
return filter(sub_actions[sub] or {})
end
if sub == 'pr' and words[3] == 'create' then
return filter(create_flags)
end
return {}
end
vim.api.nvim_create_user_command('Forge', function(opts)
local args = vim.split(vim.trim(opts.args), '%s+')
if #args == 0 or args[1] == '' then
require('forge.pickers').git()
return
end
dispatch(args)
end, {
nargs = '*',
complete = complete,
desc = 'forge.nvim',
})