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

@ -1,6 +1,7 @@
local config = require('pending.config')
local log = require('pending.log')
local oauth = require('pending.sync.oauth')
local util = require('pending.sync.util')
local M = {}
@ -195,21 +196,6 @@ local function unlink_remote(task, now_ts)
task.modified = now_ts
end
---@param parts {[1]: integer, [2]: string}[]
---@return string
local function fmt_counts(parts)
local items = {}
for _, p in ipairs(parts) do
if p[1] > 0 then
table.insert(items, p[1] .. ' ' .. p[2])
end
end
if #items == 0 then
return 'nothing to do'
end
return table.concat(items, ' | ')
end
---@param task pending.Task
---@return table
local function task_to_gtask(task)
@ -447,13 +433,8 @@ function M.push()
local by_gtasks_id = build_id_index(s)
local created, updated, deleted, failed =
push_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
s:save()
require('pending')._recompute_counts()
local buffer = require('pending.buffer')
if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then
buffer.render(buffer.bufnr())
end
log.info('gtasks push: ' .. fmt_counts({
util.finish(s)
log.info('gtasks push: ' .. util.fmt_counts({
{ created, 'added' },
{ updated, 'updated' },
{ deleted, 'deleted' },
@ -474,13 +455,8 @@ function M.pull()
local created, updated, failed, seen_remote_ids, fetched_list_ids =
pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
local unlinked = detect_remote_deletions(s, seen_remote_ids, fetched_list_ids, now_ts)
s:save()
require('pending')._recompute_counts()
local buffer = require('pending.buffer')
if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then
buffer.render(buffer.bufnr())
end
log.info('gtasks pull: ' .. fmt_counts({
util.finish(s)
log.info('gtasks pull: ' .. util.fmt_counts({
{ created, 'added' },
{ updated, 'updated' },
{ unlinked, 'unlinked' },
@ -503,18 +479,13 @@ function M.sync()
local pulled_create, pulled_update, pulled_failed, seen_remote_ids, fetched_list_ids =
pull_pass(access_token, tasklists, s, now_ts, by_gtasks_id)
local unlinked = detect_remote_deletions(s, seen_remote_ids, fetched_list_ids, now_ts)
s:save()
require('pending')._recompute_counts()
local buffer = require('pending.buffer')
if buffer.bufnr() and vim.api.nvim_buf_is_valid(buffer.bufnr()) then
buffer.render(buffer.bufnr())
end
log.info('gtasks sync — push: ' .. fmt_counts({
util.finish(s)
log.info('gtasks sync — push: ' .. util.fmt_counts({
{ pushed_create, 'added' },
{ pushed_update, 'updated' },
{ pushed_delete, 'deleted' },
{ pushed_failed, 'failed' },
}) .. ' pull: ' .. fmt_counts({
}) .. ' pull: ' .. util.fmt_counts({
{ pulled_create, 'added' },
{ pulled_update, 'updated' },
{ unlinked, 'unlinked' },
@ -533,6 +504,32 @@ M._push_pass = push_pass
M._pull_pass = pull_pass
M._detect_remote_deletions = detect_remote_deletions
---@param args? string
---@return nil
function M.auth(args)
if args == 'clear' then
oauth.google_client:clear_tokens()
log.info('gtasks: OAuth tokens cleared — run :Pending auth gtasks to re-authenticate.')
elseif args == 'reset' then
oauth.google_client:_wipe()
log.info(
'gtasks: OAuth tokens and credentials cleared — run :Pending auth gtasks to set up from scratch.'
)
else
local creds = oauth.google_client:resolve_credentials()
if creds.client_id == oauth.BUNDLED_CLIENT_ID then
oauth.google_client:setup()
else
oauth.google_client:auth()
end
end
end
---@return string[]
function M.auth_complete()
return { 'clear', 'reset' }
end
---@return nil
function M.health()
oauth.health(M.name)
@ -540,7 +537,7 @@ function M.health()
if tokens and tokens.refresh_token then
vim.health.ok('gtasks tokens found')
else
vim.health.info('no gtasks tokens — run :Pending auth')
vim.health.info('no gtasks tokens — run :Pending auth gtasks')
end
end