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 ---@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_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 M