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
This commit is contained in:
Barrett Ruth 2026-03-10 19:28:44 -04:00
parent 2d59868b82
commit 4c0ddad39c
10 changed files with 1071 additions and 23 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = '<C-x>',
},
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',

View file

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

435
lua/pending/forge.lua Normal file
View file

@ -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<string, string>
local FORGE_HOSTS = {
['github.com'] = 'github',
['gitlab.com'] = 'gitlab',
['codeberg.org'] = 'codeberg',
}
---@type table<string, string>
local FORGE_API_BASE = {
github = 'https://api.github.com',
gitlab = 'https://gitlab.com',
codeberg = 'https://codeberg.org',
}
---@type table<string, string>
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

View file

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

View file

@ -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<string, integer>
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

377
spec/forge_spec.lua Normal file
View file

@ -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)