local config = require('pending.config') local log = require('pending.log') ---@class pending.ForgeRef ---@field forge 'github'|'gitlab'|'codeberg' ---@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.forge local M = {} ---@type table local FORGE_HOSTS = { ['github.com'] = 'github', ['gitlab.com'] = 'gitlab', ['codeberg.org'] = 'codeberg', } ---@type table local FORGE_CLI = { github = 'gh', gitlab = 'glab', codeberg = 'tea', } ---@type table local FORGE_AUTH_CMD = { github = 'gh auth login', gitlab = 'glab auth login', codeberg = 'tea login add', } ---@type table local _warned_forges = {} ---@type table local SHORTHAND_PREFIX = { gh = 'github', gl = 'gitlab', cb = 'codeberg', } ---@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 forge = SHORTHAND_PREFIX[prefix] if not forge 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 host = forge == 'github' and 'github.com' or forge == 'gitlab' and 'gitlab.com' or 'codeberg.org' local url = 'https://' .. host .. '/' .. owner .. '/' .. repo .. '/issues/' .. num return { forge = forge, owner = owner, repo = repo, type = 'issue', number = num, url = url, } end ---@param url string ---@return pending.ForgeRef? function M._parse_github_url(url) 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 local forge_name = FORGE_HOSTS[host] if not forge_name then local cfg = config.get().forge or {} local gh_cfg = cfg.github or {} for _, inst in ipairs(gh_cfg.instances or {}) do if host == inst then forge_name = 'github' break end end end if forge_name ~= 'github' 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 ---@param url string ---@return pending.ForgeRef? function M._parse_gitlab_url(url) 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 local forge_name = FORGE_HOSTS[host] if not forge_name then local cfg = config.get().forge or {} local gl_cfg = cfg.gitlab or {} for _, inst in ipairs(gl_cfg.instances or {}) do if host == inst then forge_name = 'gitlab' break end end end if forge_name ~= 'gitlab' 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 ---@param url string ---@return pending.ForgeRef? function M._parse_codeberg_url(url) 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 local forge_name = FORGE_HOSTS[host] if not forge_name then local cfg = config.get().forge or {} local cb_cfg = cfg.codeberg or {} for _, inst in ipairs(cb_cfg.instances or {}) do if host == inst then forge_name = 'codeberg' break end end end if forge_name ~= 'codeberg' 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 ---@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 return M._parse_github_url(token) or M._parse_gitlab_url(token) or M._parse_codeberg_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) if ref.forge == 'github' then return { 'gh', 'api', '/repos/' .. ref.owner .. '/' .. ref.repo .. '/issues/' .. ref.number, } elseif ref.forge == 'gitlab' then 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, } else local endpoint = ref.type == 'pull_request' and 'pulls' or 'issues' return { 'tea', 'api', '/repos/' .. ref.owner .. '/' .. ref.repo .. '/' .. endpoint .. '/' .. ref.number, } end 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 fmt = forge_cfg.issue_format or '%i %o/%r#%n' local icon = forge_cfg.icon or '' 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 vim.schedule(function() local forge_cfg = config.get().forge or {} if forge_cfg.warn_missing_cli ~= false and not _warned_forges[ref.forge] then _warned_forges[ref.forge] = true local cli = FORGE_CLI[ref.forge] local auth_cmd = FORGE_AUTH_CMD[ref.forge] log.warn(('%s not found or not authenticated — run `%s`'):format(cli, auth_cmd)) end callback(nil) 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 state = 'open' if ref.forge == 'github' then if decoded.pull_request and decoded.pull_request.merged_at then state = 'merged' elseif decoded.state == 'closed' then state = 'closed' end elseif ref.forge == 'gitlab' then if decoded.state == 'merged' then state = 'merged' elseif decoded.state == 'closed' then state = 'closed' end else if decoded.state == 'closed' then state = 'closed' end end 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 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 return M