diff --git a/spec/forge_spec.lua b/spec/forge_spec.lua new file mode 100644 index 0000000..283b206 --- /dev/null +++ b/spec/forge_spec.lua @@ -0,0 +1,354 @@ +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)