local config = require('pending.config') local log = require('pending.log') ---@alias pending.ForgeType 'issue'|'pull_request'|'merge_request'|'repo' ---@alias pending.ForgeState 'open'|'closed'|'merged' ---@alias pending.ForgeAuthStatus 'unknown'|'ok'|'failed' ---@class pending.ForgeRef ---@field forge string ---@field owner string ---@field repo string ---@field type pending.ForgeType ---@field number? integer ---@field url string ---@class pending.ForgeCache ---@field title? string ---@field state pending.ForgeState ---@field labels? string[] ---@field fetched_at string ---@class pending.ForgeFetchError ---@field kind 'not_found'|'auth'|'network' ---@class pending.ForgeBackend ---@field name string ---@field shorthand string ---@field default_host string ---@field cli string ---@field auth_cmd string ---@field auth_status_args string[] ---@field default_icon string ---@field default_issue_format string ---@field _auth? pending.ForgeAuthStatus ---@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): pending.ForgeState ---@class pending.forge local M = {} ---@type pending.ForgeBackend[] local _backends = {} ---@type table local _by_name = {} ---@type table local _by_shorthand = {} ---@type table local _by_host = {} ---@type boolean local _instances_resolved = false ---@param backend pending.ForgeBackend ---@return nil function M.register(backend) backend._auth = 'unknown' table.insert(_backends, backend) _by_name[backend.name] = backend _by_shorthand[backend.shorthand] = backend _by_host[backend.default_host] = backend _instances_resolved = false end ---@return pending.ForgeBackend[] function M.backends() return _backends end ---@param forge_name string ---@return boolean function M.is_configured(forge_name) local raw = vim.g.pending if not raw or not raw.forge then return false end return raw.forge[forge_name] ~= nil end ---@param backend pending.ForgeBackend ---@param callback fun(ok: boolean) function M.check_auth(backend, callback) if backend._auth == 'ok' then callback(true) return end if backend._auth == 'failed' then callback(false) return end if vim.fn.executable(backend.cli) == 0 then backend._auth = 'failed' local forge_cfg = config.get().forge or {} if forge_cfg.warn_missing_cli ~= false then log.warn(('%s not found — run `%s`'):format(backend.cli, backend.auth_cmd)) end callback(false) return end vim.system(backend.auth_status_args, { text = true }, function(result) vim.schedule(function() if result.code == 0 then backend._auth = 'ok' callback(true) else backend._auth = 'failed' local forge_cfg = config.get().forge or {} if forge_cfg.warn_missing_cli ~= false then log.warn(('%s not authenticated — run `%s`'):format(backend.cli, backend.auth_cmd)) end callback(false) end end) end) end function M._reset_instances() _instances_resolved = false _by_shorthand = {} for _, b in ipairs(_backends) do _by_shorthand[b.shorthand] = b end end local function _ensure_instances() if _instances_resolved then return end _instances_resolved = true local cfg = config.get().forge or {} for _, backend in ipairs(_backends) do local forge_cfg = cfg[backend.name] or {} for _, inst in ipairs(forge_cfg.instances or {}) do _by_host[inst] = backend end if forge_cfg.shorthand and forge_cfg.shorthand ~= backend.shorthand then _by_shorthand[backend.shorthand] = nil backend.shorthand = forge_cfg.shorthand _by_shorthand[backend.shorthand] = backend end end end ---@param token string ---@return pending.ForgeRef? function M._parse_shorthand(token) _ensure_instances() local backend, rest for prefix, b in pairs(_by_shorthand) do local candidate = token:match('^' .. vim.pesc(prefix) .. ':(.+)$') if candidate then backend = b rest = candidate break end end if not backend then 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 return { forge = backend.name, owner = owner, repo = repo, type = 'repo', url = 'https://' .. backend.default_host .. '/' .. owner .. '/' .. repo, } end ---@param url string ---@return pending.ForgeRef? function M._parse_github_url(url) local backend = _by_name['github'] if not backend then return nil end return backend:parse_url(url) end ---@param url string ---@return pending.ForgeRef? function M._parse_gitlab_url(url) local backend = _by_name['gitlab'] if not backend then return nil end return backend:parse_url(url) end ---@param url string ---@return pending.ForgeRef? function M._parse_codeberg_url(url) local backend = _by_name['codeberg'] if not backend then return nil end return backend:parse_url(url) end ---@param token string ---@return pending.ForgeRef? function M.parse_ref(token) local short = M._parse_shorthand(token) if short then return short end if not token:match('^https?://') then return nil end _ensure_instances() local host = token:match('^https?://([^/]+)') if not host then return nil end local backend = _by_host[host] if not backend then return nil end return backend:parse_url(token) end ---@class pending.ForgeSpan ---@field ref pending.ForgeRef ---@field start_byte integer ---@field end_byte integer ---@field raw string ---@param text string ---@return pending.ForgeSpan[] function M.find_refs(text) local results = {} local pos = 1 while pos <= #text do local ws = text:find('%S', pos) if not ws then break end local token_end = text:find('%s', ws) local token = token_end and text:sub(ws, token_end - 1) or text:sub(ws) local ref = M.parse_ref(token) if ref then local eb = token_end and (token_end - 1) or #text table.insert(results, { ref = ref, start_byte = ws - 1, end_byte = eb, raw = token, }) end pos = token_end and token_end or (#text + 1) end return results end ---@param ref pending.ForgeRef ---@return string[] function M._api_args(ref) local backend = _by_name[ref.forge] if not backend or not ref.number then return {} end return backend:api_args(ref) end ---@param ref pending.ForgeRef ---@param cache? pending.ForgeCache ---@return string text ---@return string hl_group function M.format_label(ref, cache) local cfg = config.get().forge or {} local forge_cfg = cfg[ref.forge] or {} local backend = _by_name[ref.forge] 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', ref.number and tostring(ref.number) or '') local hl = 'PendingForge' if cache then if cache.state == 'closed' or cache.state == 'merged' then hl = 'PendingForgeClosed' end end return text, hl 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 local kind = 'network' local stderr = result.stderr or '' if stderr:find('404') or stderr:find('Not Found') then kind = 'not_found' elseif stderr:find('401') or stderr:find('403') or stderr:find('auth') then kind = 'auth' end vim.schedule(function() callback(nil, { kind = kind }) end) return end local ok, decoded = pcall(vim.json.decode, result.stdout) if not ok or not decoded then vim.schedule(function() callback(nil, { kind = 'network' }) end) return end local backend = _by_name[ref.forge] local state = backend and backend:parse_state(decoded) or 'open' local labels = {} if decoded.labels then for _, label in ipairs(decoded.labels) do if type(label) == 'string' then table.insert(labels, label) elseif type(label) == 'table' and label.name then table.insert(labels, label.name) end end end local cache = { title = decoded.title, state = state, labels = labels, fetched_at = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]], } vim.schedule(function() callback(cache) end) end) end ---@param s pending.Store function M.refresh(s) local forge_cfg = config.get().forge or {} if not forge_cfg.close then return end 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 and task._extra._forge_ref.type ~= 'repo' then local fname = task._extra._forge_ref.forge if not by_forge[fname] then by_forge[fname] = {} end table.insert(by_forge[fname], task) end end local any_work = false for fname, forge_tasks in pairs(by_forge) do if M.is_configured(fname) and _by_name[fname] then any_work = true M.check_auth(_by_name[fname], function(authed) if not authed then return end local remaining = #forge_tasks local any_changed = false local any_fetched = false for _, task in ipairs(forge_tasks) do local ref = task._extra._forge_ref --[[@as pending.ForgeRef]] M.fetch_metadata(ref, function(cache) remaining = remaining - 1 if cache then task._extra._forge_cache = cache any_fetched = true if (cache.state == 'closed' or cache.state == 'merged') and (task.status == 'pending' or task.status == 'wip' or task.status == 'blocked') then task.status = 'done' task['end'] = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]] any_changed = true end else task._extra._forge_cache = { state = 'open', fetched_at = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]], } end if remaining == 0 then if any_changed then s:save() end local buffer = require('pending.buffer') if (any_changed or any_fetched) and buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then buffer.render() end end end) end end) end end if not any_work then log.info('No linked tasks to refresh.') end end ---@param refs pending.ForgeRef[] 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 goto continue end M.check_auth(_by_name[fname], function(authed) if not authed then return end for _, ref in ipairs(forge_refs) do M.fetch_metadata(ref, function(_, err) if err and err.kind == 'not_found' then log.warn(('%s:%s/%s#%d not found'):format(ref.forge, ref.owner, ref.repo, ref.number)) end end) end end) ::continue:: end end ---@param opts {name: string, shorthand: string, default_host: string, cli?: string, auth_cmd?: string, auth_status_args?: string[], default_icon?: string, default_issue_format?: string} ---@return pending.ForgeBackend function M.gitea_forge(opts) return { name = opts.name, shorthand = opts.shorthand, default_host = opts.default_host, cli = opts.cli or 'tea', auth_cmd = opts.auth_cmd or 'tea login add', auth_status_args = opts.auth_status_args or { opts.cli or 'tea', 'login', 'list' }, default_icon = opts.default_icon or '', default_issue_format = opts.default_issue_format or '%i %o/%r#%n', parse_url = function(self, url) _ensure_instances() local host, owner, repo, kind, number = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$') 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 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 return nil end, api_args = function(self, ref) local endpoint = ref.type == 'pull_request' and 'pulls' or 'issues' return { self.cli, 'api', '/repos/' .. ref.owner .. '/' .. ref.repo .. '/' .. endpoint .. '/' .. ref.number, } end, parse_state = function(_, decoded) if decoded.state == 'closed' then return 'closed' end return 'open' end, } end M.register({ name = 'github', shorthand = 'gh', default_host = 'github.com', cli = 'gh', auth_cmd = 'gh auth login', auth_status_args = { 'gh', 'auth', 'status' }, default_icon = '', default_issue_format = '%i %o/%r#%n', parse_url = function(self, url) _ensure_instances() local host, owner, repo, kind, number = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$') 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 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 return nil end, api_args = function(_, ref) return { 'gh', 'api', '/repos/' .. ref.owner .. '/' .. ref.repo .. '/issues/' .. ref.number, } end, parse_state = function(_, decoded) if decoded.pull_request and decoded.pull_request.merged_at then return 'merged' elseif decoded.state == 'closed' then return 'closed' end return 'open' end, }) M.register({ name = 'gitlab', shorthand = 'gl', default_host = 'gitlab.com', cli = 'glab', auth_cmd = 'glab auth login', auth_status_args = { 'glab', 'auth', 'status' }, default_icon = '', default_issue_format = '%i %o/%r#%n', parse_url = function(self, url) _ensure_instances() local host, path, kind, number = url:match('^https?://([^/]+)/(.+)/%-/([%w_]+)/(%d+)$') 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 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 return nil end, api_args = function(_, ref) local encoded = (ref.owner .. '/' .. ref.repo):gsub('/', '%%2F') local endpoint = ref.type == 'merge_request' and 'merge_requests' or 'issues' return { 'glab', 'api', '/projects/' .. encoded .. '/' .. endpoint .. '/' .. ref.number, } end, parse_state = function(_, decoded) if decoded.state == 'merged' then return 'merged' elseif decoded.state == 'closed' then return 'closed' end return 'open' end, }) M.register({ name = 'codeberg', shorthand = 'cb', default_host = 'codeberg.org', cli = 'tea', auth_cmd = 'tea login add', auth_status_args = { 'tea', 'login', 'list' }, default_icon = '', default_issue_format = '%i %o/%r#%n', parse_url = function(self, url) _ensure_instances() local host, owner, repo, kind, number = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$') 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 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 return nil end, api_args = function(_, ref) local endpoint = ref.type == 'pull_request' and 'pulls' or 'issues' return { 'tea', 'api', '/repos/' .. ref.owner .. '/' .. ref.repo .. '/' .. endpoint .. '/' .. ref.number, } end, parse_state = function(_, decoded) if decoded.state == 'closed' then return 'closed' end return 'open' end, }) return M