local config = require('pending.config') local log = require('pending.log') ---@class pending.ForgeRef ---@field forge string ---@field owner string ---@field repo string ---@field type 'issue'|'pull_request'|'merge_request' ---@field number integer ---@field url string ---@class pending.ForgeCache ---@field title? string ---@field state 'open'|'closed'|'merged' ---@field labels? string[] ---@field fetched_at string ---@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 _warned boolean ---@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' ---@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._warned = false 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 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 end end ---@param token string ---@return pending.ForgeRef? function M._parse_shorthand(token) local prefix, rest = token:match('^(%l%l):(.+)$') if not prefix then return nil end local backend = _by_shorthand[prefix] if not backend then return nil end local owner, repo, number = rest:match('^([%w%.%-_]+)/([%w%.%-_]+)#(%d+)$') 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, } 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 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 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)) 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?) function M.fetch_metadata(ref, callback) 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 forge_cfg = config.get().forge or {} local backend = _by_name[ref.forge] if backend and forge_cfg.warn_missing_cli ~= false and not backend._warned then backend._warned = true vim.system(backend.auth_status_args, { text = true }, function(auth_result) vim.schedule(function() if auth_result.code ~= 0 then log.warn( ('%s not authenticated — run `%s`'):format(backend.cli, backend.auth_cmd) ) end callback(nil) end) end) else vim.schedule(function() callback(nil) end) end return end local ok, decoded = pcall(vim.json.decode, result.stdout) if not ok or not decoded then vim.schedule(function() callback(nil) 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.auto_close then return end local tasks = s:tasks() local pending_fetches = 0 local any_changed = false local any_fetched = false for _, task in ipairs(tasks) do if task.status ~= 'deleted' and task._extra and task._extra._forge_ref then local ref = task._extra._forge_ref --[[@as pending.ForgeRef]] pending_fetches = pending_fetches + 1 M.fetch_metadata(ref, function(cache) pending_fetches = pending_fetches - 1 if cache then task._extra._forge_cache = cache any_fetched = true local forge_cfg = config.get().forge or {} if forge_cfg.auto_close and (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 pending_fetches == 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 if pending_fetches == 0 then log.info('No linked tasks to refresh.') 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_backend(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', _warned = false, parse_url = function(self, url) _ensure_instances() local host, owner, repo, kind, number = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$') if not host then return nil end if kind ~= 'issues' and kind ~= 'pulls' then return nil 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, } 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', _warned = false, parse_url = function(self, url) _ensure_instances() local host, owner, repo, kind, number = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$') if not host then return nil end if kind ~= 'issues' and kind ~= 'pull' then return nil 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, } 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', _warned = false, parse_url = function(self, url) _ensure_instances() local host, path, kind, number = url:match('^https?://([^/]+)/(.+)/%-/([%w_]+)/(%d+)$') if not host then return nil end if kind ~= 'issues' and kind ~= 'merge_requests' then return nil 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, } 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', _warned = false, parse_url = function(self, url) _ensure_instances() local host, owner, repo, kind, number = url:match('^https?://([^/]+)/([%w%.%-_]+)/([%w%.%-_]+)/(%a+)/(%d+)$') if not host then return nil end if kind ~= 'issues' and kind ~= 'pulls' then return nil 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, } 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