local M = {} ---@class forge.Config ---@field ci forge.CIConfig ---@field sources table ---@field keys forge.KeysConfig|false ---@field display forge.DisplayConfig ---@class forge.CIConfig ---@field lines integer ---@class forge.SourceConfig ---@field hosts string[] ---@class forge.KeysConfig ---@field pr forge.PRPickerKeys? ---@field issue forge.IssuePickerKeys? ---@field ci forge.CIPickerKeys? ---@field commits forge.CommitsPickerKeys? ---@field branches forge.BranchesPickerKeys? ---@class forge.PRPickerKeys ---@field checkout string|false ---@field diff string|false ---@field worktree string|false ---@field ci string|false ---@field browse string|false ---@field manage string|false ---@field create string|false ---@field filter string|false ---@field refresh string|false ---@class forge.IssuePickerKeys ---@field browse string|false ---@field close string|false ---@field filter string|false ---@field refresh string|false ---@class forge.CIPickerKeys ---@field log string|false ---@field browse string|false ---@field failed string|false ---@field passed string|false ---@field running string|false ---@field all string|false ---@field refresh string|false ---@class forge.CommitsPickerKeys ---@field checkout string|false ---@field diff string|false ---@field browse string|false ---@field yank string|false ---@class forge.BranchesPickerKeys ---@field diff string|false ---@field browse string|false ---@class forge.DisplayConfig ---@field icons forge.IconsConfig ---@field widths forge.WidthsConfig ---@field limits forge.LimitsConfig ---@class forge.IconsConfig ---@field open string ---@field merged string ---@field closed string ---@field pass string ---@field fail string ---@field pending string ---@field skip string ---@field unknown string ---@class forge.WidthsConfig ---@field title integer ---@field author integer ---@field name integer ---@field branch integer ---@class forge.LimitsConfig ---@field pulls integer ---@field issues integer ---@field runs integer ---@type forge.Config local DEFAULTS = { ci = { lines = 10000 }, sources = {}, keys = { pr = { checkout = '', diff = '', worktree = '', ci = '', browse = '', manage = '', create = '', filter = '', refresh = '', }, issue = { browse = '', close = '', filter = '', refresh = '' }, ci = { log = '', browse = '', failed = '', passed = '', running = '', all = '', refresh = '', }, commits = { checkout = '', diff = '', browse = '', yank = '' }, branches = { diff = '', browse = '' }, }, 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 = {} ---@param name string ---@param source forge.Forge function M.register(name, source) sources[name] = source end ---@return table function M.registered_sources() return sources end local hl_defaults = { ForgeComposeComment = 'Comment', ForgeComposeBranch = 'Special', ForgeComposeForge = 'Type', ForgeComposeDraft = 'DiagnosticWarn', ForgeComposeFile = 'Directory', ForgeComposeAdded = 'Added', ForgeComposeRemoved = 'Removed', ForgeNumber = 'Number', ForgeOpen = 'DiagnosticInfo', ForgeMerged = 'Constant', ForgeClosed = 'Comment', ForgePass = 'DiagnosticOk', ForgeFail = 'DiagnosticError', ForgePending = 'DiagnosticWarn', ForgeSkip = 'Comment', ForgeBranch = 'Special', ForgeDim = 'Comment', } for group, link in pairs(hl_defaults) do vim.api.nvim_set_hl(0, group, { default = true, link = link }) end local compose_ns = vim.api.nvim_create_namespace('forge_compose') ---@param msg string ---@param level integer? function M.log(msg, level) vim.schedule(function() vim.notify('[forge.nvim]: ' .. msg, level or vim.log.levels.INFO) vim.cmd.redraw() end) end ---@param msg string ---@param level integer? function M.log_now(msg, level) vim.notify('[forge.nvim]: ' .. msg, level or vim.log.levels.INFO) vim.cmd.redraw() end ---@class forge.PRState ---@field state string ---@field mergeable string ---@field review_decision string ---@field is_draft boolean ---@class forge.Check ---@field name string ---@field status string ---@field elapsed string ---@field run_id string ---@class forge.CIRun ---@field id string ---@field name string ---@field branch string ---@field status string ---@field event string ---@field url string ---@field created_at string ---@class forge.RepoInfo ---@field permission string ---@field merge_methods string[] ---@class forge.Capabilities ---@field draft boolean ---@field reviewers boolean ---@field per_pr_checks boolean ---@field ci_json boolean ---@class forge.Forge ---@field name string ---@field cli string ---@field kinds { issue: string, pr: string } ---@field labels { issue: string, pr: string, pr_one: string, pr_full: string, ci: string } ---@field capabilities forge.Capabilities ---@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 } ---@field issue_json_fields fun(self: forge.Forge): { number: string, title: string, state: string, author: string, created_at: string } ---@field view_web fun(self: forge.Forge, kind: string, num: string) ---@field browse fun(self: forge.Forge, loc: string, branch: string) ---@field browse_root fun(self: forge.Forge) ---@field browse_branch fun(self: forge.Forge, branch: string) ---@field browse_commit fun(self: forge.Forge, sha: string) ---@field checkout_cmd fun(self: forge.Forge, num: string): string[] ---@field yank_branch fun(self: forge.Forge, loc: string) ---@field yank_commit fun(self: forge.Forge, loc: string) ---@field fetch_pr fun(self: forge.Forge, num: string): string[] ---@field pr_base_cmd fun(self: forge.Forge, num: string): string[] ---@field pr_for_branch_cmd fun(self: forge.Forge, branch: string): string[] ---@field checks_cmd fun(self: forge.Forge, num: string): string ---@field check_log_cmd fun(self: forge.Forge, run_id: string, failed_only: boolean): string[] ---@field check_tail_cmd fun(self: forge.Forge, run_id: string): string[] ---@field list_runs_json_cmd fun(self: forge.Forge, branch: string?): string[] ---@field list_runs_cmd fun(self: forge.Forge, branch: string?): string ---@field normalize_run fun(self: forge.Forge, entry: table): forge.CIRun ---@field run_log_cmd fun(self: forge.Forge, id: string, failed_only: boolean): string[] ---@field run_tail_cmd fun(self: forge.Forge, id: string): string[] ---@field merge_cmd fun(self: forge.Forge, num: string, method: string): string[] ---@field approve_cmd fun(self: forge.Forge, num: string): string[] ---@field repo_info fun(self: forge.Forge): forge.RepoInfo ---@field pr_state fun(self: forge.Forge, num: string): forge.PRState ---@field close_cmd fun(self: forge.Forge, num: string): string[] ---@field reopen_cmd fun(self: forge.Forge, num: string): string[] ---@field close_issue_cmd fun(self: forge.Forge, num: string): string[] ---@field reopen_issue_cmd fun(self: forge.Forge, num: string): string[] ---@field draft_toggle_cmd fun(self: forge.Forge, num: string, is_draft: boolean): string[]? ---@field create_pr_cmd fun(self: forge.Forge, title: string, body: string, base: string, draft: boolean, reviewers: string[]?): string[] ---@field create_pr_web_cmd fun(self: forge.Forge): string[]? ---@field default_branch_cmd fun(self: forge.Forge): string[] ---@field checks_json_cmd (fun(self: forge.Forge, num: string): string[])? ---@field template_paths fun(self: forge.Forge): string[] ---@type table local forge_cache = {} ---@type table local repo_info_cache = {} ---@type table local root_cache = {} ---@type table local list_cache = {} ---@return string? local function git_root() local cwd = vim.fn.getcwd() if root_cache[cwd] then return root_cache[cwd] end local root = vim.trim(vim.fn.system('git rev-parse --show-toplevel')) if vim.v.shell_error ~= 0 then return nil end root_cache[cwd] = 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) 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 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 ---@return forge.Forge? function M.detect() local root = git_root() if not root then return nil end if forge_cache[root] then return forge_cache[root] end local remote = vim.trim(vim.fn.system('git remote get-url origin')) if vim.v.shell_error ~= 0 then return nil end local name = detect_from_remote(remote) if not name then return nil end 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 ---@return forge.RepoInfo function M.repo_info(f) local root = git_root() if root and repo_info_cache[root] then return repo_info_cache[root] end local info = f:repo_info() if root then repo_info_cache[root] = info end return info end ---@param kind string ---@param state string ---@return string function M.list_key(kind, state) local root = git_root() or '' return root .. ':' .. kind .. ':' .. state end ---@param key string ---@return table[]? function M.get_list(key) return list_cache[key] end ---@param key string ---@param data table[] function M.set_list(key, data) list_cache[key] = data end ---@param key string? function M.clear_list(key) if key then list_cache[key] = nil else list_cache = {} end end function M.clear_cache() forge_cache = {} repo_info_cache = {} root_cache = {} list_cache = {} end ---@return string function M.file_loc() local root = git_root() if not root then return vim.fn.expand('%:t') end local file = vim.api.nvim_buf_get_name(0):sub(#root + 2) local mode = vim.fn.mode() if mode:match('[vV]') or mode == '\22' then local s = vim.fn.line('v') local e = vim.fn.line('.') if s > e then s, e = e, s end if s == e then return ('%s:%d'):format(file, s) end return ('%s:%d-%d'):format(file, s, e) end return ('%s:%d'):format(file, vim.fn.line('.')) end ---@return string function M.remote_web_url() local root = git_root() if not root then return '' end local remote = vim.trim(vim.fn.system('git remote get-url origin')) remote = remote:gsub('%.git$', '') remote = remote:gsub('^ssh://git@', 'https://') remote = remote:gsub('^git@([^:]+):', 'https://%1/') return remote end ---@param s string ---@param width integer ---@return string local function pad_or_truncate(s, width) local len = #s if len > width then return s:sub(1, width - 1) .. '…' end return s .. string.rep(' ', width - len) end ---@param iso string? ---@return integer? local function parse_iso(iso) if not iso or iso == '' then return nil end local y, mo, d, h, mi, s = iso:match('(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)') if not y then return nil end local ok, ts = pcall(os.time, { year = tonumber(y) --[[@as integer]], month = tonumber(mo) --[[@as integer]], day = tonumber(d) --[[@as integer]], hour = tonumber(h) --[[@as integer]], min = tonumber(mi) --[[@as integer]], sec = tonumber(s) --[[@as integer]], }) if ok and ts then return ts end return nil end ---@param iso string? ---@return string local function relative_time(iso) local ts = parse_iso(iso) if not ts then return '' end local diff = os.time() - ts if diff < 0 then diff = 0 end if diff < 3600 then return ('%dm'):format(math.max(1, math.floor(diff / 60))) end if diff < 86400 then return ('%dh'):format(math.floor(diff / 3600)) end if diff < 2592000 then return ('%dd'):format(math.floor(diff / 86400)) end if diff < 31536000 then return ('%dmo'):format(math.floor(diff / 2592000)) end return ('%dy'):format(math.floor(diff / 31536000)) end local event_map = { merge_request_event = 'mr', external_pull_request_event = 'ext', pull_request = 'pr', workflow_dispatch = 'manual', schedule = 'cron', pipeline = 'child', push = 'push', web = 'web', api = 'api', trigger = 'trigger', } ---@param event string ---@return string local function abbreviate_event(event) return event_map[event] or event end ---@param entry table ---@param field string ---@return string local function extract_author(entry, field) local v = entry[field] if type(v) == 'table' then return v.login or v.username or v.name or '' end return tostring(v or '') end local function hl(group, text) local utils = require('fzf-lua.utils') return utils.ansi_from_hl(group, text) end ---@param entry table ---@param fields table ---@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) local age = relative_time(entry[fields.created_at]) local prefix = '' if show_state then local state = (entry[fields.state] or ''):lower() local icon, group if state == 'open' or state == 'opened' then icon, group = icons.open, 'ForgeOpen' elseif state == 'merged' then icon, group = icons.merged, 'ForgeMerged' else icon, group = icons.closed, 'ForgeClosed' end prefix = hl(group, icon) .. ' ' end return prefix .. hl('ForgeNumber', ('#%-5s'):format(num)) .. ' ' .. pad_or_truncate(title, widths.title) .. ' ' .. hl('ForgeDim', pad_or_truncate(author, widths.author) .. (' %3s'):format(age)) end ---@param entry table ---@param fields table ---@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) local age = relative_time(entry[fields.created_at]) local prefix = '' if show_state then local state = (entry[fields.state] or ''):lower() local icon, group if state == 'open' or state == 'opened' then icon, group = icons.open, 'ForgeOpen' else icon, group = icons.closed, 'ForgeClosed' end prefix = hl(group, icon) .. ' ' end return prefix .. hl('ForgeNumber', ('#%-5s'):format(num)) .. ' ' .. pad_or_truncate(title, widths.title) .. ' ' .. hl('ForgeDim', pad_or_truncate(author, widths.author) .. (' %3s'):format(age)) 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, group if bucket == 'pass' then icon, group = icons.pass, 'ForgePass' elseif bucket == 'fail' then icon, group = icons.fail, 'ForgeFail' elseif bucket == 'pending' then icon, group = icons.pending, 'ForgePending' elseif bucket == 'skipping' or bucket == 'cancel' then icon, group = icons.skip, 'ForgeSkip' else icon, group = icons.unknown, 'ForgeSkip' end local elapsed = '' if check.startedAt and check.completedAt and check.completedAt ~= '' then local ok_s, ts = pcall(vim.fn.strptime, '%Y-%m-%dT%H:%M:%SZ', check.startedAt) local ok_e, te = pcall(vim.fn.strptime, '%Y-%m-%dT%H:%M:%SZ', check.completedAt) if ok_s and ok_e and ts > 0 and te > 0 then local secs = te - ts if secs >= 60 then elapsed = ('%dm%ds'):format(math.floor(secs / 60), secs % 60) else elapsed = ('%ds'):format(secs) end end end return hl(group, icon) .. ' ' .. pad_or_truncate(name, widths.name) .. ' ' .. hl('ForgeDim', 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, group local s = run.status:lower() if s == 'success' then icon, group = icons.pass, 'ForgePass' elseif s == 'failure' or s == 'failed' then icon, group = icons.fail, 'ForgeFail' elseif s == 'in_progress' or s == 'running' or s == 'pending' or s == 'queued' then icon, group = icons.pending, 'ForgePending' elseif s == 'cancelled' or s == 'canceled' or s == 'skipped' then icon, group = icons.skip, 'ForgeSkip' else icon, group = icons.unknown, 'ForgeSkip' end local event = abbreviate_event(run.event) local age = relative_time(run.created_at) if run.branch ~= '' then local name_w = widths.name - widths.branch + 10 return hl(group, icon) .. ' ' .. pad_or_truncate(run.name, name_w) .. ' ' .. hl('ForgeBranch', pad_or_truncate(run.branch, widths.branch)) .. ' ' .. hl('ForgeDim', ('%-6s'):format(event) .. ' ' .. age) end return hl(group, icon) .. ' ' .. pad_or_truncate(run.name, widths.name) .. ' ' .. hl('ForgeDim', ('%-6s'):format(event) .. ' ' .. age) end ---@param checks table[] ---@param filter string? ---@return table[] function M.filter_checks(checks, filter) if not filter or filter == 'all' then table.sort(checks, function(a, b) local order = { fail = 1, pending = 2, pass = 3, skipping = 4, cancel = 5 } local oa = order[(a.bucket or ''):lower()] or 9 local ob = order[(b.bucket or ''):lower()] or 9 return oa < ob end) return checks end local filtered = {} for _, c in ipairs(checks) do if (c.bucket or ''):lower() == filter then table.insert(filtered, c) end end return filtered end ---@return forge.Config function M.config() local user = vim.g.forge or {} local cfg = vim.tbl_deep_extend('force', DEFAULTS, user) if user.keys == false then cfg.keys = false end vim.validate('forge.sources', cfg.sources, 'table') vim.validate('forge.keys', cfg.keys, function(v) return v == false or type(v) == 'table' end, 'table or false') vim.validate('forge.display', cfg.display, 'table') vim.validate('forge.ci', cfg.ci, 'table') vim.validate('forge.ci.lines', cfg.ci.lines, 'number') vim.validate('forge.display.icons', cfg.display.icons, 'table') vim.validate('forge.display.icons.open', cfg.display.icons.open, 'string') vim.validate('forge.display.icons.merged', cfg.display.icons.merged, 'string') vim.validate('forge.display.icons.closed', cfg.display.icons.closed, 'string') vim.validate('forge.display.icons.pass', cfg.display.icons.pass, 'string') vim.validate('forge.display.icons.fail', cfg.display.icons.fail, 'string') vim.validate('forge.display.icons.pending', cfg.display.icons.pending, 'string') vim.validate('forge.display.icons.skip', cfg.display.icons.skip, 'string') vim.validate('forge.display.icons.unknown', cfg.display.icons.unknown, 'string') vim.validate('forge.display.widths', cfg.display.widths, 'table') vim.validate('forge.display.widths.title', cfg.display.widths.title, 'number') vim.validate('forge.display.widths.author', cfg.display.widths.author, 'number') vim.validate('forge.display.widths.name', cfg.display.widths.name, 'number') vim.validate('forge.display.widths.branch', cfg.display.widths.branch, 'number') vim.validate('forge.display.limits', cfg.display.limits, 'table') vim.validate('forge.display.limits.pulls', cfg.display.limits.pulls, 'number') vim.validate('forge.display.limits.issues', cfg.display.limits.issues, 'number') vim.validate('forge.display.limits.runs', cfg.display.limits.runs, 'number') local key_or_false = function(v) return v == false or type(v) == 'string' end if type(cfg.keys) == 'table' then local keys = cfg.keys --[[@as forge.KeysConfig]] if keys.pr ~= nil then vim.validate('forge.keys.pr', keys.pr, 'table') for _, k in ipairs({ 'checkout', 'diff', 'worktree', 'ci', 'browse', 'manage', 'create', 'filter', 'refresh', }) do vim.validate('forge.keys.pr.' .. k, keys.pr[k], key_or_false, 'string or false') end end if keys.issue ~= nil then vim.validate('forge.keys.issue', keys.issue, 'table') for _, k in ipairs({ 'browse', 'close', 'filter', 'refresh' }) do vim.validate('forge.keys.issue.' .. k, keys.issue[k], key_or_false, 'string or false') end end if keys.ci ~= nil then vim.validate('forge.keys.ci', keys.ci, 'table') for _, k in ipairs({ 'log', 'browse', 'failed', 'passed', 'running', 'all', 'refresh' }) do vim.validate('forge.keys.ci.' .. k, keys.ci[k], key_or_false, 'string or false') end end if keys.commits ~= nil then vim.validate('forge.keys.commits', keys.commits, 'table') for _, k in ipairs({ 'checkout', 'diff', 'browse', 'yank' }) do vim.validate( 'forge.keys.commits.' .. k, keys.commits[k], key_or_false, 'string or false' ) end end if keys.branches ~= nil then vim.validate('forge.keys.branches', keys.branches, 'table') for _, k in ipairs({ 'diff', 'browse' }) do vim.validate( 'forge.keys.branches.' .. k, keys.branches[k], key_or_false, 'string or false' ) end end end for name, source in pairs(cfg.sources) do vim.validate('forge.sources.' .. name, source, 'table') if source.hosts ~= nil then vim.validate('forge.sources.' .. name .. '.hosts', source.hosts, 'table') end end return cfg end ---@param args string[] function M.yank_url(args) vim.system(args, { text = true }, function(result) if result.code == 0 then local url = vim.trim(result.stdout or '') if url ~= '' then vim.schedule(function() vim.fn.setreg('+', url) end) end end end) end ---@param branch string ---@param base string ---@return string title, string body local function fill_from_commits(branch, base) local result = vim .system({ 'git', 'log', 'origin/' .. base .. '..HEAD', '--format=%s%n%b%x00' }, { text = true }) :wait() local raw = vim.trim(result.stdout or '') if raw == '' then local clean = branch:gsub('^%w+/', ''):gsub('[/-]', ' ') return clean, '' end local commits = {} for chunk in raw:gmatch('([^%z]+)') do local lines = vim.split(vim.trim(chunk), '\n', { plain = true }) local subject = lines[1] or '' local body = vim.trim(table.concat(lines, '\n', 2)) table.insert(commits, { subject = subject, body = body }) end if #commits == 0 then local clean = branch:gsub('^%w+/', ''):gsub('[/-]', ' ') return clean, '' end if #commits == 1 then return commits[1].subject, commits[1].body end local clean = branch:gsub('^%w+/', ''):gsub('[/-]', ' ') local lines = {} for _, c in ipairs(commits) do table.insert(lines, '- ' .. c.subject) end return clean, table.concat(lines, '\n') end ---@param f forge.Forge ---@param repo_root string ---@return string? local function discover_template(f, repo_root) local paths = f:template_paths() for _, p in ipairs(paths) do local full = repo_root .. '/' .. p local stat = vim.uv.fs_stat(full) if stat and stat.type == 'file' then local fd = vim.uv.fs_open(full, 'r', 438) if fd then local content = vim.uv.fs_read(fd, stat.size, 0) vim.uv.fs_close(fd) if content then return vim.trim(content) end end elseif stat and stat.type == 'directory' then local handle = vim.uv.fs_scandir(full) if handle then local templates = {} while true do local name, typ = vim.uv.fs_scandir_next(handle) if not name then break end if (typ == 'file' or not typ) and name:match('%.md$') then table.insert(templates, name) end end if #templates == 1 then local tpath = full .. '/' .. templates[1] local tstat = vim.uv.fs_stat(tpath) if tstat then local fd = vim.uv.fs_open(tpath, 'r', 438) if fd then local content = vim.uv.fs_read(fd, tstat.size, 0) vim.uv.fs_close(fd) if content then return vim.trim(content) end end end elseif #templates > 1 then table.sort(templates) local chosen vim.ui.select(templates, { prompt = 'PR template: ', }, function(choice) chosen = choice end) if chosen then local tpath = full .. '/' .. chosen local tstat = vim.uv.fs_stat(tpath) if tstat then local fd = vim.uv.fs_open(tpath, 'r', 438) if fd then local content = vim.uv.fs_read(fd, tstat.size, 0) vim.uv.fs_close(fd) if content then return vim.trim(content) end end end end end end end end return nil end ---@param f forge.Forge ---@param branch string ---@param title string ---@param body string ---@param pr_base string ---@param pr_draft boolean ---@param pr_reviewers string[]? ---@param buf integer? local function push_and_create(f, branch, title, body, pr_base, pr_draft, pr_reviewers, buf) M.log_now('pushing and creating ' .. f.labels.pr_one .. '...') vim.system({ 'git', 'push', '-u', 'origin', branch }, { text = true }, function(push_result) if push_result.code ~= 0 then local msg = vim.trim(push_result.stderr or '') if msg == '' then msg = 'push failed' end M.log(msg, vim.log.levels.ERROR) return end vim.system( f:create_pr_cmd(title, body, pr_base, pr_draft, pr_reviewers), { text = true }, function(create_result) vim.schedule(function() if create_result.code == 0 then local url = vim.trim(create_result.stdout or '') if url ~= '' then vim.fn.setreg('+', url) end M.log_now(('created %s → %s'):format(f.labels.pr_one, url)) M.clear_list() if buf and vim.api.nvim_buf_is_valid(buf) then vim.bo[buf].modified = false vim.api.nvim_buf_delete(buf, { force = true }) end else local msg = vim.trim(create_result.stderr or '') if msg == '' then msg = vim.trim(create_result.stdout or '') end if msg == '' then msg = 'creation failed' end M.log_now(msg, vim.log.levels.ERROR) end vim.cmd.redraw() end) end ) end) end ---@param f forge.Forge ---@param branch string ---@param base string ---@param draft boolean local function open_compose_buffer(f, branch, base, draft) local root = git_root() or '' local title, commit_body = fill_from_commits(branch, base) local template = discover_template(f, root) local body = template or commit_body local buf = vim.api.nvim_create_buf(false, true) vim.api.nvim_buf_set_name(buf, 'forge://pr/new') vim.bo[buf].buftype = 'acwrite' vim.bo[buf].filetype = 'markdown' vim.bo[buf].swapfile = false local lines = { title, '' } if body ~= '' then for _, line in ipairs(vim.split(body, '\n', { plain = true })) do table.insert(lines, line) end else table.insert(lines, '') end table.insert(lines, '') local comment_start = #lines + 1 local pr_kind = f.labels.pr_full:gsub('s$', '') local diff_stat = vim.fn.system('git diff --stat origin/' .. base .. '..HEAD'):gsub('%s+$', '') ---@type {line: integer, col: integer, end_col: integer, hl: string}[] local marks = {} local function add_line(fmt, ...) local text = fmt:format(...) table.insert(lines, text) return #lines end ---@param ln integer ---@param start integer ---@param len integer ---@param hl_group string local function mark(ln, start, len, hl_group) table.insert(marks, { line = ln, col = start, end_col = start + len, hl = hl_group }) end add_line('') vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) vim.bo[buf].modified = false vim.api.nvim_set_current_buf(buf) if stat_start and stat_end then for i = stat_start, stat_end do local line = lines[i] local pipe = line:find('|') if pipe then local fname_start = line:find('%S') if fname_start then mark(i, fname_start - 1, pipe - fname_start - 1, 'ForgeComposeFile') end for pos, run in line:gmatch('()([+-]+)') do if pos > pipe then local stat_hl = run:sub(1, 1) == '+' and 'ForgeComposeAdded' or 'ForgeComposeRemoved' mark(i, pos - 1, #run, stat_hl) end end end end end for _, m in ipairs(marks) do vim.api.nvim_buf_set_extmark(buf, compose_ns, m.line - 1, m.col, { end_col = m.end_col, hl_group = m.hl, priority = 200, }) end for i = comment_start, #lines do vim.api.nvim_buf_set_extmark(buf, compose_ns, i - 1, 0, { line_hl_group = 'ForgeComposeComment', priority = 200, }) end ---@return boolean, string[], string local function parse_comment() local buf_lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) local in_comment = false local pr_draft = false local pr_reviewers = {} for _, l in ipairs(buf_lines) do if l:match('^