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_args', function() it('builds GitHub CLI args', function() local args = forge._api_args({ forge = 'github', owner = 'user', repo = 'repo', type = 'issue', number = 42, url = '', }) assert.same({ 'gh', 'api', '/repos/user/repo/issues/42' }, args) end) it('builds GitLab CLI args for issue', function() local args = forge._api_args({ forge = 'gitlab', owner = 'group', repo = 'project', type = 'issue', number = 15, url = '', }) assert.same({ 'glab', 'api', '/projects/group%2Fproject/issues/15' }, args) end) it('builds GitLab CLI args for merge request', function() local args = forge._api_args({ forge = 'gitlab', owner = 'group', repo = 'project', type = 'merge_request', number = 5, url = '', }) assert.same({ 'glab', 'api', '/projects/group%2Fproject/merge_requests/5' }, args) end) it('builds Codeberg CLI args', function() local args = forge._api_args({ forge = 'codeberg', owner = 'user', repo = 'repo', type = 'issue', number = 3, url = '', }) assert.same({ 'tea', 'api', '/repos/user/repo/issues/3' }, args) 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 registry', function() it('backends() returns all registered backends', function() local backends = forge.backends() assert.is_true(#backends >= 3) local names = {} for _, b in ipairs(backends) do names[b.name] = true end assert.is_true(names['github']) assert.is_true(names['gitlab']) assert.is_true(names['codeberg']) end) it('register() with custom backend resolves URLs', function() local custom = forge.gitea_backend({ name = 'mygitea', shorthand = 'mg', default_host = 'gitea.example.com', }) forge.register(custom) local ref = forge.parse_ref('https://gitea.example.com/alice/proj/issues/7') assert.is_not_nil(ref) assert.equals('mygitea', ref.forge) assert.equals('alice', ref.owner) assert.equals('proj', ref.repo) assert.equals('issue', ref.type) assert.equals(7, ref.number) end) it('register() with custom shorthand resolves', function() local ref = forge._parse_shorthand('mg:alice/proj#7') assert.is_not_nil(ref) assert.equals('mygitea', ref.forge) assert.equals('alice', ref.owner) assert.equals('proj', ref.repo) assert.equals(7, ref.number) end) it('_api_args dispatches to custom backend', function() local args = forge._api_args({ forge = 'mygitea', owner = 'alice', repo = 'proj', type = 'issue', number = 7, url = '', }) assert.same({ 'tea', 'api', '/repos/alice/proj/issues/7' }, args) end) it('gitea_backend() creates a working backend', function() local b = forge.gitea_backend({ name = 'forgejo', shorthand = 'fj', default_host = 'forgejo.example.com', cli = 'forgejo-cli', auth_cmd = 'forgejo-cli login', }) assert.equals('forgejo', b.name) assert.equals('fj', b.shorthand) assert.equals('forgejo-cli', b.cli) local ref = b:parse_url('https://forgejo.example.com/bob/repo/pulls/3') assert.is_nil(ref) forge.register(b) ref = b:parse_url('https://forgejo.example.com/bob/repo/pulls/3') assert.is_not_nil(ref) assert.equals('forgejo', ref.forge) assert.equals('pull_request', ref.type) assert.equals(3, ref.number) 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)