diff --git a/lsp/gitlab_duo.lua b/lsp/gitlab_duo.lua new file mode 100644 index 0000000000..ebdb2ddeaf --- /dev/null +++ b/lsp/gitlab_duo.lua @@ -0,0 +1,440 @@ +---@brief +--- +--- GitLab Duo Language Server Configuration for Neovim +--- +--- https://gitlab.com/gitlab-org/editor-extensions/gitlab-lsp +--- +--- The GitLab LSP enables any editor or IDE to integrate with GitLab Duo +--- for AI-powered code suggestions via the Language Server Protocol. +--- +--- Prerequisites: +--- - Node.js and npm installed +--- - GitLab account with Duo Pro license +--- - Internet connection for OAuth device flow +--- +--- Setup: +--- 1. Run :LspGitLabDuoSignIn to start OAuth authentication +--- 2. Follow the browser prompts to authorize +--- 3. Enable inline completion in LspAttach event (see example below) +--- +--- Inline Completion Example: +--- ```lua +--- vim.api.nvim_create_autocmd('LspAttach', { +--- callback = function(args) +--- local bufnr = args.buf +--- local client = assert(vim.lsp.get_client_by_id(args.data.client_id)) +--- +--- if vim.lsp.inline_completion and +--- client:supports_method(vim.lsp.protocol.Methods.textDocument_inlineCompletion, bufnr) then +--- vim.lsp.inline_completion.enable(true, { bufnr = bufnr }) +--- +--- -- Tab to accept suggestion +--- vim.keymap.set('i', '', function() +--- if vim.lsp.inline_completion.is_visible() then +--- return vim.lsp.inline_completion.accept() +--- else +--- return '' +--- end +--- end, { expr = true, buffer = bufnr, desc = 'GitLab Duo: Accept suggestion' }) +--- +--- -- Alt/Option+[ for previous suggestion +--- vim.keymap.set('i', '', vim.lsp.inline_completion.select_prev, +--- { buffer = bufnr, desc = 'GitLab Duo: Previous suggestion' }) +--- +--- -- Alt/Option+] for next suggestion +--- vim.keymap.set('i', '', vim.lsp.inline_completion.select_next, +--- { buffer = bufnr, desc = 'GitLab Duo: Next suggestion' }) +--- end +--- end +--- }) +--- ``` + +-- Configuration +local config = { + gitlab_url = 'https://gitlab.com', + -- This is a oauth application created from tachyons-gitlab account with `api` scope + client_id = '00bb391f527d2e77b3467b0b6b900151cc6a28dcfb18fa1249871e43bc3e5832', + scopes = 'api', + token_file = vim.fn.stdpath('data') .. '/gitlab_duo_oauth.json', +} + +-- Helper function to make POST requests with curl via vim.system +local function curl_post(url, data, headers) + local curl_args = { + 'curl', + '-s', + '-w', + '\n%{http_code}', + '-X', + 'POST', + url, + } + + -- Add headers + for key, value in pairs(headers or {}) do + table.insert(curl_args, '-H') + table.insert(curl_args, key .. ': ' .. value) + end + + -- Add data + if data then + table.insert(curl_args, '-d') + table.insert(curl_args, data) + end + + local result = vim.system(curl_args, { text = true }):wait() + + -- Split body and status code + local output = result.stdout or '' + local body_end = output:match('.*\n()%d+$') + + local body = '' + local status = 0 + + if body_end then + body = output:sub(1, body_end - 2) -- -2 to remove trailing newline + status = tonumber(output:match('\n(%d+)$')) or 0 + else + body = output + end + + return { + status = status, + body = body, + } +end + +-- Token management +local function save_token(token_data) + token_data.saved_at = os.time() + local file = io.open(config.token_file, 'w') + if file then + file:write(vim.json.encode(token_data)) + file:close() + return true + end + return false +end + +local function load_token() + if vim.fn.filereadable(config.token_file) == 0 then + return nil + end + + local blob = vim.fn.readblob(config.token_file) + return vim.json.decode(blob) +end + +local function is_token_expired(token_data) + if not token_data or not token_data.saved_at or not token_data.expires_in then + return true + end + local token_age = os.time() - token_data.saved_at + return token_age >= (token_data.expires_in - 60) -- 60 second buffer +end + +local function refresh_access_token(refresh_token) + vim.notify('Refreshing GitLab OAuth token...', vim.log.levels.INFO) + + local response = curl_post( + config.gitlab_url .. '/oauth/token', + string.format('client_id=%s&refresh_token=%s&grant_type=refresh_token', config.client_id, refresh_token), + { ['Content-Type'] = 'application/x-www-form-urlencoded' } + ) + + if response.status ~= 200 then + vim.notify('Failed to refresh token: ' .. (response.body or 'Unknown error'), vim.log.levels.ERROR) + return nil + end + + local ok, body = pcall(vim.json.decode, response.body) + if not ok or not body.access_token then + vim.notify('Invalid refresh response', vim.log.levels.ERROR) + return nil + end + + save_token(body) + vim.notify('Token refreshed successfully', vim.log.levels.INFO) + return body +end + +local function get_valid_token() + local token_data = load_token() + + if not token_data then + return nil, 'no_token' + end + + if is_token_expired(token_data) then + if token_data.refresh_token then + local new_token_data = refresh_access_token(token_data.refresh_token) + if new_token_data then + return new_token_data.access_token, 'refreshed' + end + return nil, 'refresh_failed' + end + return nil, 'expired' + end + + return token_data.access_token, 'valid' +end + +-- OAuth Device Flow +local function device_authorization() + local response = curl_post( + config.gitlab_url .. '/oauth/authorize_device', + string.format('client_id=%s&scope=%s', config.client_id, config.scopes), + { ['Content-Type'] = 'application/x-www-form-urlencoded' } + ) + + if response.status ~= 200 then + vim.notify('Device authorization failed: ' .. response.status, vim.log.levels.ERROR) + return nil + end + + local data = vim.json.decode(response.body) + + return data +end + +local function poll_for_token(device_code, interval, client) + local max_attempts = 60 + local attempts = 0 + + local function poll() + attempts = attempts + 1 + + local response = curl_post( + config.gitlab_url .. '/oauth/token', + string.format( + 'client_id=%s&device_code=%s&grant_type=urn:ietf:params:oauth:grant-type:device_code', + config.client_id, + device_code + ), + { ['Content-Type'] = 'application/x-www-form-urlencoded' } + ) + + local ok, body = pcall(vim.json.decode, response.body) + if not ok then + vim.notify('Failed to parse token response', vim.log.levels.ERROR) + return + end + + if response.status == 200 and body.access_token then + save_token(body) + vim.notify('GitLab Duo authentication successful!', vim.log.levels.INFO) + + -- Update LSP with new token + vim.schedule(function() + client.notify('workspace/didChangeConfiguration', { + settings = { + token = body.access_token, + baseUrl = config.gitlab_url, + }, + }) + end) + return + end + + if body.error == 'authorization_pending' then + if attempts < max_attempts then + vim.defer_fn(poll, interval * 1000) + else + vim.notify('Authorization timed out', vim.log.levels.ERROR) + end + elseif body.error == 'slow_down' then + vim.defer_fn(poll, (interval + 5) * 1000) + elseif body.error == 'access_denied' then + vim.notify('Authorization denied', vim.log.levels.ERROR) + elseif body.error == 'expired_token' then + vim.notify('Device code expired. Please run :LspGitLabDuoSignIn again', vim.log.levels.ERROR) + else + vim.notify('OAuth error: ' .. (body.error or 'unknown'), vim.log.levels.ERROR) + end + end + + poll() +end + +---@param bufnr integer +---@param client vim.lsp.Client +local function sign_in(_bufnr, client) + vim.notify('Starting GitLab device authorization...', vim.log.levels.INFO) + + local auth_data = device_authorization() + if not auth_data then + return + end + + vim.ui.open(auth_data.verification_uri .. '?user_code=' .. auth_data.user_code) + + poll_for_token(auth_data.device_code, auth_data.interval or 5, client) +end + +---@param client vim.lsp.Client +local function sign_out(_, client) + local ok = os.remove(config.token_file) + if ok then + vim.notify('Signed out. Token removed.', vim.log.levels.INFO) + client.notify('workspace/didChangeConfiguration', { + settings = { token = '' }, + }) + else + vim.notify('Failed to remove token file', vim.log.levels.ERROR) + end +end + +---@param client vim.lsp.Client +local function show_status(_, _client) + local token_data = load_token() + + if not token_data then + vim.notify('Not signed in. Run :LspGitLabDuoSignIn to authenticate.', vim.log.levels.INFO) + return + end + + local info = { + 'GitLab Duo Status:', + '', + 'Instance: ' .. config.gitlab_url, + 'Signed in: Yes', + 'Has refresh token: ' .. (token_data.refresh_token and 'Yes' or 'No'), + } + + if token_data.saved_at and token_data.expires_in then + local time_left = token_data.expires_in - (os.time() - token_data.saved_at) + if time_left > 0 then + local hours = math.floor(time_left / 3600) + local minutes = math.floor((time_left % 3600) / 60) + table.insert(info, string.format('Token expires in: %dh %dm', hours, minutes)) + else + table.insert(info, 'Token status: EXPIRED') + end + end + + vim.notify(table.concat(info, '\n'), vim.log.levels.INFO) +end + +---@type vim.lsp.Config +return { + cmd = { + 'npx', + '--registry=https://gitlab.com/api/v4/packages/npm/', + '@gitlab-org/gitlab-lsp', + '--stdio', + }, + root_markers = { '.git' }, + filetypes = { + 'ruby', + 'go', + 'javascript', + 'typescript', + 'typescriptreact', + 'javascriptreact', + 'rust', + 'lua', + 'python', + 'java', + 'cpp', + 'c', + 'php', + 'cs', + 'kotlin', + 'swift', + 'scala', + 'vue', + 'svelte', + 'html', + 'css', + 'scss', + 'json', + 'yaml', + }, + init_options = { + editorInfo = { + name = 'Neovim', + version = tostring(vim.version()), + }, + editorPluginInfo = { + name = 'Neovim LSP', + version = tostring(vim.version()), + }, + ide = { + name = 'Neovim', + version = tostring(vim.version()), + vendor = 'Neovim', + }, + extension = { + name = 'Neovim LSP Client', + version = tostring(vim.version()), + }, + }, + settings = { + baseUrl = config.gitlab_url, + logLevel = 'info', + codeCompletion = { + enableSecretRedaction = true, + }, + telemetry = { + enabled = false, + }, + featureFlags = { + streamCodeGenerations = false, + }, + }, + on_init = function(client) + -- Handle token validation errors + client.handlers['$/gitlab/token/check'] = function(_, result) + if result and result.reason then + vim.notify(string.format('GitLab Duo: %s - %s', result.reason, result.message or ''), vim.log.levels.ERROR) + + -- Try to refresh if possible + local token_data = load_token() + if token_data and token_data.refresh_token then + vim.schedule(function() + local new_token_data = refresh_access_token(token_data.refresh_token) + if new_token_data then + client.notify('workspace/didChangeConfiguration', { + settings = { token = new_token_data.access_token }, + }) + else + vim.notify('Run :LspGitLabDuoSignIn to re-authenticate', vim.log.levels.WARN) + end + end) + else + vim.notify('Run :LspGitLabDuoSignIn to authenticate', vim.log.levels.WARN) + end + end + end + + -- Handle feature state changes + client.handlers['$/gitlab/featureStateChange'] = function(_, result) + if result and result.state == 'disabled' and result.checks then + for _, check in ipairs(result.checks) do + vim.notify(string.format('GitLab Duo: %s', check.message or check.id), vim.log.levels.WARN) + end + end + end + + -- Check authentication status + local token, status = get_valid_token() + if not token then + vim.notify('GitLab Duo: Not authenticated. Run :LspGitLabDuoSignIn to sign in.', vim.log.levels.WARN) + elseif status == 'refreshed' then + vim.notify('GitLab Duo: Token refreshed automatically', vim.log.levels.INFO) + end + end, + on_attach = function(client, bufnr) + vim.api.nvim_buf_create_user_command(bufnr, 'LspGitLabDuoSignIn', function() + sign_in(bufnr, client) + end, { desc = 'Sign in to GitLab Duo with OAuth' }) + + vim.api.nvim_buf_create_user_command(bufnr, 'LspGitLabDuoSignOut', function() + sign_out(bufnr, client) + end, { desc = 'Sign out from GitLab Duo' }) + + vim.api.nvim_buf_create_user_command(bufnr, 'LspGitLabDuoStatus', function() + show_status(bufnr, client) + end, { desc = 'Show GitLab Duo authentication status' }) + end, +}