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_API_BASE = { github = 'https://api.github.com', gitlab = 'https://gitlab.com', codeberg = 'https://codeberg.org', } ---@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 ---@param ref pending.ForgeRef ---@return string function M._api_url(ref) if ref.forge == 'github' then return FORGE_API_BASE.github .. '/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 FORGE_API_BASE.gitlab .. '/api/v4/projects/' .. encoded .. '/' .. endpoint .. '/' .. ref.number else local endpoint = ref.type == 'pull_request' and 'pulls' or 'issues' return FORGE_API_BASE.codeberg .. '/api/v1/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 forge string ---@return string? function M.get_token(forge) local cfg = config.get().forge or {} local forge_cfg = cfg[forge] or {} if forge_cfg.token then return forge_cfg.token end if forge == 'github' then local result = vim.fn.system({ 'gh', 'auth', 'token' }) if vim.v.shell_error == 0 and result and result ~= '' then return vim.trim(result) end elseif forge == 'gitlab' then local result = vim.fn.system({ 'glab', 'auth', 'token' }) if vim.v.shell_error == 0 and result and result ~= '' then return vim.trim(result) end end return nil end ---@param ref pending.ForgeRef ---@param callback fun(cache: pending.ForgeCache?) function M.fetch_metadata(ref, callback) local token = M.get_token(ref.forge) local url = M._api_url(ref) local args = { 'curl', '-s', '-L' } if token then table.insert(args, '-H') if ref.forge == 'gitlab' then table.insert(args, 'PRIVATE-TOKEN: ' .. token) else table.insert(args, 'Authorization: Bearer ' .. token) end end table.insert(args, '-H') table.insert(args, 'Accept: application/json') table.insert(args, url) vim.system(args, { text = true }, function(result) if result.code ~= 0 or not result.stdout or result.stdout == '' then vim.schedule(function() 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 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 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 string[] function M.conceal_patterns() local patterns = { 'gh:[A-Za-z0-9._-]\\+\\/[A-Za-z0-9._-]\\+#\\d\\+', 'gl:[A-Za-z0-9._-]\\+\\/[A-Za-z0-9._-]\\+#\\d\\+', 'cb:[A-Za-z0-9._-]\\+\\/[A-Za-z0-9._-]\\+#\\d\\+', 'https\\?:\\/\\/github\\.com\\/\\S\\+', 'https\\?:\\/\\/gitlab\\.com\\/\\S\\+', 'https\\?:\\/\\/codeberg\\.org\\/\\S\\+', } local cfg = config.get().forge or {} for _, forge_key in ipairs({ 'github', 'gitlab', 'codeberg' }) do local forge_cfg = cfg[forge_key] or {} for _, inst in ipairs(forge_cfg.instances or {}) do local escaped = inst:gsub('%.', '\\.'):gsub('/', '\\/') table.insert(patterns, 'https\\?:\\/\\/' .. escaped .. '\\/\\S\\+') end end return patterns end return M