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

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