diff --git a/lua/pending/forge.lua b/lua/pending/forge.lua new file mode 100644 index 0000000..667eada --- /dev/null +++ b/lua/pending/forge.lua @@ -0,0 +1,419 @@ +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