feat: auth backend (#111)

* refactor(types): extract inline anonymous types into named classes

Problem: several functions used inline `{...}` table types in their
`@param` and `@return` annotations, making them hard to read and
impossible to reference from other modules.

Solution: extract each into a named `---@class`: `pending.Metadata`,
`pending.TaskFields`, `pending.CompletionItem`, `pending.SystemResult`,
and `pending.OAuthClientOpts`.

* refactor(sync): extract shared utilities into `sync/util.lua`

Problem: sync epilogue code (`s:save()`, `_recompute_counts()`,
`buffer.render()`) and `fmt_counts` were duplicated across `gcal.lua`
and `gtasks.lua`. The concurrency guard lived in `oauth.lua`, coupling
non-OAuth backends to the OAuth module.

Solution: create `sync/util.lua` with `async`, `system`, `with_guard`,
`finish`, and `fmt_counts`. Delegate from `oauth.lua` and replace
duplicated code in both backends. Add per-backend `auth()` and
`auth_complete()` methods to `gcal.lua` and `gtasks.lua`.

* feat(sync): auto-discover backends, per-backend auth, S3 backend

Problem: sync backends were hardcoded in `SYNC_BACKENDS` list in
`init.lua`, auth routed directly through `oauth.google_client`, and
adding a non-OAuth backend required editing multiple files.

Solution: replace hardcoded list with `discover_backends()` that globs
`lua/pending/sync/*.lua` at runtime. Rewrite `M.auth()` to dispatch
to per-backend `auth()` methods with `vim.ui.select` fallback. Add
`lua/pending/sync/s3.lua` with push/pull/sync via AWS CLI, per-task
merge by `_s3_sync_id` (UUID), and `pending.S3Config` type.
This commit is contained in:
Barrett Ruth 2026-03-08 19:53:42 -04:00
parent 34a68db6d0
commit d12838abbf
13 changed files with 1173 additions and 107 deletions

View file

@ -110,5 +110,76 @@ describe('sync', function()
local gcal = require('pending.sync.gcal')
assert.are.equal('function', type(gcal.health))
end)
it('has auth function', function()
local gcal = require('pending.sync.gcal')
assert.are.equal('function', type(gcal.auth))
end)
it('has auth_complete function', function()
local gcal = require('pending.sync.gcal')
local completions = gcal.auth_complete()
assert.is_true(vim.tbl_contains(completions, 'clear'))
assert.is_true(vim.tbl_contains(completions, 'reset'))
end)
end)
describe('auto-discovery', function()
it('discovers gcal and gtasks backends', function()
local backends = pending.sync_backends()
assert.is_true(vim.tbl_contains(backends, 'gcal'))
assert.is_true(vim.tbl_contains(backends, 'gtasks'))
end)
it('excludes modules without name field', function()
local set = pending.sync_backend_set()
assert.is_nil(set['oauth'])
assert.is_nil(set['util'])
end)
it('populates backend set correctly', function()
local set = pending.sync_backend_set()
assert.is_true(set['gcal'] == true)
assert.is_true(set['gtasks'] == true)
end)
end)
describe('auth dispatch', function()
it('routes auth to specific backend', function()
local called_with = nil
local gcal = require('pending.sync.gcal')
local orig_auth = gcal.auth
gcal.auth = function(args)
called_with = args or 'default'
end
pending.auth('gcal')
gcal.auth = orig_auth
assert.are.equal('default', called_with)
end)
it('routes auth with sub-action', function()
local called_with = nil
local gcal = require('pending.sync.gcal')
local orig_auth = gcal.auth
gcal.auth = function(args)
called_with = args
end
pending.auth('gcal clear')
gcal.auth = orig_auth
assert.are.equal('clear', called_with)
end)
it('errors on unknown backend', function()
local msg
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
pending.auth('nonexistent')
vim.notify = orig
assert.truthy(msg and msg:find('No auth method'))
end)
end)
end)