aboutsummaryrefslogtreecommitdiffstats
path: root/lsp/gitlab_duo.lua
diff options
context:
space:
mode:
authorAboobacker MK <aboobackervyd@gmail.com>2025-11-15 03:25:52 +0530
committerGitHub <noreply@github.com>2025-11-14 13:55:52 -0800
commitadbfc92dca5be02fc9426f52afa2e1d520a4dc05 (patch)
tree16ac377cba6de46fb79ff7c70ee677137cdbc126 /lsp/gitlab_duo.lua
parentfix(eslint.lua): nonsense passed to list_extend() (diff)
downloadnvim-lspconfig-adbfc92dca5be02fc9426f52afa2e1d520a4dc05.tar
nvim-lspconfig-adbfc92dca5be02fc9426f52afa2e1d520a4dc05.tar.gz
nvim-lspconfig-adbfc92dca5be02fc9426f52afa2e1d520a4dc05.tar.bz2
nvim-lspconfig-adbfc92dca5be02fc9426f52afa2e1d520a4dc05.tar.lz
nvim-lspconfig-adbfc92dca5be02fc9426f52afa2e1d520a4dc05.tar.xz
nvim-lspconfig-adbfc92dca5be02fc9426f52afa2e1d520a4dc05.tar.zst
nvim-lspconfig-adbfc92dca5be02fc9426f52afa2e1d520a4dc05.zip
feat(Gitlab Duo): login + inline_completion #4157
Prerequisites: - Node.js and npm installed - GitLab account with Duo Pro license - Neovim 0.12+ (for inline completion support) Limitations: This initial implementation only supports GitLab.com. Support for self-managed GitLab instances may be added in a future iteration.
Diffstat (limited to 'lsp/gitlab_duo.lua')
-rw-r--r--lsp/gitlab_duo.lua440
1 files changed, 440 insertions, 0 deletions
diff --git a/lsp/gitlab_duo.lua b/lsp/gitlab_duo.lua
new file mode 100644
index 00000000..ebdb2dde
--- /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', '<Tab>', function()
+--- if vim.lsp.inline_completion.is_visible() then
+--- return vim.lsp.inline_completion.accept()
+--- else
+--- return '<Tab>'
+--- end
+--- end, { expr = true, buffer = bufnr, desc = 'GitLab Duo: Accept suggestion' })
+---
+--- -- Alt/Option+[ for previous suggestion
+--- vim.keymap.set('i', '<M-[>', vim.lsp.inline_completion.select_prev,
+--- { buffer = bufnr, desc = 'GitLab Duo: Previous suggestion' })
+---
+--- -- Alt/Option+] for next suggestion
+--- vim.keymap.set('i', '<M-]>', 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,
+}