From 07024671eb6312821d0648391349848d9e03960f Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 10 Mar 2026 19:28:44 -0400 Subject: [PATCH] feat(forge): inline overlay rendering for forge links (#126) * docs: document S3 backend, auto-auth, and `:Pending done` command Problem: The S3 backend had no `:Pending s3` entry in the COMMANDS section, `:Pending auth` only mentioned Google, the `sync` config field omitted `s3`, `_s3_sync_id` was missing from the data format section, `:Pending done` was implemented but undocumented, and the README lacked a features overview. Solution: Add `:Pending s3` and `:Pending done` command docs, rewrite `:Pending auth` to cover all backends and sub-actions, update config and data format references, add `aws` CLI to requirements, and add a Features section to `README.md`. * feat(forge): add forge link parser and metadata fetcher Problem: no way to associate tasks with GitHub, GitLab, or Codeberg issues/PRs, or to track their remote state. Solution: add `forge.lua` with shorthand (`gh:user/repo#42`) and full URL parsing, async metadata fetching via `curl`, label formatting, conceal pattern generation, token resolution, and `refresh()` for state pull (closed/merged -> done). * feat(config): add forge config defaults and `%l` eol specifier Problem: no configuration surface for forge link rendering, icons, issue format, or self-hosted instances. Solution: add `pending.ForgeConfig` class with per-forge `token`, `icon`, `issue_format`, and `instances` fields. Add `%l` to the default `eol_format` so forge labels render in virtual text. * feat(parse): extract forge refs from task body Problem: `parse.body()` had no awareness of forge link tokens, so `gh:user/repo#42` stayed in the description instead of metadata. Solution: add `forge_ref` field to `pending.Metadata` and extend the right-to-left token loop in `body()` to call `forge.parse_ref()` as the final fallback before breaking. * feat(diff): persist forge refs in store on write Problem: forge refs parsed from buffer lines were discarded during diff reconciliation and never stored in the JSON. Solution: thread `forge_ref` through `parse_buffer` entries into `diff.apply`, storing it in `task._extra._forge_ref` for both new and existing tasks. * feat(views): pass forge ref and cache to line metadata Problem: `LineMeta` had no forge fields, so `buffer.lua` could not render forge labels or apply forge-specific highlights. Solution: add `forge_ref` and `forge_cache` fields to `LineMeta`, populated from `task._extra` in both `category_view` and `priority_view`. * feat(buffer): render forge links as concealed text with eol virt text Problem: forge tokens were visible as raw text with no virtual text labels, and the eol separator logic collapsed all gaps when non-adjacent specifiers were absent. Solution: add forge conceal syntax patterns in `setup_syntax()`, add `PendingForge`/`PendingForgeClosed` highlight groups, handle the `%l` specifier in `build_eol_virt()`, fix separator collapsing to buffer one separator between present segments, and change `concealcursor` to `nc` (reveal in visual and insert mode). * feat(complete): add forge shorthand omnifunc completions Problem: no completion support for `gh:`, `gl:`, or `cb:` tokens, requiring users to type owner/repo from memory. Solution: extend `omnifunc` to detect `gh:`/`gl:`/`cb:` prefixes and complete with `owner/repo#` candidates from existing forge refs in the store. * feat: trigger forge refresh on buffer open Problem: forge metadata was never fetched, so virt text highlights could not reflect remote issue/PR state. Solution: call `forge.refresh()` in `M.open()` so metadata is fetched once per `:Pending` invocation rather than on every render. * test(forge): add forge parsing spec Problem: no test coverage for forge link shorthand parsing, URL parsing, label formatting, or API URL generation. Solution: add `spec/forge_spec.lua` covering `_parse_shorthand`, `parse_ref` for all three forges, full URL parsing including nested GitLab groups, `format_label`, and `_api_url`. * docs: document forge links feature Problem: no user-facing documentation for forge link syntax, configuration, or behavior. Solution: add forge links section to `README.md` and `pending.txt` covering shorthand/URL syntax, config options, virtual text rendering, state pull, and auth resolution. * feat(forge): add `find_refs()` inline token scanner Problem: forge tokens were extracted by `parse.body()` which stripped them from the description, making editing awkward and multi-ref lines impossible. Solution: add `find_refs(text)` that scans a string for all forge tokens by whitespace tokenization, returning byte offsets and parsed refs without modifying the input. Remove unused `conceal_patterns()`. * refactor: move forge ref detection from `parse.body()` to `diff` Problem: `parse.body()` stripped forge tokens from the description, losing the raw text. This made inline overlay rendering impossible since the token no longer existed in the buffer. Solution: remove the `forge.parse_ref()` branch from `parse.body()` and call `forge.find_refs()` in `diff.parse_buffer()` instead. The description now retains forge tokens verbatim; `_extra._forge_ref` is still populated from the first matched ref. * feat(buffer): render forge links as inline conceal overlays Problem: forge tokens were stripped from the buffer and shown as EOL virtual text via `%l`. The token disappeared from the editable line, and multi-ref tasks broke. Solution: compute `forge_spans` in `views.lua` with byte offsets for each forge token in the rendered line. In `apply_inline_row()`, place extmarks with `conceal=''` and `virt_text_pos='inline'` to visually replace each raw token with its formatted label. Clear stale `forge_spans` on dirty rows to prevent `end_col` out-of-range errors after edits like `dd`. * fix(config): remove `%l` from default `eol_format` Problem: forge links are now rendered inline, making the `%l` EOL specifier redundant in the default format. Solution: change default `eol_format` from `'%l %c %r %d'` to `'%c %r %d'`. The `%l` specifier remains functional for users who explicitly set it. * test(forge): update specs for inline forge refs Problem: existing tests asserted that `parse.body()` stripped forge tokens from the description and populated `meta.forge_ref`. The `conceal_patterns` test referenced a removed function. Solution: update `parse.body` integration tests to assert tokens stay in the description. Add `find_refs()` tests covering single/multiple refs, URLs, byte offsets, and empty cases. Remove `conceal_patterns` test. Update diff tests to assert description includes the token. * docs: update forge links for inline overlay rendering Problem: documentation described forge tokens as stripped from the description and rendered via EOL `%l` specifier by default. Solution: update forge links section to describe inline conceal overlay rendering. Update default `eol_format` reference. Change `issue_format` field description from "EOL label" to "inline overlay label". * ci: format --- README.md | 1 + doc/pending.txt | 123 ++++++++++- lua/pending/buffer.lua | 53 +++-- lua/pending/complete.lua | 21 ++ lua/pending/config.lua | 29 +++ lua/pending/diff.lua | 14 ++ lua/pending/forge.lua | 435 +++++++++++++++++++++++++++++++++++++++ lua/pending/init.lua | 2 + lua/pending/views.lua | 39 ++++ spec/forge_spec.lua | 377 +++++++++++++++++++++++++++++++++ 10 files changed, 1071 insertions(+), 23 deletions(-) create mode 100644 lua/pending/forge.lua create mode 100644 spec/forge_spec.lua diff --git a/README.md b/README.md index cff442d..1780cab 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ https://github.com/user-attachments/assets/f3898ecb-ec95-43fe-a71f-9c9f49628ba9 - Google Tasks bidirectional sync via OAuth PKCE - S3 whole-store sync via AWS CLI with cross-device merge - Auto-authentication: sync actions trigger auth flows automatically +- Forge links: reference GitHub/GitLab/Codeberg issues and PRs inline ## Requirements diff --git a/doc/pending.txt b/doc/pending.txt index f79961e..9ec9b34 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -43,6 +43,7 @@ Features: ~ - Google Calendar one-way push via OAuth PKCE - Google Tasks bidirectional sync via OAuth PKCE - S3 whole-store sync via AWS CLI +- Forge links: reference GitHub/GitLab/Codeberg issues and PRs inline ============================================================================== CONTENTS *pending-contents* @@ -68,8 +69,9 @@ CONTENTS *pending-contents* 19. Google Tasks ............................................ |pending-gtasks| 20. Google Authentication ......................... |pending-google-auth| 21. S3 Sync ................................................... |pending-s3| - 22. Data Format .............................................. |pending-data| - 23. Health Check ........................................... |pending-health| + 22. Forge Links ........................................... |pending-forge| + 23. Data Format .............................................. |pending-data| + 24. Health Check ........................................... |pending-health| ============================================================================== REQUIREMENTS *pending-requirements* @@ -738,7 +740,7 @@ loads: >lua max_priority = 3, view = { default = 'category', - eol_format = '%c %r %d', + eol_format = '%l %c %r %d', category = { order = {}, folding = true, @@ -840,6 +842,7 @@ Fields: ~ {eol_format} (string, default: '%c %r %d') Format string for end-of-line virtual text. Specifiers: + `%l` forge link label (`PendingForge`) `%c` category icon + name (`PendingHeader`) `%r` recurrence icon + pattern (`PendingRecur`) `%d` due icon + date (`PendingDue`/`PendingOverdue`) @@ -1009,6 +1012,15 @@ PendingFilter Applied to the `FILTER:` header line shown at the top of the buffer when a filter is active. Default: links to `DiagnosticWarn`. + *PendingForge* +PendingForge Applied to forge link virtual text (issue/PR reference). + Default: links to `DiagnosticInfo`. + + *PendingForgeClosed* +PendingForgeClosed Applied to forge link virtual text when the remote + issue/PR is closed or merged. + Default: links to `Comment`. + To override a group in your colorscheme or config: >lua vim.api.nvim_set_hl(0, 'PendingDue', { fg = '#aaaaaa', italic = true }) < @@ -1443,6 +1455,110 @@ Downloads the remote store from S3, then merges per-task by `_s3_sync_id`: `:Pending s3 sync` behavior: ~ Pulls first (merge), then pushes the merged result. +============================================================================== +FORGE LINKS *pending-forge* + +Tasks can reference remote issues, pull requests, and merge requests from +GitHub, GitLab, and Codeberg (or Gitea). References are parsed from inline +tokens, concealed in the buffer, and rendered as configurable virtual text. + +Inline syntax: ~ + +Two input forms, both parsed on `:w`: + + Shorthand: ~ + `gh:user/repo#42` GitHub issue or PR + `gl:group/project#15` GitLab issue or MR + `cb:user/repo#3` Codeberg issue or PR + + Full URL: ~ + `https://github.com/user/repo/issues/42` + `https://gitlab.com/group/project/-/merge_requests/15` + `https://codeberg.org/user/repo/issues/3` + +Example: > + Fix login bug gh:user/repo#42 due:friday +< + +On `:w`, the forge reference stays in the description and is also stored in +the task's `_extra._forge_ref` field. The raw token is visually replaced +inline with a formatted label using overlay extmarks (same technique as +checkbox icons). Multiple forge references in one line are each overlaid +independently. + +The `%l` specifier in `eol_format` is still supported for users who prefer +the link label in EOL virtual text, but it is no longer in the default +format (`'%c %r %d'`). + +Format string: ~ + *pending-forge-format* +Each forge has a configurable `issue_format` string with these placeholders: + `%i` Forge icon (nerd font) + `%o` Repository owner + `%r` Repository name + `%n` Issue/PR number + +Default: `'%i %o/%r#%n'` (e.g. ` user/repo#42`). + +Configuration: ~ + *pending.ForgeConfig* +>lua + vim.g.pending = { + forge = { + github = { + token = nil, + icon = '', + issue_format = '%i %o/%r#%n', + instances = {}, + }, + gitlab = { + token = nil, + icon = '', + issue_format = '%i %o/%r#%n', + instances = {}, + }, + codeberg = { + token = nil, + icon = '', + issue_format = '%i %o/%r#%n', + instances = {}, + }, + }, + } +< + +Fields (per forge): ~ + {token} (string, optional) API token for authenticated requests. + Falls back to CLI: `gh auth token` (GitHub), `glab auth + token` (GitLab). Codeberg uses token only. + {icon} (string) Nerd font icon used in virtual text. + {issue_format} (string) Format string for the inline overlay label. + {instances} (string[]) Additional hostnames for self-hosted instances + (e.g. `{ 'github.company.com' }`). + +Authentication: ~ +Token retrieval is CLI-preferred, config fallback: +1. GitHub: `gh auth token` stdout. Falls back to `forge.github.token`. +2. GitLab: `glab auth token` stdout. Falls back to `forge.gitlab.token`. +3. Codeberg: `forge.codeberg.token` only (no standard CLI). + +Unauthenticated requests work for public repositories. Private repositories +require a token. + +Metadata fetching: ~ +On buffer open, tasks with a `_forge_ref` whose cached metadata is older +than 5 minutes are re-fetched asynchronously. The buffer renders immediately +with cached data and updates extmarks when the fetch completes. + +State pull: ~ +After fetching, if the remote issue/PR is closed or merged and the local +task is pending/wip/blocked, the task is automatically marked as done. This +is one-way: local status changes do not push back to the forge. + +Highlight groups: ~ + |PendingForge| Open issue/PR link label + |PendingForgeClosed| Closed/merged issue/PR link label + ============================================================================== DATA FORMAT *pending-data* @@ -1481,6 +1597,7 @@ save. This is used internally to store sync backend metadata: - Google Calendar: `_gcal_event_id`, `_gcal_calendar_id` - Google Tasks: `_gtasks_task_id`, `_gtasks_list_id` - S3: `_s3_sync_id` (UUID for cross-device merge) +- Forge links: `_forge_ref` (parsed reference), `_forge_cache` (fetched state) Third-party tooling can annotate tasks via `_extra` without data loss. The `version` field is checked on load. If the file version is newer than the diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 7f8d94c..e162f67 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -168,6 +168,19 @@ local function apply_inline_row(bufnr, row, m, icons) virt_text_pos = 'overlay', priority = 100, }) + if m.forge_spans then + local forge = require('pending.forge') + for _, span in ipairs(m.forge_spans) do + local label_text, hl_group = forge.format_label(span.ref, span.cache) + vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, span.col_start, { + end_col = span.col_end, + conceal = '', + virt_text = { { label_text, hl_group } }, + virt_text_pos = 'inline', + priority = 90, + }) + end + end elseif m.type == 'header' then local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, { @@ -213,6 +226,7 @@ function M.reapply_dirty_inline(bufnr) local line = vim.api.nvim_buf_get_lines(bufnr, row - 1, row, false)[1] or '' local old_status = m.status m.status = infer_status(line) or m.status + m.forge_spans = nil log.debug( ('reapply_dirty: row=%d line=%q old_status=%s new_status=%s'):format( row, @@ -365,7 +379,7 @@ end ---@param winid integer local function set_win_options(winid) vim.wo[winid].conceallevel = 3 - vim.wo[winid].concealcursor = 'nvic' + vim.wo[winid].concealcursor = 'nc' vim.wo[winid].winfixheight = true end @@ -432,7 +446,7 @@ end ---@class pending.EolSegment ---@field type 'specifier'|'literal' ----@field key? 'c'|'r'|'d' +---@field key? 'c'|'r'|'d'|'l' ---@field text? string ---@param fmt string @@ -444,7 +458,7 @@ local function parse_eol_format(fmt) while pos <= len do if fmt:sub(pos, pos) == '%' and pos + 1 <= len then local key = fmt:sub(pos + 1, pos + 1) - if key == 'c' or key == 'r' or key == 'd' then + if key == 'c' or key == 'r' or key == 'd' or key == 'l' then table.insert(segments, { type = 'specifier', key = key }) pos = pos + 2 else @@ -471,7 +485,10 @@ local function build_eol_virt(segments, m, icons) for i, seg in ipairs(segments) do if seg.type == 'specifier' then local text, hl - if seg.key == 'c' and m.show_category and m.category then + if seg.key == 'l' and m.forge_ref then + local forge = require('pending.forge') + text, hl = forge.format_label(m.forge_ref, m.forge_cache) + elseif seg.key == 'c' and m.show_category and m.category then text = icons.category .. ' ' .. m.category hl = 'PendingHeader' elseif seg.key == 'r' and m.recur then @@ -488,26 +505,20 @@ local function build_eol_virt(segments, m, icons) end local virt_parts = {} - for i, r in ipairs(resolved) do + local pending_sep = nil + for _, r in ipairs(resolved) do if r.literal then - local prev_present, next_present = false, false - for j = i - 1, 1, -1 do - if not resolved[j].literal then - prev_present = resolved[j].present - break - end - end - for j = i + 1, #resolved do - if not resolved[j].literal then - next_present = resolved[j].present - break - end - end - if prev_present and next_present then - table.insert(virt_parts, { r.text, r.hl }) + if #virt_parts > 0 and not pending_sep then + pending_sep = { r.text, r.hl } end elseif r.present then + if pending_sep then + table.insert(virt_parts, pending_sep) + pending_sep = nil + end table.insert(virt_parts, { r.text, r.hl }) + else + pending_sep = nil end end return virt_parts @@ -552,6 +563,8 @@ local function setup_highlights() vim.api.nvim_set_hl(0, 'PendingBlocked', { link = 'DiagnosticError', default = true }) vim.api.nvim_set_hl(0, 'PendingRecur', { link = 'DiagnosticInfo', default = true }) vim.api.nvim_set_hl(0, 'PendingFilter', { link = 'DiagnosticWarn', default = true }) + vim.api.nvim_set_hl(0, 'PendingForge', { link = 'DiagnosticInfo', default = true }) + vim.api.nvim_set_hl(0, 'PendingForgeClosed', { link = 'Comment', default = true }) end ---@return string diff --git a/lua/pending/complete.lua b/lua/pending/complete.lua index 6ee3320..135d1a4 100644 --- a/lua/pending/complete.lua +++ b/lua/pending/complete.lua @@ -128,6 +128,9 @@ function M.omnifunc(findstart, base) { vim.pesc(dk) .. ':([%S]*)$', dk }, { 'cat:([%S]*)$', 'cat' }, { vim.pesc(rk) .. ':([%S]*)$', rk }, + { 'gh:([%S]*)$', 'gh' }, + { 'gl:([%S]*)$', 'gl' }, + { 'cb:([%S]*)$', 'cb' }, } for _, check in ipairs(checks) do @@ -169,6 +172,24 @@ function M.omnifunc(findstart, base) table.insert(matches, { word = c.word, menu = '[' .. source .. ']', info = c.info }) end end + elseif source == 'gh' or source == 'gl' or source == 'cb' then + local s = require('pending.buffer').store() + if s then + local seen = {} + for _, task in ipairs(s:tasks()) do + if task._extra and task._extra._forge_ref then + local ref = task._extra._forge_ref + local key = ref.owner .. '/' .. ref.repo + if not seen[key] then + seen[key] = true + local word = key .. '#' + if base == '' or word:sub(1, #base) == base then + table.insert(matches, { word = word, menu = '[' .. source .. ']' }) + end + end + end + end + end end return matches diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 1d4c00a..842dfc0 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -33,6 +33,17 @@ ---@field profile? string ---@field region? string +---@class pending.ForgeInstanceConfig +---@field token? string +---@field icon? string +---@field issue_format? string +---@field instances? string[] + +---@class pending.ForgeConfig +---@field github? pending.ForgeInstanceConfig +---@field gitlab? pending.ForgeInstanceConfig +---@field codeberg? pending.ForgeInstanceConfig + ---@class pending.SyncConfig ---@field remote_delete? boolean ---@field gcal? pending.GcalConfig @@ -90,6 +101,7 @@ ---@field view pending.ViewConfig ---@field max_priority? integer ---@field sync? pending.SyncConfig +---@field forge? pending.ForgeConfig ---@field icons pending.Icons ---@class pending.config @@ -141,6 +153,23 @@ local defaults = { priority_down = '', }, sync = {}, + forge = { + github = { + icon = '', + issue_format = '%i %o/%r#%n', + instances = {}, + }, + gitlab = { + icon = '', + issue_format = '%i %o/%r#%n', + instances = {}, + }, + codeberg = { + icon = '', + issue_format = '%i %o/%r#%n', + instances = {}, + }, + }, icons = { pending = ' ', done = 'x', diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 723dee1..29c292b 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -1,4 +1,5 @@ local config = require('pending.config') +local forge = require('pending.forge') local parse = require('pending.parse') ---@class pending.ParsedEntry @@ -11,6 +12,7 @@ local parse = require('pending.parse') ---@field due? string ---@field rec? string ---@field rec_mode? string +---@field forge_ref? pending.ForgeRef ---@field lnum integer ---@class pending.diff @@ -55,6 +57,8 @@ function M.parse_buffer(lines) end local description, metadata = parse.body(stripped) if description and description ~= '' then + local refs = forge.find_refs(description) + local forge_ref = refs[1] and refs[1].ref or nil table.insert(result, { type = 'task', id = id and tonumber(id) or nil, @@ -65,6 +69,7 @@ function M.parse_buffer(lines) due = metadata.due, rec = metadata.rec, rec_mode = metadata.rec_mode, + forge_ref = forge_ref, lnum = i, }) end @@ -113,6 +118,7 @@ function M.apply(lines, s, hidden_ids) recur = entry.rec, recur_mode = entry.rec_mode, order = order_counter, + _extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil, }) else seen_ids[entry.id] = true @@ -147,6 +153,13 @@ function M.apply(lines, s, hidden_ids) changed = true end end + if entry.forge_ref ~= nil then + if not task._extra then + task._extra = {} + end + task._extra._forge_ref = entry.forge_ref + changed = true + end if entry.status and task.status ~= entry.status then task.status = entry.status if entry.status == 'done' then @@ -173,6 +186,7 @@ function M.apply(lines, s, hidden_ids) recur = entry.rec, recur_mode = entry.rec_mode, order = order_counter, + _extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil, }) end diff --git a/lua/pending/forge.lua b/lua/pending/forge.lua new file mode 100644 index 0000000..d2092e4 --- /dev/null +++ b/lua/pending/forge.lua @@ -0,0 +1,435 @@ +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 diff --git a/lua/pending/init.lua b/lua/pending/init.lua index d372f3a..6c5eaee 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -189,6 +189,8 @@ function M.open() local bufnr = buffer.open() M._setup_autocmds(bufnr) M._setup_buf_mappings(bufnr) + local forge = require('pending.forge') + forge.refresh(s) return bufnr end diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 8d4bda5..fd76a49 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -1,6 +1,13 @@ local config = require('pending.config') +local forge = require('pending.forge') local parse = require('pending.parse') +---@class pending.ForgeLineMeta +---@field ref pending.ForgeRef +---@field cache? pending.ForgeCache +---@field col_start integer +---@field col_end integer + ---@class pending.LineMeta ---@field type 'task'|'header'|'blank'|'filter' ---@field id? integer @@ -12,6 +19,9 @@ local parse = require('pending.parse') ---@field show_category? boolean ---@field priority? integer ---@field recur? string +---@field forge_ref? pending.ForgeRef +---@field forge_cache? pending.ForgeCache +---@field forge_spans? pending.ForgeLineMeta[] ---@class pending.views local M = {} @@ -41,6 +51,27 @@ local function format_due(due) return formatted end +---@param task pending.Task +---@param prefix_len integer +---@return pending.ForgeLineMeta[]? +local function compute_forge_spans(task, prefix_len) + local refs = forge.find_refs(task.description) + if #refs == 0 then + return nil + end + local cache = task._extra and task._extra._forge_cache or nil + local spans = {} + for _, r in ipairs(refs) do + table.insert(spans, { + ref = r.ref, + cache = cache, + col_start = prefix_len + r.start_byte, + col_end = prefix_len + r.end_byte, + }) + end + return spans +end + ---@type table local status_rank = { wip = 0, pending = 1, blocked = 2, done = 3 } @@ -176,6 +207,7 @@ function M.category_view(tasks) local prefix = '/' .. task.id .. '/' local state = state_char(task) local line = prefix .. '- [' .. state .. '] ' .. task.description + local prefix_len = #prefix + #('- [' .. state .. '] ') table.insert(lines, line) table.insert(meta, { type = 'task', @@ -187,6 +219,9 @@ function M.category_view(tasks) priority = task.priority, overdue = task.status ~= 'done' and task.due ~= nil and parse.is_overdue(task.due) or nil, recur = task.recur, + forge_ref = task._extra and task._extra._forge_ref or nil, + forge_cache = task._extra and task._extra._forge_cache or nil, + forge_spans = compute_forge_spans(task, prefix_len), }) end end @@ -227,6 +262,7 @@ function M.priority_view(tasks) local prefix = '/' .. task.id .. '/' local state = task.status == 'done' and 'x' or (task.priority > 0 and '!' or ' ') local line = prefix .. '- [' .. state .. '] ' .. task.description + local prefix_len = #prefix + #('- [' .. state .. '] ') table.insert(lines, line) table.insert(meta, { type = 'task', @@ -239,6 +275,9 @@ function M.priority_view(tasks) overdue = task.status ~= 'done' and task.due ~= nil and parse.is_overdue(task.due) or nil, show_category = true, recur = task.recur, + forge_ref = task._extra and task._extra._forge_ref or nil, + forge_cache = task._extra and task._extra._forge_cache or nil, + forge_spans = compute_forge_spans(task, prefix_len), }) end diff --git a/spec/forge_spec.lua b/spec/forge_spec.lua new file mode 100644 index 0000000..3d17374 --- /dev/null +++ b/spec/forge_spec.lua @@ -0,0 +1,377 @@ +require('spec.helpers') + +local forge = require('pending.forge') + +describe('forge', function() + describe('_parse_shorthand', function() + it('parses gh: shorthand', function() + local ref = forge._parse_shorthand('gh:user/repo#42') + assert.is_not_nil(ref) + assert.equals('github', ref.forge) + assert.equals('user', ref.owner) + assert.equals('repo', ref.repo) + assert.equals('issue', ref.type) + assert.equals(42, ref.number) + assert.equals('https://github.com/user/repo/issues/42', ref.url) + end) + + it('parses gl: shorthand', function() + local ref = forge._parse_shorthand('gl:group/project#15') + assert.is_not_nil(ref) + assert.equals('gitlab', ref.forge) + assert.equals('group', ref.owner) + assert.equals('project', ref.repo) + assert.equals(15, ref.number) + end) + + it('parses cb: shorthand', function() + local ref = forge._parse_shorthand('cb:user/repo#3') + assert.is_not_nil(ref) + assert.equals('codeberg', ref.forge) + assert.equals('user', ref.owner) + assert.equals('repo', ref.repo) + assert.equals(3, ref.number) + end) + + it('handles hyphens and dots in owner/repo', function() + local ref = forge._parse_shorthand('gh:my-org/my.repo#100') + assert.is_not_nil(ref) + assert.equals('my-org', ref.owner) + assert.equals('my.repo', ref.repo) + end) + + it('rejects invalid prefix', function() + assert.is_nil(forge._parse_shorthand('xx:user/repo#1')) + end) + + it('rejects missing number', function() + assert.is_nil(forge._parse_shorthand('gh:user/repo')) + end) + + it('rejects missing repo', function() + assert.is_nil(forge._parse_shorthand('gh:user#1')) + end) + end) + + describe('_parse_github_url', function() + it('parses issue URL', function() + local ref = forge._parse_github_url('https://github.com/user/repo/issues/42') + assert.is_not_nil(ref) + assert.equals('github', ref.forge) + assert.equals('user', ref.owner) + assert.equals('repo', ref.repo) + assert.equals('issue', ref.type) + assert.equals(42, ref.number) + end) + + it('parses pull request URL', function() + local ref = forge._parse_github_url('https://github.com/user/repo/pull/10') + assert.is_not_nil(ref) + assert.equals('pull_request', ref.type) + end) + + it('rejects non-github URL', function() + assert.is_nil(forge._parse_github_url('https://example.com/user/repo/issues/1')) + end) + end) + + describe('_parse_gitlab_url', function() + it('parses issue URL', function() + local ref = forge._parse_gitlab_url('https://gitlab.com/group/project/-/issues/15') + assert.is_not_nil(ref) + assert.equals('gitlab', ref.forge) + assert.equals('group', ref.owner) + assert.equals('project', ref.repo) + assert.equals('issue', ref.type) + assert.equals(15, ref.number) + end) + + it('parses merge request URL', function() + local ref = forge._parse_gitlab_url('https://gitlab.com/group/project/-/merge_requests/5') + assert.is_not_nil(ref) + assert.equals('merge_request', ref.type) + end) + + it('handles nested groups', function() + local ref = forge._parse_gitlab_url('https://gitlab.com/org/sub/project/-/issues/1') + assert.is_not_nil(ref) + assert.equals('org/sub', ref.owner) + assert.equals('project', ref.repo) + end) + end) + + describe('_parse_codeberg_url', function() + it('parses issue URL', function() + local ref = forge._parse_codeberg_url('https://codeberg.org/user/repo/issues/3') + assert.is_not_nil(ref) + assert.equals('codeberg', ref.forge) + assert.equals('user', ref.owner) + assert.equals('repo', ref.repo) + assert.equals('issue', ref.type) + assert.equals(3, ref.number) + end) + + it('parses pull URL', function() + local ref = forge._parse_codeberg_url('https://codeberg.org/user/repo/pulls/7') + assert.is_not_nil(ref) + assert.equals('pull_request', ref.type) + end) + end) + + describe('parse_ref', function() + it('dispatches shorthand', function() + local ref = forge.parse_ref('gh:user/repo#1') + assert.is_not_nil(ref) + assert.equals('github', ref.forge) + end) + + it('dispatches GitHub URL', function() + local ref = forge.parse_ref('https://github.com/user/repo/issues/1') + assert.is_not_nil(ref) + assert.equals('github', ref.forge) + end) + + it('dispatches GitLab URL', function() + local ref = forge.parse_ref('https://gitlab.com/group/project/-/issues/1') + assert.is_not_nil(ref) + assert.equals('gitlab', ref.forge) + end) + + it('returns nil for non-forge token', function() + assert.is_nil(forge.parse_ref('hello')) + assert.is_nil(forge.parse_ref('due:tomorrow')) + end) + end) + + describe('find_refs', function() + it('finds a single shorthand ref', function() + local refs = forge.find_refs('Fix bug gh:user/repo#42') + assert.equals(1, #refs) + assert.equals('github', refs[1].ref.forge) + assert.equals(42, refs[1].ref.number) + assert.equals('gh:user/repo#42', refs[1].raw) + assert.equals(8, refs[1].start_byte) + assert.equals(23, refs[1].end_byte) + end) + + it('finds multiple refs', function() + local refs = forge.find_refs('Fix gh:a/b#1 gh:c/d#2') + assert.equals(2, #refs) + assert.equals('a', refs[1].ref.owner) + assert.equals('c', refs[2].ref.owner) + end) + + it('finds full URL refs', function() + local refs = forge.find_refs('Fix https://github.com/user/repo/issues/10') + assert.equals(1, #refs) + assert.equals('github', refs[1].ref.forge) + assert.equals(10, refs[1].ref.number) + end) + + it('returns empty for no refs', function() + local refs = forge.find_refs('Fix the bug') + assert.equals(0, #refs) + end) + + it('skips invalid forge-like tokens', function() + local refs = forge.find_refs('Fix the gh: prefix handling') + assert.equals(0, #refs) + end) + + it('records correct byte offsets', function() + local refs = forge.find_refs('gh:a/b#1') + assert.equals(1, #refs) + assert.equals(0, refs[1].start_byte) + assert.equals(8, refs[1].end_byte) + end) + end) + + describe('_api_url', function() + it('builds GitHub API URL', function() + local url = forge._api_url({ + forge = 'github', + owner = 'user', + repo = 'repo', + type = 'issue', + number = 42, + url = '', + }) + assert.equals('https://api.github.com/repos/user/repo/issues/42', url) + end) + + it('builds GitLab API URL for issue', function() + local url = forge._api_url({ + forge = 'gitlab', + owner = 'group', + repo = 'project', + type = 'issue', + number = 15, + url = '', + }) + assert.equals('https://gitlab.com/api/v4/projects/group%2Fproject/issues/15', url) + end) + + it('builds GitLab API URL for merge request', function() + local url = forge._api_url({ + forge = 'gitlab', + owner = 'group', + repo = 'project', + type = 'merge_request', + number = 5, + url = '', + }) + assert.equals('https://gitlab.com/api/v4/projects/group%2Fproject/merge_requests/5', url) + end) + + it('builds Codeberg API URL', function() + local url = forge._api_url({ + forge = 'codeberg', + owner = 'user', + repo = 'repo', + type = 'issue', + number = 3, + url = '', + }) + assert.equals('https://codeberg.org/api/v1/repos/user/repo/issues/3', url) + end) + end) + + describe('format_label', function() + it('formats with default format', function() + local text, hl = forge.format_label({ + forge = 'github', + owner = 'user', + repo = 'repo', + type = 'issue', + number = 42, + url = '', + }, nil) + assert.truthy(text:find('user/repo#42')) + assert.equals('PendingForge', hl) + end) + + it('uses closed highlight for closed state', function() + local _, hl = forge.format_label({ + forge = 'github', + owner = 'user', + repo = 'repo', + type = 'issue', + number = 42, + url = '', + }, { state = 'closed', fetched_at = '2026-01-01T00:00:00Z' }) + assert.equals('PendingForgeClosed', hl) + end) + + it('uses closed highlight for merged state', function() + local _, hl = forge.format_label({ + forge = 'gitlab', + owner = 'group', + repo = 'project', + type = 'merge_request', + number = 5, + url = '', + }, { state = 'merged', fetched_at = '2026-01-01T00:00:00Z' }) + assert.equals('PendingForgeClosed', hl) + end) + end) +end) + +describe('forge parse.body integration', function() + local parse = require('pending.parse') + + it('keeps gh: shorthand in description', function() + local desc, meta = parse.body('Fix login bug gh:user/repo#42') + assert.equals('Fix login bug gh:user/repo#42', desc) + assert.is_nil(meta.forge_ref) + end) + + it('keeps gl: shorthand in description', function() + local desc, meta = parse.body('Update docs gl:group/project#15') + assert.equals('Update docs gl:group/project#15', desc) + assert.is_nil(meta.forge_ref) + end) + + it('keeps GitHub URL in description', function() + local desc, meta = parse.body('Fix bug https://github.com/user/repo/issues/10') + assert.equals('Fix bug https://github.com/user/repo/issues/10', desc) + assert.is_nil(meta.forge_ref) + end) + + it('extracts due date but keeps forge ref in description', function() + local desc, meta = parse.body('Fix bug gh:user/repo#42 due:tomorrow') + assert.equals('Fix bug gh:user/repo#42', desc) + assert.is_not_nil(meta.due) + end) + + it('extracts category but keeps forge ref in description', function() + local desc, meta = parse.body('Fix bug gh:user/repo#42 cat:Work') + assert.equals('Fix bug gh:user/repo#42', desc) + assert.equals('Work', meta.cat) + end) + + it('leaves non-forge tokens as description', function() + local desc, meta = parse.body('Fix the gh: prefix handling') + assert.equals('Fix the gh: prefix handling', desc) + assert.is_nil(meta.forge_ref) + end) +end) + +describe('forge diff integration', function() + local store = require('pending.store') + local diff = require('pending.diff') + + it('stores forge_ref in _extra on new task', function() + local tmp = os.tmpname() + local s = store.new(tmp) + s:load() + diff.apply({ '- [ ] Fix bug gh:user/repo#42' }, s) + local tasks = s:active_tasks() + assert.equals(1, #tasks) + assert.equals('Fix bug gh:user/repo#42', tasks[1].description) + assert.is_not_nil(tasks[1]._extra) + assert.is_not_nil(tasks[1]._extra._forge_ref) + assert.equals('github', tasks[1]._extra._forge_ref.forge) + assert.equals(42, tasks[1]._extra._forge_ref.number) + os.remove(tmp) + end) + + it('stores forge_ref in _extra on existing task', function() + local tmp = os.tmpname() + local s = store.new(tmp) + s:load() + local task = s:add({ description = 'Fix bug' }) + s:save() + diff.apply({ '/' .. task.id .. '/- [ ] Fix bug gh:user/repo#10' }, s) + local updated = s:get(task.id) + assert.equals('Fix bug gh:user/repo#10', updated.description) + assert.is_not_nil(updated._extra) + assert.is_not_nil(updated._extra._forge_ref) + assert.equals(10, updated._extra._forge_ref.number) + os.remove(tmp) + end) + + it('preserves existing forge_ref when not in parsed line', function() + local tmp = os.tmpname() + local s = store.new(tmp) + s:load() + local task = s:add({ + description = 'Fix bug', + _extra = { + _forge_ref = { + forge = 'github', + owner = 'a', + repo = 'b', + type = 'issue', + number = 1, + url = '', + }, + }, + }) + s:save() + diff.apply({ '/' .. task.id .. '/- [ ] Fix bug' }, s) + local updated = s:get(task.id) + assert.is_not_nil(updated._extra._forge_ref) + assert.equals(1, updated._extra._forge_ref.number) + os.remove(tmp) + end) +end)