From 5fcbcfcf99844ed6eea54ee799752d1a292f9bb0 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 28 Mar 2026 00:24:26 -0400 Subject: [PATCH] feat: cli --- lua/forge/codeberg.lua | 5 - lua/forge/github.lua | 11 +- lua/forge/gitlab.lua | 17 +- lua/forge/health.lua | 11 + lua/forge/init.lua | 197 ++++-- lua/forge/pickers.lua | 1031 ++++++++++++++++++++++++++++++ lua/forge/review.lua | 101 +++ plugin/forge.lua | 1373 +++++++++++++--------------------------- 8 files changed, 1727 insertions(+), 1019 deletions(-) create mode 100644 lua/forge/pickers.lua create mode 100644 lua/forge/review.lua diff --git a/lua/forge/codeberg.lua b/lua/forge/codeberg.lua index cf6093e..7925588 100644 --- a/lua/forge/codeberg.lua +++ b/lua/forge/codeberg.lua @@ -16,11 +16,6 @@ local M = { ---@param kind string ---@param state string ----@return string -function M:list_cmd(kind, state) - return ('tea %s list --state %s'):format(kind, state) -end - ---@param state string ---@return string[] function M:list_pr_json_cmd(state) diff --git a/lua/forge/github.lua b/lua/forge/github.lua index b8e35fd..39b91a7 100644 --- a/lua/forge/github.lua +++ b/lua/forge/github.lua @@ -21,11 +21,6 @@ end ---@param kind string ---@param state string ----@return string -function M:list_cmd(kind, state) - return ('gh %s list --limit 100 --state %s'):format(kind, state) -end - ---@param state string ---@return string[] function M:list_pr_json_cmd(state) @@ -34,7 +29,7 @@ function M:list_pr_json_cmd(state) 'pr', 'list', '--limit', - '100', + tostring(forge.config().display.limits.pulls), '--state', state, '--json', @@ -50,7 +45,7 @@ function M:list_issue_json_cmd(state) 'issue', 'list', '--limit', - '100', + tostring(forge.config().display.limits.issues), '--state', state, '--json', @@ -200,7 +195,7 @@ function M:list_runs_json_cmd(branch) '--json', 'databaseId,name,headBranch,status,conclusion,event,url,createdAt', '--limit', - '30', + tostring(forge.config().display.limits.runs), } if branch then table.insert(cmd, '--branch') diff --git a/lua/forge/gitlab.lua b/lua/forge/gitlab.lua index 5ba4170..15baf6c 100644 --- a/lua/forge/gitlab.lua +++ b/lua/forge/gitlab.lua @@ -16,17 +16,6 @@ local M = { ---@param kind string ---@param state string ----@return string -function M:list_cmd(kind, state) - local cmd = ('glab %s list --per-page 100'):format(kind) - if state == 'closed' then - cmd = cmd .. ' --closed' - elseif state == 'all' then - cmd = cmd .. ' --all' - end - return cmd -end - ---@param state string ---@return string[] function M:list_pr_json_cmd(state) @@ -35,7 +24,7 @@ function M:list_pr_json_cmd(state) 'mr', 'list', '--per-page', - '100', + tostring(forge.config().display.limits.pulls), '--output', 'json', } @@ -55,7 +44,7 @@ function M:list_issue_json_cmd(state) 'issue', 'list', '--per-page', - '100', + tostring(forge.config().display.limits.issues), '--output', 'json', } @@ -201,7 +190,7 @@ function M:list_runs_json_cmd(branch) '--output', 'json', '--per-page', - '30', + tostring(forge.config().display.limits.runs), } if branch then table.insert(cmd, '--ref') diff --git a/lua/forge/health.lua b/lua/forge/health.lua index 8667d76..aa34fa2 100644 --- a/lua/forge/health.lua +++ b/lua/forge/health.lua @@ -42,6 +42,17 @@ function M.check() else vim.health.info('vim-fugitive not found (fugitive keymaps disabled)') end + + local forge_mod = require('forge') + for name, source in pairs(forge_mod.registered_sources()) do + if name ~= 'github' and name ~= 'gitlab' and name ~= 'codeberg' then + if vim.fn.executable(source.cli) == 1 then + vim.health.ok(source.cli .. ' found (custom: ' .. name .. ')') + else + vim.health.warn(source.cli .. ' not found (custom: ' .. name .. ' disabled)') + end + end + end end return M diff --git a/lua/forge/init.lua b/lua/forge/init.lua index 441cf25..b79d9bb 100644 --- a/lua/forge/init.lua +++ b/lua/forge/init.lua @@ -1,5 +1,67 @@ local M = {} +local DEFAULTS = { + ci = { lines = 10000 }, + sources = {}, + keys = { + picker = '', + next_qf = ']q', + prev_qf = '[q', + next_loc = ']l', + prev_loc = '[l', + review_toggle = 's', + terminal_open = 'gx', + fugitive = { + create = 'cpr', + create_draft = 'cpd', + create_fill = 'cpf', + create_web = 'cpw', + }, + }, + picker_keys = { + pr = { checkout = 'default', diff = 'ctrl-d', worktree = 'ctrl-w', checks = 'ctrl-t', browse = 'ctrl-x', manage = 'ctrl-e', create = 'ctrl-a', toggle = 'ctrl-o', refresh = 'ctrl-r' }, + issue = { browse = 'default', close_reopen = 'ctrl-s', toggle = 'ctrl-o', refresh = 'ctrl-r' }, + checks = { log = 'default', browse = 'ctrl-x', failed = 'ctrl-f', passed = 'ctrl-p', running = 'ctrl-n', all = 'ctrl-a' }, + ci = { log = 'default', browse = 'ctrl-x', refresh = 'ctrl-r' }, + commits = { checkout = 'default', diff = 'ctrl-d', browse = 'ctrl-x', yank = 'ctrl-y' }, + branches = { diff = 'ctrl-d', browse = 'ctrl-x' }, + }, + display = { + icons = { + open = '+', + merged = 'm', + closed = 'x', + pass = '*', + fail = 'x', + pending = '~', + skip = '-', + unknown = '?', + }, + widths = { + title = 45, + author = 15, + name = 35, + branch = 25, + }, + limits = { + pulls = 100, + issues = 100, + runs = 30, + }, + }, +} + +---@type table +local sources = {} + +function M.register(name, source) + sources[name] = source +end + +function M.registered_sources() + return sources +end + local hl_defaults = { ForgeComposeComment = 'Comment', ForgeComposeBranch = 'Special', @@ -62,7 +124,6 @@ end ---@field cli string ---@field kinds { issue: string, pr: string } ---@field labels { issue: string, pr: string, pr_one: string, pr_full: string, ci: string } ----@field list_cmd fun(self: forge.Forge, kind: string, state: string): string ---@field list_pr_json_cmd fun(self: forge.Forge, state: string): string[] ---@field list_issue_json_cmd fun(self: forge.Forge, state: string): string[] ---@field pr_json_fields fun(self: forge.Forge): { number: string, title: string, branch: string, state: string, author: string, created_at: string } @@ -126,21 +187,45 @@ local function git_root() return root end +local builtin_hosts = { + github = { 'github' }, + gitlab = { 'gitlab' }, + codeberg = { 'codeberg', 'gitea', 'forgejo' }, +} + +local function resolve_source(name) + if sources[name] then + return sources[name] + end + local ok, mod = pcall(require, 'forge.' .. name) + if ok then + sources[name] = mod + return mod + end + return nil +end + ---@param remote string ---@return string? forge_name local function detect_from_remote(remote) - if remote:find('github') and vim.fn.executable('gh') == 1 then - return 'github' + local cfg = M.config().sources + + for name, opts in pairs(cfg) do + for _, host in ipairs(opts.hosts or {}) do + if remote:find(host, 1, true) then + return name + end + end end - if remote:find('gitlab') and vim.fn.executable('glab') == 1 then - return 'gitlab' - end - if - (remote:find('codeberg') or remote:find('gitea') or remote:find('forgejo')) - and vim.fn.executable('tea') == 1 - then - return 'codeberg' + + for name, patterns in pairs(builtin_hosts) do + for _, pattern in ipairs(patterns) do + if remote:find(pattern, 1, true) then + return name + end + end end + return nil end @@ -161,9 +246,15 @@ function M.detect() if not name then return nil end - local f = require('forge.' .. name) - forge_cache[root] = f - return f + local source = resolve_source(name) + if not source then + return nil + end + if vim.fn.executable(source.cli) ~= 1 then + return nil + end + forge_cache[root] = source + return source end ---@param f forge.Forge @@ -362,6 +453,9 @@ end ---@param show_state boolean ---@return string function M.format_pr(entry, fields, show_state) + local display = M.config().display + local icons = display.icons + local widths = display.widths local num = tostring(entry[fields.number] or '') local title = entry[fields.title] or '' local author = extract_author(entry, fields.author) @@ -371,19 +465,19 @@ function M.format_pr(entry, fields, show_state) local state = (entry[fields.state] or ''):lower() local icon, color if state == 'open' or state == 'opened' then - icon, color = '+', '\27[34m' + icon, color = icons.open, '\27[34m' elseif state == 'merged' then - icon, color = 'm', '\27[35m' + icon, color = icons.merged, '\27[35m' else - icon, color = 'x', '\27[31m' + icon, color = icons.closed, '\27[31m' end prefix = color .. icon .. '\27[0m ' end - return ('%s\27[34m#%-5s\27[0m %s \27[2m%-15s %3s\27[0m'):format( + return ('%s\27[34m#%-5s\27[0m %s \27[2m%-' .. widths.author .. 's %3s\27[0m'):format( prefix, num, - pad_or_truncate(title, 45), - pad_or_truncate(author, 15), + pad_or_truncate(title, widths.title), + pad_or_truncate(author, widths.author), age ) end @@ -393,6 +487,9 @@ end ---@param show_state boolean ---@return string function M.format_issue(entry, fields, show_state) + local display = M.config().display + local icons = display.icons + local widths = display.widths local num = tostring(entry[fields.number] or '') local title = entry[fields.title] or '' local author = extract_author(entry, fields.author) @@ -402,17 +499,17 @@ function M.format_issue(entry, fields, show_state) local state = (entry[fields.state] or ''):lower() local icon, color if state == 'open' or state == 'opened' then - icon, color = '+', '\27[34m' + icon, color = icons.open, '\27[34m' else - icon, color = '*', '\27[2m' + icon, color = icons.closed, '\27[2m' end prefix = color .. icon .. '\27[0m ' end - return ('%s\27[34m#%-5s\27[0m %s \27[2m%-15s %3s\27[0m'):format( + return ('%s\27[34m#%-5s\27[0m %s \27[2m%-' .. widths.author .. 's %3s\27[0m'):format( prefix, num, - pad_or_truncate(title, 45), - pad_or_truncate(author, 15), + pad_or_truncate(title, widths.title), + pad_or_truncate(author, widths.author), age ) end @@ -420,19 +517,22 @@ end ---@param check table ---@return string function M.format_check(check) + local display = M.config().display + local icons = display.icons + local widths = display.widths local bucket = (check.bucket or 'pending'):lower() local name = check.name or '' local icon, color if bucket == 'pass' then - icon, color = '*', '\27[32m' + icon, color = icons.pass, '\27[32m' elseif bucket == 'fail' then - icon, color = 'x', '\27[31m' + icon, color = icons.fail, '\27[31m' elseif bucket == 'pending' then - icon, color = '~', '\27[33m' + icon, color = icons.pending, '\27[33m' elseif bucket == 'skipping' or bucket == 'cancel' then - icon, color = '-', '\27[2m' + icon, color = icons.skip, '\27[2m' else - icon, color = '?', '\27[2m' + icon, color = icons.unknown, '\27[2m' end local elapsed = '' if check.startedAt and check.completedAt and check.completedAt ~= '' then @@ -447,33 +547,37 @@ function M.format_check(check) end end end - return ('%s%s\27[0m %s \27[2m%s\27[0m'):format(color, icon, pad_or_truncate(name, 35), elapsed) + return ('%s%s\27[0m %s \27[2m%s\27[0m'):format(color, icon, pad_or_truncate(name, widths.name), elapsed) end ---@param run forge.CIRun ---@return string function M.format_run(run) + local display = M.config().display + local icons = display.icons + local widths = display.widths local icon, color local s = run.status:lower() if s == 'success' then - icon, color = '*', '\27[32m' + icon, color = icons.pass, '\27[32m' elseif s == 'failure' or s == 'failed' then - icon, color = 'x', '\27[31m' + icon, color = icons.fail, '\27[31m' elseif s == 'in_progress' or s == 'running' or s == 'pending' or s == 'queued' then - icon, color = '~', '\27[33m' + icon, color = icons.pending, '\27[33m' elseif s == 'cancelled' or s == 'canceled' or s == 'skipped' then - icon, color = '-', '\27[2m' + icon, color = icons.skip, '\27[2m' else - icon, color = '?', '\27[2m' + icon, color = icons.unknown, '\27[2m' end local event = abbreviate_event(run.event) local date = compact_date(run.created_at) if run.branch ~= '' then + local name_w = widths.name - widths.branch + 10 return ('%s%s\27[0m %s \27[36m%s\27[0m \27[2m%-6s %s\27[0m'):format( color, icon, - pad_or_truncate(run.name, 20), - pad_or_truncate(run.branch, 25), + pad_or_truncate(run.name, name_w), + pad_or_truncate(run.branch, widths.branch), event, date ) @@ -481,7 +585,7 @@ function M.format_run(run) return ('%s%s\27[0m %s \27[2m%-6s %s\27[0m'):format( color, icon, - pad_or_truncate(run.name, 35), + pad_or_truncate(run.name, widths.name), event, date ) @@ -510,14 +614,17 @@ function M.filter_checks(checks, filter) end function M.config() - return vim.tbl_deep_extend('force', { - ci = { lines = 10000 }, - }, vim.g.forge or {}) + local user = vim.g.forge or {} + local cfg = vim.tbl_deep_extend('force', DEFAULTS, user) + if user.keys == false then + cfg.keys = false + end + if user.picker_keys == false then + cfg.picker_keys = false + end + return cfg end ----@type { base: string?, mode: 'unified'|'split' } -M.review = { base = nil, mode = 'unified' } - ---@param args string[] function M.yank_url(args) vim.system(args, { text = true }, function(result) diff --git a/lua/forge/pickers.lua b/lua/forge/pickers.lua new file mode 100644 index 0000000..4c7888f --- /dev/null +++ b/lua/forge/pickers.lua @@ -0,0 +1,1031 @@ +local M = {} + +---@param result vim.SystemCompleted +---@param fallback string +---@return string +local function cmd_error(result, fallback) + local msg = result.stderr or '' + if vim.trim(msg) == '' then + msg = result.stdout or '' + end + msg = vim.trim(msg) + if msg == '' then + msg = fallback + end + return msg +end + +local fzf_args = (vim.env.FZF_DEFAULT_OPTS or '') + :gsub('%-%-bind=[^%s]+', '') + :gsub('%-%-color=[^%s]+', '') + +local function make_header(bindings) + local utils = require('fzf-lua.utils') + local parts = {} + for _, b in ipairs(bindings) do + local key = utils.ansi_from_hl('FzfLuaHeaderBind', '<' .. b[1] .. '>') + local desc = utils.ansi_from_hl('FzfLuaHeaderText', b[2]) + table.insert(parts, key .. ' to ' .. desc) + end + return ':: ' .. table.concat(parts, '|') +end + +local function build_actions(picker_name, action_defs) + local cfg = require('forge').config() + local pk = cfg.picker_keys + if pk == false then + pk = {} + end + local bindings = pk[picker_name] or {} + local actions = {} + local header_entries = {} + for _, def in ipairs(action_defs) do + local key = bindings[def.name] + if key then + actions[key] = def.fn + if def.label then + local display_key = key == 'default' and 'enter' or key:gsub('ctrl%-', 'ctrl-') + table.insert(header_entries, { display_key, def.label }) + end + end + end + return actions, make_header(header_entries) +end + +local function terminal_open_key() + local cfg = require('forge').config() + if cfg.keys == false then + return nil + end + return cfg.keys.terminal_open +end + +---@param kind string +---@param num string +---@param label string +---@param cmd string[] +---@param success_msg string +---@param fail_msg string +local function run_forge_cmd(kind, num, label, cmd, success_msg, fail_msg) + require('forge').log_now(label .. ' ' .. kind .. ' #' .. num .. '...') + vim.system(cmd, { text = true }, function(result) + vim.schedule(function() + if result.code == 0 then + vim.notify(('[forge]: %s %s #%s'):format(success_msg, kind, num)) + else + vim.notify('[forge]: ' .. cmd_error(result, fail_msg), vim.log.levels.ERROR) + end + vim.cmd.redraw() + end) + end) +end + +---@param f forge.Forge +---@param num string +---@param is_open boolean +local function issue_toggle_state(f, num, is_open) + if is_open then + run_forge_cmd('issue', num, 'closing', f:close_issue_cmd(num), 'closed', 'close failed') + else + run_forge_cmd('issue', num, 'reopening', f:reopen_issue_cmd(num), 'reopened', 'reopen failed') + end +end + +---@param f forge.Forge +---@param num string +---@return table +local function pr_actions(f, num) + local kind = f.labels.pr_one + + local defs = { + { + name = 'checkout', + fn = function() + local forge_mod = require('forge') + forge_mod.log_now(('checking out %s #%s...'):format(kind, num)) + vim.system(f:checkout_cmd(num), { text = true }, function(result) + vim.schedule(function() + if result.code == 0 then + vim.notify(('[forge]: checked out %s #%s'):format(kind, num)) + else + vim.notify('[forge]: ' .. cmd_error(result, 'checkout failed'), vim.log.levels.ERROR) + end + vim.cmd.redraw() + end) + end) + end, + }, + { + name = 'browse', + fn = function() + f:view_web(f.kinds.pr, num) + end, + }, + { + name = 'worktree', + fn = function() + local forge_mod = require('forge') + local fetch_cmd = f:fetch_pr(num) + local branch = fetch_cmd[#fetch_cmd]:match(':(.+)$') + if not branch then + return + end + local root = vim.trim(vim.fn.system('git rev-parse --show-toplevel')) + local wt_path = vim.fs.normalize(root .. '/../' .. branch) + forge_mod.log_now(('fetching %s #%s into worktree...'):format(kind, num)) + vim.system(fetch_cmd, { text = true }, function() + vim.system({ 'git', 'worktree', 'add', wt_path, branch }, { text = true }, function(result) + vim.schedule(function() + if result.code == 0 then + vim.notify(('[forge]: worktree at %s'):format(wt_path)) + else + vim.notify('[forge]: ' .. cmd_error(result, 'worktree failed'), vim.log.levels.ERROR) + end + vim.cmd.redraw() + end) + end) + end) + end, + }, + { + name = 'diff', + fn = function() + local forge_mod = require('forge') + local review = require('forge.review') + local repo_root = vim.trim(vim.fn.system('git rev-parse --show-toplevel')) + + forge_mod.log_now(('reviewing %s #%s...'):format(kind, num)) + vim.system(f:checkout_cmd(num), { text = true }, function(co_result) + if co_result.code ~= 0 then + vim.schedule(function() + forge_mod.log('checkout skipped, proceeding with diff') + end) + end + + vim.system(f:pr_base_cmd(num), { text = true }, function(base_result) + vim.schedule(function() + local base = vim.trim(base_result.stdout or '') + if base == '' or base_result.code ~= 0 then + base = 'main' + end + local range = 'origin/' .. base + review.start(range) + local ok, commands = pcall(require, 'diffs.commands') + if ok then + commands.greview(range, { repo_root = repo_root }) + end + forge_mod.log(('review ready for %s #%s against %s'):format(kind, num, base)) + end) + end) + end) + end, + }, + { + name = 'checks', + fn = function() + M.checks(f, num) + end, + }, + { + name = 'manage', + fn = function() + M.pr_manage(f, num) + end, + }, + } + + local name_to_fn = {} + for _, def in ipairs(defs) do + name_to_fn[def.name] = def.fn + end + + local actions = build_actions('pr', defs) + actions._by_name = name_to_fn + return actions +end + +---@param f forge.Forge +---@param num string +local function pr_manage_picker(f, num) + local forge_mod = require('forge') + local kind = f.labels.pr_one + forge_mod.log_now('loading actions for ' .. kind .. ' #' .. num .. '...') + + local info = forge_mod.repo_info(f) + local can_write = info.permission == 'ADMIN' + or info.permission == 'MAINTAIN' + or info.permission == 'WRITE' + local pr_state = f:pr_state(num) + local is_open = pr_state.state == 'OPEN' or pr_state.state == 'OPENED' + + local entries = {} + local action_map = {} + + local function add(label, fn) + table.insert(entries, label) + action_map[label] = fn + end + + if can_write and is_open then + add('Approve', function() + run_forge_cmd(kind, num, 'approving', f:approve_cmd(num), 'approved', 'approve failed') + end) + end + + if can_write and is_open then + for _, method in ipairs(info.merge_methods) do + add('Merge (' .. method .. ')', function() + run_forge_cmd( + kind, + num, + 'merging (' .. method .. ')', + f:merge_cmd(num, method), + 'merged (' .. method .. ')', + 'merge failed' + ) + end) + end + end + + if is_open then + add('Close', function() + run_forge_cmd(kind, num, 'closing', f:close_cmd(num), 'closed', 'close failed') + end) + else + add('Reopen', function() + run_forge_cmd(kind, num, 'reopening', f:reopen_cmd(num), 'reopened', 'reopen failed') + end) + end + + local draft_cmd = f:draft_toggle_cmd(num, pr_state.is_draft) + if draft_cmd then + local draft_label = pr_state.is_draft and 'Mark as ready' or 'Mark as draft' + local draft_done = pr_state.is_draft and 'marked as ready' or 'marked as draft' + add(draft_label, function() + run_forge_cmd(kind, num, 'toggling draft', draft_cmd, draft_done, 'draft toggle failed') + end) + end + + require('fzf-lua').fzf_exec(entries, { + fzf_args = fzf_args, + prompt = ('%s #%s Actions> '):format(kind, num), + fzf_opts = { ['--no-multi'] = '' }, + actions = { + ['default'] = function(selected) + if selected[1] and action_map[selected[1]] then + action_map[selected[1]]() + end + end, + }, + }) +end + +---@param f forge.Forge +---@param num string +---@param filter string? +---@param cached_checks table[]? +function M.checks(f, num, filter, cached_checks) + filter = filter or 'all' + local forge_mod = require('forge') + + local function open_picker(checks) + local filtered = forge_mod.filter_checks(checks, filter) + local lines = {} + for i, c in ipairs(filtered) do + local line = ('%d\t%s'):format(i, forge_mod.format_check(c)) + table.insert(lines, line) + end + + local function get_check(selected) + if not selected[1] then + return nil + end + local idx = tonumber(selected[1]:match('^(%d+)')) + return idx and filtered[idx] or nil + end + + local labels = { + all = 'all', + fail = 'failed', + pass = 'passed', + pending = 'running', + } + + local check_actions, check_header = build_actions('checks', { + { + name = 'log', + label = 'log', + fn = function(selected) + local c = get_check(selected) + if not c then + return + end + local run_id = (c.link or ''):match('/actions/runs/(%d+)') + if not run_id then + return + end + forge_mod.log_now('fetching check logs...') + local bucket = (c.bucket or ''):lower() + local cmd + if bucket == 'pending' then + cmd = f:check_tail_cmd(run_id) + else + cmd = f:check_log_cmd(run_id, bucket == 'fail') + end + vim.cmd('noautocmd botright new') + vim.fn.termopen(cmd) + vim.api.nvim_feedkeys( + vim.api.nvim_replace_termcodes('G', true, false, true), + 'n', + false + ) + local to_key = terminal_open_key() + if c.link and to_key then + vim.b.forge_check_url = c.link + vim.keymap.set('n', to_key, function() + vim.ui.open(vim.b.forge_check_url) + end, { + buffer = true, + desc = 'open check in browser', + }) + end + end, + }, + { + name = 'browse', + label = 'browse', + fn = function(selected) + local c = get_check(selected) + if c and c.link then + vim.ui.open(c.link) + end + end, + }, + { + name = 'failed', + label = 'failed', + fn = function() + M.checks(f, num, 'fail', checks) + end, + }, + { + name = 'passed', + label = 'passed', + fn = function() + M.checks(f, num, 'pass', checks) + end, + }, + { + name = 'running', + label = 'running', + fn = function() + M.checks(f, num, 'pending', checks) + end, + }, + { + name = 'all', + label = 'all', + fn = function() + M.checks(f, num, 'all', checks) + end, + }, + }) + + require('fzf-lua').fzf_exec(lines, { + fzf_args = fzf_args, + prompt = ('Checks (#%s, %s)> '):format(num, labels[filter] or filter), + fzf_opts = { + ['--ansi'] = '', + ['--no-multi'] = '', + ['--with-nth'] = '2..', + ['--delimiter'] = '\t', + ['--header'] = check_header, + }, + actions = check_actions, + }) + end + + if cached_checks then + forge_mod.log(('checks picker (%s #%s, cached)'):format(f.labels.pr_one, num)) + open_picker(cached_checks) + return + end + + if f.checks_json_cmd then + forge_mod.log_now(('fetching checks for %s #%s...'):format(f.labels.pr_one, num)) + vim.system(f:checks_json_cmd(num), { text = true }, function(result) + vim.schedule(function() + local ok, checks = pcall(vim.json.decode, result.stdout or '[]') + if ok and checks then + open_picker(checks) + else + vim.notify('[forge]: no checks found', vim.log.levels.INFO) + vim.cmd.redraw() + end + end) + end) + else + require('fzf-lua').fzf_exec(f:checks_cmd(num), { + fzf_args = fzf_args, + prompt = ('Checks (#%s)> '):format(num), + fzf_opts = { ['--ansi'] = '' }, + actions = { + ['ctrl-r'] = function() + M.checks(f, num, filter) + end, + }, + }) + end +end + +---@param f forge.Forge +---@param branch string? +function M.ci(f, branch) + local forge_mod = require('forge') + + local function open_picker(runs) + local normalized = {} + for _, entry in ipairs(runs) do + table.insert(normalized, f:normalize_run(entry)) + end + + local lines = {} + for i, run in ipairs(normalized) do + table.insert(lines, ('%d\t%s'):format(i, forge_mod.format_run(run))) + end + + local function get_run(selected) + if not selected[1] then + return nil + end + local idx = tonumber(selected[1]:match('^(%d+)')) + return idx and normalized[idx] or nil + end + + local ci_actions, ci_header = build_actions('ci', { + { + name = 'log', + label = 'log', + fn = function(selected) + local run = get_run(selected) + if not run then + return + end + forge_mod.log_now('fetching CI/CD logs...') + local s = run.status:lower() + local cmd + if s == 'in_progress' or s == 'running' or s == 'pending' or s == 'queued' then + cmd = f:run_tail_cmd(run.id) + elseif s == 'failure' or s == 'failed' then + cmd = f:run_log_cmd(run.id, true) + else + cmd = f:run_log_cmd(run.id, false) + end + vim.cmd('noautocmd botright new') + vim.fn.termopen(cmd) + vim.api.nvim_feedkeys( + vim.api.nvim_replace_termcodes('G', true, false, true), + 'n', + false + ) + local to_key = terminal_open_key() + if run.url ~= '' and to_key then + vim.b.forge_run_url = run.url + vim.keymap.set('n', to_key, function() + vim.ui.open(vim.b.forge_run_url) + end, { + buffer = true, + desc = 'open run in browser', + }) + end + end, + }, + { + name = 'browse', + label = 'browse', + fn = function(selected) + local run = get_run(selected) + if run and run.url ~= '' then + vim.ui.open(run.url) + end + end, + }, + { + name = 'refresh', + label = 'refresh', + fn = function() + M.ci(f, branch) + end, + }, + }) + + require('fzf-lua').fzf_exec(lines, { + fzf_args = fzf_args, + prompt = ('%s (%s)> '):format(f.labels.ci, branch or 'all'), + fzf_opts = { + ['--ansi'] = '', + ['--no-multi'] = '', + ['--with-nth'] = '2..', + ['--delimiter'] = '\t', + ['--header'] = ci_header, + }, + actions = ci_actions, + }) + end + + if f.list_runs_json_cmd then + forge_mod.log_now('fetching CI runs...') + vim.system(f:list_runs_json_cmd(branch), { text = true }, function(result) + vim.schedule(function() + local ok, runs = pcall(vim.json.decode, result.stdout or '[]') + if ok and runs and #runs > 0 then + open_picker(runs) + else + vim.notify('[forge]: no CI runs found', vim.log.levels.INFO) + vim.cmd.redraw() + end + end) + end) + elseif f.list_runs_cmd then + require('fzf-lua').fzf_exec(f:list_runs_cmd(branch), { + fzf_args = fzf_args, + prompt = f.labels.ci .. '> ', + fzf_opts = { ['--ansi'] = '' }, + }) + end +end + +---@param f forge.Forge +function M.commits(f) + local forge_mod = require('forge') + local review = require('forge.review') + local log_cmd = + 'git log --color --pretty=format:"%C(yellow)%h%Creset %Cgreen(%><(12)%cr%><|(12))%Creset %s %C(blue)<%an>%Creset"' + + local function with_sha(selected, fn) + local sha = selected[1] and selected[1]:match('%S+') + if sha then + fn(sha) + end + end + + local defs = { + { + name = 'checkout', + label = 'checkout', + fn = function(selected) + with_sha(selected, function(sha) + 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]: ' .. cmd_error(result, 'checkout failed'), + vim.log.levels.ERROR + ) + end + vim.cmd.redraw() + end) + end) + end) + end, + }, + { + name = 'diff', + label = 'diff', + fn = function(selected) + with_sha(selected, function(sha) + 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) + end) + end, + }, + { + name = 'browse', + label = 'browse', + fn = function(selected) + with_sha(selected, function(sha) + if f then + f:browse_commit(sha) + end + end) + end, + }, + { + name = 'yank', + label = 'yank hash', + fn = function(selected) + with_sha(selected, function(sha) + vim.fn.setreg('+', sha) + vim.notify('[forge]: copied ' .. sha) + end) + end, + }, + } + + local commit_actions, commit_header = build_actions('commits', defs) + + require('fzf-lua').fzf_exec(log_cmd, { + fzf_args = fzf_args, + prompt = 'Commits> ', + fzf_opts = { + ['--ansi'] = '', + ['--no-multi'] = '', + ['--preview'] = 'git show --color {1}', + ['--header'] = commit_header, + }, + actions = commit_actions, + }) +end + +---@param f forge.Forge? +function M.branches(f) + local forge_mod = require('forge') + local review = require('forge.review') + + local defs = { + { + name = 'diff', + fn = function(selected) + if not selected[1] then + return + end + local br = selected[1]:match('%s-[%+%*]?%s+([^ ]+)') + if not br then + return + end + review.start(br) + local ok, commands = pcall(require, 'diffs.commands') + if ok then + commands.greview(br) + end + forge_mod.log_now('reviewing ' .. br) + end, + }, + { + name = 'browse', + fn = function(selected) + if not selected[1] then + return + end + local br = selected[1]:match('%s-[%+%*]?%s+([^ ]+)') + if br and f then + f:browse_branch(br) + end + end, + }, + } + + local branch_actions = build_actions('branches', defs) + require('fzf-lua').git_branches({ actions = branch_actions }) +end + +---@param state 'all'|'open'|'closed' +---@param f forge.Forge +function M.pr(state, f) + local cli_kind = f.kinds.pr + local next_state = ({ all = 'open', open = 'closed', closed = 'all' })[state] + local forge_mod = require('forge') + local cache_key = forge_mod.list_key('pr', state) + local pr_fields = f:pr_json_fields() + local show_state = state ~= 'open' + + local function open_pr_list(prs) + local lines = {} + for _, pr in ipairs(prs) do + table.insert(lines, forge_mod.format_pr(pr, pr_fields, show_state)) + end + local function with_pr_num(selected, fn) + local num = selected[1] and selected[1]:match('[#!](%d+)') + if num then + fn(num) + end + end + + local list_actions, list_header = build_actions('pr', { + { + name = 'checkout', + label = 'checkout', + fn = function(selected) + with_pr_num(selected, function(num) + pr_actions(f, num)._by_name.checkout() + end) + end, + }, + { + name = 'diff', + label = 'diff', + fn = function(selected) + with_pr_num(selected, function(num) + pr_actions(f, num)._by_name.diff() + end) + end, + }, + { + name = 'worktree', + label = 'worktree', + fn = function(selected) + with_pr_num(selected, function(num) + pr_actions(f, num)._by_name.worktree() + end) + end, + }, + { + name = 'checks', + label = 'checks', + fn = function(selected) + with_pr_num(selected, function(num) + pr_actions(f, num)._by_name.checks() + end) + end, + }, + { + name = 'browse', + label = 'browse', + fn = function(selected) + with_pr_num(selected, function(num) + f:view_web(cli_kind, num) + end) + end, + }, + { + name = 'manage', + label = 'manage', + fn = function(selected) + with_pr_num(selected, function(num) + pr_actions(f, num)._by_name.manage() + end) + end, + }, + { + name = 'create', + label = 'new', + fn = function() + forge_mod.create_pr() + end, + }, + { + name = 'toggle', + label = 'toggle', + fn = function() + M.pr(next_state, f) + end, + }, + { + name = 'refresh', + fn = function() + forge_mod.clear_list(cache_key) + M.pr(state, f) + end, + }, + }) + + require('fzf-lua').fzf_exec(lines, { + fzf_args = fzf_args, + prompt = ('%s (%s)> '):format(f.labels.pr, state), + fzf_opts = { + ['--ansi'] = '', + ['--no-multi'] = '', + ['--header'] = list_header, + }, + actions = list_actions, + }) + end + + local cached = forge_mod.get_list(cache_key) + if cached then + open_pr_list(cached) + else + forge_mod.log_now(('fetching %s list (%s)...'):format(f.labels.pr, state)) + vim.system(f:list_pr_json_cmd(state), { text = true }, function(result) + vim.schedule(function() + local ok, prs = pcall(vim.json.decode, result.stdout or '[]') + if ok and prs then + forge_mod.set_list(cache_key, prs) + open_pr_list(prs) + end + end) + end) + end +end + +---@param state 'all'|'open'|'closed' +---@param f forge.Forge +function M.issue(state, f) + local cli_kind = f.kinds.issue + local next_state = ({ all = 'open', open = 'closed', closed = 'all' })[state] + local forge_mod = require('forge') + local cache_key = forge_mod.list_key('issue', state) + local issue_fields = f:issue_json_fields() + local num_field = issue_fields.number + local issue_show_state = state == 'all' + + local function open_issue_list(issues) + table.sort(issues, function(a, b) + return (a[num_field] or 0) > (b[num_field] or 0) + end) + local state_field = issue_fields.state + local state_map = {} + local lines = {} + for _, issue in ipairs(issues) do + local n = tostring(issue[num_field] or '') + local s = (issue[state_field] or ''):lower() + state_map[n] = s == 'open' or s == 'opened' + table.insert(lines, forge_mod.format_issue(issue, issue_fields, issue_show_state)) + end + local function with_issue_num(selected, fn) + local num = selected[1] and selected[1]:match('[#!](%d+)') + if num then + fn(num) + end + end + + local issue_actions, issue_header = build_actions('issue', { + { + name = 'browse', + label = 'browse', + fn = function(selected) + with_issue_num(selected, function(num) + f:view_web(cli_kind, num) + end) + end, + }, + { + name = 'close_reopen', + label = 'close/reopen', + fn = function(selected) + with_issue_num(selected, function(num) + issue_toggle_state(f, num, state_map[num] ~= false) + end) + end, + }, + { + name = 'toggle', + label = 'toggle', + fn = function() + M.issue(next_state, f) + end, + }, + { + name = 'refresh', + fn = function() + forge_mod.clear_list(cache_key) + M.issue(state, f) + end, + }, + }) + + require('fzf-lua').fzf_exec(lines, { + fzf_args = fzf_args, + prompt = ('%s (%s)> '):format(f.labels.issue, state), + fzf_opts = { + ['--ansi'] = '', + ['--no-multi'] = '', + ['--header'] = issue_header, + }, + actions = issue_actions, + }) + end + + local cached = forge_mod.get_list(cache_key) + if cached then + open_issue_list(cached) + else + forge_mod.log_now('fetching issue list (' .. state .. ')...') + vim.system(f:list_issue_json_cmd(state), { text = true }, function(result) + vim.schedule(function() + local ok, issues = pcall(vim.json.decode, result.stdout or '[]') + if ok and issues then + forge_mod.set_list(cache_key, issues) + open_issue_list(issues) + end + end) + end) + end +end + +---@param f forge.Forge +---@param num string +function M.pr_manage(f, num) + pr_manage_picker(f, num) +end + +---@param f forge.Forge +---@param num string +function M.issue_close(f, num) + run_forge_cmd('issue', num, 'closing', f:close_issue_cmd(num), 'closed', 'close failed') +end + +---@param f forge.Forge +---@param num string +function M.issue_reopen(f, num) + run_forge_cmd('issue', num, 'reopening', f:reopen_issue_cmd(num), 'reopened', 'reopen failed') +end + +---@param f forge.Forge +---@param num string +---@return table +function M.pr_actions(f, num) + return pr_actions(f, num) +end + +function M.git() + 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 + end + + local forge_mod = require('forge') + local f = forge_mod.detect() + + local loc = forge_mod.file_loc() + local buf_name = vim.api.nvim_buf_get_name(0) + local has_file = buf_name ~= '' + and not buf_name:match('^fugitive://') + and not buf_name:match('^term://') + and not buf_name:match('^diffs://') + local branch = vim.trim(vim.fn.system('git branch --show-current')) + + local items = {} + local actions = {} + + local function add(label, action) + table.insert(items, label) + actions[label] = action + end + + if f then + local pr_label = f.labels.pr_full + local ci_label = f.labels.ci + + add(pr_label, function() + M.pr('open', f) + end) + + add('Issues', function() + M.issue('all', f) + end) + + add(ci_label, function() + M.ci(f, branch ~= '' and branch or nil) + end) + + add('Browse Remote', function() + f:browse_root() + end) + + if has_file then + add('Open File', function() + if branch == '' then + vim.notify('[forge]: detached HEAD', vim.log.levels.WARN) + return + end + f:browse(loc, branch) + end) + + add('Yank Commit URL', function() + f:yank_commit(loc) + end) + + add('Yank Branch URL', function() + f:yank_branch(loc) + end) + end + end + + add('Commits', function() + M.commits(f) + end) + + add('Branches', function() + M.branches(f) + end) + + add('Worktrees', function() + require('fzf-lua').git_worktrees() + end) + + local prompt = f and (f.name:sub(1, 1):upper() .. f.name:sub(2)) .. '> ' or 'Git> ' + + require('fzf-lua').fzf_exec(items, { + fzf_args = fzf_args, + prompt = prompt, + actions = { + ['default'] = function(selected) + if selected[1] and actions[selected[1]] then + actions[selected[1]]() + end + end, + }, + }) +end + +return M diff --git a/lua/forge/review.lua b/lua/forge/review.lua new file mode 100644 index 0000000..5c6ef8a --- /dev/null +++ b/lua/forge/review.lua @@ -0,0 +1,101 @@ +local M = {} + +---@type { base: string?, mode: 'unified'|'split' } +M.state = { base = nil, mode = 'unified' } + +local review_augroup = vim.api.nvim_create_augroup('ForgeReview', { clear = true }) + +local function close_view() + for _, win in ipairs(vim.api.nvim_list_wins()) do + local buf = vim.api.nvim_win_get_buf(win) + local name = vim.api.nvim_buf_get_name(buf) + if name:match('^fugitive://') or name:match('^diffs://review:') then + pcall(vim.api.nvim_win_close, win, true) + end + end + pcall(vim.cmd, 'diffoff!') +end + +function M.stop() + M.state.base = nil + M.state.mode = 'unified' + local cfg = require('forge').config() + local lhs = cfg.keys ~= false and cfg.keys.review_toggle + if lhs then + pcall(vim.keymap.del, 'n', lhs) + end + vim.api.nvim_clear_autocmds({ group = review_augroup }) +end + +function M.toggle() + if not M.state.base then + return + end + if M.state.mode == 'unified' then + local ok, commands = pcall(require, 'diffs.commands') + if not ok then + return + end + local file = commands.review_file_at_line(vim.api.nvim_get_current_buf(), vim.fn.line('.')) + M.state.mode = 'split' + if file then + vim.cmd('edit ' .. vim.fn.fnameescape(file)) + pcall(vim.cmd, 'Gvdiffsplit ' .. M.state.base) + end + else + local current_file = vim.fn.expand('%:.') + close_view() + M.state.mode = 'unified' + local ok, commands = pcall(require, 'diffs.commands') + if ok then + commands.greview(M.state.base) + end + if current_file ~= '' then + vim.fn.search('diff %-%-git a/' .. vim.pesc(current_file), 'cw') + end + end +end + +---@param base string +---@param mode string? +function M.start(base, mode) + M.state.base = base + M.state.mode = mode or 'unified' + local cfg = require('forge').config() + local lhs = cfg.keys ~= false and cfg.keys.review_toggle + if lhs then + vim.keymap.set('n', lhs, M.toggle, { desc = 'toggle review split/unified' }) + end + vim.api.nvim_clear_autocmds({ group = review_augroup }) + vim.api.nvim_create_autocmd('BufWipeout', { + group = review_augroup, + pattern = 'diffs://review:*', + callback = M.stop, + }) +end + +---@param nav_cmd string +---@return function +function M.nav(nav_cmd) + return function() + if M.state.base and M.state.mode == 'split' then + close_view() + end + local wrap = { + cnext = 'cfirst', + cprev = 'clast', + lnext = 'lfirst', + lprev = 'llast', + } + if not pcall(vim.cmd, nav_cmd) then + if not pcall(vim.cmd, wrap[nav_cmd]) then + return + end + end + if M.state.base and M.state.mode == 'split' then + pcall(vim.cmd, 'Gvdiffsplit ' .. M.state.base) + end + end +end + +return M diff --git a/plugin/forge.lua b/plugin/forge.lua index 41f6caf..7986c77 100644 --- a/plugin/forge.lua +++ b/plugin/forge.lua @@ -1,920 +1,66 @@ ----@param result vim.SystemCompleted ----@param fallback string ----@return string -local function cmd_error(result, fallback) - local msg = result.stderr or '' - if vim.trim(msg) == '' then - msg = result.stdout or '' - end - msg = vim.trim(msg) - if msg == '' then - msg = fallback - end - return msg -end +local cfg = require('forge').config() -local fzf_args = (vim.env.FZF_DEFAULT_OPTS or '') - :gsub('%-%-bind=[^%s]+', '') - :gsub('%-%-color=[^%s]+', '') +if cfg.keys ~= false then + local k = cfg.keys -local function make_header(bindings) - local utils = require('fzf-lua.utils') - local parts = {} - for _, b in ipairs(bindings) do - local key = utils.ansi_from_hl('FzfLuaHeaderBind', '<' .. b[1] .. '>') - local desc = utils.ansi_from_hl('FzfLuaHeaderText', b[2]) - table.insert(parts, key .. ' to ' .. desc) - end - return ':: ' .. table.concat(parts, '|') -end - ----@param f forge.Forge ----@param num string ----@param filter string? ----@param cached_checks table[]? -local function checks_picker(f, num, filter, cached_checks) - filter = filter or 'all' - local forge_mod = require('forge') - - local function open_picker(checks) - local filtered = forge_mod.filter_checks(checks, filter) - local lines = {} - for i, c in ipairs(filtered) do - local line = ('%d\t%s'):format(i, forge_mod.format_check(c)) - table.insert(lines, line) - end - - local function get_check(selected) - if not selected[1] then - return nil - end - local idx = tonumber(selected[1]:match('^(%d+)')) - return idx and filtered[idx] or nil - end - - local labels = { - all = 'all', - fail = 'failed', - pass = 'passed', - pending = 'running', - } - - require('fzf-lua').fzf_exec(lines, { - fzf_args = fzf_args, - prompt = ('Checks (#%s, %s)> '):format(num, labels[filter] or filter), - fzf_opts = { - ['--ansi'] = '', - ['--no-multi'] = '', - ['--with-nth'] = '2..', - ['--delimiter'] = '\t', - ['--header'] = make_header({ - { 'enter', 'log' }, - { 'ctrl-x', 'browse' }, - { 'ctrl-f', 'failed' }, - { 'ctrl-p', 'passed' }, - { 'ctrl-n', 'running' }, - { 'ctrl-a', 'all' }, - }), - }, - actions = { - ['default'] = function(selected) - local c = get_check(selected) - if not c then - return - end - local run_id = (c.link or ''):match('/actions/runs/(%d+)') - if not run_id then - return - end - forge_mod.log_now('fetching check logs...') - local bucket = (c.bucket or ''):lower() - local cmd - if bucket == 'pending' then - cmd = f:check_tail_cmd(run_id) - else - cmd = f:check_log_cmd(run_id, bucket == 'fail') - end - vim.cmd('noautocmd botright new') - vim.fn.termopen(cmd) - vim.api.nvim_feedkeys( - vim.api.nvim_replace_termcodes('G', true, false, true), - 'n', - false - ) - if c.link then - vim.b.forge_check_url = c.link - vim.keymap.set('n', 'gx', function() - vim.ui.open(vim.b.forge_check_url) - end, { - buffer = true, - desc = 'open check in browser', - }) - end - end, - ['ctrl-x'] = function(selected) - local c = get_check(selected) - if c and c.link then - vim.ui.open(c.link) - end - end, - ['ctrl-f'] = function() - checks_picker(f, num, 'fail', checks) - end, - ['ctrl-p'] = function() - checks_picker(f, num, 'pass', checks) - end, - ['ctrl-n'] = function() - checks_picker(f, num, 'pending', checks) - end, - ['ctrl-a'] = function() - checks_picker(f, num, 'all', checks) - end, - ['ctrl-r'] = function() - checks_picker(f, num, filter) - end, - }, - }) + if k.picker then + vim.keymap.set({ 'n', 'v' }, k.picker, function() + require('forge.pickers').git() + end, { desc = 'forge git picker' }) end - if cached_checks then - forge_mod.log(('checks picker (%s #%s, cached)'):format(f.labels.pr_one, num)) - open_picker(cached_checks) - return + if k.next_qf then + vim.keymap.set('n', k.next_qf, require('forge.review').nav('cnext'), { desc = 'next quickfix entry' }) end - if f.checks_json_cmd then - forge_mod.log_now(('fetching checks for %s #%s...'):format(f.labels.pr_one, num)) - vim.system(f:checks_json_cmd(num), { text = true }, function(result) - vim.schedule(function() - local ok, checks = pcall(vim.json.decode, result.stdout or '[]') - if ok and checks then - open_picker(checks) - else - vim.notify('[forge]: no checks found', vim.log.levels.INFO) - vim.cmd.redraw() + if k.prev_qf then + vim.keymap.set('n', k.prev_qf, require('forge.review').nav('cprev'), { desc = 'prev quickfix entry' }) + end + + if k.next_loc then + vim.keymap.set('n', k.next_loc, require('forge.review').nav('lnext'), { desc = 'next loclist entry' }) + end + + if k.prev_loc then + vim.keymap.set('n', k.prev_loc, require('forge.review').nav('lprev'), { desc = 'prev loclist entry' }) + end + + if k.fugitive ~= false then + vim.api.nvim_create_autocmd('FileType', { + pattern = 'fugitive', + callback = function(args) + local forge_mod = require('forge') + local f = forge_mod.detect() + if not f then + return end - end) - end) - else - require('fzf-lua').fzf_exec(f:checks_cmd(num), { - fzf_args = fzf_args, - prompt = ('Checks (#%s)> '):format(num), - fzf_opts = { ['--ansi'] = '' }, - actions = { - ['ctrl-r'] = function() - checks_picker(f, num, filter) - end, - }, - }) - end -end - ----@param kind string ----@param num string ----@param label string ----@param cmd string[] ----@param success_msg string ----@param fail_msg string -local function run_forge_cmd(kind, num, label, cmd, success_msg, fail_msg) - require('forge').log_now(label .. ' ' .. kind .. ' #' .. num .. '...') - vim.system(cmd, { text = true }, function(result) - vim.schedule(function() - if result.code == 0 then - vim.notify(('[forge]: %s %s #%s'):format(success_msg, kind, num)) - else - vim.notify('[forge]: ' .. cmd_error(result, fail_msg), vim.log.levels.ERROR) - end - vim.cmd.redraw() - end) - end) -end - ----@param f forge.Forge ----@param num string ----@param is_open boolean -local function issue_toggle_state(f, num, is_open) - if is_open then - run_forge_cmd('issue', num, 'closing', f:close_issue_cmd(num), 'closed', 'close failed') - else - run_forge_cmd('issue', num, 'reopening', f:reopen_issue_cmd(num), 'reopened', 'reopen failed') - end -end - ----@param f forge.Forge ----@param num string -local function pr_manage_picker(f, num) - local forge_mod = require('forge') - local kind = f.labels.pr_one - forge_mod.log_now('loading actions for ' .. kind .. ' #' .. num .. '...') - - local info = forge_mod.repo_info(f) - local can_write = info.permission == 'ADMIN' - or info.permission == 'MAINTAIN' - or info.permission == 'WRITE' - local pr_state = f:pr_state(num) - local is_open = pr_state.state == 'OPEN' or pr_state.state == 'OPENED' - - local entries = {} - local action_map = {} - - local function add(label, fn) - table.insert(entries, label) - action_map[label] = fn - end - - if can_write and is_open then - add('Approve', function() - run_forge_cmd(kind, num, 'approving', f:approve_cmd(num), 'approved', 'approve failed') - end) - end - - if can_write and is_open then - for _, method in ipairs(info.merge_methods) do - add('Merge (' .. method .. ')', function() - run_forge_cmd( - kind, - num, - 'merging (' .. method .. ')', - f:merge_cmd(num, method), - 'merged (' .. method .. ')', - 'merge failed' - ) - end) - end - end - - if is_open then - add('Close', function() - run_forge_cmd(kind, num, 'closing', f:close_cmd(num), 'closed', 'close failed') - end) - else - add('Reopen', function() - run_forge_cmd(kind, num, 'reopening', f:reopen_cmd(num), 'reopened', 'reopen failed') - end) - end - - local draft_cmd = f:draft_toggle_cmd(num, pr_state.is_draft) - if draft_cmd then - local draft_label = pr_state.is_draft and 'Mark as ready' or 'Mark as draft' - local draft_done = pr_state.is_draft and 'marked as ready' or 'marked as draft' - add(draft_label, function() - run_forge_cmd(kind, num, 'toggling draft', draft_cmd, draft_done, 'draft toggle failed') - end) - end - - require('fzf-lua').fzf_exec(entries, { - fzf_args = fzf_args, - prompt = ('%s #%s Actions> '):format(kind, num), - fzf_opts = { ['--no-multi'] = '' }, - actions = { - ['default'] = function(selected) - if selected[1] and action_map[selected[1]] then - action_map[selected[1]]() + local fk = k.fugitive + local buf = args.buf + if fk.create then + vim.keymap.set('n', fk.create, function() + forge_mod.create_pr({ draft = false }) + end, { buffer = buf, desc = 'create PR' }) + end + if fk.create_draft then + vim.keymap.set('n', fk.create_draft, function() + forge_mod.create_pr({ draft = true }) + end, { buffer = buf, desc = 'create draft PR' }) + end + if fk.create_fill then + vim.keymap.set('n', fk.create_fill, function() + forge_mod.create_pr({ instant = true }) + end, { buffer = buf, desc = 'create PR (fill)' }) + end + if fk.create_web then + vim.keymap.set('n', fk.create_web, function() + forge_mod.create_pr({ web = true }) + end, { buffer = buf, desc = 'create PR (web)' }) end end, - }, - }) -end - -local function close_review_view() - for _, win in ipairs(vim.api.nvim_list_wins()) do - local buf = vim.api.nvim_win_get_buf(win) - local name = vim.api.nvim_buf_get_name(buf) - if name:match('^fugitive://') or name:match('^diffs://review:') then - pcall(vim.api.nvim_win_close, win, true) - end - end - pcall(vim.cmd, 'diffoff!') -end - -local review_augroup = vim.api.nvim_create_augroup('ForgeReview', { clear = true }) - -local function end_review() - local review = require('forge').review - review.base = nil - review.mode = 'unified' - pcall(vim.keymap.del, 'n', 's') - vim.api.nvim_clear_autocmds({ group = review_augroup }) -end - -local function toggle_review_mode() - local review = require('forge').review - if not review.base then - return - end - if review.mode == 'unified' then - local ok, commands = pcall(require, 'diffs.commands') - if not ok then - return - end - local file = commands.review_file_at_line(vim.api.nvim_get_current_buf(), vim.fn.line('.')) - review.mode = 'split' - if file then - vim.cmd('edit ' .. vim.fn.fnameescape(file)) - pcall(vim.cmd, 'Gvdiffsplit ' .. review.base) - end - else - local current_file = vim.fn.expand('%:.') - close_review_view() - review.mode = 'unified' - local ok, commands = pcall(require, 'diffs.commands') - if ok then - commands.greview(review.base) - end - if current_file ~= '' then - vim.fn.search('diff %-%-git a/' .. vim.pesc(current_file), 'cw') - end - end -end - -local function start_review(base, mode) - local review = require('forge').review - review.base = base - review.mode = mode or 'unified' - vim.keymap.set('n', 's', toggle_review_mode, { desc = 'toggle review split/unified' }) - vim.api.nvim_clear_autocmds({ group = review_augroup }) - vim.api.nvim_create_autocmd('BufWipeout', { - group = review_augroup, - pattern = 'diffs://review:*', - callback = end_review, - }) -end - ----@param f forge.Forge ----@param num string ----@return table -local function pr_actions(f, num) - local kind = f.labels.pr_one - local actions = {} - - actions['default'] = function() - local forge_mod = require('forge') - forge_mod.log_now(('checking out %s #%s...'):format(kind, num)) - vim.system(f:checkout_cmd(num), { text = true }, function(result) - vim.schedule(function() - if result.code == 0 then - vim.notify(('[forge]: checked out %s #%s'):format(kind, num)) - else - vim.notify('[forge]: ' .. cmd_error(result, 'checkout failed'), vim.log.levels.ERROR) - end - vim.cmd.redraw() - end) - end) - end - - actions['ctrl-x'] = function() - f:view_web(f.kinds.pr, num) - end - - actions['ctrl-w'] = function() - local forge_mod = require('forge') - local fetch_cmd = f:fetch_pr(num) - local branch = fetch_cmd[#fetch_cmd]:match(':(.+)$') - if not branch then - return - end - local root = vim.trim(vim.fn.system('git rev-parse --show-toplevel')) - local wt_path = vim.fs.normalize(root .. '/../' .. branch) - forge_mod.log_now(('fetching %s #%s into worktree...'):format(kind, num)) - vim.system(fetch_cmd, { text = true }, function() - vim.system({ 'git', 'worktree', 'add', wt_path, branch }, { text = true }, function(result) - vim.schedule(function() - if result.code == 0 then - vim.notify(('[forge]: worktree at %s'):format(wt_path)) - else - vim.notify('[forge]: ' .. cmd_error(result, 'worktree failed'), vim.log.levels.ERROR) - end - vim.cmd.redraw() - end) - end) - end) - end - - actions['ctrl-d'] = function() - local forge_mod = require('forge') - local repo_root = vim.trim(vim.fn.system('git rev-parse --show-toplevel')) - - forge_mod.log_now(('reviewing %s #%s...'):format(kind, num)) - vim.system(f:checkout_cmd(num), { text = true }, function(co_result) - if co_result.code ~= 0 then - vim.schedule(function() - forge_mod.log('checkout skipped, proceeding with diff') - end) - end - - vim.system(f:pr_base_cmd(num), { text = true }, function(base_result) - vim.schedule(function() - local base = vim.trim(base_result.stdout or '') - if base == '' or base_result.code ~= 0 then - base = 'main' - end - local range = 'origin/' .. base - start_review(range) - local ok, commands = pcall(require, 'diffs.commands') - if ok then - commands.greview(range, { repo_root = repo_root }) - end - forge_mod.log(('review ready for %s #%s against %s'):format(kind, num, base)) - end) - end) - end) - end - - actions['ctrl-t'] = function() - checks_picker(f, num) - end - - actions['ctrl-a'] = function() - pr_manage_picker(f, num) - end - - return actions -end - ----@param kind 'issue'|'pr' ----@param state 'all'|'open'|'closed' ----@param f forge.Forge -local function forge_picker(kind, state, f) - local cli_kind = f.kinds[kind] - local next_state = ({ all = 'open', open = 'closed', closed = 'all' })[state] - - local forge_mod = require('forge') - local cache_key = forge_mod.list_key(kind, state) - - if kind == 'pr' then - local pr_fields = f:pr_json_fields() - - local show_state = state ~= 'open' - - local function open_pr_list(prs) - local lines = {} - for _, pr in ipairs(prs) do - table.insert(lines, forge_mod.format_pr(pr, pr_fields, show_state)) - end - local function with_pr_num(selected, fn) - local num = selected[1] and selected[1]:match('[#!](%d+)') - if num then - fn(num) - end - end - - require('fzf-lua').fzf_exec(lines, { - fzf_args = fzf_args, - prompt = ('%s (%s)> '):format(f.labels[kind], state), - fzf_opts = { - ['--ansi'] = '', - ['--no-multi'] = '', - ['--header'] = make_header({ - { 'enter', 'checkout' }, - { 'ctrl-d', 'diff' }, - { 'ctrl-w', 'worktree' }, - { 'ctrl-t', 'checks' }, - { 'ctrl-x', 'browse' }, - { 'ctrl-e', 'manage' }, - { 'ctrl-a', 'new' }, - { 'ctrl-o', 'toggle' }, - }), - }, - actions = { - ['default'] = function(selected) - with_pr_num(selected, function(num) - pr_actions(f, num)['default']() - end) - end, - ['ctrl-x'] = function(selected) - with_pr_num(selected, function(num) - f:view_web(cli_kind, num) - end) - end, - ['ctrl-d'] = function(selected) - with_pr_num(selected, function(num) - pr_actions(f, num)['ctrl-d']() - end) - end, - ['ctrl-w'] = function(selected) - with_pr_num(selected, function(num) - pr_actions(f, num)['ctrl-w']() - end) - end, - ['ctrl-t'] = function(selected) - with_pr_num(selected, function(num) - pr_actions(f, num)['ctrl-t']() - end) - end, - ['ctrl-e'] = function(selected) - with_pr_num(selected, function(num) - pr_actions(f, num)['ctrl-a']() - end) - end, - ['ctrl-a'] = function() - forge_mod.create_pr() - end, - ['ctrl-o'] = function() - forge_picker(kind, next_state, f) - end, - ['ctrl-r'] = function() - forge_mod.clear_list(cache_key) - forge_picker(kind, state, f) - end, - }, - }) - end - - local cached = forge_mod.get_list(cache_key) - if cached then - open_pr_list(cached) - else - forge_mod.log_now(('fetching %s list (%s)...'):format(f.labels.pr, state)) - vim.system(f:list_pr_json_cmd(state), { text = true }, function(result) - vim.schedule(function() - local ok, prs = pcall(vim.json.decode, result.stdout or '[]') - if ok and prs then - forge_mod.set_list(cache_key, prs) - open_pr_list(prs) - end - end) - end) - end - else - local issue_fields = f:issue_json_fields() - local num_field = issue_fields.number - local issue_show_state = state == 'all' - - local function open_issue_list(issues) - table.sort(issues, function(a, b) - return (a[num_field] or 0) > (b[num_field] or 0) - end) - local state_field = issue_fields.state - local state_map = {} - local lines = {} - for _, issue in ipairs(issues) do - local n = tostring(issue[num_field] or '') - local s = (issue[state_field] or ''):lower() - state_map[n] = s == 'open' or s == 'opened' - table.insert(lines, forge_mod.format_issue(issue, issue_fields, issue_show_state)) - end - local function with_issue_num(selected, fn) - local num = selected[1] and selected[1]:match('[#!](%d+)') - if num then - fn(num) - end - end - - require('fzf-lua').fzf_exec(lines, { - fzf_args = fzf_args, - prompt = ('%s (%s)> '):format(f.labels[kind], state), - fzf_opts = { - ['--ansi'] = '', - ['--no-multi'] = '', - ['--header'] = make_header({ - { 'enter', 'browse' }, - { 'ctrl-s', 'close/reopen' }, - { 'ctrl-o', 'toggle' }, - }), - }, - actions = { - ['default'] = function(selected) - with_issue_num(selected, function(num) - f:view_web(cli_kind, num) - end) - end, - ['ctrl-x'] = function(selected) - with_issue_num(selected, function(num) - f:view_web(cli_kind, num) - end) - end, - ['ctrl-s'] = function(selected) - with_issue_num(selected, function(num) - issue_toggle_state(f, num, state_map[num] ~= false) - end) - end, - ['ctrl-o'] = function() - forge_picker(kind, next_state, f) - end, - ['ctrl-r'] = function() - forge_mod.clear_list(cache_key) - forge_picker(kind, state, f) - end, - }, - }) - end - - local cached = forge_mod.get_list(cache_key) - if cached then - open_issue_list(cached) - else - forge_mod.log_now('fetching issue list (' .. state .. ')...') - vim.system(f:list_issue_json_cmd(state), { text = true }, function(result) - vim.schedule(function() - local ok, issues = pcall(vim.json.decode, result.stdout or '[]') - if ok and issues then - forge_mod.set_list(cache_key, issues) - open_issue_list(issues) - end - end) - end) - end - end -end - -local function ci_picker(f, branch) - local forge_mod = require('forge') - - local function open_picker(runs) - local normalized = {} - for _, entry in ipairs(runs) do - table.insert(normalized, f:normalize_run(entry)) - end - - local lines = {} - for i, run in ipairs(normalized) do - table.insert(lines, ('%d\t%s'):format(i, forge_mod.format_run(run))) - end - - local function get_run(selected) - if not selected[1] then - return nil - end - local idx = tonumber(selected[1]:match('^(%d+)')) - return idx and normalized[idx] or nil - end - - require('fzf-lua').fzf_exec(lines, { - fzf_args = fzf_args, - prompt = ('%s (%s)> '):format(f.labels.ci, branch or 'all'), - fzf_opts = { - ['--ansi'] = '', - ['--no-multi'] = '', - ['--with-nth'] = '2..', - ['--delimiter'] = '\t', - ['--header'] = make_header({ - { 'enter', 'log' }, - { 'ctrl-x', 'browse' }, - { 'ctrl-r', 'refresh' }, - }), - }, - actions = { - ['default'] = function(selected) - local run = get_run(selected) - if not run then - return - end - forge_mod.log_now('fetching CI/CD logs...') - local s = run.status:lower() - local cmd - if s == 'in_progress' or s == 'running' or s == 'pending' or s == 'queued' then - cmd = f:run_tail_cmd(run.id) - elseif s == 'failure' or s == 'failed' then - cmd = f:run_log_cmd(run.id, true) - else - cmd = f:run_log_cmd(run.id, false) - end - vim.cmd('noautocmd botright new') - vim.fn.termopen(cmd) - vim.api.nvim_feedkeys( - vim.api.nvim_replace_termcodes('G', true, false, true), - 'n', - false - ) - if run.url ~= '' then - vim.b.forge_run_url = run.url - vim.keymap.set('n', 'gx', function() - vim.ui.open(vim.b.forge_run_url) - end, { - buffer = true, - desc = 'open run in browser', - }) - end - end, - ['ctrl-x'] = function(selected) - local run = get_run(selected) - if run and run.url ~= '' then - vim.ui.open(run.url) - end - end, - ['ctrl-r'] = function() - ci_picker(f, branch) - end, - }, - }) - end - - if f.list_runs_json_cmd then - forge_mod.log_now('fetching CI runs...') - vim.system(f:list_runs_json_cmd(branch), { text = true }, function(result) - vim.schedule(function() - local ok, runs = pcall(vim.json.decode, result.stdout or '[]') - if ok and runs and #runs > 0 then - open_picker(runs) - else - vim.notify('[forge]: no CI runs found', vim.log.levels.INFO) - vim.cmd.redraw() - end - end) - end) - elseif f.list_runs_cmd then - require('fzf-lua').fzf_exec(f:list_runs_cmd(branch), { - fzf_args = fzf_args, - prompt = f.labels.ci .. '> ', - fzf_opts = { ['--ansi'] = '' }, }) end end -local git_picker = function() - 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 - end - - local forge_mod = require('forge') - local f = forge_mod.detect() - - local loc = forge_mod.file_loc() - local buf_name = vim.api.nvim_buf_get_name(0) - local has_file = buf_name ~= '' - and not buf_name:match('^fugitive://') - and not buf_name:match('^term://') - and not buf_name:match('^diffs://') - local branch = vim.trim(vim.fn.system('git branch --show-current')) - - local items = {} - local actions = {} - - local function add(label, action) - table.insert(items, label) - actions[label] = action - end - - if f then - local pr_label = f.labels.pr_full - local ci_label = f.labels.ci - - add(pr_label, function() - forge_picker('pr', 'open', f) - end) - - add('Issues', function() - forge_picker('issue', 'all', f) - end) - - add(ci_label, function() - ci_picker(f, branch ~= '' and branch or nil) - end) - - add('Browse Remote', function() - f:browse_root() - end) - - if has_file then - add('Open File', function() - if branch == '' then - vim.notify('[forge]: detached HEAD', vim.log.levels.WARN) - return - end - f:browse(loc, branch) - end) - - add('Yank Commit URL', function() - f:yank_commit(loc) - end) - - add('Yank Branch URL', function() - f:yank_branch(loc) - end) - end - end - - add('Commits', function() - local log_cmd = - 'git log --color --pretty=format:"%C(yellow)%h%Creset %Cgreen(%><(12)%cr%><|(12))%Creset %s %C(blue)<%an>%Creset"' - - local hints = { - { 'enter', 'checkout' }, - { 'ctrl-d', 'diff' }, - { 'ctrl-y', 'yank hash' }, - } - if f then - table.insert(hints, 3, { 'ctrl-x', 'browse' }) - end - - local function with_sha(selected, fn) - local sha = selected[1] and selected[1]:match('%S+') - if sha then - fn(sha) - end - end - - require('fzf-lua').fzf_exec(log_cmd, { - fzf_args = fzf_args, - prompt = 'Commits> ', - fzf_opts = { - ['--ansi'] = '', - ['--no-multi'] = '', - ['--preview'] = 'git show --color {1}', - ['--header'] = make_header(hints), - }, - actions = { - ['default'] = function(selected) - with_sha(selected, function(sha) - 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]: ' .. cmd_error(result, 'checkout failed'), - vim.log.levels.ERROR - ) - end - vim.cmd.redraw() - end) - end) - end) - end, - ['ctrl-d'] = function(selected) - with_sha(selected, function(sha) - local range = sha .. '^..' .. sha - start_review(range) - local ok, commands = pcall(require, 'diffs.commands') - if ok then - commands.greview(range) - end - forge_mod.log_now('reviewing ' .. sha) - end) - end, - ['ctrl-x'] = function(selected) - with_sha(selected, function(sha) - if f then - f:browse_commit(sha) - end - end) - end, - ['ctrl-y'] = function(selected) - with_sha(selected, function(sha) - vim.fn.setreg('+', sha) - vim.notify('[forge]: copied ' .. sha) - end) - end, - }, - }) - end) - - add('Branches', function() - local branch_actions = { - ['ctrl-d'] = function(selected) - if not selected[1] then - return - end - local br = selected[1]:match('%s-[%+%*]?%s+([^ ]+)') - if not br then - return - end - start_review(br) - local ok, commands = pcall(require, 'diffs.commands') - if ok then - commands.greview(br) - end - forge_mod.log_now('reviewing ' .. br) - end, - } - if f then - branch_actions['ctrl-x'] = function(selected) - if not selected[1] then - return - end - local br = selected[1]:match('%s-[%+%*]?%s+([^ ]+)') - if br then - f:browse_branch(br) - end - end - end - require('fzf-lua').git_branches({ actions = branch_actions }) - end) - - add('Worktrees', function() - require('fzf-lua').git_worktrees() - end) - - local prompt = f and (f.name:sub(1, 1):upper() .. f.name:sub(2)) .. '> ' or 'Git> ' - - require('fzf-lua').fzf_exec(items, { - fzf_args = fzf_args, - prompt = prompt, - actions = { - ['default'] = function(selected) - if selected[1] and actions[selected[1]] then - actions[selected[1]]() - end - end, - }, - }) -end - -vim.keymap.set({ 'n', 'v' }, '', git_picker, { desc = 'forge git picker' }) - vim.api.nvim_create_autocmd('FileType', { pattern = 'qf', callback = function() @@ -936,54 +82,387 @@ vim.api.nvim_create_autocmd('FileType', { end, }) -local function review_nav(nav_cmd) - return function() - local review = require('forge').review - if review.base and review.mode == 'split' then - close_review_view() - end - local wrap = { - cnext = 'cfirst', - cprev = 'clast', - lnext = 'lfirst', - lprev = 'llast', - } - if not pcall(vim.cmd, nav_cmd) then - if not pcall(vim.cmd, wrap[nav_cmd]) then - return - end - end - if review.base and review.mode == 'split' then - pcall(vim.cmd, 'Gvdiffsplit ' .. review.base) - 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 -vim.keymap.set('n', ']q', review_nav('cnext'), { desc = 'next quickfix entry' }) -vim.keymap.set('n', '[q', review_nav('cprev'), { desc = 'prev quickfix entry' }) -vim.keymap.set('n', ']l', review_nav('lnext'), { desc = 'next loclist entry' }) -vim.keymap.set('n', '[l', review_nav('lprev'), { desc = 'prev loclist entry' }) +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 -vim.api.nvim_create_autocmd('FileType', { - pattern = 'fugitive', - callback = function(args) - local forge_mod = require('forge') - local f = forge_mod.detect() +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 buf = args.buf - vim.keymap.set('n', 'cpr', function() - forge_mod.create_pr({ draft = false }) - end, { buffer = buf, desc = 'create PR' }) - vim.keymap.set('n', 'cpd', function() - forge_mod.create_pr({ draft = true }) - end, { buffer = buf, desc = 'create draft PR' }) - vim.keymap.set('n', 'cpf', function() - forge_mod.create_pr({ instant = true }) - end, { buffer = buf, desc = 'create PR (fill)' }) - vim.keymap.set('n', 'cpw', function() - forge_mod.create_pr({ web = true }) - end, { buffer = buf, desc = 'create PR (web)' }) - 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 == 'checks' then + pickers.checks(f, num) + 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 == 'cache' then + if #args < 2 then + vim.notify('[forge]: missing cache action (clear)', vim.log.levels.WARN) + return + end + if args[2] == 'clear' then + require('forge').clear_cache() + vim.notify('[forge]: cache cleared') + else + vim.notify('[forge]: unknown cache action: ' .. args[2], vim.log.levels.WARN) + end + return + end + + vim.notify('[forge]: unknown command: ' .. sub, vim.log.levels.WARN) +end + +local function complete(arglead, cmdline, _) + local parts = vim.split(vim.trim(cmdline), '%s+') + local subcmds = { 'pr', 'issue', 'ci', 'commit', 'branch', 'worktree', 'browse', 'yank', 'review', 'cache' } + local sub_actions = { + pr = { 'checkout', 'diff', 'worktree', 'checks', 'browse', 'manage', 'create', '--state=open', '--state=closed', '--state=all' }, + issue = { 'browse', 'close', 'reopen', '--state=open', '--state=closed', '--state=all' }, + ci = { '--all' }, + commit = { 'checkout', 'diff', 'browse' }, + branch = { 'diff', 'browse' }, + review = { 'end', 'toggle' }, + cache = { 'clear' }, + browse = { '--root', '--commit' }, + yank = { '--commit' }, + } + local create_flags = { '--draft', '--fill', '--web' } + + if #parts <= 2 then + return vim.tbl_filter(function(s) return s:find(arglead, 1, true) == 1 end, subcmds) + end + local sub = parts[2] + if #parts == 3 or (#parts == 4 and sub == 'pr' and parts[3] == 'create') then + local candidates = sub_actions[sub] or {} + if sub == 'pr' and #parts >= 3 and parts[3] == 'create' then + candidates = create_flags + end + return vim.tbl_filter(function(s) return s:find(arglead, 1, true) == 1 end, candidates) + end + if sub == 'pr' and parts[3] == 'create' then + return vim.tbl_filter(function(s) return s:find(arglead, 1, true) == 1 end, 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', })