From 46b5d52b608f44e22412207fbaee8c6b41204fde Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:34:17 -0400 Subject: [PATCH] feat(forge): support bare repo-level forge refs (#135) (#140) Problem: Forge refs required an issue/PR number (`gh:user/repo#42`). Users wanting to link a repo without a specific issue had no option. Solution: Accept `gh:user/repo` shorthand and `https://github.com/user/repo` URLs as `type='repo'` refs with `number=nil`. These conceal and render virtual text like numbered refs but skip all API calls (no validate, no fetch, no close). `format_label` strips `#%n` for bare refs. Omnifunc offers both `owner/repo#` and `owner/repo` completions. Closes #135 --- lua/pending/complete.lua | 12 ++- lua/pending/forge.lua | 220 ++++++++++++++++++++++++--------------- spec/forge_spec.lua | 117 ++++++++++++++++++++- 3 files changed, 261 insertions(+), 88 deletions(-) diff --git a/lua/pending/complete.lua b/lua/pending/complete.lua index 98291ce..26f8798 100644 --- a/lua/pending/complete.lua +++ b/lua/pending/complete.lua @@ -194,9 +194,15 @@ function M.omnifunc(findstart, base) local key = ref.owner .. '/' .. ref.repo if not seen[key] then seen[key] = true - local word = key .. '#' - if base == '' or word:sub(1, #base) == base then - table.insert(matches, { word = word, menu = '[' .. source .. ']' }) + local word_num = key .. '#' + if base == '' or word_num:sub(1, #base) == base then + table.insert(matches, { word = word_num, menu = '[' .. source .. ']' }) + end + if base == '' or key:sub(1, #base) == base then + table.insert( + matches, + { word = key, menu = '[' .. source .. ']', info = 'Bare repo link' } + ) end end end diff --git a/lua/pending/forge.lua b/lua/pending/forge.lua index 78f6654..6116a9f 100644 --- a/lua/pending/forge.lua +++ b/lua/pending/forge.lua @@ -5,8 +5,8 @@ local log = require('pending.log') ---@field forge string ---@field owner string ---@field repo string ----@field type 'issue'|'pull_request'|'merge_request' ----@field number integer +---@field type 'issue'|'pull_request'|'merge_request'|'repo' +---@field number? integer ---@field url string ---@class pending.ForgeCache @@ -27,7 +27,7 @@ local log = require('pending.log') ---@field auth_status_args string[] ---@field default_icon string ---@field default_issue_format string ----@field _auth 'unknown'|'ok'|'failed' +---@field _auth? 'unknown'|'ok'|'failed' ---@field parse_url fun(self: pending.ForgeBackend, url: string): pending.ForgeRef? ---@field api_args fun(self: pending.ForgeBackend, ref: pending.ForgeRef): string[] ---@field parse_state fun(self: pending.ForgeBackend, decoded: table): 'open'|'closed'|'merged' @@ -157,18 +157,35 @@ function M._parse_shorthand(token) return nil end local owner, repo, number = rest:match('^([%w%.%-_]+)/([%w%.%-_]+)#(%d+)$') + if owner then + local num = tonumber(number) --[[@as integer]] + local url = 'https://' + .. backend.default_host + .. '/' + .. owner + .. '/' + .. repo + .. '/issues/' + .. num + return { + forge = backend.name, + owner = owner, + repo = repo, + type = 'issue', + number = num, + url = url, + } + end + owner, repo = rest:match('^([%w%.%-_]+)/([%w%.%-_]+)$') if not owner then return nil end - local num = tonumber(number) --[[@as integer]] - local url = 'https://' .. backend.default_host .. '/' .. owner .. '/' .. repo .. '/issues/' .. num return { forge = backend.name, owner = owner, repo = repo, - type = 'issue', - number = num, - url = url, + type = 'repo', + url = 'https://' .. backend.default_host .. '/' .. owner .. '/' .. repo, } end @@ -261,7 +278,7 @@ end ---@return string[] function M._api_args(ref) local backend = _by_name[ref.forge] - if not backend then + if not backend or not ref.number then return {} end return backend:api_args(ref) @@ -278,12 +295,15 @@ function M.format_label(ref, cache) local default_icon = backend and backend.default_icon or '' local default_fmt = backend and backend.default_issue_format or '%i %o/%r#%n' local fmt = forge_cfg.issue_format or default_fmt + if ref.type == 'repo' then + fmt = fmt:gsub('#?%%n', ''):gsub('%s+$', '') + end local icon = forge_cfg.icon or default_icon local text = fmt :gsub('%%i', icon) :gsub('%%o', ref.owner) :gsub('%%r', ref.repo) - :gsub('%%n', tostring(ref.number)) + :gsub('%%n', ref.number and tostring(ref.number) or '') local hl = 'PendingForge' if cache then if cache.state == 'closed' or cache.state == 'merged' then @@ -296,6 +316,10 @@ end ---@param ref pending.ForgeRef ---@param callback fun(cache: pending.ForgeCache?, err: pending.ForgeFetchError?) function M.fetch_metadata(ref, callback) + if ref.type == 'repo' then + callback(nil) + return + end local args = M._api_args(ref) vim.system(args, { text = true }, function(result) if result.code ~= 0 or not result.stdout or result.stdout == '' then @@ -351,7 +375,12 @@ function M.refresh(s) local tasks = s:tasks() local by_forge = {} ---@type table for _, task in ipairs(tasks) do - if task.status ~= 'deleted' and task._extra and task._extra._forge_ref then + if + task.status ~= 'deleted' + and task._extra + and task._extra._forge_ref + and task._extra._forge_ref.type ~= 'repo' + then local fname = task._extra._forge_ref.forge if not by_forge[fname] then by_forge[fname] = {} @@ -419,11 +448,15 @@ end function M.validate_refs(refs) local by_forge = {} ---@type table for _, ref in ipairs(refs) do + if ref.type == 'repo' then + goto skip_ref + end local fname = ref.forge if not by_forge[fname] then by_forge[fname] = {} end table.insert(by_forge[fname], ref) + ::skip_ref:: end for fname, forge_refs in pairs(by_forge) do if not M.is_configured(fname) or not _by_name[fname] then @@ -461,25 +494,29 @@ function M.gitea_forge(opts) _ensure_instances() local host, owner, repo, kind, number = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$') - if not host then - return nil + if host and (kind == 'issues' or kind == 'pulls') and _by_host[host] == self then + local num = tonumber(number) --[[@as integer]] + local ref_type = kind == 'pulls' and 'pull_request' or 'issue' + return { + forge = self.name, + owner = owner, + repo = repo, + type = ref_type, + number = num, + url = url, + } end - if kind ~= 'issues' and kind ~= 'pulls' then - return nil + host, owner, repo = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/?$') + if host and _by_host[host] == self then + return { + forge = self.name, + owner = owner, + repo = repo, + type = 'repo', + url = url, + } end - if _by_host[host] ~= self then - return nil - end - local num = tonumber(number) --[[@as integer]] - local ref_type = kind == 'pulls' and 'pull_request' or 'issue' - return { - forge = self.name, - owner = owner, - repo = repo, - type = ref_type, - number = num, - url = url, - } + return nil end, api_args = function(self, ref) local endpoint = ref.type == 'pull_request' and 'pulls' or 'issues' @@ -511,25 +548,29 @@ M.register({ _ensure_instances() local host, owner, repo, kind, number = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$') - if not host then - return nil + if host and (kind == 'issues' or kind == 'pull') and _by_host[host] == self then + local num = tonumber(number) --[[@as integer]] + local ref_type = kind == 'pull' and 'pull_request' or 'issue' + return { + forge = 'github', + owner = owner, + repo = repo, + type = ref_type, + number = num, + url = url, + } end - if kind ~= 'issues' and kind ~= 'pull' then - return nil + host, owner, repo = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/?$') + if host and _by_host[host] == self then + return { + forge = 'github', + owner = owner, + repo = repo, + type = 'repo', + url = url, + } end - if _by_host[host] ~= self then - return nil - end - local num = tonumber(number) --[[@as integer]] - local ref_type = kind == 'pull' and 'pull_request' or 'issue' - return { - forge = 'github', - owner = owner, - repo = repo, - type = ref_type, - number = num, - url = url, - } + return nil end, api_args = function(_, ref) return { @@ -560,29 +601,38 @@ M.register({ parse_url = function(self, url) _ensure_instances() local host, path, kind, number = url:match('^https?://([^/]+)/(.+)/%-/([%w_]+)/(%d+)$') - if not host then - return nil + if host and (kind == 'issues' or kind == 'merge_requests') and _by_host[host] == self then + local owner, repo = path:match('^(.+)/([^/]+)$') + if owner then + local num = tonumber(number) --[[@as integer]] + local ref_type = kind == 'merge_requests' and 'merge_request' or 'issue' + return { + forge = 'gitlab', + owner = owner, + repo = repo, + type = ref_type, + number = num, + url = url, + } + end end - if kind ~= 'issues' and kind ~= 'merge_requests' then - return nil + host, path = url:match('^https?://([^/]+)/(.+)$') + if host and _by_host[host] == self then + local trimmed = path:gsub('/$', '') + if not trimmed:find('/%-/') then + local owner, repo = trimmed:match('^(.+)/([^/]+)$') + if owner then + return { + forge = 'gitlab', + owner = owner, + repo = repo, + type = 'repo', + url = url, + } + end + end end - if _by_host[host] ~= self then - return nil - end - local owner, repo = path:match('^(.+)/([^/]+)$') - if not owner then - return nil - end - local num = tonumber(number) --[[@as integer]] - local ref_type = kind == 'merge_requests' and 'merge_request' or 'issue' - return { - forge = 'gitlab', - owner = owner, - repo = repo, - type = ref_type, - number = num, - url = url, - } + return nil end, api_args = function(_, ref) local encoded = (ref.owner .. '/' .. ref.repo):gsub('/', '%%2F') @@ -616,25 +666,29 @@ M.register({ _ensure_instances() local host, owner, repo, kind, number = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$') - if not host then - return nil + if host and (kind == 'issues' or kind == 'pulls') and _by_host[host] == self then + local num = tonumber(number) --[[@as integer]] + local ref_type = kind == 'pulls' and 'pull_request' or 'issue' + return { + forge = 'codeberg', + owner = owner, + repo = repo, + type = ref_type, + number = num, + url = url, + } end - if kind ~= 'issues' and kind ~= 'pulls' then - return nil + host, owner, repo = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/?$') + if host and _by_host[host] == self then + return { + forge = 'codeberg', + owner = owner, + repo = repo, + type = 'repo', + url = url, + } end - if _by_host[host] ~= self then - return nil - end - local num = tonumber(number) --[[@as integer]] - local ref_type = kind == 'pulls' and 'pull_request' or 'issue' - return { - forge = 'codeberg', - owner = owner, - repo = repo, - type = ref_type, - number = num, - url = url, - } + return nil end, api_args = function(_, ref) local endpoint = ref.type == 'pull_request' and 'pulls' or 'issues' diff --git a/spec/forge_spec.lua b/spec/forge_spec.lua index 067548e..84c812c 100644 --- a/spec/forge_spec.lua +++ b/spec/forge_spec.lua @@ -44,12 +44,30 @@ describe('forge', function() assert.is_nil(forge._parse_shorthand('xx:user/repo#1')) end) - it('rejects missing number', function() - assert.is_nil(forge._parse_shorthand('gh:user/repo')) + it('parses bare gh: shorthand without number', function() + local ref = forge._parse_shorthand('gh:user/repo') + assert.is_not_nil(ref) + assert.equals('github', ref.forge) + assert.equals('user', ref.owner) + assert.equals('repo', ref.repo) + assert.equals('repo', ref.type) + assert.is_nil(ref.number) + assert.equals('https://github.com/user/repo', ref.url) + end) + + it('parses bare gl: shorthand without number', function() + local ref = forge._parse_shorthand('gl:group/project') + assert.is_not_nil(ref) + assert.equals('gitlab', ref.forge) + assert.equals('group', ref.owner) + assert.equals('project', ref.repo) + assert.equals('repo', ref.type) + assert.is_nil(ref.number) end) it('rejects missing repo', function() assert.is_nil(forge._parse_shorthand('gh:user#1')) + assert.is_nil(forge._parse_shorthand('gh:user')) end) end) @@ -73,6 +91,23 @@ describe('forge', function() it('rejects non-github URL', function() assert.is_nil(forge._parse_github_url('https://example.com/user/repo/issues/1')) end) + + it('parses bare repo URL', function() + local ref = forge._parse_github_url('https://github.com/user/repo') + assert.is_not_nil(ref) + assert.equals('github', ref.forge) + assert.equals('user', ref.owner) + assert.equals('repo', ref.repo) + assert.equals('repo', ref.type) + assert.is_nil(ref.number) + end) + + it('parses bare repo URL with trailing slash', function() + local ref = forge._parse_github_url('https://github.com/user/repo/') + assert.is_not_nil(ref) + assert.equals('repo', ref.type) + assert.is_nil(ref.number) + end) end) describe('_parse_gitlab_url', function() @@ -98,6 +133,16 @@ describe('forge', function() assert.equals('org/sub', ref.owner) assert.equals('project', ref.repo) end) + + it('parses bare repo URL', function() + local ref = forge._parse_gitlab_url('https://gitlab.com/group/project') + assert.is_not_nil(ref) + assert.equals('gitlab', ref.forge) + assert.equals('group', ref.owner) + assert.equals('project', ref.repo) + assert.equals('repo', ref.type) + assert.is_nil(ref.number) + end) end) describe('_parse_codeberg_url', function() @@ -116,6 +161,16 @@ describe('forge', function() assert.is_not_nil(ref) assert.equals('pull_request', ref.type) end) + + it('parses bare repo URL', function() + local ref = forge._parse_codeberg_url('https://codeberg.org/user/repo') + assert.is_not_nil(ref) + assert.equals('codeberg', ref.forge) + assert.equals('user', ref.owner) + assert.equals('repo', ref.repo) + assert.equals('repo', ref.type) + assert.is_nil(ref.number) + end) end) describe('parse_ref', function() @@ -141,6 +196,14 @@ describe('forge', function() assert.is_nil(forge.parse_ref('hello')) assert.is_nil(forge.parse_ref('due:tomorrow')) end) + + it('dispatches bare shorthand', function() + local ref = forge.parse_ref('gh:user/repo') + assert.is_not_nil(ref) + assert.equals('github', ref.forge) + assert.equals('repo', ref.type) + assert.is_nil(ref.number) + end) end) describe('find_refs', function() @@ -184,6 +247,17 @@ describe('forge', function() assert.equals(0, refs[1].start_byte) assert.equals(8, refs[1].end_byte) end) + + it('finds bare shorthand ref', function() + local refs = forge.find_refs('Fix gh:user/repo') + assert.equals(1, #refs) + assert.equals('github', refs[1].ref.forge) + assert.equals('repo', refs[1].ref.type) + assert.is_nil(refs[1].ref.number) + assert.equals('gh:user/repo', refs[1].raw) + assert.equals(4, refs[1].start_byte) + assert.equals(16, refs[1].end_byte) + end) end) describe('_api_args', function() @@ -262,6 +336,30 @@ describe('forge', function() assert.equals('PendingForgeClosed', hl) end) + it('formats bare repo ref without #N', function() + local text = forge.format_label({ + forge = 'github', + owner = 'user', + repo = 'repo', + type = 'repo', + url = '', + }, nil) + assert.truthy(text:find('user/repo')) + assert.is_nil(text:find('#')) + end) + + it('still formats numbered ref with #N', function() + local text = forge.format_label({ + forge = 'github', + owner = 'user', + repo = 'repo', + type = 'issue', + number = 42, + url = '', + }, nil) + assert.truthy(text:find('user/repo#42')) + end) + it('uses closed highlight for merged state', function() local _, hl = forge.format_label({ forge = 'gitlab', @@ -542,4 +640,19 @@ describe('forge diff integration', function() assert.equals(1, updated._extra._forge_ref.number) os.remove(tmp) end) + + it('stores bare forge_ref in _extra on new task', function() + local tmp = os.tmpname() + local s = store.new(tmp) + s:load() + diff.apply({ '- [ ] Check out gh:user/repo' }, s) + local tasks = s:active_tasks() + assert.equals(1, #tasks) + assert.is_not_nil(tasks[1]._extra) + assert.is_not_nil(tasks[1]._extra._forge_ref) + assert.equals('github', tasks[1]._extra._forge_ref.forge) + assert.equals('repo', tasks[1]._extra._forge_ref.type) + assert.is_nil(tasks[1]._extra._forge_ref.number) + os.remove(tmp) + end) end)