From 5ee2cc567a04e108d29344b686fb4d623ca642cd Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 27 Mar 2026 17:19:09 -0400 Subject: [PATCH] ci: format --- lua/forge/codeberg.lua | 296 ++++--- lua/forge/github.lua | 413 +++++---- lua/forge/gitlab.lua | 488 +++++------ lua/forge/health.lua | 70 +- lua/forge/init.lua | 1425 +++++++++++++++--------------- plugin/forge.lua | 1875 ++++++++++++++++++---------------------- 6 files changed, 2173 insertions(+), 2394 deletions(-) diff --git a/lua/forge/codeberg.lua b/lua/forge/codeberg.lua index f47f7b5..3a402ad 100644 --- a/lua/forge/codeberg.lua +++ b/lua/forge/codeberg.lua @@ -2,240 +2,234 @@ local forge = require('forge') ---@type forge.Forge local M = { - name = 'codeberg', - cli = 'tea', - kinds = { issue = 'issues', pr = 'pulls' }, - labels = { - issue = 'Issues', - pr = 'PRs', - pr_one = 'PR', - pr_full = 'Pull Requests', - ci = 'CI/CD', - }, + name = 'codeberg', + cli = 'tea', + kinds = { issue = 'issues', pr = 'pulls' }, + labels = { + issue = 'Issues', + pr = 'PRs', + pr_one = 'PR', + pr_full = 'Pull Requests', + ci = 'CI/CD', + }, } ---@param kind string ---@param state string ---@return string function M:list_cmd(kind, state) - return ('tea %s list --state %s'):format(kind, state) + return ('tea %s list --state %s'):format(kind, state) end ---@param state string ---@return string[] function M:list_pr_json_cmd(state) - return { - 'tea', - 'pulls', - 'list', - '--state', - state, - '--output', - 'json', - '--fields', - 'index,title,head,state,poster,created_at', - } + return { + 'tea', + 'pulls', + 'list', + '--state', + state, + '--output', + 'json', + '--fields', + 'index,title,head,state,poster,created_at', + } end ---@param state string ---@return string[] function M:list_issue_json_cmd(state) - return { - 'tea', - 'issues', - 'list', - '--state', - state, - '--output', - 'json', - '--fields', - 'index,title,state,poster,created_at', - } + return { + 'tea', + 'issues', + 'list', + '--state', + state, + '--output', + 'json', + '--fields', + 'index,title,state,poster,created_at', + } end function M:pr_json_fields() - return { - number = 'index', - title = 'title', - branch = 'head', - state = 'state', - author = 'poster', - created_at = 'created_at', - } + return { + number = 'index', + title = 'title', + branch = 'head', + state = 'state', + author = 'poster', + created_at = 'created_at', + } end function M:issue_json_fields() - return { - number = 'index', - title = 'title', - state = 'state', - author = 'poster', - created_at = 'created_at', - } + return { + number = 'index', + title = 'title', + state = 'state', + author = 'poster', + created_at = 'created_at', + } end ---@param kind string ---@param num string function M:view_web(kind, num) - local base = forge.remote_web_url() - vim.ui.open(('%s/%s/%s'):format(base, kind, num)) + local base = forge.remote_web_url() + vim.ui.open(('%s/%s/%s'):format(base, kind, num)) end ---@param loc string ---@param branch string function M:browse(loc, branch) - local base = forge.remote_web_url() - local file, lines = loc:match('^(.+):(.+)$') - vim.ui.open(('%s/src/branch/%s/%s#L%s'):format(base, branch, file, lines)) + local base = forge.remote_web_url() + local file, lines = loc:match('^(.+):(.+)$') + vim.ui.open(('%s/src/branch/%s/%s#L%s'):format(base, branch, file, lines)) end function M:browse_root() - vim.ui.open(forge.remote_web_url()) + vim.ui.open(forge.remote_web_url()) end function M:browse_branch(branch) - local base = forge.remote_web_url() - vim.ui.open(base .. '/src/branch/' .. branch) + local base = forge.remote_web_url() + vim.ui.open(base .. '/src/branch/' .. branch) end function M:browse_commit(sha) - local base = forge.remote_web_url() - vim.ui.open(base .. '/commit/' .. sha) + local base = forge.remote_web_url() + vim.ui.open(base .. '/commit/' .. sha) end function M:checkout_cmd(num) - return { 'tea', 'pr', 'checkout', num } + return { 'tea', 'pr', 'checkout', num } end ---@param loc string function M:yank_branch(loc) - local branch = vim.trim(vim.fn.system('git branch --show-current')) - local base = forge.remote_web_url() - local file, lines = loc:match('^(.+):(.+)$') - vim.fn.setreg( - '+', - ('%s/src/branch/%s/%s#L%s'):format(base, branch, file, lines) - ) + local branch = vim.trim(vim.fn.system('git branch --show-current')) + local base = forge.remote_web_url() + local file, lines = loc:match('^(.+):(.+)$') + vim.fn.setreg('+', ('%s/src/branch/%s/%s#L%s'):format(base, branch, file, lines)) end ---@param loc string function M:yank_commit(loc) - local commit = vim.trim(vim.fn.system('git rev-parse HEAD')) - local base = forge.remote_web_url() - local file, lines = loc:match('^(.+):(.+)$') - vim.fn.setreg( - '+', - ('%s/src/commit/%s/%s#L%s'):format(base, commit, file, lines) - ) + local commit = vim.trim(vim.fn.system('git rev-parse HEAD')) + local base = forge.remote_web_url() + local file, lines = loc:match('^(.+):(.+)$') + vim.fn.setreg('+', ('%s/src/commit/%s/%s#L%s'):format(base, commit, file, lines)) end ---@param num string ---@return string[] function M:fetch_pr(num) - return { 'git', 'fetch', 'origin', ('pull/%s/head:pr-%s'):format(num, num) } + return { 'git', 'fetch', 'origin', ('pull/%s/head:pr-%s'):format(num, num) } end ---@param num string ---@return string[] function M:pr_base_cmd(num) - return { 'tea', 'pr', num, '--fields', 'base', '--output', 'simple' } + return { 'tea', 'pr', num, '--fields', 'base', '--output', 'simple' } end ---@param branch string ---@return string[] function M:pr_for_branch_cmd(_branch) - return { - 'tea', - 'pr', - 'list', - '--fields', - 'index,head', - '--output', - 'simple', - '--state', - 'open', - } + return { + 'tea', + 'pr', + 'list', + '--fields', + 'index,head', + '--output', + 'simple', + '--state', + 'open', + } end ---@param num string ---@return string function M:checks_cmd(num) - local _ = num - return 'tea actions runs list' + local _ = num + return 'tea actions runs list' end ---@param run_id string ---@param failed_only boolean ---@return string[] function M:check_log_cmd(run_id, failed_only) - local _ = failed_only - local lines = forge.config().ci.lines - return { - 'sh', - '-c', - ('tea actions runs logs %s | tail -n %d'):format(run_id, lines), - } + local _ = failed_only + local lines = forge.config().ci.lines + return { + 'sh', + '-c', + ('tea actions runs logs %s | tail -n %d'):format(run_id, lines), + } end ---@param run_id string ---@return string[] function M:check_tail_cmd(run_id) - return { 'tea', 'actions', 'runs', 'logs', run_id, '--follow' } + return { 'tea', 'actions', 'runs', 'logs', run_id, '--follow' } end function M:list_runs_cmd(_branch) - return 'tea actions runs list' + return 'tea actions runs list' end function M:run_log_cmd(id, failed_only) - local _ = failed_only - local lines = forge.config().ci.lines - return { - 'sh', - '-c', - ('tea actions runs logs %s | tail -n %d'):format(id, lines), - } + local _ = failed_only + local lines = forge.config().ci.lines + return { + 'sh', + '-c', + ('tea actions runs logs %s | tail -n %d'):format(id, lines), + } end function M:run_tail_cmd(id) - return { 'tea', 'actions', 'runs', 'logs', id, '--follow' } + return { 'tea', 'actions', 'runs', 'logs', id, '--follow' } end ---@param num string ---@param method string ---@return string[] function M:merge_cmd(num, method) - return { 'tea', 'pr', 'merge', num, '--style', method } + return { 'tea', 'pr', 'merge', num, '--style', method } end ---@param num string ---@return string[] function M:approve_cmd(num) - return { 'tea', 'pr', 'approve', num } + return { 'tea', 'pr', 'approve', num } end ---@param num string ---@return string[] function M:close_cmd(num) - return { 'tea', 'pulls', 'close', num } + return { 'tea', 'pulls', 'close', num } end ---@param num string ---@return string[] function M:reopen_cmd(num) - return { 'tea', 'pulls', 'reopen', num } + return { 'tea', 'pulls', 'reopen', num } end ---@param num string ---@return string[] function M:close_issue_cmd(num) - return { 'tea', 'issues', 'close', num } + return { 'tea', 'issues', 'close', num } end ---@param num string ---@return string[] function M:reopen_issue_cmd(num) - return { 'tea', 'issues', 'reopen', num } + return { 'tea', 'issues', 'reopen', num } end ---@param title string @@ -245,72 +239,74 @@ end ---@param _reviewers string[]? ---@return string[] function M:create_pr_cmd(title, body, base, _draft, _reviewers) - return { 'tea', 'pr', 'create', '--title', title, '--description', body, '--base', base } + return { 'tea', 'pr', 'create', '--title', title, '--description', body, '--base', base } end ---@return string[]? function M:create_pr_web_cmd() - local branch = vim.trim(vim.fn.system('git branch --show-current')) - local base_url = forge.remote_web_url() - local default = vim.trim(vim.fn.system( - "git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||'" - )) - if default == '' then - default = 'main' - end - vim.ui.open(('%s/compare/%s...%s'):format(base_url, default, branch)) - return nil + local branch = vim.trim(vim.fn.system('git branch --show-current')) + local base_url = forge.remote_web_url() + local default = vim.trim( + vim.fn.system( + "git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||'" + ) + ) + if default == '' then + default = 'main' + end + vim.ui.open(('%s/compare/%s...%s'):format(base_url, default, branch)) + return nil end ---@return string[] function M:default_branch_cmd() - return { - 'sh', '-c', - "git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||'", - } + return { + 'sh', + '-c', + "git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||'", + } end ---@return string[] function M:template_paths() - return { - '.gitea/pull_request_template.md', - '.github/pull_request_template.md', - '.github/PULL_REQUEST_TEMPLATE.md', - } + return { + '.gitea/pull_request_template.md', + '.github/pull_request_template.md', + '.github/PULL_REQUEST_TEMPLATE.md', + } end ---@param _num string ---@param _is_draft boolean ---@return string[]? function M:draft_toggle_cmd(_num, _is_draft) - return nil + return nil end ---@return forge.RepoInfo function M:repo_info() - return { - permission = 'ADMIN', - merge_methods = { 'squash', 'rebase', 'merge' }, - } + return { + permission = 'ADMIN', + merge_methods = { 'squash', 'rebase', 'merge' }, + } end ---@param num string ---@return forge.PRState function M:pr_state(num) - local result = vim.system( - { 'tea', 'pr', num, '--fields', 'state,mergeable', '--output', 'json' }, - { text = true } - ):wait() - local ok, data = pcall(vim.json.decode, result.stdout or '{}') - if not ok or type(data) ~= 'table' then - data = {} - end - return { - state = (data.state or 'unknown'):upper(), - mergeable = data.mergeable and 'MERGEABLE' or 'UNKNOWN', - review_decision = '', - is_draft = false, - } + local result = vim + .system({ 'tea', 'pr', num, '--fields', 'state,mergeable', '--output', 'json' }, { text = true }) + :wait() + local ok, data = pcall(vim.json.decode, result.stdout or '{}') + if not ok or type(data) ~= 'table' then + data = {} + end + return { + state = (data.state or 'unknown'):upper(), + mergeable = data.mergeable and 'MERGEABLE' or 'UNKNOWN', + review_decision = '', + is_draft = false, + } end return M diff --git a/lua/forge/github.lua b/lua/forge/github.lua index 97721f8..f53f8eb 100644 --- a/lua/forge/github.lua +++ b/lua/forge/github.lua @@ -2,283 +2,278 @@ local forge = require('forge') ---@type forge.Forge local M = { - name = 'github', - cli = 'gh', - kinds = { issue = 'issue', pr = 'pr' }, - labels = { - issue = 'Issues', - pr = 'PRs', - pr_one = 'PR', - pr_full = 'Pull Requests', - ci = 'CI/CD', - }, + name = 'github', + cli = 'gh', + kinds = { issue = 'issue', pr = 'pr' }, + labels = { + issue = 'Issues', + pr = 'PRs', + pr_one = 'PR', + pr_full = 'Pull Requests', + ci = 'CI/CD', + }, } local function nwo() - local url = forge.remote_web_url() - return url:match('github%.com/(.+)$') or '' + local url = forge.remote_web_url() + return url:match('github%.com/(.+)$') or '' 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) + return ('gh %s list --limit 100 --state %s'):format(kind, state) end ---@param state string ---@return string[] function M:list_pr_json_cmd(state) - return { - 'gh', - 'pr', - 'list', - '--limit', - '100', - '--state', - state, - '--json', - 'number,title,headRefName,state,author,createdAt', - } + return { + 'gh', + 'pr', + 'list', + '--limit', + '100', + '--state', + state, + '--json', + 'number,title,headRefName,state,author,createdAt', + } end ---@param state string ---@return string[] function M:list_issue_json_cmd(state) - return { - 'gh', - 'issue', - 'list', - '--limit', - '100', - '--state', - state, - '--json', - 'number,title,state,author,createdAt', - } + return { + 'gh', + 'issue', + 'list', + '--limit', + '100', + '--state', + state, + '--json', + 'number,title,state,author,createdAt', + } end function M:pr_json_fields() - return { - number = 'number', - title = 'title', - branch = 'headRefName', - state = 'state', - author = 'author', - created_at = 'createdAt', - } + return { + number = 'number', + title = 'title', + branch = 'headRefName', + state = 'state', + author = 'author', + created_at = 'createdAt', + } end function M:issue_json_fields() - return { - number = 'number', - title = 'title', - state = 'state', - author = 'author', - created_at = 'createdAt', - } + return { + number = 'number', + title = 'title', + state = 'state', + author = 'author', + created_at = 'createdAt', + } end ---@param kind string ---@param num string function M:view_web(kind, num) - vim.system({ 'gh', kind, 'view', num, '--web' }) + vim.system({ 'gh', kind, 'view', num, '--web' }) end ---@param loc string ---@param branch string function M:browse(loc, branch) - vim.system({ 'gh', 'browse', loc, '--branch', branch }) + vim.system({ 'gh', 'browse', loc, '--branch', branch }) end function M:browse_root() - vim.system({ 'gh', 'browse' }) + vim.system({ 'gh', 'browse' }) end function M:browse_branch(branch) - vim.system({ 'gh', 'browse', '--branch', branch }) + vim.system({ 'gh', 'browse', '--branch', branch }) end function M:browse_commit(sha) - vim.system({ 'gh', 'browse', sha }) + vim.system({ 'gh', 'browse', sha }) end function M:checkout_cmd(num) - return { 'gh', 'pr', 'checkout', num } + return { 'gh', 'pr', 'checkout', num } end ---@param loc string function M:yank_branch(loc) - forge.yank_url({ 'gh', 'browse', loc, '-n' }) + forge.yank_url({ 'gh', 'browse', loc, '-n' }) end ---@param loc string function M:yank_commit(loc) - forge.yank_url({ 'gh', 'browse', loc, '--commit=last', '-n' }) + forge.yank_url({ 'gh', 'browse', loc, '--commit=last', '-n' }) end ---@param num string ---@return string[] function M:fetch_pr(num) - return { 'git', 'fetch', 'origin', ('pull/%s/head:pr-%s'):format(num, num) } + return { 'git', 'fetch', 'origin', ('pull/%s/head:pr-%s'):format(num, num) } end ---@param num string ---@return string[] function M:pr_base_cmd(num) - return { - 'gh', - 'pr', - 'view', - num, - '--json', - 'baseRefName', - '--jq', - '.baseRefName', - } + return { + 'gh', + 'pr', + 'view', + num, + '--json', + 'baseRefName', + '--jq', + '.baseRefName', + } end ---@param branch string ---@return string[] function M:pr_for_branch_cmd(branch) - return { - 'gh', - 'pr', - 'list', - '--head', - branch, - '--json', - 'number', - '--jq', - '.[0].number', - } + return { + 'gh', + 'pr', + 'list', + '--head', + branch, + '--json', + 'number', + '--jq', + '.[0].number', + } end ---@param num string ---@return string function M:checks_cmd(num) - return ('gh pr checks %s'):format(num) + return ('gh pr checks %s'):format(num) end ---@param num string ---@return string[] function M:checks_json_cmd(num) - return { - 'gh', - 'pr', - 'checks', - num, - '--json', - 'name,bucket,link,state,startedAt,completedAt', - } + return { + 'gh', + 'pr', + 'checks', + num, + '--json', + 'name,bucket,link,state,startedAt,completedAt', + } end ---@param run_id string ---@param failed_only boolean ---@return string[] function M:check_log_cmd(run_id, failed_only) - local lines = forge.config().ci.lines - local flag = failed_only and '--log-failed' or '--log' - return { - 'sh', - '-c', - ('gh run view %s -R %s %s | tail -n %d'):format( - run_id, - nwo(), - flag, - lines - ), - } + local lines = forge.config().ci.lines + local flag = failed_only and '--log-failed' or '--log' + return { + 'sh', + '-c', + ('gh run view %s -R %s %s | tail -n %d'):format(run_id, nwo(), flag, lines), + } end ---@param run_id string ---@return string[] function M:check_tail_cmd(run_id) - return { 'gh', 'run', 'watch', run_id, '-R', nwo() } + return { 'gh', 'run', 'watch', run_id, '-R', nwo() } end function M:list_runs_json_cmd(branch) - local cmd = { - 'gh', - 'run', - 'list', - '--json', - 'databaseId,name,headBranch,status,conclusion,event,url,createdAt', - '--limit', - '30', - } - if branch then - table.insert(cmd, '--branch') - table.insert(cmd, branch) - end - return cmd + local cmd = { + 'gh', + 'run', + 'list', + '--json', + 'databaseId,name,headBranch,status,conclusion,event,url,createdAt', + '--limit', + '30', + } + if branch then + table.insert(cmd, '--branch') + table.insert(cmd, branch) + end + return cmd end function M:normalize_run(entry) - local status = entry.status or '' - if status == 'completed' then - status = entry.conclusion or 'unknown' - end - return { - id = tostring(entry.databaseId or ''), - name = entry.name or '', - branch = entry.headBranch or '', - status = status, - event = entry.event or '', - url = entry.url or '', - created_at = entry.createdAt or '', - } + local status = entry.status or '' + if status == 'completed' then + status = entry.conclusion or 'unknown' + end + return { + id = tostring(entry.databaseId or ''), + name = entry.name or '', + branch = entry.headBranch or '', + status = status, + event = entry.event or '', + url = entry.url or '', + created_at = entry.createdAt or '', + } end function M:run_log_cmd(id, failed_only) - local lines = forge.config().ci.lines - local flag = failed_only and '--log-failed' or '--log' - return { - 'sh', - '-c', - ('gh run view %s -R %s %s | tail -n %d'):format(id, nwo(), flag, lines), - } + local lines = forge.config().ci.lines + local flag = failed_only and '--log-failed' or '--log' + return { + 'sh', + '-c', + ('gh run view %s -R %s %s | tail -n %d'):format(id, nwo(), flag, lines), + } end function M:run_tail_cmd(id) - return { 'gh', 'run', 'watch', id, '-R', nwo() } + return { 'gh', 'run', 'watch', id, '-R', nwo() } end ---@param num string ---@param method string ---@return string[] function M:merge_cmd(num, method) - return { 'gh', 'pr', 'merge', num, '--' .. method } + return { 'gh', 'pr', 'merge', num, '--' .. method } end ---@param num string ---@return string[] function M:approve_cmd(num) - return { 'gh', 'pr', 'review', num, '--approve' } + return { 'gh', 'pr', 'review', num, '--approve' } end ---@param num string ---@return string[] function M:close_cmd(num) - return { 'gh', 'pr', 'close', num } + return { 'gh', 'pr', 'close', num } end ---@param num string ---@return string[] function M:reopen_cmd(num) - return { 'gh', 'pr', 'reopen', num } + return { 'gh', 'pr', 'reopen', num } end ---@param num string ---@return string[] function M:close_issue_cmd(num) - return { 'gh', 'issue', 'close', num } + return { 'gh', 'issue', 'close', num } end ---@param num string ---@return string[] function M:reopen_issue_cmd(num) - return { 'gh', 'issue', 'reopen', num } + return { 'gh', 'issue', 'reopen', num } end ---@param title string @@ -288,100 +283,104 @@ end ---@param reviewers string[]? ---@return string[] function M:create_pr_cmd(title, body, base, draft, reviewers) - local cmd = { 'gh', 'pr', 'create', '--title', title, '--body', body, '--base', base } - if draft then - table.insert(cmd, '--draft') - end - for _, r in ipairs(reviewers or {}) do - table.insert(cmd, '--reviewer') - table.insert(cmd, r) - end - return cmd + local cmd = { 'gh', 'pr', 'create', '--title', title, '--body', body, '--base', base } + if draft then + table.insert(cmd, '--draft') + end + for _, r in ipairs(reviewers or {}) do + table.insert(cmd, '--reviewer') + table.insert(cmd, r) + end + return cmd end ---@return string[] function M:create_pr_web_cmd() - return { 'gh', 'pr', 'create', '--web' } + return { 'gh', 'pr', 'create', '--web' } end ---@return string[] function M:default_branch_cmd() - return { 'gh', 'repo', 'view', '--json', 'defaultBranchRef', '--jq', '.defaultBranchRef.name' } + return { 'gh', 'repo', 'view', '--json', 'defaultBranchRef', '--jq', '.defaultBranchRef.name' } end ---@return string[] function M:template_paths() - return { - '.github/pull_request_template.md', - '.github/PULL_REQUEST_TEMPLATE.md', - '.github/PULL_REQUEST_TEMPLATE/', - } + return { + '.github/pull_request_template.md', + '.github/PULL_REQUEST_TEMPLATE.md', + '.github/PULL_REQUEST_TEMPLATE/', + } end ---@param num string ---@param is_draft boolean ---@return string[]? function M:draft_toggle_cmd(num, is_draft) - if is_draft then - return { 'gh', 'pr', 'ready', num } - end - return { 'gh', 'pr', 'ready', num, '--undo' } + if is_draft then + return { 'gh', 'pr', 'ready', num } + end + return { 'gh', 'pr', 'ready', num, '--undo' } end ---@return forge.RepoInfo function M:repo_info() - local result = vim.system({ - 'gh', - 'repo', - 'view', - nwo(), - '--json', - 'viewerPermission,squashMergeAllowed,rebaseMergeAllowed,mergeCommitAllowed', - }, { text = true }):wait() + local result = vim + .system({ + 'gh', + 'repo', + 'view', + nwo(), + '--json', + 'viewerPermission,squashMergeAllowed,rebaseMergeAllowed,mergeCommitAllowed', + }, { text = true }) + :wait() - local ok, data = pcall(vim.json.decode, result.stdout or '{}') - if not ok or type(data) ~= 'table' then - data = {} - end - local methods = {} - if data.squashMergeAllowed then - table.insert(methods, 'squash') - end - if data.rebaseMergeAllowed then - table.insert(methods, 'rebase') - end - if data.mergeCommitAllowed then - table.insert(methods, 'merge') - end + local ok, data = pcall(vim.json.decode, result.stdout or '{}') + if not ok or type(data) ~= 'table' then + data = {} + end + local methods = {} + if data.squashMergeAllowed then + table.insert(methods, 'squash') + end + if data.rebaseMergeAllowed then + table.insert(methods, 'rebase') + end + if data.mergeCommitAllowed then + table.insert(methods, 'merge') + end - return { - permission = (data.viewerPermission or 'READ'):upper(), - merge_methods = methods, - } + return { + permission = (data.viewerPermission or 'READ'):upper(), + merge_methods = methods, + } end ---@param num string ---@return forge.PRState function M:pr_state(num) - local result = vim.system({ - 'gh', - 'pr', - 'view', - num, - '--json', - 'state,mergeable,reviewDecision,isDraft', - }, { text = true }):wait() + local result = vim + .system({ + 'gh', + 'pr', + 'view', + num, + '--json', + 'state,mergeable,reviewDecision,isDraft', + }, { text = true }) + :wait() - local ok, data = pcall(vim.json.decode, result.stdout or '{}') - if not ok or type(data) ~= 'table' then - data = {} - end - return { - state = data.state or 'UNKNOWN', - mergeable = data.mergeable or 'UNKNOWN', - review_decision = data.reviewDecision or '', - is_draft = data.isDraft == true, - } + local ok, data = pcall(vim.json.decode, result.stdout or '{}') + if not ok or type(data) ~= 'table' then + data = {} + end + return { + state = data.state or 'UNKNOWN', + mergeable = data.mergeable or 'UNKNOWN', + review_decision = data.reviewDecision or '', + is_draft = data.isDraft == true, + } end return M diff --git a/lua/forge/gitlab.lua b/lua/forge/gitlab.lua index 58a445e..7e90f2f 100644 --- a/lua/forge/gitlab.lua +++ b/lua/forge/gitlab.lua @@ -2,306 +2,296 @@ local forge = require('forge') ---@type forge.Forge local M = { - name = 'gitlab', - cli = 'glab', - kinds = { issue = 'issue', pr = 'mr' }, - labels = { - issue = 'Issues', - pr = 'MRs', - pr_one = 'MR', - pr_full = 'Merge Requests', - ci = 'CI/CD', - }, + name = 'gitlab', + cli = 'glab', + kinds = { issue = 'issue', pr = 'mr' }, + labels = { + issue = 'Issues', + pr = 'MRs', + pr_one = 'MR', + pr_full = 'Merge Requests', + ci = 'CI/CD', + }, } ---@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 + 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) - local cmd = { - 'glab', - 'mr', - 'list', - '--per-page', - '100', - '--output', - 'json', - } - if state == 'closed' then - table.insert(cmd, '--closed') - elseif state == 'all' then - table.insert(cmd, '--all') - end - return cmd + local cmd = { + 'glab', + 'mr', + 'list', + '--per-page', + '100', + '--output', + 'json', + } + if state == 'closed' then + table.insert(cmd, '--closed') + elseif state == 'all' then + table.insert(cmd, '--all') + end + return cmd end ---@param state string ---@return string[] function M:list_issue_json_cmd(state) - local cmd = { - 'glab', - 'issue', - 'list', - '--per-page', - '100', - '--output', - 'json', - } - if state == 'closed' then - table.insert(cmd, '--closed') - elseif state == 'all' then - table.insert(cmd, '--all') - end - return cmd + local cmd = { + 'glab', + 'issue', + 'list', + '--per-page', + '100', + '--output', + 'json', + } + if state == 'closed' then + table.insert(cmd, '--closed') + elseif state == 'all' then + table.insert(cmd, '--all') + end + return cmd end function M:pr_json_fields() - return { - number = 'iid', - title = 'title', - branch = 'source_branch', - state = 'state', - author = 'author', - created_at = 'created_at', - } + return { + number = 'iid', + title = 'title', + branch = 'source_branch', + state = 'state', + author = 'author', + created_at = 'created_at', + } end function M:issue_json_fields() - return { - number = 'iid', - title = 'title', - state = 'state', - author = 'author', - created_at = 'created_at', - } + return { + number = 'iid', + title = 'title', + state = 'state', + author = 'author', + created_at = 'created_at', + } end ---@param kind string ---@param num string function M:view_web(kind, num) - vim.system({ 'glab', kind, 'view', num, '--web' }) + vim.system({ 'glab', kind, 'view', num, '--web' }) end ---@param loc string ---@param branch string function M:browse(loc, branch) - local base = forge.remote_web_url() - local file, lines = loc:match('^(.+):(.+)$') - vim.ui.open(('%s/-/blob/%s/%s#L%s'):format(base, branch, file, lines)) + local base = forge.remote_web_url() + local file, lines = loc:match('^(.+):(.+)$') + vim.ui.open(('%s/-/blob/%s/%s#L%s'):format(base, branch, file, lines)) end function M:browse_root() - vim.system({ 'glab', 'repo', 'view', '--web' }) + vim.system({ 'glab', 'repo', 'view', '--web' }) end function M:browse_branch(branch) - local base = forge.remote_web_url() - vim.ui.open(base .. '/-/tree/' .. branch) + local base = forge.remote_web_url() + vim.ui.open(base .. '/-/tree/' .. branch) end function M:browse_commit(sha) - local base = forge.remote_web_url() - vim.ui.open(base .. '/-/commit/' .. sha) + local base = forge.remote_web_url() + vim.ui.open(base .. '/-/commit/' .. sha) end function M:checkout_cmd(num) - return { 'glab', 'mr', 'checkout', num } + return { 'glab', 'mr', 'checkout', num } end ---@param loc string function M:yank_branch(loc) - local branch = vim.trim(vim.fn.system('git branch --show-current')) - local base = forge.remote_web_url() - local file, lines = loc:match('^(.+):(.+)$') - vim.fn.setreg( - '+', - ('%s/-/blob/%s/%s#L%s'):format(base, branch, file, lines) - ) + local branch = vim.trim(vim.fn.system('git branch --show-current')) + local base = forge.remote_web_url() + local file, lines = loc:match('^(.+):(.+)$') + vim.fn.setreg('+', ('%s/-/blob/%s/%s#L%s'):format(base, branch, file, lines)) end ---@param loc string function M:yank_commit(loc) - local commit = vim.trim(vim.fn.system('git rev-parse HEAD')) - local base = forge.remote_web_url() - local file, lines = loc:match('^(.+):(.+)$') - vim.fn.setreg( - '+', - ('%s/-/blob/%s/%s#L%s'):format(base, commit, file, lines) - ) + local commit = vim.trim(vim.fn.system('git rev-parse HEAD')) + local base = forge.remote_web_url() + local file, lines = loc:match('^(.+):(.+)$') + vim.fn.setreg('+', ('%s/-/blob/%s/%s#L%s'):format(base, commit, file, lines)) end ---@param num string ---@return string[] function M:fetch_pr(num) - return { - 'git', - 'fetch', - 'origin', - ('merge-requests/%s/head:mr-%s'):format(num, num), - } + return { + 'git', + 'fetch', + 'origin', + ('merge-requests/%s/head:mr-%s'):format(num, num), + } end ---@param num string ---@return string[] function M:pr_base_cmd(num) - return { - 'sh', - '-c', - ('glab mr view %s -F json | jq -r .target_branch'):format(num), - } + return { + 'sh', + '-c', + ('glab mr view %s -F json | jq -r .target_branch'):format(num), + } end ---@param branch string ---@return string[] function M:pr_for_branch_cmd(branch) - return { - 'sh', - '-c', - ("glab mr list --source-branch '%s' -F json | jq -r '.[0].iid // empty'"):format( - branch - ), - } + return { + 'sh', + '-c', + ("glab mr list --source-branch '%s' -F json | jq -r '.[0].iid // empty'"):format(branch), + } end ---@param num string ---@return string function M:checks_cmd(num) - local _ = num - return 'glab ci list' + local _ = num + return 'glab ci list' end ---@param run_id string ---@param failed_only boolean ---@return string[] function M:check_log_cmd(run_id, failed_only) - local _ = failed_only - local lines = forge.config().ci.lines - return { - 'sh', - '-c', - ('glab ci trace %s | tail -n %d'):format(run_id, lines), - } + local _ = failed_only + local lines = forge.config().ci.lines + return { + 'sh', + '-c', + ('glab ci trace %s | tail -n %d'):format(run_id, lines), + } end ---@param run_id string ---@return string[] function M:check_tail_cmd(run_id) - return { 'glab', 'ci', 'trace', run_id } + return { 'glab', 'ci', 'trace', run_id } end function M:list_runs_json_cmd(branch) - local cmd = { - 'glab', - 'ci', - 'list', - '--output', - 'json', - '--per-page', - '30', - } - if branch then - table.insert(cmd, '--ref') - table.insert(cmd, branch) - end - return cmd + local cmd = { + 'glab', + 'ci', + 'list', + '--output', + 'json', + '--per-page', + '30', + } + if branch then + table.insert(cmd, '--ref') + table.insert(cmd, branch) + end + return cmd end function M:normalize_run(entry) - local ref = entry.ref or '' - local mr_num = ref:match('^refs/merge%-requests/(%d+)/head$') - return { - id = tostring(entry.id or ''), - name = mr_num and ('!%s'):format(mr_num) or ref, - branch = '', - status = entry.status or '', - event = entry.source or '', - url = entry.web_url or '', - created_at = entry.created_at or '', - } + local ref = entry.ref or '' + local mr_num = ref:match('^refs/merge%-requests/(%d+)/head$') + return { + id = tostring(entry.id or ''), + name = mr_num and ('!%s'):format(mr_num) or ref, + branch = '', + status = entry.status or '', + event = entry.source or '', + url = entry.web_url or '', + created_at = entry.created_at or '', + } end function M:run_log_cmd(id, failed_only) - local lines = forge.config().ci.lines - local jq_filter = failed_only - and '[.[] | select(.status=="failed")][0].id // .[0].id' - or '.[0].id' - return { - 'sh', - '-c', - ('JOB=$(glab api \'projects/:id/pipelines/%s/jobs?per_page=100\' | jq -r \'%s\') && [ "$JOB" != "null" ] && glab ci trace "$JOB" | tail -n %d'):format( - id, - jq_filter, - lines - ), - } + local lines = forge.config().ci.lines + local jq_filter = failed_only and '[.[] | select(.status=="failed")][0].id // .[0].id' + or '.[0].id' + return { + 'sh', + '-c', + ('JOB=$(glab api \'projects/:id/pipelines/%s/jobs?per_page=100\' | jq -r \'%s\') && [ "$JOB" != "null" ] && glab ci trace "$JOB" | tail -n %d'):format( + id, + jq_filter, + lines + ), + } end function M:run_tail_cmd(id) - local jq_filter = - '[.[] | select(.status=="running" or .status=="pending")][0].id // .[0].id' - return { - 'sh', - '-c', - ('JOB=$(glab api \'projects/:id/pipelines/%s/jobs?per_page=100\' | jq -r \'%s\') && [ "$JOB" != "null" ] && glab ci trace "$JOB"'):format( - id, - jq_filter - ), - } + local jq_filter = '[.[] | select(.status=="running" or .status=="pending")][0].id // .[0].id' + return { + 'sh', + '-c', + ('JOB=$(glab api \'projects/:id/pipelines/%s/jobs?per_page=100\' | jq -r \'%s\') && [ "$JOB" != "null" ] && glab ci trace "$JOB"'):format( + id, + jq_filter + ), + } end ---@param num string ---@param method string ---@return string[] function M:merge_cmd(num, method) - local cmd = { 'glab', 'mr', 'merge', num } - if method == 'squash' then - table.insert(cmd, '--squash') - elseif method == 'rebase' then - table.insert(cmd, '--rebase') - end - return cmd + local cmd = { 'glab', 'mr', 'merge', num } + if method == 'squash' then + table.insert(cmd, '--squash') + elseif method == 'rebase' then + table.insert(cmd, '--rebase') + end + return cmd end ---@param num string ---@return string[] function M:approve_cmd(num) - return { 'glab', 'mr', 'approve', num } + return { 'glab', 'mr', 'approve', num } end ---@param num string ---@return string[] function M:close_cmd(num) - return { 'glab', 'mr', 'close', num } + return { 'glab', 'mr', 'close', num } end ---@param num string ---@return string[] function M:reopen_cmd(num) - return { 'glab', 'mr', 'reopen', num } + return { 'glab', 'mr', 'reopen', num } end ---@param num string ---@return string[] function M:close_issue_cmd(num) - return { 'glab', 'issue', 'close', num } + return { 'glab', 'issue', 'close', num } end ---@param num string ---@return string[] function M:reopen_issue_cmd(num) - return { 'glab', 'issue', 'reopen', num } + return { 'glab', 'issue', 'reopen', num } end ---@param title string @@ -311,111 +301,111 @@ end ---@param reviewers string[]? ---@return string[] function M:create_pr_cmd(title, body, base, draft, reviewers) - local cmd = { - 'glab', 'mr', 'create', - '--title', title, - '--description', body, - '--target-branch', base, - '--yes', - } - if draft then - table.insert(cmd, '--draft') - end - for _, r in ipairs(reviewers or {}) do - table.insert(cmd, '--reviewer') - table.insert(cmd, r) - end - return cmd + local cmd = { + 'glab', + 'mr', + 'create', + '--title', + title, + '--description', + body, + '--target-branch', + base, + '--yes', + } + if draft then + table.insert(cmd, '--draft') + end + for _, r in ipairs(reviewers or {}) do + table.insert(cmd, '--reviewer') + table.insert(cmd, r) + end + return cmd end ---@return string[] function M:create_pr_web_cmd() - return { 'glab', 'mr', 'create', '--web' } + return { 'glab', 'mr', 'create', '--web' } end ---@return string[] function M:default_branch_cmd() - return { - 'sh', '-c', - "glab repo view -F json | jq -r '.default_branch'", - } + return { + 'sh', + '-c', + "glab repo view -F json | jq -r '.default_branch'", + } end ---@return string[] function M:template_paths() - return { '.gitlab/merge_request_templates/' } + return { '.gitlab/merge_request_templates/' } end ---@param num string ---@param is_draft boolean ---@return string[]? function M:draft_toggle_cmd(num, is_draft) - if is_draft then - return { 'glab', 'mr', 'update', num, '--ready' } - end - return { 'glab', 'mr', 'update', num, '--draft' } + if is_draft then + return { 'glab', 'mr', 'update', num, '--ready' } + end + return { 'glab', 'mr', 'update', num, '--draft' } end ---@return forge.RepoInfo function M:repo_info() - local result = vim.system( - { 'glab', 'api', 'projects/:id' }, - { text = true } - ) - :wait() - local ok, data = pcall(vim.json.decode, result.stdout or '{}') - if not ok or type(data) ~= 'table' then - data = {} - end - local perms = type(data.permissions) == 'table' and data.permissions or {} - local pa = type(perms.project_access) == 'table' and perms.project_access - or {} - local ga = type(perms.group_access) == 'table' and perms.group_access or {} - local access = pa.access_level or 0 - local group_access = ga.access_level or 0 - local level = math.max(access, group_access) + local result = vim.system({ 'glab', 'api', 'projects/:id' }, { text = true }):wait() + local ok, data = pcall(vim.json.decode, result.stdout or '{}') + if not ok or type(data) ~= 'table' then + data = {} + end + local perms = type(data.permissions) == 'table' and data.permissions or {} + local pa = type(perms.project_access) == 'table' and perms.project_access or {} + local ga = type(perms.group_access) == 'table' and perms.group_access or {} + local access = pa.access_level or 0 + local group_access = ga.access_level or 0 + local level = math.max(access, group_access) - local permission = 'READ' - if level >= 40 then - permission = 'ADMIN' - elseif level >= 30 then - permission = 'WRITE' - end + local permission = 'READ' + if level >= 40 then + permission = 'ADMIN' + elseif level >= 30 then + permission = 'WRITE' + end - local methods = {} - local merge_method = data.merge_method or 'merge' - if merge_method == 'ff' or merge_method == 'rebase_merge' then - table.insert(methods, 'rebase') - else - table.insert(methods, 'merge') - end - if data.squash_option ~= 'never' then - table.insert(methods, 'squash') - end + local methods = {} + local merge_method = data.merge_method or 'merge' + if merge_method == 'ff' or merge_method == 'rebase_merge' then + table.insert(methods, 'rebase') + else + table.insert(methods, 'merge') + end + if data.squash_option ~= 'never' then + table.insert(methods, 'squash') + end - return { - permission = permission, - merge_methods = methods, - } + return { + permission = permission, + merge_methods = methods, + } end ---@param num string ---@return forge.PRState function M:pr_state(num) - local result = vim.system( - { 'glab', 'mr', 'view', num, '--output', 'json' }, - { text = true } - ):wait() - local ok, data = pcall(vim.json.decode, result.stdout or '{}') - if not ok or type(data) ~= 'table' then - data = {} - end - return { - state = (data.state or 'unknown'):upper(), - mergeable = data.merge_status or 'unknown', - review_decision = '', - is_draft = data.draft == true, - } + local result = vim + .system({ 'glab', 'mr', 'view', num, '--output', 'json' }, { text = true }) + :wait() + local ok, data = pcall(vim.json.decode, result.stdout or '{}') + if not ok or type(data) ~= 'table' then + data = {} + end + return { + state = (data.state or 'unknown'):upper(), + mergeable = data.merge_status or 'unknown', + review_decision = '', + is_draft = data.draft == true, + } end return M diff --git a/lua/forge/health.lua b/lua/forge/health.lua index 18ede08..8667d76 100644 --- a/lua/forge/health.lua +++ b/lua/forge/health.lua @@ -1,47 +1,47 @@ local M = {} function M.check() - vim.health.start('forge.nvim') + vim.health.start('forge.nvim') - if vim.fn.executable('git') == 1 then - vim.health.ok('git found') + if vim.fn.executable('git') == 1 then + vim.health.ok('git found') + else + vim.health.error('git not found') + end + + local clis = { + { 'gh', 'GitHub' }, + { 'glab', 'GitLab' }, + { 'tea', 'Codeberg/Gitea/Forgejo' }, + } + for _, cli in ipairs(clis) do + if vim.fn.executable(cli[1]) == 1 then + vim.health.ok(cli[1] .. ' found (' .. cli[2] .. ')') else - vim.health.error('git not found') + vim.health.info(cli[1] .. ' not found (' .. cli[2] .. ' support disabled)') end + end - local clis = { - { 'gh', 'GitHub' }, - { 'glab', 'GitLab' }, - { 'tea', 'Codeberg/Gitea/Forgejo' }, - } - for _, cli in ipairs(clis) do - if vim.fn.executable(cli[1]) == 1 then - vim.health.ok(cli[1] .. ' found (' .. cli[2] .. ')') - else - vim.health.info(cli[1] .. ' not found (' .. cli[2] .. ' support disabled)') - end - end + local has_fzf = pcall(require, 'fzf-lua') + if has_fzf then + vim.health.ok('fzf-lua found') + else + vim.health.error('fzf-lua not found (required)') + end - local has_fzf = pcall(require, 'fzf-lua') - if has_fzf then - vim.health.ok('fzf-lua found') - else - vim.health.error('fzf-lua not found (required)') - end + local has_diffs = pcall(require, 'diffs') + if has_diffs then + vim.health.ok('diffs.nvim found (review mode available)') + else + vim.health.info('diffs.nvim not found (review mode disabled)') + end - local has_diffs = pcall(require, 'diffs') - if has_diffs then - vim.health.ok('diffs.nvim found (review mode available)') - else - vim.health.info('diffs.nvim not found (review mode disabled)') - end - - local has_fugitive = vim.fn.exists(':Git') == 2 - if has_fugitive then - vim.health.ok('vim-fugitive found (fugitive keymaps available)') - else - vim.health.info('vim-fugitive not found (fugitive keymaps disabled)') - end + local has_fugitive = vim.fn.exists(':Git') == 2 + if has_fugitive then + vim.health.ok('vim-fugitive found (fugitive keymaps available)') + else + vim.health.info('vim-fugitive not found (fugitive keymaps disabled)') + end end return M diff --git a/lua/forge/init.lua b/lua/forge/init.lua index 4b24823..20af8aa 100644 --- a/lua/forge/init.lua +++ b/lua/forge/init.lua @@ -1,17 +1,17 @@ local M = {} local hl_defaults = { - ForgeComposeComment = 'Comment', - ForgeComposeBranch = 'Normal', - ForgeComposeForge = 'Type', - ForgeComposeDraft = 'DiagnosticWarn', - ForgeComposeFile = 'Normal', - ForgeComposeAdded = 'Added', - ForgeComposeRemoved = 'Removed', + ForgeComposeComment = 'Comment', + ForgeComposeBranch = 'Normal', + ForgeComposeForge = 'Type', + ForgeComposeDraft = 'DiagnosticWarn', + ForgeComposeFile = 'Normal', + ForgeComposeAdded = 'Added', + ForgeComposeRemoved = 'Removed', } for group, link in pairs(hl_defaults) do - vim.api.nvim_set_hl(0, group, { default = true, link = link }) + vim.api.nvim_set_hl(0, group, { default = true, link = link }) end ---@type table> @@ -19,43 +19,43 @@ local compose_marks = {} local compose_ns = vim.api.nvim_create_namespace('forge_pr_compose') vim.api.nvim_set_decoration_provider(compose_ns, { - on_win = function(_, _, bufnr) - return compose_marks[bufnr] ~= nil - end, - on_line = function(_, _, bufnr, row) - local by_line = compose_marks[bufnr] - if not by_line then - return - end - local row_marks = by_line[row] - if not row_marks then - return - end - for _, m in ipairs(row_marks) do - vim.api.nvim_buf_set_extmark(bufnr, compose_ns, row, m.col, { - end_col = m.end_col, - hl_group = m.hl, - ephemeral = true, - priority = 4097, - }) - end - end, + on_win = function(_, _, bufnr) + return compose_marks[bufnr] ~= nil + end, + on_line = function(_, _, bufnr, row) + local by_line = compose_marks[bufnr] + if not by_line then + return + end + local row_marks = by_line[row] + if not row_marks then + return + end + for _, m in ipairs(row_marks) do + vim.api.nvim_buf_set_extmark(bufnr, compose_ns, row, m.col, { + end_col = m.end_col, + hl_group = m.hl, + ephemeral = true, + priority = 4097, + }) + end + end, }) ---@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) + 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() + vim.notify('[forge.nvim]: ' .. msg, level or vim.log.levels.INFO) + vim.cmd.redraw() end ---@class forge.PRState @@ -140,262 +140,259 @@ 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 + 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 ---@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' - 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' - end - return nil + if remote:find('github') and vim.fn.executable('gh') == 1 then + return 'github' + 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' + 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 - M.log('forge cache hit (' .. forge_cache[root].name .. ')') - 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 f = require('forge.' .. name) - forge_cache[root] = f - M.log('detected ' .. name .. ' via origin remote') - return f + local root = git_root() + if not root then + return nil + end + if forge_cache[root] then + M.log('forge cache hit (' .. forge_cache[root].name .. ')') + 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 f = require('forge.' .. name) + forge_cache[root] = f + M.log('detected ' .. name .. ' via origin remote') + return f 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 - M.log('repo_info cache hit') - return repo_info_cache[root] - end - M.log('fetching repo info...') - local info = f:repo_info() - if root then - repo_info_cache[root] = info - end - M.log('repo_info fetched (permission: ' .. info.permission .. ')') - return info + local root = git_root() + if root and repo_info_cache[root] then + M.log('repo_info cache hit') + return repo_info_cache[root] + end + M.log('fetching repo info...') + local info = f:repo_info() + if root then + repo_info_cache[root] = info + end + M.log('repo_info fetched (permission: ' .. info.permission .. ')') + 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 + local root = git_root() or '' + return root .. ':' .. kind .. ':' .. state end ---@param key string ---@return table[]? function M.get_list(key) - if list_cache[key] then - M.log('list cache hit (' .. key .. ')') - end - return list_cache[key] + if list_cache[key] then + M.log('list cache hit (' .. key .. ')') + end + return list_cache[key] end ---@param key string ---@param data table[] function M.set_list(key, data) - list_cache[key] = data - M.log('list cache set (' .. key .. ', ' .. #data .. ' items)') + list_cache[key] = data + M.log('list cache set (' .. key .. ', ' .. #data .. ' items)') end ---@param key string? function M.clear_list(key) - if key then - list_cache[key] = nil - M.log('list cache cleared (' .. key .. ')') - else - list_cache = {} - M.log('list cache cleared (all)') - end + if key then + list_cache[key] = nil + M.log('list cache cleared (' .. key .. ')') + else + list_cache = {} + M.log('list cache cleared (all)') + end end function M.clear_cache() - forge_cache = {} - repo_info_cache = {} - root_cache = {} - list_cache = {} - M.log('all caches cleared') + forge_cache = {} + repo_info_cache = {} + root_cache = {} + list_cache = {} + M.log('all caches cleared') end ---@return string function M.file_loc() - local root = git_root() - if not root then - return vim.fn.expand('%:t') + 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 - 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) + if s == e then + return ('%s:%d'):format(file, s) end - return ('%s:%d'):format(file, vim.fn.line('.')) + 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 + 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) + 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), - month = tonumber(mo), - day = tonumber(d), - hour = tonumber(h), - min = tonumber(mi), - sec = tonumber(s), - }) - if ok and ts then - return ts - end + 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), + month = tonumber(mo), + day = tonumber(d), + hour = tonumber(h), + min = tonumber(mi), + sec = tonumber(s), + }) + 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)) + 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 ---@param iso string? ---@return string local function compact_date(iso) - local ts = parse_iso(iso) - if not ts then - return '' - end - local current_year = os.date('%Y') - local entry_year = os.date('%Y', ts) - if entry_year == current_year then - return os.date('%d/%m %H:%M', ts) - end - return os.date('%d/%m/%y %H:%M', ts) + local ts = parse_iso(iso) + if not ts then + return '' + end + local current_year = os.date('%Y') + local entry_year = os.date('%Y', ts) + if entry_year == current_year then + return os.date('%d/%m %H:%M', ts) + end + return os.date('%d/%m/%y %H:%M', ts) 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', + 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 + 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 '') + 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 ---@param entry table @@ -403,30 +400,30 @@ end ---@param show_state boolean ---@return string function M.format_pr(entry, fields, show_state) - 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, color - if state == 'open' or state == 'opened' then - icon, color = '+', '\27[34m' - elseif state == 'merged' then - icon, color = 'm', '\27[35m' - else - icon, color = 'x', '\27[31m' - end - prefix = color .. icon .. '\27[0m ' + 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, color + if state == 'open' or state == 'opened' then + icon, color = '+', '\27[34m' + elseif state == 'merged' then + icon, color = 'm', '\27[35m' + else + icon, color = 'x', '\27[31m' end - return ('%s\27[34m#%-5s\27[0m %s \27[2m%-15s %3s\27[0m'):format( - prefix, - num, - pad_or_truncate(title, 45), - pad_or_truncate(author, 15), - age - ) + prefix = color .. icon .. '\27[0m ' + end + return ('%s\27[34m#%-5s\27[0m %s \27[2m%-15s %3s\27[0m'):format( + prefix, + num, + pad_or_truncate(title, 45), + pad_or_truncate(author, 15), + age + ) end ---@param entry table @@ -434,139 +431,126 @@ end ---@param show_state boolean ---@return string function M.format_issue(entry, fields, show_state) - 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, color - if state == 'open' or state == 'opened' then - icon, color = '+', '\27[34m' - else - icon, color = '*', '\27[2m' - end - prefix = color .. icon .. '\27[0m ' + 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, color + if state == 'open' or state == 'opened' then + icon, color = '+', '\27[34m' + else + icon, color = '*', '\27[2m' end - return ('%s\27[34m#%-5s\27[0m %s \27[2m%-15s %3s\27[0m'):format( - prefix, - num, - pad_or_truncate(title, 45), - pad_or_truncate(author, 15), - age - ) + prefix = color .. icon .. '\27[0m ' + end + return ('%s\27[34m#%-5s\27[0m %s \27[2m%-15s %3s\27[0m'):format( + prefix, + num, + pad_or_truncate(title, 45), + pad_or_truncate(author, 15), + age + ) end ---@param check table ---@return string function M.format_check(check) - local bucket = (check.bucket or 'pending'):lower() - local name = check.name or '' - local icon, color - if bucket == 'pass' then - icon, color = '*', '\27[32m' - elseif bucket == 'fail' then - icon, color = 'x', '\27[31m' - elseif bucket == 'pending' then - icon, color = '~', '\27[33m' - elseif bucket == 'skipping' or bucket == 'cancel' then - icon, color = '-', '\27[2m' - else - icon, color = '?', '\27[2m' + local bucket = (check.bucket or 'pending'):lower() + local name = check.name or '' + local icon, color + if bucket == 'pass' then + icon, color = '*', '\27[32m' + elseif bucket == 'fail' then + icon, color = 'x', '\27[31m' + elseif bucket == 'pending' then + icon, color = '~', '\27[33m' + elseif bucket == 'skipping' or bucket == 'cancel' then + icon, color = '-', '\27[2m' + else + icon, color = '?', '\27[2m' + 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 - 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 ('%s%s\27[0m %s \27[2m%s\27[0m'):format( - color, - icon, - pad_or_truncate(name, 35), - elapsed - ) + end + return ('%s%s\27[0m %s \27[2m%s\27[0m'):format(color, icon, pad_or_truncate(name, 35), elapsed) end ---@param run forge.CIRun ---@return string function M.format_run(run) - local icon, color - local s = run.status:lower() - if s == 'success' then - icon, color = '*', '\27[32m' - elseif s == 'failure' or s == 'failed' then - icon, color = 'x', '\27[31m' - elseif - s == 'in_progress' - or s == 'running' - or s == 'pending' - or s == 'queued' - then - icon, color = '~', '\27[33m' - elseif s == 'cancelled' or s == 'canceled' or s == 'skipped' then - icon, color = '-', '\27[2m' - else - icon, color = '?', '\27[2m' - end - local event = abbreviate_event(run.event) - local date = compact_date(run.created_at) - if run.branch ~= '' then - 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), - event, - date - ) - end - return ('%s%s\27[0m %s \27[2m%-6s %s\27[0m'):format( - color, - icon, - pad_or_truncate(run.name, 35), - event, - date + local icon, color + local s = run.status:lower() + if s == 'success' then + icon, color = '*', '\27[32m' + elseif s == 'failure' or s == 'failed' then + icon, color = 'x', '\27[31m' + elseif s == 'in_progress' or s == 'running' or s == 'pending' or s == 'queued' then + icon, color = '~', '\27[33m' + elseif s == 'cancelled' or s == 'canceled' or s == 'skipped' then + icon, color = '-', '\27[2m' + else + icon, color = '?', '\27[2m' + end + local event = abbreviate_event(run.event) + local date = compact_date(run.created_at) + if run.branch ~= '' then + 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), + event, + date ) + end + return ('%s%s\27[0m %s \27[2m%-6s %s\27[0m'):format( + color, + icon, + pad_or_truncate(run.name, 35), + event, + date + ) 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 + 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 - 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 filtered end function M.config() - return vim.tbl_deep_extend('force', { - ci = { lines = 10000 }, - }, vim.g.forge or {}) + return vim.tbl_deep_extend('force', { + ci = { lines = 10000 }, + }, vim.g.forge or {}) end ---@type { base: string?, mode: 'unified'|'split' } @@ -574,128 +558,126 @@ M.review = { base = nil, mode = 'unified' } ---@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) + 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 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('[/-]', ' ') - local lines = {} - for _, c in ipairs(commits) do - table.insert(lines, '- ' .. c.subject) - end - return clean, table.concat(lines, '\n') + 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) + 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, stat.size, 0) + 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 - 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 + return vim.trim(content) end + end end + end end + end end - return nil + end + return nil end ---@param f forge.Forge @@ -707,51 +689,47 @@ end ---@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 .. '...') + 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( - { '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 + 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 - 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 + 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 @@ -759,208 +737,200 @@ end ---@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 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 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, '') + 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, '') - local comment_start = #lines + 1 + end - local pr_kind = f.labels.pr_full:gsub('s$', '') - local diff_stat = vim.fn.system( - 'git diff --stat origin/' .. base .. '..HEAD' - ):gsub('%s+$', '') + table.insert(lines, '') + local comment_start = #lines + 1 - ---@type {line: integer, col: integer, end_col: integer, hl: string}[] - local marks = {} + local pr_kind = f.labels.pr_full:gsub('s$', '') + local diff_stat = vim.fn.system('git diff --stat origin/' .. base .. '..HEAD'):gsub('%s+$', '') - local function add_line(fmt, ...) - local text = fmt:format(...) - table.insert(lines, text) - return #lines - end + ---@type {line: integer, col: integer, end_col: integer, hl: string}[] + local marks = {} - ---@param ln integer - ---@param start integer - ---@param len integer - ---@param hl string - local function mark(ln, start, len, hl) - table.insert(marks, { line = ln, col = start, end_col = start + len, hl = hl }) - end + local function add_line(fmt, ...) + local text = fmt:format(...) + table.insert(lines, text) + return #lines + end - add_line('') + stat_start = #lines + 1 + for _, sl in ipairs(vim.split(diff_stat, '\n', { plain = true })) do + table.insert(lines, ' ' .. sl) + end + stat_end = #lines + end + add_line('') + add_line(' An empty title or body aborts creation.') + add_line('-->') - vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) - vim.bo[buf].modified = false + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.bo[buf].modified = false - vim.api.nvim_set_current_buf(buf) + 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 hl = run:sub(1, 1) == '+' and 'ForgeComposeAdded' or 'ForgeComposeRemoved' - mark(i, pos - 1, #run, hl) - end - end - end + 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 - end - - local by_line = {} - for _, m in ipairs(marks) do - local row = m.line - 1 - if not by_line[row] then - by_line[row] = {} + for pos, run in line:gmatch('()([+-]+)') do + if pos > pipe then + local hl = run:sub(1, 1) == '+' and 'ForgeComposeAdded' or 'ForgeComposeRemoved' + mark(i, pos - 1, #run, hl) + end end - table.insert(by_line[row], { col = m.col, end_col = m.end_col, hl = m.hl }) + end end - compose_marks[buf] = by_line + end - local comment_ns = vim.api.nvim_create_namespace('forge_pr_comment') - for i = comment_start, #lines do - vim.api.nvim_buf_set_extmark(buf, comment_ns, i - 1, 0, { - line_hl_group = 'ForgeComposeComment', - }) + local by_line = {} + for _, m in ipairs(marks) do + local row = m.line - 1 + if not by_line[row] then + by_line[row] = {} end + table.insert(by_line[row], { col = m.col, end_col = m.end_col, hl = m.hl }) + end + compose_marks[buf] = by_line - vim.api.nvim_create_autocmd('BufWipeout', { - buffer = buf, - callback = function() - compose_marks[buf] = nil - end, + local comment_ns = vim.api.nvim_create_namespace('forge_pr_comment') + for i = comment_start, #lines do + vim.api.nvim_buf_set_extmark(buf, comment_ns, i - 1, 0, { + line_hl_group = 'ForgeComposeComment', }) + 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('^