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('_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) describe('conceal_patterns', function() it('returns base patterns', function() local patterns = forge.conceal_patterns() assert.is_true(#patterns >= 6) end) end) end) describe('forge parse.body integration', function() local parse = require('pending.parse') it('extracts gh: shorthand from task text', function() local desc, meta = parse.body('Fix login bug gh:user/repo#42') assert.equals('Fix login bug', desc) assert.is_not_nil(meta.forge_ref) assert.equals('github', meta.forge_ref.forge) assert.equals(42, meta.forge_ref.number) end) it('extracts gl: shorthand', function() local desc, meta = parse.body('Update docs gl:group/project#15') assert.equals('Update docs', desc) assert.is_not_nil(meta.forge_ref) assert.equals('gitlab', meta.forge_ref.forge) end) it('extracts GitHub URL', function() local desc, meta = parse.body('Fix bug https://github.com/user/repo/issues/10') assert.equals('Fix bug', desc) assert.is_not_nil(meta.forge_ref) assert.equals('github', meta.forge_ref.forge) assert.equals(10, meta.forge_ref.number) end) it('combines forge ref with due date', function() local desc, meta = parse.body('Fix bug gh:user/repo#42 due:tomorrow') assert.equals('Fix bug', desc) assert.is_not_nil(meta.forge_ref) assert.is_not_nil(meta.due) end) it('combines forge ref with category', function() local desc, meta = parse.body('Fix bug gh:user/repo#42 cat:Work') assert.equals('Fix bug', desc) assert.is_not_nil(meta.forge_ref) 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) it('only takes one forge ref', function() local desc, meta = parse.body('Fix gh:a/b#1 gh:c/d#2') assert.equals('Fix gh:a/b#1', desc) assert.is_not_nil(meta.forge_ref) assert.equals('c', meta.forge_ref.owner) assert.equals('d', meta.forge_ref.repo) 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.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.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)