From 8a4945a5fd3ecafa79d0eadf9e67f897b28cea09 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Fri, 23 Oct 2026 16:28:21 +1100 Subject: [PATCH 01/13] test: add an integration dev shell with the forge CLIs and servers The new `integration` shell has the packages of the default shell, and also curl, gh, glab, tea, fj (forgejo-cli), and the gitea and forgejo servers, at the versions of the locked nixpkgs. Forgejo is broken on aarch64-darwin, so the shell has it only where it builds. The default, nvim-0_12_0, and nightly shells do not change. --- flake.nix | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/flake.nix b/flake.nix index c926465..dad1867 100644 --- a/flake.nix +++ b/flake.nix @@ -29,23 +29,39 @@ system: pkgs: let mv = multiverse.multiverse.${system}; + packages = neovim: [ + neovim + pkgs.stylua + pkgs.selene + pkgs.lua-language-server + pkgs.gnumake + pkgs.git + ]; shell = neovim: pkgs.mkShell { - packages = [ - neovim - pkgs.stylua - pkgs.selene - pkgs.lua-language-server - pkgs.gnumake - pkgs.git - ]; + packages = packages neovim; # scripts/nightly-cache.sh reads the store path of Neovim from here passthru = { inherit neovim; }; }; in { default = shell pkgs.neovim-unwrapped; + # The CLIs and the servers of `make test-integration`. Forgejo is broken on aarch64-darwin, so its server + # cases skip there. + integration = pkgs.mkShell { + packages = + packages pkgs.neovim-unwrapped + ++ [ + pkgs.curl + pkgs.gh + pkgs.glab + pkgs.tea + pkgs.forgejo-cli + pkgs.gitea + ] + ++ pkgs.lib.optional (!pkgs.forgejo.meta.broken) pkgs.forgejo; + }; # The minimum supported version nvim-0_12_0 = shell (mv.fast.version "neovim-unwrapped" "0.12.0"); # The overlay CI pushes only this output to nix-community.cachix.org. It uses the nixpkgs of the From bdb4eda2745d5ce2801bd5b126fa75f9b93eeddf Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Fri, 23 Oct 2026 16:28:21 +1100 Subject: [PATCH 02/13] test: run integration tests against a fake HTTP server `make test-integration` runs the files in `tests/integration/`, and `make test` now leaves them out. `make test-file` runs a file of either kind. The new blocking `Integration` CI job runs the target in the `integration` shell with `CMP_GIT_INTEGRATION_REQUIRE=1`, so that a missing CLI fails its cases and does not skip them. `tests/integration/helpers.lua` has a fake HTTP/1.1 server on `vim.uv`, over TCP or a Unix socket, with a route table, a request log, and a proxy mode that removes a path prefix. It also gives gh and glab config directories that send their requests to the server. `helpers.new_set` takes `pre_once` and `post_once` hooks, and `helpers.defer` runs a function at the end of a case. --- .github/workflows/ci.yml | 26 ++ Makefile | 8 +- tests/helpers.lua | 12 +- tests/integration/helpers.lua | 321 +++++++++++++++++++++++++ tests/integration/test_http_server.lua | 109 +++++++++ tests/minimal_init.lua | 11 + 6 files changed, 485 insertions(+), 2 deletions(-) create mode 100644 tests/integration/helpers.lua create mode 100644 tests/integration/test_http_server.lua diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 253fb6a..e705dd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,32 @@ jobs: - name: Test run: nix develop ".#${{ matrix.shell }}" -c make test + # The real gh, glab, tea, and fj, and real Gitea and Forgejo servers + integration: + name: Integration + if: github.event_name != 'schedule' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install Nix + uses: cachix/install-nix-action@448cde739abec1eaf0646249e3d25bb8d9bc7702 # v31.11.2 + with: + extra_nix_config: | + extra-substituters = https://nix-community.cachix.org + extra-trusted-public-keys = nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs= + - name: Show versions + run: >- + nix develop .#integration -c sh -c + 'nvim --version | head -1; gh --version | head -1; glab --version; tea --version; fj version; + gitea --version; forgejo --version' + - name: Test + env: + # A missing CLI or server fails its cases, and does not skip them + CMP_GIT_INTEGRATION_REQUIRE: 1 + run: nix develop .#integration -c make test-integration + # Shared runners are noisy, so a failed comparison is a warning to read in the job summary, not a failed check bench: name: Benchmarks diff --git a/Makefile b/Makefile index ba3ea6a..ff4d20c 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ MINI_DIR := deps/mini.nvim LUA_PATHS := lua plugin tests benchmarks REF ?= main -.PHONY: test-deps test test-file bench bench-compare format format-check lint typecheck ci +.PHONY: test-deps test test-integration test-file bench bench-compare format format-check lint typecheck ci test-deps: @if [ ! -d "$(MINI_DIR)/.git" ]; then \ @@ -20,9 +20,15 @@ test-deps: git -C "$(MINI_DIR)" checkout --detach "$(MINI_VERSION)"; \ fi +# Runs the files in tests/ without tests/integration/ test: test-deps $(NVIM) --headless --noplugin -u tests/minimal_init.lua -c "lua MiniTest.run()" +# Runs the files in tests/integration/. They need the CLIs and the servers of `nix develop .#integration`. +test-integration: test-deps + TEST_SUITE=integration $(NVIM) --headless --noplugin -u tests/minimal_init.lua -c "lua MiniTest.run()" + +# Also runs a file in tests/integration/ test-file: test-deps @test -n "$(FILE)" || (printf '%s\n' 'Usage: make test-file FILE=tests/**/test_*.lua' >&2; exit 2) TEST_FILE="$(FILE)" $(NVIM) --headless --noplugin -u tests/minimal_init.lua \ diff --git a/tests/helpers.lua b/tests/helpers.lua index 4635404..69a6230 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -123,6 +123,12 @@ function M.stub(target, key, value) target[key] = value end +---Run `fn` at the end of the current case, in reverse order with the restores of `M.stub`. +---@param fn fun() +function M.defer(fn) + table.insert(case.restores, fn) +end + ---An empty directory that the case hooks delete. ---@return string path The resolved path, so that it compares equal to `getcwd()`. function M.tempdir() @@ -235,12 +241,16 @@ local function teardown_case() end ---The test set for a file. Its hooks isolate each case. +---@param hooks? { pre_once?: fun(), post_once?: fun() } Run once before the first case and after the last case. ---@return table -function M.new_set() +function M.new_set(hooks) + hooks = hooks or {} return MiniTest.new_set({ hooks = { + pre_once = hooks.pre_once, pre_case = setup_case, post_case = teardown_case, + post_once = hooks.post_once, }, }) end diff --git a/tests/integration/helpers.lua b/tests/integration/helpers.lua new file mode 100644 index 0000000..684a689 --- /dev/null +++ b/tests/integration/helpers.lua @@ -0,0 +1,321 @@ +-- Helpers for the tests that run the real CLIs. The cases must not use the network: gh and glab talk to a fake HTTP +-- server in this process, and tea, the fj token, and curl talk to Gitea and Forgejo servers on 127.0.0.1. +local helpers = require("tests.helpers") + +local uv = vim.uv + +local M = {} + +---With CMP_GIT_INTEGRATION_REQUIRE=1, as in CI, a missing binary fails the case. Otherwise, it skips the case. +---@param names string[] +function M.require_binaries(names) + local missing = vim.tbl_filter(function(name) + return vim.fn.executable(name) ~= 1 + end, names) + if #missing == 0 then + return + end + + local reason = "Missing " .. table.concat(missing, ", ") .. ". Run the tests in `nix develop .#integration`." + if vim.env.CMP_GIT_INTEGRATION_REQUIRE == "1" then + error(reason, 2) + end + helpers.skip(reason) +end + +---A request that the fake server got. The path is not decoded, so that `%2F` in a GitLab project ID stays. +---@class tests.integration.Request +---@field method string +---@field target string The path and the query. +---@field path string +---@field query table The decoded query parameters. +---@field headers table By lowercase name. +---@field body string + +---@class tests.integration.Response +---@field status? integer 200 when it is nil. +---@field body? string|table A table is sent as JSON. +---@field headers? table + +---@alias tests.integration.Route tests.integration.Response | fun(request: tests.integration.Request): tests.integration.Response + +local STATUS_TEXT = { + [200] = "OK", + [201] = "Created", + [204] = "No Content", + [401] = "Unauthorized", + [403] = "Forbidden", + [404] = "Not Found", + [502] = "Bad Gateway", +} + +---@param text string +---@return string +local function decode_component(text) + text = string.gsub(text, "+", " ") + return (string.gsub(text, "%%(%x%x)", function(hex) + return string.char(tonumber(hex, 16)) + end)) +end + +---@param query string +---@return table +local function parse_query(query) + local result = {} + for pair in vim.gsplit(query, "&", { plain = true, trimempty = true }) do + local key, value = string.match(pair, "^([^=]*)=?(.*)$") + result[decode_component(key)] = decode_component(value) + end + return result +end + +---@param buffer string The bytes so far. +---@return tests.integration.Request? request nil until the buffer has the head and the body. +local function parse_request(buffer) + local head_end = string.find(buffer, "\r\n\r\n", 1, true) + if head_end == nil then + return nil + end + + local lines = vim.split(string.sub(buffer, 1, head_end - 1), "\r\n", { plain = true }) + local method, target = string.match(lines[1], "^(%S+) (%S+) HTTP/1%.%d$") + local headers = {} + for i = 2, #lines do + local name, value = string.match(lines[i], "^([^:]+):%s*(.-)%s*$") + if name ~= nil then + headers[name:lower()] = value + end + end + + -- The CLIs send a body with Content-Length. A chunked body would wait here until the client gives up. + local length = tonumber(headers["content-length"] or "0") or 0 + local body_start = head_end + 4 + if #buffer < body_start + length - 1 then + return nil + end + + local path, query = string.match(target or "", "^([^?]*)%??(.*)$") + return { + method = method or "", + target = target or "", + path = path or "", + query = parse_query(query or ""), + headers = headers, + body = string.sub(buffer, body_start, body_start + length - 1), + } +end + +---Write the response and close the connection, so that each request needs a new connection. +---@param client uv.uv_stream_t +---@param response tests.integration.Response +local function send(client, response) + local body = response.body or "" + if type(body) == "table" then + body = vim.json.encode(body) + end + local status = response.status or 200 + local headers = vim.tbl_extend("force", { ["Content-Type"] = "application/json" }, response.headers or {}) + headers["Content-Length"] = tostring(#body) + headers["Connection"] = "close" + + local lines = { string.format("HTTP/1.1 %d %s", status, STATUS_TEXT[status] or "Status") } + for name, value in pairs(headers) do + table.insert(lines, name .. ": " .. value) + end + client:write(table.concat(lines, "\r\n") .. "\r\n\r\n" .. body, function() + client:shutdown(function() + client:close() + end) + end) +end + +---Send the request to the upstream server without the path prefix, and copy its response to the client. +---@param client uv.uv_stream_t +---@param request tests.integration.Request +---@param upstream { port: integer, prefix: string } +local function forward(client, request, upstream) + local target = string.sub(request.target, #upstream.prefix + 1) + if string.sub(target, 1, 1) ~= "/" then + target = "/" .. target + end + local lines = { string.format("%s %s HTTP/1.1", request.method, target) } + for name, value in pairs(request.headers) do + if name ~= "connection" then + table.insert(lines, name .. ": " .. value) + end + end + -- The upstream server then closes the connection after the response, and the proxy closes the client + table.insert(lines, "connection: close") + + local connection = assert(uv.new_tcp()) + connection:connect("127.0.0.1", upstream.port, function(err) + if err then + connection:close() + send(client, { status = 502, body = { message = err } }) + return + end + connection:write(table.concat(lines, "\r\n") .. "\r\n\r\n" .. request.body) + connection:read_start(function(read_err, chunk) + if chunk ~= nil then + client:write(chunk) + return + end + if read_err then + send(client, { status = 502, body = { message = read_err } }) + end + connection:close() + client:shutdown(function() + client:close() + end) + end) + end) +end + +---A fake HTTP/1.1 server in this Neovim. It answers while a case waits with `vim.wait`. +---@class tests.integration.HttpServer +---@field url? string `http://127.0.0.1:` for a TCP server. +---@field port? integer +---@field socket? string The path of a Unix socket server. +---@field routes table By ` `, such as `GET /api/v4/version`. +---@field requests tests.integration.Request[] The requests so far, in order. +---@field upstream? { port: integer, prefix: string } Set to make the server a proxy that removes the prefix. +---@field handle uv.uv_stream_t +local HttpServer = {} +HttpServer.__index = HttpServer + +---@param request tests.integration.Request +---@return tests.integration.Response +function HttpServer:respond(request) + local route = self.routes[request.method .. " " .. request.path] + if type(route) == "function" then + return route(request) + end + if route ~= nil then + return route + end + return { status = 404, body = { message = "No route for " .. request.method .. " " .. request.path } } +end + +---@param client uv.uv_stream_t +function HttpServer:serve(client) + local buffer = "" + client:read_start(function(err, chunk) + if err or chunk == nil then + client:close() + return + end + + buffer = buffer .. chunk + -- For example the TLS handshake of an https URL. The client then fails at once and does not wait. + if not string.find(buffer, "^%u") then + client:close() + return + end + local request = parse_request(buffer) + if request == nil then + return + end + client:read_stop() + table.insert(self.requests, request) + + local upstream = self.upstream + if upstream == nil then + -- An error in a route must not kill the loop callback, or the client waits until its timeout + local ok, response = pcall(self.respond, self, request) + send(client, ok and response or { status = 500, body = { message = tostring(response) } }) + elseif string.sub(request.path .. "/", 1, #upstream.prefix + 1) == upstream.prefix .. "/" then + forward(client, request, upstream) + else + send(client, { status = 404, body = { message = "Not under " .. upstream.prefix } }) + end + end) +end + +---The requests so far as ` `. +---@return string[] +function HttpServer:request_lines() + return vim.tbl_map(function(request) + return request.method .. " " .. request.target + end, self.requests) +end + +function HttpServer:close() + if not self.handle:is_closing() then + self.handle:close() + end +end + +---Start a fake server on a free TCP port of 127.0.0.1, or on a Unix socket. +---@param opts? { routes?: table, socket?: string, upstream?: { port: integer, prefix: string } } +---@return tests.integration.HttpServer +function M.http_server(opts) + opts = opts or {} + local self = setmetatable({ routes = opts.routes or {}, requests = {}, upstream = opts.upstream }, HttpServer) + + if opts.socket then + local pipe = assert(uv.new_pipe(false)) + assert(pipe:bind(opts.socket)) + self.handle = pipe + self.socket = opts.socket + else + local tcp = assert(uv.new_tcp()) + assert(tcp:bind("127.0.0.1", 0)) + self.handle = tcp + self.port = tcp:getsockname().port + self.url = "http://127.0.0.1:" .. self.port + end + + assert(self.handle:listen(64, function(err) + assert(not err, err) + local client = assert(opts.socket and uv.new_pipe(false) or uv.new_tcp(), "cannot make the client handle") + self.handle:accept(client) + self:serve(client) + end)) + return self +end + +---@param path string +---@param lines string[] +local function write_file(path, lines) + vim.fn.mkdir(vim.fs.dirname(path), "p") + assert(vim.fn.writefile(lines, path) == 0, "cannot write " .. path) + -- glab reads no config file that other users can read + assert(uv.fs_chmod(path, tonumber("600", 8))) +end + +---Make gh send all its requests to the server until the end of the case. gh then uses plain HTTP over the socket for +---each host. +---@param server tests.integration.HttpServer A Unix socket server. +function M.use_gh(server) + local dir = helpers.tempdir() + write_file(dir .. "/config.yml", { "http_unix_socket: " .. assert(server.socket, "gh needs a Unix socket") }) + helpers.stub(vim.env, "GH_CONFIG_DIR", dir) + helpers.stub(vim.env, "GH_TOKEN", "gh-token") + helpers.stub(vim.env, "GH_PROMPT_DISABLED", "1") + helpers.stub(vim.env, "GH_NO_UPDATE_NOTIFIER", "1") +end + +---Make glab send its requests for the host to the server until the end of the case. +---@param server tests.integration.HttpServer A TCP server. +---@return string host The host of the server, `127.0.0.1:`. +function M.use_glab(server) + local host = "127.0.0.1:" .. assert(server.port, "glab needs a TCP server") + local dir = helpers.tempdir() + write_file(dir .. "/config.yml", { + "check_update: false", + "hosts:", + " " .. host .. ":", + " api_protocol: http", + " api_host: " .. host, + " token: glab-token", + }) + helpers.stub(vim.env, "GLAB_CONFIG_DIR", dir) + helpers.stub(vim.env, "GITLAB_HOST", host) + helpers.stub(vim.env, "GITLAB_TOKEN", "glab-token") + helpers.stub(vim.env, "GLAB_SEND_TELEMETRY", "false") + helpers.stub(vim.env, "GLAB_SHOW_WHATS_NEW", "false") + helpers.stub(vim.env, "GLAB_CHECK_UPDATE", "false") + return host +end + +return M diff --git a/tests/integration/test_http_server.lua b/tests/integration/test_http_server.lua new file mode 100644 index 0000000..9170ca8 --- /dev/null +++ b/tests/integration/test_http_server.lua @@ -0,0 +1,109 @@ +local helpers = require("tests.helpers") +local integration = require("tests.integration.helpers") + +local eq = helpers.eq + +local T = helpers.new_set() + +---A fake server that closes at the end of the case. +---@param opts? table +---@return tests.integration.HttpServer +local function server(opts) + local fake = integration.http_server(opts) + helpers.defer(function() + fake:close() + end) + return fake +end + +---Run curl and wait for it, while the server answers in the event loop. +---@param args string[] +---@return { code: integer, stdout: string } +local function curl(args) + local result + vim.system(vim.list_extend({ "curl", "-s", "-S" }, args), { text = true }, function(completed) + result = completed + end) + helpers.wait_for(function() + return result ~= nil + end, "curl ends") + return result +end + +T["a_route_answers_and_the_server_records_the_request"] = function() + integration.require_binaries({ "curl" }) + local fake = server({ routes = { ["GET /api/v1/items"] = { body = { { id = 1 } } } } }) + + local result = curl({ "-H", "Authorization: token secret", fake.url .. "/api/v1/items?state=open&q=a%20b" }) + + eq(result.code, 0, "curl succeeds") + eq(vim.json.decode(result.stdout), { { id = 1 } }, "the JSON of the route") + eq(fake:request_lines(), { "GET /api/v1/items?state=open&q=a%20b" }, "the request") + eq(fake.requests[1].query, { state = "open", q = "a b" }, "the decoded query") + eq(fake.requests[1].headers.authorization, "token secret", "the headers by lowercase name") +end + +T["a_function_route_gets_the_body"] = function() + integration.require_binaries({ "curl" }) + local fake = server({ + routes = { + ["POST /graphql"] = function(request) + return { status = 201, body = { echo = vim.json.decode(request.body).query } } + end, + }, + }) + + local result = curl({ "-w", "\n%{http_code}", "-d", '{"query":"IssueList"}', fake.url .. "/graphql" }) + + eq(vim.split(result.stdout, "\n"), { '{"echo":"IssueList"}', "201" }, "the body and the status of the route") +end + +T["a_missing_route_or_a_failed_route_is_an_http_error"] = function() + integration.require_binaries({ "curl" }) + local fake = server({ + routes = { + ["GET /broken"] = function() + error("broken route") + end, + }, + }) + + eq(curl({ "--fail", fake.url .. "/missing" }).code, 22, "a missing route is a 404") + eq(curl({ "-o", "/dev/null", "-w", "%{http_code}", fake.url .. "/broken" }).stdout, "500", "a route error") +end + +T["a_server_on_a_unix_socket"] = function() + integration.require_binaries({ "curl" }) + local socket = helpers.tempdir() .. "/server.sock" + local fake = server({ socket = socket, routes = { ["GET /user"] = { body = '{"login":"octocat"}' } } }) + + local result = curl({ "--unix-socket", socket, "http://api.github.com/user" }) + + eq(result.stdout, '{"login":"octocat"}', "the body of the route") + eq(fake.requests[1].headers.host, "api.github.com", "the host of the URL") +end + +T["a_proxy_removes_the_path_prefix"] = function() + integration.require_binaries({ "curl" }) + local upstream = server({ routes = { ["GET /api/v1/version"] = { body = { version = "1" } } } }) + local proxy = server({ upstream = { port = assert(upstream.port, "a TCP server has a port"), prefix = "/gitea" } }) + + eq(curl({ proxy.url .. "/gitea/api/v1/version?x=1" }).stdout, '{"version":"1"}', "the upstream answers") + eq(upstream:request_lines(), { "GET /api/v1/version?x=1" }, "the upstream gets the path without the prefix") + eq(curl({ "--fail", proxy.url .. "/api/v1/version" }).code, 22, "a path outside the prefix is a 404") + eq(curl({ "--fail", proxy.url .. "/giteax/api/v1/version" }).code, 22, "the prefix is a whole segment") + eq(#upstream.requests, 1, "the upstream gets only the requests under the prefix") +end + +T["a_tls_handshake_fails_at_once"] = function() + integration.require_binaries({ "curl" }) + local fake = server() + + local start = vim.uv.now() + local result = curl({ "--max-time", "5", "https://127.0.0.1:" .. fake.port .. "/" }) + + helpers.neq(result.code, 0, "curl fails") + helpers.ok(vim.uv.now() - start < 4000, "without a wait for the timeout") +end + +return T diff --git a/tests/minimal_init.lua b/tests/minimal_init.lua index d9e9aa5..5a98cc1 100644 --- a/tests/minimal_init.lua +++ b/tests/minimal_init.lua @@ -6,7 +6,18 @@ vim.opt.runtimepath:prepend(root) -- Lets test files `require("tests.helpers")` after a case changes the cwd package.path = root .. "/?.lua;" .. package.path +-- `make test` runs the unit tests. `make test-integration` sets TEST_SUITE=integration and runs the tests in +-- `tests/integration/`, which need the CLIs and the servers of the `integration` shell. +local integration = vim.env.TEST_SUITE == "integration" + require("mini.test").setup({ + collect = { + find_files = function() + return vim.tbl_filter(function(file) + return (file:find("^tests/integration/") ~= nil) == integration + end, vim.fn.globpath("tests", "**/test_*.lua", true, true)) + end, + }, execute = { reporter = require("mini.test").gen_reporter.stdout(), }, From d406627e9744e323bbdcb786c7845797940d7b4f Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Fri, 23 Oct 2026 16:30:09 +1100 Subject: [PATCH 03/13] test: run the GitHub source with the real gh against a fake server The cases send all requests of gh 2.102.0 to the fake server over a Unix socket. They cover the GraphQL queries of `gh issue list --json` and `gh pr list --json` with the state and the limit of the config, the mentions preflight and two pages of collaborators with `gh api`, and the contributors after a failed preflight. `gh repo set-default --view` reads the default repository from the git config and sends no request. The commands run in a directory outside a repository, and a curl that fails at once keeps the curl fallback off the network. --- tests/integration/helpers.lua | 27 +++++ tests/integration/test_gh.lua | 190 ++++++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 tests/integration/test_gh.lua diff --git a/tests/integration/helpers.lua b/tests/integration/helpers.lua index 684a689..8d86865 100644 --- a/tests/integration/helpers.lua +++ b/tests/integration/helpers.lua @@ -283,6 +283,33 @@ local function write_file(path, lines) assert(uv.fs_chmod(path, tonumber("600", 8))) end +---Put commands that exit with a code first on PATH until the end of the case. The other commands stay. +---@param codes table The exit code of each command, by name. +function M.failing_commands(codes) + local bin = helpers.tempdir() + for name, code in pairs(codes) do + local path = bin .. "/" .. name + assert(vim.fn.writefile({ "#!/bin/sh", "exit " .. code }, path) == 0, "cannot write " .. path) + assert(uv.fs_chmod(path, tonumber("755", 8))) + end + helpers.stub(vim.env, "PATH", bin .. ":" .. vim.env.PATH) +end + +---The curl fallback of GitHub and GitLab uses the real hosts. A curl that fails at once, as without a connection, +---keeps the cases off the network when a CLI fails. +function M.offline_curl() + -- 7 is the exit code of curl for a failed connection + M.failing_commands({ curl = 7 }) +end + +---Open a buffer in a new directory outside a repository. The commands of its requests run there, so that the git +---config of the cmp-git checkout, such as the default repository of gh, does not reach them. +---@return integer bufnr +function M.edit_outside_repository() + vim.cmd.edit(vim.fn.fnameescape(helpers.tempdir() .. "/COMMIT_EDITMSG")) + return vim.api.nvim_get_current_buf() +end + ---Make gh send all its requests to the server until the end of the case. gh then uses plain HTTP over the socket for ---each host. ---@param server tests.integration.HttpServer A Unix socket server. diff --git a/tests/integration/test_gh.lua b/tests/integration/test_gh.lua new file mode 100644 index 0000000..933be25 --- /dev/null +++ b/tests/integration/test_gh.lua @@ -0,0 +1,190 @@ +-- The real gh against a fake server on a Unix socket. gh 2.102.0 sends `gh issue list --json` as the GraphQL query +-- `IssueList` and `gh pr list --json` as `PullRequestList`, and reads `data.repository.issues` and +-- `data.repository.pullRequests`. A gh update that changes these needs new canned answers. +local helpers = require("tests.helpers") +local integration = require("tests.integration.helpers") +local config = require("cmp_git.config") +local GitHub = require("cmp_git.sources.github") + +local eq = helpers.eq + +local T = helpers.new_set() + +local ISSUES = { + { title = "Crash on start", number = 12, body = "Steps", updatedAt = "2026-01-02T00:00:00Z", state = "OPEN" }, +} +local PULL_REQUESTS = { + { title = "Fix the crash", number = 34, body = "Fixes #12", updatedAt = "2026-01-03T00:00:00Z", state = "OPEN" }, +} + +---@param nodes table[] +local function connection(nodes) + return { totalCount = #nodes, nodes = nodes, pageInfo = { hasNextPage = false, endCursor = "" } } +end + +---The GraphQL route. It answers the issue and pull request lists of gh, and records their variables. +---@param variables table[] Gets the variables of each query. +---@return tests.integration.Route +local function graphql(variables) + return function(request) + local payload = vim.json.decode(request.body) + table.insert(variables, payload.variables) + if string.find(payload.query, "query IssueList", 1, true) then + return { body = { data = { repository = { hasIssuesEnabled = true, issues = connection(ISSUES) } } } } + end + if string.find(payload.query, "query PullRequestList", 1, true) then + return { body = { data = { repository = { pullRequests = connection(PULL_REQUESTS) } } } } + end + return { status = 400, body = { message = "Unknown query" } } + end +end + +---A fake GitHub API for gh, and a buffer outside a repository. +---@return tests.integration.HttpServer server +---@return integer bufnr +local function github_server() + integration.require_binaries({ "gh" }) + local server = integration.http_server({ socket = helpers.tempdir() .. "/gh.sock" }) + helpers.defer(function() + server:close() + end) + integration.use_gh(server) + integration.offline_curl() + return server, integration.edit_outside_repository() +end + +---Run a request of the source and wait for its final list. +---@param source cmp_git.Source.GitHub +---@param method string +---@param git_info cmp_git.GitInfo +---@param trigger_char string +---@param bufnr integer +---@return cmp_git.CompletionList +local function complete(source, method, git_info, trigger_char, bufnr) + local callback, lists = helpers.recorder() + eq(source[method](source, callback, git_info, trigger_char, bufnr), true, method .. " handles the host") + helpers.wait_for(function() + return #lists > 0 and not lists[#lists].isIncomplete + end, method .. " answers") + return lists[#lists] +end + +---@param list cmp_git.CompletionList +---@return string[] +local function labels(list) + return vim.tbl_map(function(item) + return item.label + end, list.items) +end + +local REMOTE = { host = "github.com", owner = "owner", repo = "repo" } + +T["issues_and_pull_requests_come_from_the_graphql_api"] = function() + local server, bufnr = github_server() + local variables = {} + server.routes["POST /graphql"] = graphql(variables) + local source = GitHub.new(config.normalize({}).github) + + local list = complete(source, "complete_issues_and_change_requests", REMOTE, "#", bufnr) + + eq(labels(list), { "#12: Crash on start", "#34: Fix the crash" }, "the issues, then the pull requests") + eq(list.items[2].documentation.value, "# Fix the crash\n\nFixes #12", "the body of the pull request") + eq(server:request_lines(), { "POST /graphql", "POST /graphql" }, "one query for each list") + table.sort(variables, function(a, b) + return a.states ~= nil and b.states == nil + end) + eq(variables, { + { limit = 100, owner = "owner", repo = "repo", states = { "OPEN" } }, + { limit = 100, owner = "owner", repo = "repo", state = { "OPEN" } }, + }, "the repository, the limit, and the state of each list") + eq(server.requests[1].headers.authorization, "token gh-token", "gh sends its token") +end + +T["the_state_and_the_limit_reach_gh"] = function() + local server, bufnr = github_server() + local variables = {} + server.routes["POST /graphql"] = graphql(variables) + local source = GitHub.new(config.normalize({ + github = { issues = { state = "closed", limit = 5 }, pull_requests = { state = "merged", limit = 7 } }, + }).github) + + eq(labels(complete(source, "complete_issues", REMOTE, "#", bufnr)), { "#12: Crash on start" }, "the issues") + eq(labels(complete(source, "complete_change_requests", REMOTE, "#", bufnr)), { "#34: Fix the crash" }, "the PRs") + + eq(variables, { + { limit = 5, owner = "owner", repo = "repo", states = { "CLOSED" } }, + { limit = 7, owner = "owner", repo = "repo", state = { "MERGED" } }, + }, "gh sends the state and the limit of the config") +end + +---@param count integer +---@param first integer The number in the first login. +---@return table[] +local function users(count, first) + local result = {} + for i = 1, count do + table.insert(result, { login = string.format("user%03d", first + i - 1) }) + end + return result +end + +T["mentions_come_from_the_collaborators_in_pages"] = function() + local server, bufnr = github_server() + server.routes["GET /repos/owner/repo/collaborators/testing/permission"] = { body = { permission = "admin" } } + server.routes["GET /repos/owner/repo/collaborators"] = function(request) + return { body = request.query.page == "1" and users(100, 1) or users(1, 101) } + end + local source = GitHub.new(config.normalize({ github = { mentions = { limit = math.huge } } }).github) + + local list = complete(source, "complete_mentions", REMOTE, "@", bufnr) + + eq(#list.items, 101, "the users of both pages") + eq({ list.items[1].label, list.items[101].label }, { "@user001", "@user101" }, "in the order of the pages") + eq(server:request_lines(), { + "GET /repos/owner/repo/collaborators/testing/permission", + "GET /repos/owner/repo/collaborators?per_page=100&page=1", + "GET /repos/owner/repo/collaborators?per_page=100&page=2", + }, "the preflight, then a request for each page") +end + +T["mentions_come_from_the_contributors_when_the_preflight_fails"] = function() + local server, bufnr = github_server() + server.routes["GET /repos/owner/repo/collaborators/testing/permission"] = { + status = 403, + body = { message = "Must have push access to view collaborator permission.", status = "403" }, + } + server.routes["GET /repos/owner/repo/contributors"] = { body = users(2, 1) } + local source = GitHub.new(config.normalize({}).github) + + local list = complete(source, "complete_mentions", REMOTE, "@", bufnr) + + eq(labels(list), { "@user001", "@user002" }, "the contributors") + eq(server:request_lines(), { + "GET /repos/owner/repo/collaborators/testing/permission", + "GET /repos/owner/repo/contributors?per_page=100&page=1", + }, "the preflight, then the contributors") +end + +T["the_default_repository_of_gh_needs_no_server"] = function() + local server = github_server() + local variables = {} + server.routes["POST /graphql"] = graphql(variables) + local repo = helpers.git_repo({ + remotes = { + { "origin", "https://github.com/owner/repo.git" }, + { "upstream", "https://github.com/upstream/project.git" }, + }, + }) + -- What `gh repo set-default upstream/project` writes + repo.git("config", "remote.upstream.gh-resolved", "base") + vim.cmd.edit(vim.fn.fnameescape(repo.dir .. "/COMMIT_EDITMSG")) + local source = GitHub.new(config.normalize({}).github) + + local list = complete(source, "complete_issues", REMOTE, "#", vim.api.nvim_get_current_buf()) + + eq(labels(list), { "#12: Crash on start" }, "the issues") + eq(server:request_lines(), { "POST /graphql" }, "gh reads the default repository without a request") + eq({ variables[1].owner, variables[1].repo }, { "upstream", "project" }, "from the default repository") +end + +return T From f004fe7d32c4515967c83990cd97d36fead2b4ff Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Fri, 23 Oct 2026 16:30:09 +1100 Subject: [PATCH 04/13] test: run the GitLab source with the real glab against a fake server The cases send the `glab api` requests for the issues, the merge requests, and the users of a project in a subgroup to the fake server. They check the items, the requests, and the token of glab. An HTTP error gives no items and is not cached. --- tests/integration/test_glab.lua | 87 +++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/integration/test_glab.lua diff --git a/tests/integration/test_glab.lua b/tests/integration/test_glab.lua new file mode 100644 index 0000000..f135627 --- /dev/null +++ b/tests/integration/test_glab.lua @@ -0,0 +1,87 @@ +-- The real glab against a fake GitLab API on 127.0.0.1. `glab api` prints the body of the API. +local helpers = require("tests.helpers") +local integration = require("tests.integration.helpers") +local config = require("cmp_git.config") +local GitLab = require("cmp_git.sources.gitlab") + +local eq = helpers.eq + +local T = helpers.new_set() + +---A fake GitLab API for glab, a GitLab source for its host, and the remote of a project on it. +---@return tests.integration.HttpServer server +---@return cmp_git.Source.Gitlab source +---@return cmp_git.GitInfo git_info +local function gitlab_server() + integration.require_binaries({ "glab" }) + local server = integration.http_server() + helpers.defer(function() + server:close() + end) + local host = integration.use_glab(server) + integration.offline_curl() + integration.edit_outside_repository() + local source = GitLab.new(config.normalize({ gitlab = { hosts = { host } } }).gitlab) + return server, source, { host = host, owner = "group/sub", repo = "project" } +end + +---@param source cmp_git.Source.Gitlab +---@param method string +---@param git_info cmp_git.GitInfo +---@param trigger_char string +---@return string[] labels +local function complete(source, method, git_info, trigger_char) + local callback, lists = helpers.recorder() + eq(source[method](source, callback, git_info, trigger_char), true, method .. " handles the host") + helpers.wait_for(function() + return #lists > 0 + end, method .. " answers") + return vim.tbl_map(function(item) + return item.label + end, lists[1].items) +end + +local PROJECT = "/api/v4/projects/group%2Fsub%2Fproject" + +T["issues_come_from_glab_api"] = function() + local server, source, git_info = gitlab_server() + server.routes["GET " .. PROJECT .. "/issues"] = { + body = { + { id = 101, iid = 1, title = "Slow pipeline", description = vim.NIL, updated_at = "2026-01-02T00:00:00Z" }, + }, + } + + eq(complete(source, "complete_issues", git_info, "#"), { "#1: Slow pipeline" }, "the issues") + eq(server:request_lines(), { "GET " .. PROJECT .. "/issues?per_page=100&state=opened" }, "one request") + eq(server.requests[1].headers["private-token"], "glab-token", "glab sends its token") +end + +T["merge_requests_come_from_glab_api"] = function() + local server, source, git_info = gitlab_server() + server.routes["GET " .. PROJECT .. "/merge_requests"] = { + body = { { id = 102, iid = 2, title = "Speed up", description = "", updated_at = "2026-01-03T00:00:00Z" } }, + } + + eq(complete(source, "complete_change_requests", git_info, "!"), { "!2: Speed up" }, "the merge requests") + eq(server:request_lines(), { "GET " .. PROJECT .. "/merge_requests?per_page=100&state=opened" }, "one request") +end + +T["mentions_come_from_the_users_of_the_project"] = function() + local server, source, git_info = gitlab_server() + server.routes["GET " .. PROJECT .. "/users"] = { + body = { { id = 1, username = "ada", name = "Ada Lovelace" }, { id = 2, username = "grace", name = vim.NIL } }, + } + + eq(complete(source, "complete_mentions", git_info, "@"), { "@ada", "@grace" }, "the users") + eq(server:request_lines(), { "GET " .. PROJECT .. "/users?per_page=100" }, "one request") +end + +T["an_http_error_gives_no_items"] = function() + local server, source, git_info = gitlab_server() + + eq(complete(source, "complete_issues", git_info, "#"), {}, "no issues") + eq(#server.requests, 1, "glab asks once, and the curl fallback does not reach the network") + eq(source:_entry("issues", git_info):get(), nil, "the failure is not cached") +end + +return T From 7d9ac12b3b92dbaa3645344b348a8400982240e0 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Fri, 23 Oct 2026 16:31:02 +1100 Subject: [PATCH 05/13] feat: keep the scheme of an http or https remote `remote_url.parse` now also returns `scheme = "http"` or `scheme = "https"` for an `http://` or `https://` remote. Other remotes have no scheme. Discovery passes the field through the SSH aliases and the host prefixes, so the Gitea and Forgejo providers can use the scheme of the remote for their API URLs. The field is optional, and the GitHub and GitLab providers do not read it. --- lua/cmp_git/repository/remote_url.lua | 11 +++-- tests/repository/test_init.lua | 68 +++++++++++++++++++-------- tests/repository/test_remote_url.lua | 28 ++++++++--- 3 files changed, 78 insertions(+), 29 deletions(-) diff --git a/lua/cmp_git/repository/remote_url.lua b/lua/cmp_git/repository/remote_url.lua index 6ec617f..922f530 100644 --- a/lua/cmp_git/repository/remote_url.lua +++ b/lua/cmp_git/repository/remote_url.lua @@ -4,6 +4,7 @@ local M = {} ---@field host string? ---@field owner string? ---@field repo string? +---@field scheme? "http" | "https" The scheme of an http(s) remote. The Gitea and Forgejo API URLs use it. ---@param authority string `[user[:password]@]host[:port]` ---@return string? host @@ -28,6 +29,7 @@ function M.parse(remote_url) local url = vim.trim(remote_url) local host, port, path ---@type string?, string?, string? + local web_scheme ---@type "http" | "https" | nil local scheme, rest = url:match("^(%a[%w+.-]*)://(.*)$") if scheme ~= nil then scheme = scheme:lower() @@ -40,8 +42,11 @@ function M.parse(remote_url) host, port = split_authority(authority) -- The API of an http(s) remote is on the same origin. Other ports, such as the one of an `ssh://` remote, -- are not the port of the API. - if host ~= nil and port ~= "" and (scheme == "http" or scheme == "https") then - host = host .. ":" .. port + if scheme == "http" or scheme == "https" then + web_scheme = scheme + if host ~= nil and port ~= "" then + host = host .. ":" .. port + end end else -- Git reads `[user@]host:path` as an scp-like ssh URL when no slash comes before the first colon. Anything @@ -65,7 +70,7 @@ function M.parse(remote_url) return {} end - return { host = host, owner = owner, repo = repo } + return { host = host, owner = owner, repo = repo, scheme = web_scheme } end return M diff --git a/tests/repository/test_init.lua b/tests/repository/test_init.lua index 53aca3f..607e9ae 100644 --- a/tests/repository/test_init.lua +++ b/tests/repository/test_init.lua @@ -140,7 +140,7 @@ T["get_cwd_buffer_in_missing_directory"] = function() eq(repository.get_cwd(), repo.dir, "a directory that does not exist falls back to the cwd") eq(is_git_repo(), true, "the cwd repository") - eq(discover(), { host = "github.com", owner = "owner", repo = "repo" }, "the cwd repository") + eq(discover(), { host = "github.com", owner = "owner", repo = "repo", scheme = "https" }, "the cwd repository") end ---Run `git rev-parse --show-toplevel` as the completion commands run. @@ -194,7 +194,11 @@ T["discover_first_remote_matches"] = function() new_buffer() vim.fn.chdir(repo.dir) - eq(discover(), { host = "github.com", owner = "owner", repo = "repo" }, "the first configured remote wins") + eq( + discover(), + { host = "github.com", owner = "owner", repo = "repo", scheme = "https" }, + "the first configured remote wins" + ) end T["discover_falls_back_to_next_remote"] = function() @@ -217,7 +221,11 @@ T["discover_skips_unparsable_remote"] = function() new_buffer() vim.fn.chdir(repo.dir) - eq(discover(), { host = "github.com", owner = "owner", repo = "repo" }, "a local path remote falls back") + eq( + discover(), + { host = "github.com", owner = "owner", repo = "repo", scheme = "https" }, + "a local path remote falls back" + ) end T["discover_without_matching_remote"] = function() @@ -239,7 +247,7 @@ T["discover_with_remote_url_rewrites"] = function() eq(discover().host, "work", "the configured URL without rewrites") eq( discover({ enableRemoteUrlRewrites = true }), - { host = "github.example.com", owner = "owner", repo = "repo" }, + { host = "github.example.com", owner = "owner", repo = "repo", scheme = "https" }, "the rewritten URL" ) end @@ -282,10 +290,14 @@ T["discover_with_host_prefix"] = function() with_origin("https://example.com/gitlab/group/repo.git") local hosts = { "github.com", "gitlab.com", "example.com/gitlab" } - eq(discover(), { host = "example.com", owner = "gitlab/group", repo = "repo" }, "the URL without hosts") + eq( + discover(), + { host = "example.com", owner = "gitlab/group", repo = "repo", scheme = "https" }, + "the URL without hosts" + ) eq( discover({ hosts = hosts }), - { host = "example.com/gitlab", owner = "group", repo = "repo" }, + { host = "example.com/gitlab", owner = "group", repo = "repo", scheme = "https" }, "the prefix moves from the owner to the host" ) eq(discover().owner, "gitlab/group", "the prefix does not change the cache") @@ -296,14 +308,14 @@ T["discover_with_host_prefix_of_several_segments"] = function() eq( discover({ hosts = { "example.com/tools/gitlab" } }), - { host = "example.com/tools/gitlab", owner = "group", repo = "repo" }, + { host = "example.com/tools/gitlab", owner = "group", repo = "repo", scheme = "https" }, "a prefix with two segments" ) end T["discover_with_longest_host_prefix"] = function() with_origin("https://example.com/gitlab/internal/group/repo.git") - local expected = { host = "example.com/gitlab/internal", owner = "group", repo = "repo" } + local expected = { host = "example.com/gitlab/internal", owner = "group", repo = "repo", scheme = "https" } eq( discover({ hosts = { "example.com/gitlab", "example.com/gitlab/internal" } }), @@ -322,7 +334,7 @@ T["discover_with_host_prefix_and_subgroups"] = function() eq( discover({ hosts = { "example.com/gitlab" } }), - { host = "example.com/gitlab", owner = "group/sub", repo = "repo" }, + { host = "example.com/gitlab", owner = "group/sub", repo = "repo", scheme = "https" }, "the subgroups stay in the owner" ) end @@ -332,12 +344,12 @@ T["discover_with_host_prefix_and_port"] = function() eq( discover({ hosts = { "example.com/gitlab", "example.com:8443/gitlab" } }), - { host = "example.com:8443/gitlab", owner = "group", repo = "repo" }, + { host = "example.com:8443/gitlab", owner = "group", repo = "repo", scheme = "https" }, "the entry with the port" ) eq( discover({ hosts = { "example.com/gitlab" } }), - { host = "example.com:8443", owner = "gitlab/group", repo = "repo" }, + { host = "example.com:8443", owner = "gitlab/group", repo = "repo", scheme = "https" }, "an entry without the port does not apply" ) end @@ -347,11 +359,21 @@ T["discover_with_host_prefix_and_ipv6_address"] = function() eq( discover({ hosts = { "[::1]:8443/gitlab" } }), - { host = "[::1]:8443/gitlab", owner = "group", repo = "repo" }, + { host = "[::1]:8443/gitlab", owner = "group", repo = "repo", scheme = "https" }, "an IPv6 address is not a pattern" ) end +T["discover_keeps_the_scheme_of_an_http_remote"] = function() + with_origin("http://127.0.0.1:3000/gitea/owner/repo.git") + + eq( + discover({ hosts = { "127.0.0.1:3000/gitea" } }), + { host = "127.0.0.1:3000/gitea", owner = "owner", repo = "repo", scheme = "http" }, + "the scheme stays with a host prefix" + ) +end + T["discover_ssh_remote_with_host_prefix"] = function() with_origin("git@example.com:group/repo.git") local expected = { host = "example.com/gitlab", owner = "group", repo = "repo" } @@ -390,12 +412,12 @@ T["discover_with_owner_equal_to_host_prefix"] = function() eq( discover({ hosts = { "example.com", "example.com/gitlab" } }), - { host = "example.com", owner = "gitlab", repo = "repo" }, + { host = "example.com", owner = "gitlab", repo = "repo", scheme = "https" }, "no owner is left after the prefix" ) eq( discover({ hosts = { "example.com/gitlab" } }), - { host = "example.com/gitlab", owner = "gitlab", repo = "repo" }, + { host = "example.com/gitlab", owner = "gitlab", repo = "repo", scheme = "https" }, "the only prefixed entry keeps the owner" ) end @@ -405,7 +427,7 @@ T["discover_with_host_prefix_of_other_host"] = function() eq( discover({ hosts = { "github.com", "gitlab.com", "example.com/gitlab", "gitlab.co/gitlab" } }), - { host = "gitlab.com", owner = "gitlab/group", repo = "repo" }, + { host = "gitlab.com", owner = "gitlab/group", repo = "repo", scheme = "https" }, "the entries of other hosts do not apply" ) eq(discover({ hosts = { "gitlabXcom/gitlab" } }).host, "gitlab.com", "a . in a host is not a pattern") @@ -429,7 +451,7 @@ T["discover_falls_back_to_cwd"] = function() edit(helpers.tempdir() .. "/notes.md", "markdown") vim.fn.chdir(repo.dir) - eq(discover(), { host = "github.com", owner = "owner", repo = "repo" }, "the cwd repository") + eq(discover(), { host = "github.com", owner = "owner", repo = "repo", scheme = "https" }, "the cwd repository") end T["discover_uses_buffer_directory"] = function() @@ -448,7 +470,11 @@ T["discover_inside_git_dir"] = function() vim.fn.chdir(helpers.tempdir()) edit(repo.dir .. "/.git/COMMIT_EDITMSG", "gitcommit") - eq(discover(), { host = "github.com", owner = "owner", repo = "repo" }, "a commit message in .git") + eq( + discover(), + { host = "github.com", owner = "owner", repo = "repo", scheme = "https" }, + "a commit message in .git" + ) end ---What core does for each completion request. @@ -485,7 +511,7 @@ T["discovery_starts_two_processes"] = function() edit(repo.dir .. "/notes.md", "markdown") local commands = record_processes() - eq(request(), { host = "github.com", owner = "owner", repo = "repo" }, "the second remote") + eq(request(), { host = "github.com", owner = "owner", repo = "repo", scheme = "https" }, "the second remote") eq(#commands, 2, "a request in a new buffer: " .. vim.inspect(command_lines(commands))) edit(bare.dir .. "/notes.md", "markdown") @@ -556,7 +582,11 @@ T["discovery_concurrent_requests_share_processes"] = function() end, "each discover calls back") for _, metadata in ipairs(results) do - eq(metadata, { host = "github.com", owner = "owner", repo = "repo" }, "each request gets the repository") + eq( + metadata, + { host = "github.com", owner = "owner", repo = "repo", scheme = "https" }, + "each request gets the repository" + ) end helpers.ok(results[1] ~= results[2], "each request gets its own table, because callers change it") eq(#commands, 2, "concurrent requests: " .. vim.inspect(command_lines(commands))) diff --git a/tests/repository/test_remote_url.lua b/tests/repository/test_remote_url.lua index a096dfd..5d60582 100644 --- a/tests/repository/test_remote_url.lua +++ b/tests/repository/test_remote_url.lua @@ -30,7 +30,7 @@ end T["parse_remote_url_repo_with_git_in_name"] = function() eq( remote_url.parse("https://github.com/user/user.github.io.git"), - { host = "github.com", owner = "user", repo = "user.github.io" }, + { host = "github.com", owner = "user", repo = "user.github.io", scheme = "https" }, "only a trailing .git is removed" ) eq(remote_url.parse("git@github.com:owner/git.git").repo, "git", "a repo named git") @@ -38,8 +38,9 @@ end T["parse_remote_url_subgroups"] = function() local expected = { host = "gitlab.com", owner = "group/sub", repo = "repo" } - eq(remote_url.parse("https://gitlab.com/group/sub/repo"), expected, "https remote in a subgroup") - eq(remote_url.parse("https://gitlab.com/group/sub/repo.git"), expected, "https remote in a subgroup with .git") + local https = vim.tbl_extend("force", expected, { scheme = "https" }) + eq(remote_url.parse("https://gitlab.com/group/sub/repo"), https, "https remote in a subgroup") + eq(remote_url.parse("https://gitlab.com/group/sub/repo.git"), https, "https remote in a subgroup with .git") eq(remote_url.parse("git@gitlab.com:group/sub/repo.git"), expected, "scp-like remote in a subgroup") eq(remote_url.parse("ssh://git@gitlab.com/group/sub/repo.git"), expected, "ssh url remote in a subgroup") end @@ -52,7 +53,7 @@ T["parse_remote_url_ports"] = function() ) eq( remote_url.parse("https://gitlab.example.com:8443/group/repo.git"), - { host = "gitlab.example.com:8443", owner = "group", repo = "repo" }, + { host = "gitlab.example.com:8443", owner = "group", repo = "repo", scheme = "https" }, "the https port is part of the host, because the API is on the same origin" ) eq(remote_url.parse("git://example.com:9418/owner/repo.git").host, "example.com", "the git daemon port") @@ -61,7 +62,7 @@ end T["parse_remote_url_credentials"] = function() eq( remote_url.parse("https://user:token@github.com/o/r"), - { host = "github.com", owner = "o", repo = "r" }, + { host = "github.com", owner = "o", repo = "r", scheme = "https" }, "credentials are not part of the host" ) eq(remote_url.parse("https://token@github.com:8443/o/r").host, "github.com:8443", "a token and a port") @@ -71,11 +72,24 @@ end T["parse_remote_url_trailing_slash"] = function() local expected = { host = "github.com", owner = "owner", repo = "repo" } - eq(remote_url.parse("https://github.com/owner/repo/"), expected, "a trailing slash") - eq(remote_url.parse("https://github.com/owner/repo.git/"), expected, "a trailing slash after .git") + local https = vim.tbl_extend("force", expected, { scheme = "https" }) + eq(remote_url.parse("https://github.com/owner/repo/"), https, "a trailing slash") + eq(remote_url.parse("https://github.com/owner/repo.git/"), https, "a trailing slash after .git") eq(remote_url.parse("git@github.com:owner/repo.git\n"), expected, "a trailing newline") end +T["parse_remote_url_scheme"] = function() + eq(remote_url.parse("https://example.com/owner/repo.git").scheme, "https", "an https remote") + eq( + remote_url.parse("HTTP://127.0.0.1:3000/owner/repo.git"), + { host = "127.0.0.1:3000", owner = "owner", repo = "repo", scheme = "http" }, + "an http remote keeps its port and its scheme in lowercase" + ) + eq(remote_url.parse("git@example.com:owner/repo.git").scheme, nil, "an scp-like remote has no scheme") + eq(remote_url.parse("ssh://git@example.com/owner/repo.git").scheme, nil, "an ssh remote has no scheme") + eq(remote_url.parse("git://example.com/owner/repo.git").scheme, nil, "a git remote has no scheme") +end + T["parse_remote_url_unsupported"] = function() eq(remote_url.parse(nil), {}, "no URL") eq(remote_url.parse(""), {}, "an empty URL") From 42fefcf94dab20c7f7b80b657df8967347c791b3 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Fri, 23 Oct 2026 16:39:55 +1100 Subject: [PATCH 06/13] refactor: share the combined issues list and the null text helper `change_requests.with_issues` starts the jobs of the issues and the change requests at once and gives one list, as the GitHub source did in `_issues_and_change_requests`. `utils.empty_nulls` replaces the JSON null text fields, as the GitLab adapter did. The Gitea and Forgejo providers use both. The test helper `helpers.fake_commands` replaces the copies in the GitHub and GitLab source tests. --- lua/cmp_git/provider/adapters/gitlab.lua | 21 ++------ .../provider/capabilities/change_requests.lua | 52 ++++++++++++++++++ lua/cmp_git/sources/github.lua | 53 ++----------------- lua/cmp_git/utils.lua | 15 ++++++ tests/helpers.lua | 17 ++++++ tests/sources/test_github.lua | 22 ++------ tests/sources/test_gitlab.lua | 24 ++------- 7 files changed, 100 insertions(+), 104 deletions(-) diff --git a/lua/cmp_git/provider/adapters/gitlab.lua b/lua/cmp_git/provider/adapters/gitlab.lua index fb12dfe..2a6f72d 100644 --- a/lua/cmp_git/provider/adapters/gitlab.lua +++ b/lua/cmp_git/provider/adapters/gitlab.lua @@ -31,21 +31,6 @@ local function env() } end ----Replace the missing and null text fields that the formats read. JSON null is `vim.NIL`, which a format would show as ----the text "vim.NIL". ----@generic T: table ----@param item T ----@param fields string[] ----@return T item -local function empty_nulls(item, fields) - for _, field in ipairs(fields) do - if item[field] == nil or item[field] == vim.NIL then - item[field] = "" - end - end - return item -end - ---@param curl_url string ---@return cmp_git.CommandSpec local function curl_command(curl_url) @@ -87,7 +72,7 @@ function M.issues_request(git_info, trigger_char, config) ), }, handle_item = function(issue) - return format.item(config, trigger_char, empty_nulls(issue, { "title", "description" })) + return format.item(config, trigger_char, utils.empty_nulls(issue, { "title", "description" })) end, } end @@ -115,7 +100,7 @@ function M.change_requests_request(git_info, trigger_char, config) ), }, handle_item = function(mr) - return format.item(config, trigger_char, empty_nulls(mr, { "title", "description" })) + return format.item(config, trigger_char, utils.empty_nulls(mr, { "title", "description" })) end, } end @@ -137,7 +122,7 @@ function M.mentions_request(git_info, trigger_char, config) ), }, handle_item = function(mention) - return format.item(config, trigger_char, empty_nulls(mention, { "name" })) + return format.item(config, trigger_char, utils.empty_nulls(mention, { "name" })) end, } end diff --git a/lua/cmp_git/provider/capabilities/change_requests.lua b/lua/cmp_git/provider/capabilities/change_requests.lua index a2d0578..6189dad 100644 --- a/lua/cmp_git/provider/capabilities/change_requests.lua +++ b/lua/cmp_git/provider/capabilities/change_requests.lua @@ -1,4 +1,5 @@ local issues = require("cmp_git.provider.capabilities.issues") +local log = require("cmp_git.log") local M = {} @@ -27,4 +28,55 @@ function M.refresh(args, done) issues.fetch(args.runner, args.adapter.change_requests_request(args.git_info, args.trigger_char, args.config), done) end +---Give one list with the issues, then the change requests. Each job calls back once: at once on a cache hit, and with +---an empty list on a failure. So a failure of one request still gives the items of the other. +---@param callback fun(list: cmp_git.CompletionList) +---@param issues_job fun(callback: fun(list: cmp_git.CompletionList)): cmp_git.SystemJob? Makes the job of the issues. +---@param change_requests_job fun(callback: fun(list: cmp_git.CompletionList)): cmp_git.SystemJob? +---@return { command: string, cancel: fun() } controller +function M.with_issues(callback, issues_job, change_requests_job) + local results = {} + local pending = 2 + local cancelled = false + + ---@param kind "issues" | "change_requests" + local function collect(kind) + return function(list) + if cancelled then + return + end + results[kind] = list.items + pending = pending - 1 + if pending > 0 then + return + end + + -- Each request gets new lists, because the cache keeps the raw items + local items = vim.list_extend(results.issues, results.change_requests) + log.fmt_debug("Got %d issues and change requests", #items) + callback({ items = items, isIncomplete = false }) + end + end + + local jobs = { issues_job(collect("issues")) or false, change_requests_job(collect("change_requests")) or false } + -- Both commands run at once, so that a cold request waits for the slower one, not for both in turn + for _, job in ipairs(jobs) do + if job then + job:start() + end + end + + return { + command = "issues_and_change_requests", + cancel = function() + cancelled = true + for _, job in ipairs(jobs) do + if job and job.cancel then + job:cancel() + end + end + end, + } +end + return M diff --git a/lua/cmp_git/sources/github.lua b/lua/cmp_git/sources/github.lua index efd1ef8..da38db6 100644 --- a/lua/cmp_git/sources/github.lua +++ b/lua/cmp_git/sources/github.lua @@ -4,7 +4,6 @@ local command = require("cmp_git.command") local issues = require("cmp_git.provider.capabilities.issues") local mentions = require("cmp_git.provider.capabilities.mentions") local change_requests = require("cmp_git.provider.capabilities.change_requests") -local log = require("cmp_git.log") ---The result of `gh repo set-default --view` for a repository. Until it is `done`, the requests wait in `waiters`. ---@class cmp_git.Source.GitHub.DefaultRepo @@ -241,53 +240,11 @@ end ---@param issues_entry cmp_git.CacheEntry ---@param change_requests_entry cmp_git.CacheEntry function GitHub:_issues_and_change_requests(callback, git_info, trigger_char, issues_entry, change_requests_entry) - local results = {} - local pending = 2 - local cancelled = false - - -- Each capability calls back once: at once on a cache hit, and with an empty list on a failure. So a failure of - -- one request still gives the items of the other. - ---@param kind "issues" | "change_requests" - local function collect(kind) - return function(list) - if cancelled then - return - end - results[kind] = list.items - pending = pending - 1 - if pending > 0 then - return - end - - -- Each request gets new lists, because the cache keeps the raw items - local items = vim.list_extend(results.issues, results.change_requests) - log.fmt_debug("Got %d issues and prs", #items) - callback({ items = items, isIncomplete = false }) - end - end - - local issues_job = self:_issues_job(collect("issues"), git_info, trigger_char, issues_entry) - local change_requests_job = - self:_change_requests_job(collect("change_requests"), git_info, trigger_char, change_requests_entry) - -- Both commands run at once, so that a cold request waits for the slower one, not for both in turn - if issues_job then - issues_job:start() - end - if change_requests_job then - change_requests_job:start() - end - - return { - command = "github_issues_and_change_requests", - cancel = function() - cancelled = true - for _, job in ipairs({ issues_job or false, change_requests_job or false }) do - if job and job.cancel then - job:cancel() - end - end - end, - } + return change_requests.with_issues(callback, function(on_list) + return self:_issues_job(on_list, git_info, trigger_char, issues_entry) + end, function(on_list) + return self:_change_requests_job(on_list, git_info, trigger_char, change_requests_entry) + end) end ---@param callback fun(list: cmp_git.CompletionList) diff --git a/lua/cmp_git/utils.lua b/lua/cmp_git/utils.lua index bb905fd..d42b20c 100644 --- a/lua/cmp_git/utils.lua +++ b/lua/cmp_git/utils.lua @@ -135,4 +135,19 @@ function M.parse_github_date(d) return parse_date(d) end +---Replace the missing and null text fields that the formats read. JSON null is `vim.NIL`, which a format would show as +---the text "vim.NIL". +---@generic T: table +---@param item T +---@param fields string[] +---@return T item +function M.empty_nulls(item, fields) + for _, field in ipairs(fields) do + if item[field] == nil or item[field] == vim.NIL then + item[field] = "" + end + end + return item +end + return M diff --git a/tests/helpers.lua b/tests/helpers.lua index 69a6230..8f4da7f 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -138,6 +138,23 @@ function M.tempdir() return assert(vim.uv.fs_realpath(dir)) end +---Make PATH a directory with only these commands until the end of the case. The scripts can use only the builtins of +---`sh`. +---@param scripts table The shell script body of each command, by name. +---@return string bin The directory. +function M.fake_commands(scripts) + local bin = M.tempdir() + for name, body in pairs(scripts) do + local path = bin .. "/" .. name + local file = assert(io.open(path, "w")) + file:write("#!/bin/sh\n" .. body .. "\n") + file:close() + vim.uv.fs_chmod(path, tonumber("755", 8)) + end + M.stub(vim.env, "PATH", bin) + return bin +end + ---@class tests.GitRepo ---@field dir string ---@field git fun(...: string): string Runs git in the repository and returns stdout. diff --git a/tests/sources/test_github.lua b/tests/sources/test_github.lua index de03f4c..efef435 100644 --- a/tests/sources/test_github.lua +++ b/tests/sources/test_github.lua @@ -34,22 +34,6 @@ end local git_info = { host = "github.com", owner = "owner", repo = "repo" } ----Make PATH a directory with only these commands until the end of the case. ----@param scripts table The shell script body of each command, by name. ----@return string bin The directory. -local function fake_commands(scripts) - local bin = helpers.tempdir() - for name, body in pairs(scripts) do - local path = bin .. "/" .. name - local file = assert(io.open(path, "w")) - file:write("#!/bin/sh\n" .. body .. "\n") - file:close() - vim.uv.fs_chmod(path, tonumber("755", 8)) - end - helpers.stub(vim.env, "PATH", bin) - return bin -end - local records = { issue = { number = 1, title = "an issue", body = "", updatedAt = "2024-01-01T00:00:00Z" }, pr = { number = 2, title = "a pull request", body = "", updatedAt = "2024-01-01T00:00:00Z" }, @@ -63,7 +47,7 @@ local function fake_github() local requests = { issue = 0, pr = 0 } -- No gh on PATH, so that the default repository lookup does not run - fake_commands({}) + helpers.fake_commands({}) helpers.stub(command, "build_fallback_list", function(commands, callback, handle_item) local kind = commands[1].args[1] return { @@ -147,7 +131,7 @@ local function manual_github() local jobs = {} -- No gh on PATH, so that the default repository lookup does not run - fake_commands({}) + helpers.fake_commands({}) helpers.stub(command, "build_fallback_list", function(commands, callback, handle_item) local kind = commands[1].args[1] local job = { command = "fake", started = false, cancelled = false } @@ -232,7 +216,7 @@ local function recording_github(default_repo) string.format("printf '%%s\\n' '%s'", default_repo), }, "\n") end - fake_commands(scripts) + helpers.fake_commands(scripts) local repos = {} ---@param args string[] diff --git a/tests/sources/test_gitlab.lua b/tests/sources/test_gitlab.lua index c98779f..4ba3e31 100644 --- a/tests/sources/test_gitlab.lua +++ b/tests/sources/test_gitlab.lua @@ -33,20 +33,6 @@ end local git_info = { host = "gitlab.com", owner = "owner", repo = "repo" } local EMPTY = { items = {}, isIncomplete = false } ----Make PATH a directory with only these commands until the end of the case. ----@param scripts table The shell script body of each command, by name. -local function fake_commands(scripts) - local bin = helpers.tempdir() - for name, body in pairs(scripts) do - local path = bin .. "/" .. name - local file = assert(io.open(path, "w")) - file:write("#!/bin/sh\n" .. body .. "\n") - file:close() - vim.uv.fs_chmod(path, tonumber("755", 8)) - end - helpers.stub(vim.env, "PATH", bin) -end - ---Run a request of the source and return the lists that it gave. ---@param source cmp_git.Source.Gitlab ---@param method string @@ -64,7 +50,7 @@ local function answers(source, method, trigger_char) end T["failed_commands_give_an_empty_list"] = function() - fake_commands({ glab = "exit 1", curl = "exit 22" }) + helpers.fake_commands({ glab = "exit 1", curl = "exit 22" }) local source = GitLab.new(config.normalize({}).gitlab) eq(answers(source, "complete_issues", "#"), { EMPTY }, "issues") @@ -76,7 +62,7 @@ T["failed_commands_give_an_empty_list"] = function() end T["missing_commands_give_an_empty_list"] = function() - fake_commands({}) + helpers.fake_commands({}) local source = GitLab.new(config.normalize({}).gitlab) eq(answers(source, "complete_issues", "#"), { EMPTY }, "issues") @@ -93,14 +79,14 @@ local function labels(list) end T["glab_items_are_cached_for_the_next_session"] = function() - fake_commands({ + helpers.fake_commands({ glab = [[printf '%s' '[{"iid":1,"title":"an issue","description":null,"updated_at":"2024-01-01T00:00:00Z"}]']], }) local source = GitLab.new(config.normalize({}).gitlab) eq(labels(answers(source, "complete_issues", "#")[1]), { "#1: an issue" }, "the issues of glab") cache.forget() - fake_commands({ glab = "exit 1" }) + helpers.fake_commands({ glab = "exit 1" }) local callback, lists = helpers.recorder() GitLab.new(config.normalize({}).gitlab):complete_issues(callback, git_info, "#") @@ -110,7 +96,7 @@ T["glab_items_are_cached_for_the_next_session"] = function() end T["stale_items_refresh_in_the_background"] = function() - fake_commands({ + helpers.fake_commands({ glab = [[printf '%s' '[{"iid":2,"title":"a new issue","description":"","updated_at":"2024-01-01T00:00:00Z"}]']], }) local source = GitLab.new(config.normalize({}).gitlab, { ttl = 60 }) From 7eeb1043e80e1eb2b3036f8cea491fd0f92183a8 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Fri, 23 Oct 2026 16:40:02 +1100 Subject: [PATCH 07/13] feat: add Gitea and Forgejo providers that use the Gitea API with curl The new `gitea` provider completes the issues, the pull requests, and the mentions of gitea.com and of the hosts in `gitea.hosts`. The new `forgejo` provider does the same for codeberg.org and `forgejo.hosts`. Both use `/api/v1` of the server, which Forgejo kept after its fork, through one shared adapter and one shared source. - `#` completes the issues and the pull requests, `!` the pull requests, and `@` the assignees of the repository. A pull request shows as `#N` under `#` and as `!N` under `!`. - The issues and the pull requests come from the issues API, with `type=issues` and `type=pulls`, one page of at most `limit` items. The default limit is 50, the most that the servers give. - curl sends `GITEA_TOKEN` or `FORGEJO_TOKEN`, then asks again without a token. A token of another host fails also on a public repository, so the last request gets the public items. The assignees API needs a token, so the mentions have no request without one. - An `http://` remote uses plain HTTP for the API. All other remotes use HTTPS. A host can have a port and a path prefix. The items go to the disk cache in `gitea-*` and `forgejo-*` files. Core gets the `gitea_*` and `forgejo_*` actions and passes the hosts of both providers to discovery. The default trigger actions run them after the GitHub and GitLab actions. --- lua/cmp_git/cache.lua | 6 +- lua/cmp_git/config.lua | 71 +++++- lua/cmp_git/core.lua | 20 +- lua/cmp_git/format.lua | 70 ++++++ lua/cmp_git/provider/adapters/forgejo.lua | 9 + lua/cmp_git/provider/adapters/gitea.lua | 13 ++ lua/cmp_git/provider/adapters/gitea_api.lua | 142 ++++++++++++ lua/cmp_git/repository/init.lua | 4 +- lua/cmp_git/sort.lua | 17 ++ lua/cmp_git/sources/forgejo.lua | 13 ++ lua/cmp_git/sources/gitea.lua | 13 ++ lua/cmp_git/sources/gitea_api.lua | 230 ++++++++++++++++++++ lua/cmp_git/utils.lua | 7 + tests/helpers.lua | 2 + tests/provider/adapters/test_gitea_api.lua | 140 ++++++++++++ tests/sources/fake_gitea_api.lua | 47 ++++ tests/sources/test_forgejo.lua | 74 +++++++ tests/sources/test_gitea.lua | 159 ++++++++++++++ tests/test_config.lua | 66 ++++++ tests/test_core.lua | 103 ++++++++- tests/test_format.lua | 52 +++++ tests/test_sort.lua | 22 ++ 22 files changed, 1260 insertions(+), 20 deletions(-) create mode 100644 lua/cmp_git/provider/adapters/forgejo.lua create mode 100644 lua/cmp_git/provider/adapters/gitea.lua create mode 100644 lua/cmp_git/provider/adapters/gitea_api.lua create mode 100644 lua/cmp_git/sources/forgejo.lua create mode 100644 lua/cmp_git/sources/gitea.lua create mode 100644 lua/cmp_git/sources/gitea_api.lua create mode 100644 tests/provider/adapters/test_gitea_api.lua create mode 100644 tests/sources/fake_gitea_api.lua create mode 100644 tests/sources/test_forgejo.lua create mode 100644 tests/sources/test_gitea.lua diff --git a/lua/cmp_git/cache.lua b/lua/cmp_git/cache.lua index 9e9a0fe..911345c 100644 --- a/lua/cmp_git/cache.lua +++ b/lua/cmp_git/cache.lua @@ -1,6 +1,6 @@ --- The cache of the GitHub and GitLab items. An entry holds the raw items of one list of one remote, and a file under --- `stdpath("cache")/cmp-git` keeps them for the next sessions. The items are raw and not completion items, because the --- formats are user functions, and the sort text depends on the time of the request. +-- The cache of the GitHub, GitLab, Gitea, and Forgejo items. An entry holds the raw items of one list of one remote, +-- and a file under `stdpath("cache")/cmp-git` keeps them for the next sessions. The items are raw and not completion +-- items, because the formats are user functions, and the sort text depends on the time of the request. local log = require("cmp_git.log") local M = {} diff --git a/lua/cmp_git/config.lua b/lua/cmp_git/config.lua index ab20ad2..44a3e98 100644 --- a/lua/cmp_git/config.lua +++ b/lua/cmp_git/config.lua @@ -52,7 +52,7 @@ local defaults = { enableRemoteUrlRewrites = false, -- enable git url rewrites, see https://git-scm.com/docs/git-config#Documentation/git-config.txt-urlltbasegtinsteadOf ---@type table ssh_aliases = {}, - ---The disk cache of the GitHub and GitLab items + ---The disk cache of the GitHub, GitLab, Gitea, and Forgejo items ---@class cmp_git.Config.Cache cache = { ---Seconds after which a request also refreshes the items in the background. 0 refreshes after each request, @@ -159,6 +159,60 @@ local defaults = { format = format.gitlab.merge_requests, }, }, + ---@class cmp_git.Config.Gitea + ---@field filter_fn? fun(trigger_char: string, item: cmp_git.Gitea.Issue | cmp_git.Gitea.Mention): string Compatibility alias for all Gitea capability format.filterText functions. + gitea = { + ---@type string[] + hosts = {}, + ---@class cmp_git.Config.Gitea.List + issues = { + -- The servers give at most 50 items (`MAX_RESPONSE_ITEMS`) + limit = 50, + ---@type 'open' | 'closed' | 'all' + state = "open", + sort_by = sort.gitea.issues, + format = format.gitea.issues, + }, + mentions = { + limit = 100, + sort_by = sort.gitea.mentions, + format = format.gitea.mentions, + }, + ---@type cmp_git.Config.Gitea.List + pull_requests = { + limit = 50, + ---@type 'open' | 'closed' | 'all' + state = "open", + sort_by = sort.gitea.pull_requests, + format = format.gitea.pull_requests, + }, + }, + ---@class cmp_git.Config.Forgejo: cmp_git.Config.Gitea + forgejo = { + ---@type string[] + hosts = {}, + ---@type cmp_git.Config.Gitea.List + issues = { + limit = 50, + ---@type 'open' | 'closed' | 'all' + state = "open", + sort_by = sort.forgejo.issues, + format = format.forgejo.issues, + }, + mentions = { + limit = 100, + sort_by = sort.forgejo.mentions, + format = format.forgejo.mentions, + }, + ---@type cmp_git.Config.Gitea.List + pull_requests = { + limit = 50, + ---@type 'open' | 'closed' | 'all' + state = "open", + sort_by = sort.forgejo.pull_requests, + format = format.forgejo.pull_requests, + }, + }, ---@type (cmp_git.Config.NamedTriggerActions|cmp_git.Config.PatternTriggerActions|cmp_git.Config.TriggerAction)[] trigger_actions = { { @@ -176,15 +230,20 @@ local defaults = { }, { trigger_character = "#", - actions = { "gitlab_issues", "github_issues_and_change_requests" }, + actions = { + "gitlab_issues", + "github_issues_and_change_requests", + "gitea_issues_and_change_requests", + "forgejo_issues_and_change_requests", + }, }, { trigger_character = "@", - actions = { "gitlab_mentions", "github_mentions" }, + actions = { "gitlab_mentions", "github_mentions", "gitea_mentions", "forgejo_mentions" }, }, { trigger_character = "!", - actions = { "gitlab_change_requests" }, + actions = { "gitlab_change_requests", "gitea_change_requests", "forgejo_change_requests" }, }, }, } @@ -233,10 +292,14 @@ function M.normalize(overrides) ensure_contains(normalized.github.hosts, "github.com") ensure_contains(normalized.gitlab.hosts, "gitlab.com") + ensure_contains(normalized.gitea.hosts, "gitea.com") + ensure_contains(normalized.forgejo.hosts, "codeberg.org") apply_filter_fn(normalized.git, { "commits" }) apply_filter_fn(normalized.github, { "issues", "mentions", "pull_requests" }) apply_filter_fn(normalized.gitlab, { "issues", "mentions", "merge_requests" }) + apply_filter_fn(normalized.gitea, { "issues", "mentions", "pull_requests" }) + apply_filter_fn(normalized.forgejo, { "issues", "mentions", "pull_requests" }) normalized.trigger_actions = vim.tbl_map(upgrade_trigger_action, normalized.trigger_actions) diff --git a/lua/cmp_git/core.lua b/lua/cmp_git/core.lua index 6f1513f..34477ae 100644 --- a/lua/cmp_git/core.lua +++ b/lua/cmp_git/core.lua @@ -35,7 +35,7 @@ end ---A built-in action of a hosted provider. It checks the host before it gets the source, so that a request loads only ---the provider of the remote. ----@param provider "github" | "gitlab" +---@param provider "github" | "gitlab" | "gitea" | "forgejo" ---@param method string The method of the source that completes. ---@return fun(core: table, callback: fun(list: cmp_git.CompletionList), context: table, git_info: cmp_git.GitInfo, trigger_char: string?): string, table? local function hosted_action(provider, method) @@ -71,6 +71,14 @@ local builtin_trigger_actions = { gitlab_change_requests = hosted_action("gitlab", "complete_change_requests"), gitlab_mrs = hosted_action("gitlab", "complete_change_requests"), github_issues_and_prs = hosted_action("github", "complete_issues_and_change_requests"), + gitea_issues = hosted_action("gitea", "complete_issues"), + gitea_mentions = hosted_action("gitea", "complete_mentions"), + gitea_change_requests = hosted_action("gitea", "complete_change_requests"), + gitea_issues_and_change_requests = hosted_action("gitea", "complete_issues_and_change_requests"), + forgejo_issues = hosted_action("forgejo", "complete_issues"), + forgejo_mentions = hosted_action("forgejo", "complete_mentions"), + forgejo_change_requests = hosted_action("forgejo", "complete_change_requests"), + forgejo_issues_and_change_requests = hosted_action("forgejo", "complete_issues_and_change_requests"), } -- Built-in actions that read only the local repository. They need no remote, and they always handle the request. @@ -196,11 +204,15 @@ end ---@field git cmp_git.Source.Git ---@field github cmp_git.Source.GitHub ---@field gitlab cmp_git.Source.Gitlab +---@field gitea cmp_git.Source.GiteaApi +---@field forgejo cmp_git.Source.GiteaApi local SOURCE_MODULES = { git = "cmp_git.sources.git", github = "cmp_git.sources.github", gitlab = "cmp_git.sources.gitlab", + gitea = "cmp_git.sources.gitea", + forgejo = "cmp_git.sources.forgejo", } ---The sources, each made on first use. A legacy callback action gets this table, so a source must also load when an @@ -234,7 +246,11 @@ function Core.new(overrides) instances[self] = true self.config = config.normalize(overrides) - self.hosts = vim.list_extend(vim.list_extend({}, self.config.github.hosts), self.config.gitlab.hosts) + -- Discovery reads the host prefixes from these hosts + self.hosts = {} + for _, provider in ipairs({ "github", "gitlab", "gitea", "forgejo" }) do + vim.list_extend(self.hosts, self.config[provider].hosts) + end self.filetypes = {} self.sources = lazy_sources(self.config) self.trigger_actions = {} diff --git a/lua/cmp_git/format.lua b/lua/cmp_git/format.lua index 55b6947..1b659f7 100644 --- a/lua/cmp_git/format.lua +++ b/lua/cmp_git/format.lua @@ -8,6 +8,74 @@ local sort = require("cmp_git.sort") ---documentation: fun(trigger_char: string?, item: TItem): lsp.MarkupContent; ---} +---Gitea and Forgejo read `#N` and `!N` as the same pull request. A pull request under `!` uses `!N`, as in GitLab. +---@param trigger_char string? +---@return string +local function pull_request_prefix(trigger_char) + return trigger_char == "!" and "!" or "#" +end + +---The formats of Gitea and Forgejo, which have the same API. Each call gives new tables, so that a change to the +---formats of one provider does not change the other. +local function gitea_formats() + return { + ---@type cmp_git.FormatConfig + issues = { + label = function(_trigger_char, issue) + return string.format("#%s: %s", issue.number, issue.title) + end, + insertText = function(_trigger_char, issue) + return string.format("#%s", issue.number) + end, + filterText = function(trigger_char, issue) + return string.format("%s %s %s", trigger_char, issue.number, issue.title) + end, + documentation = function(_trigger_char, issue) + return { + kind = "markdown", + value = string.format("# %s\n\n%s", issue.title, issue.body), + } + end, + }, + ---@type cmp_git.FormatConfig + mentions = { + label = function(_trigger_char, mention) + return string.format("@%s", mention.login) + end, + insertText = function(_trigger_char, mention) + return string.format("@%s", mention.login) + end, + filterText = function(trigger_char, mention) + return string.format("%s %s", trigger_char, mention.login) + end, + documentation = function(_trigger_char, mention) + return { + kind = "markdown", + value = string.format("# %s\n\n%s", mention.login, mention.full_name), + } + end, + }, + ---@type cmp_git.FormatConfig + pull_requests = { + label = function(trigger_char, pr) + return string.format("%s%s: %s", pull_request_prefix(trigger_char), pr.number, pr.title) + end, + insertText = function(trigger_char, pr) + return string.format("%s%s", pull_request_prefix(trigger_char), pr.number) + end, + filterText = function(trigger_char, pr) + return string.format("%s %s %s", trigger_char, pr.number, pr.title) + end, + documentation = function(_trigger_char, pr) + return { + kind = "markdown", + value = string.format("# %s\n\n%s", pr.title, pr.body), + } + end, + }, + } +end + local M = { git = { ---@type cmp_git.FormatConfig @@ -231,6 +299,8 @@ local M = { end, }, }, + gitea = gitea_formats(), + forgejo = gitea_formats(), } ---@class cmp_git.CompletionItem : lsp.CompletionItem diff --git a/lua/cmp_git/provider/adapters/forgejo.lua b/lua/cmp_git/provider/adapters/forgejo.lua new file mode 100644 index 0000000..f387dfd --- /dev/null +++ b/lua/cmp_git/provider/adapters/forgejo.lua @@ -0,0 +1,9 @@ +local gitea_api = require("cmp_git.provider.adapters.gitea_api") + +---The adapter of a request: curl with `FORGEJO_TOKEN`, then curl without a token. +return gitea_api.adapter({ + token_env = "FORGEJO_TOKEN", + commands = function() + return {} + end, +}) diff --git a/lua/cmp_git/provider/adapters/gitea.lua b/lua/cmp_git/provider/adapters/gitea.lua new file mode 100644 index 0000000..e71758f --- /dev/null +++ b/lua/cmp_git/provider/adapters/gitea.lua @@ -0,0 +1,13 @@ +local gitea_api = require("cmp_git.provider.adapters.gitea_api") + +local M = {} + +---The adapter of a request without tea: curl with `GITEA_TOKEN`, then curl without a token. +M.curl = gitea_api.adapter({ + token_env = "GITEA_TOKEN", + commands = function() + return {} + end, +}) + +return M diff --git a/lua/cmp_git/provider/adapters/gitea_api.lua b/lua/cmp_git/provider/adapters/gitea_api.lua new file mode 100644 index 0000000..83efe4c --- /dev/null +++ b/lua/cmp_git/provider/adapters/gitea_api.lua @@ -0,0 +1,142 @@ +-- The API of Gitea and Forgejo, which Forgejo kept after its fork. The `gitea` and `forgejo` adapters add their own +-- commands before the curl commands of this module. +local format = require("cmp_git.format") +local utils = require("cmp_git.utils") + +local M = {} + +---An issue or a pull request from the issues API. +---@class cmp_git.Gitea.Issue +---@field number integer +---@field title string +---@field body string +---@field state string +---@field updated_at string +---@field user { login: string } +---@field pull_request? table Set for a pull request. + +---A user from the assignees API. +---@class cmp_git.Gitea.Mention +---@field login string +---@field full_name string + +---The adapter of a provider. It gives the commands of the provider for an API path. +---@class cmp_git.Gitea.Provider +---@field token_env string The variable with the token for curl, such as `GITEA_TOKEN`. +---@field commands fun(git_info: cmp_git.GitInfo, path: string): cmp_git.CommandSpec[] The commands before curl. + +---The URL of an API path. An `http://` remote gives a plain HTTP URL, and all other remotes give HTTPS. The host can +---have a port and a path prefix. +---@param git_info cmp_git.GitInfo +---@param path string The path after `/api/v1/`. +---@return string +function M.api_url(git_info, path) + local scheme = git_info.scheme == "http" and "http" or "https" + return string.format("%s://%s/api/v1/%s", scheme, git_info.host, path) +end + +---@param git_info cmp_git.GitInfo +---@param endpoint string The path after the repository, with the query. +---@return string path The API path of the endpoint of the repository. +function M.repo_path(git_info, endpoint) + local owner = utils.url_encode(git_info.owner) + local repo = utils.url_encode(git_info.repo) + return string.format("repos/%s/%s/%s", owner, repo, endpoint) +end + +---@param url string +---@param token? string +---@return cmp_git.CommandSpec +function M.curl_command(url, token) + -- Without --fail, curl exits with 0 on an HTTP error, and the next command does not run + local args = { "-s", "-S", "--fail", "-L" } + local stdin = nil + + -- A request without a header, because an empty or invalid token fails also on a public repository + if token then + -- `-H @-` reads the header from stdin, so that the token is not in the process list + vim.list_extend(args, { "-H", "@-" }) + stdin = string.format("Authorization: token %s\n", token) + end + + table.insert(args, url) + return { exec = "curl", args = args, stdin = stdin } +end + +---@param item cmp_git.Gitea.Issue +---@return cmp_git.Gitea.Issue +local function normalize_issue(item) + utils.empty_nulls(item, { "title", "body" }) + -- The web editor saves the body with CRLF line endings + item.body = string.gsub(item.body, "\r", "") + return item +end + +---The adapter of a provider. The commands of a request are the commands of the provider, then curl with the token of +---`token_env` if it is set, then curl without a token. One variable can give the token of another host, which fails +---also on a public repository, so the last command still gets the public items. +---@param provider cmp_git.Gitea.Provider +function M.adapter(provider) + ---@param git_info cmp_git.GitInfo + ---@param path string + ---@param anonymous boolean Add curl without a token. + ---@return cmp_git.CommandSpec[] + local function commands(git_info, path, anonymous) + local list = provider.commands(git_info, path) + local url = M.api_url(git_info, path) + local token = vim.env[provider.token_env] + if token and token ~= "" then + table.insert(list, M.curl_command(url, token)) + end + if anonymous then + table.insert(list, M.curl_command(url)) + end + return list + end + + ---The issues API gives the pull requests too, in the same shape, with `type=pulls` + ---@param git_info cmp_git.GitInfo + ---@param trigger_char string + ---@param config cmp_git.Config.Gitea.List + ---@param type "issues" | "pulls" + ---@return cmp_git.Provider.ListRequest + local function list_request(git_info, trigger_char, config, type) + local endpoint = + string.format("issues?state=%s&type=%s&limit=%d&page=1", utils.url_encode(config.state), type, config.limit) + return { + commands = commands(git_info, M.repo_path(git_info, endpoint), true), + handle_item = function(issue) + return format.item(config, trigger_char, normalize_issue(issue)) + end, + } + end + + local adapter = {} + + function adapter.issues_request(git_info, trigger_char, config) + return list_request(git_info, trigger_char, config, "issues") + end + + function adapter.change_requests_request(git_info, trigger_char, config) + return list_request(git_info, trigger_char, config, "pulls") + end + + function adapter.mentions_request(git_info, trigger_char, config) + return { + -- The assignees API needs a token, so a request without one would always fail + commands = commands(git_info, M.repo_path(git_info, "assignees"), false), + handle_item = function(mention) + return format.item(config, trigger_char, utils.empty_nulls(mention, { "full_name" })) + end, + } + end + + ---The assignees API gives all users on one page + function adapter.mentions_has_more() + return false + end + + return adapter +end + +return M diff --git a/lua/cmp_git/repository/init.lua b/lua/cmp_git/repository/init.lua index b4f4b15..ed195da 100644 --- a/lua/cmp_git/repository/init.lua +++ b/lua/cmp_git/repository/init.lua @@ -351,7 +351,7 @@ local function apply_host_prefixes(metadata, hosts) if entry == host then has_plain_host = true elseif #entry > #host_slash and entry:sub(1, #host_slash) == host_slash then - -- The GitHub and GitLab lists can both have the same entry + -- The lists of two providers can have the same entry if prefixed_entry == nil then prefixed_entry = entry elseif entry ~= prefixed_entry then @@ -400,7 +400,7 @@ end ---@class cmp_git.DiscoverOptions ---@field enableRemoteUrlRewrites boolean ---@field ssh_aliases {[string]: string} ----@field hosts? string[] The configured GitHub and GitLab hosts. An entry can have a path prefix. +---@field hosts? string[] The configured GitHub, GitLab, Gitea, and Forgejo hosts. An entry can have a path prefix. ---@field on_complete fun(metadata: cmp_git.RepositoryMetadata): nil ---@field bufnr? integer The buffer, by default the current one. diff --git a/lua/cmp_git/sort.lua b/lua/cmp_git/sort.lua index 3da7077..0bd9bbb 100644 --- a/lua/cmp_git/sort.lua +++ b/lua/cmp_git/sort.lua @@ -1,5 +1,12 @@ local utils = require("cmp_git.utils") +---The Gitea and Forgejo issues and pull requests sort the last updated first. +---@param item cmp_git.Gitea.Issue +---@return string +local function gitea_updated_at(item) + return string.format("%010d", os.difftime(os.time(), utils.parse_gitea_date(item.updated_at))) +end + local M = { git = { ---@param commit cmp_git.Commit @@ -50,6 +57,16 @@ local M = { return string.format("%010d", os.difftime(os.time(), utils.parse_gitlab_date(mr.updated_at))) end, }, + gitea = { + issues = gitea_updated_at, -- nil, "number", "title", "body", or custom function + mentions = nil, -- nil, "login", "full_name", or custom function + pull_requests = gitea_updated_at, -- nil, "number", "title", "body", or custom function + }, + forgejo = { + issues = gitea_updated_at, -- nil, "number", "title", "body", or custom function + mentions = nil, -- nil, "login", "full_name", or custom function + pull_requests = gitea_updated_at, -- nil, "number", "title", "body", or custom function + }, } ---@generic TItem diff --git a/lua/cmp_git/sources/forgejo.lua b/lua/cmp_git/sources/forgejo.lua new file mode 100644 index 0000000..e0bd3e2 --- /dev/null +++ b/lua/cmp_git/sources/forgejo.lua @@ -0,0 +1,13 @@ +local adapter = require("cmp_git.provider.adapters.forgejo") +local GiteaApi = require("cmp_git.sources.gitea_api") + +local M = {} + +---@param overrides cmp_git.Config.Forgejo +---@param cache_config? cmp_git.Config.Cache +---@return cmp_git.Source.GiteaApi +function M.new(overrides, cache_config) + return GiteaApi.new({ name = "forgejo", adapter = adapter }, overrides, cache_config) +end + +return M diff --git a/lua/cmp_git/sources/gitea.lua b/lua/cmp_git/sources/gitea.lua new file mode 100644 index 0000000..f306923 --- /dev/null +++ b/lua/cmp_git/sources/gitea.lua @@ -0,0 +1,13 @@ +local adapter = require("cmp_git.provider.adapters.gitea") +local GiteaApi = require("cmp_git.sources.gitea_api") + +local M = {} + +---@param overrides cmp_git.Config.Gitea +---@param cache_config? cmp_git.Config.Cache +---@return cmp_git.Source.GiteaApi +function M.new(overrides, cache_config) + return GiteaApi.new({ name = "gitea", adapter = adapter.curl }, overrides, cache_config) +end + +return M diff --git a/lua/cmp_git/sources/gitea_api.lua b/lua/cmp_git/sources/gitea_api.lua new file mode 100644 index 0000000..aebbf79 --- /dev/null +++ b/lua/cmp_git/sources/gitea_api.lua @@ -0,0 +1,230 @@ +-- The source of Gitea and Forgejo. `cmp_git.sources.gitea` and `cmp_git.sources.forgejo` give it their adapter. +local cache = require("cmp_git.cache") +local change_requests = require("cmp_git.provider.capabilities.change_requests") +local issues = require("cmp_git.provider.capabilities.issues") +local mentions = require("cmp_git.provider.capabilities.mentions") + +---What the source needs from a provider. +---@class cmp_git.Source.GiteaApi.Provider +---@field name "gitea" | "forgejo" The prefix of the cache files. +---@field adapter table The adapter of a request without a lookup. A cache hit makes no request with it. +---@field with_adapter? fun(git_info: cmp_git.GitInfo, bufnr: integer, on_adapter: fun(adapter: table)) Looks up the +---adapter of a request, and calls `on_adapter` once with it. Without it, the requests use `adapter`. + +---The capability of the issues or the pull requests. +---@class cmp_git.Source.GiteaApi.ListCapability: cmp_git.Provider.Capability +---@field job fun(args: cmp_git.Provider.CapabilityArgs): cmp_git.SystemJob? + +---@class cmp_git.Source.GiteaApi +---@field provider cmp_git.Source.GiteaApi.Provider +---@field config cmp_git.Config.Gitea +---@field cache_config cmp_git.Config.Cache +local GiteaApi = {} + +---@param provider cmp_git.Source.GiteaApi.Provider +---@param overrides cmp_git.Config.Gitea +---@param cache_config? cmp_git.Config.Cache +---@return cmp_git.Source.GiteaApi +function GiteaApi.new(provider, overrides, cache_config) + local self = setmetatable({}, { + __index = GiteaApi, + }) + + self.provider = provider + self.config = overrides or {} + self.cache_config = cache_config or require("cmp_git.config").cache + + return self +end + +---@param git_info cmp_git.GitInfo +function GiteaApi:is_valid_host(git_info) + if + git_info.host == nil + or git_info.owner == nil + or git_info.repo == nil + or not vim.tbl_contains(self.config.hosts, git_info.host) + then + return false + end + return true +end + +---@param kind "issues" | "pull_requests" | "mentions" +---@param git_info cmp_git.GitInfo +---@return cmp_git.CacheEntry +function GiteaApi:_entry(kind, git_info) + return cache.entry(git_info, self.provider.name .. "-" .. kind, self.config[kind]) +end + +---Run `start` with the adapter of the request. A cache hit makes no request, so it starts no lookup. +---@param git_info cmp_git.GitInfo +---@param bufnr integer The buffer of the request. The lookup runs in its repository. +---@param cached boolean +---@param start fun(adapter: table): table? Starts the request and gives its job, if any. +---@return table? job nil or a table with an optional `cancel` method. +function GiteaApi:_with_adapter(git_info, bufnr, cached, start) + local provider = self.provider + if cached or provider.with_adapter == nil then + return start(provider.adapter) + end + + local state = { cancelled = false, active = nil } + provider.with_adapter(git_info, bufnr, function(adapter) + if state.cancelled then + return + end + state.active = start(adapter) + end) + + return { + command = provider.name, + cancel = function() + state.cancelled = true + if state.active and state.active.cancel then + state.active:cancel() + end + end, + } +end + +---Refresh the entry in the background when its items are stale. +---@param entry cmp_git.CacheEntry +---@param capability cmp_git.Provider.Capability +---@param kind "issues" | "pull_requests" | "mentions" +---@param git_info cmp_git.GitInfo +---@param trigger_char string +---@param bufnr integer +function GiteaApi:_refresh(entry, capability, kind, git_info, trigger_char, bufnr) + entry:refresh(self.cache_config.ttl, function(done) + self:_with_adapter(git_info, bufnr, false, function(adapter) + capability.refresh({ + adapter = adapter, + config = self.config[kind], + git_info = git_info, + trigger_char = trigger_char, + }, done) + end) + end) +end + +---@param capability cmp_git.Source.GiteaApi.ListCapability +---@param kind "issues" | "pull_requests" +---@param adapter table +---@param callback fun(list: cmp_git.CompletionList) +---@param git_info cmp_git.GitInfo +---@param trigger_char string +---@param entry cmp_git.CacheEntry +---@return cmp_git.SystemJob? +function GiteaApi:_list_job(capability, kind, adapter, callback, git_info, trigger_char, entry) + return capability.job({ + adapter = adapter, + cache = entry, + callback = callback, + config = self.config[kind], + git_info = git_info, + trigger_char = trigger_char, + }) +end + +---@param capability cmp_git.Source.GiteaApi.ListCapability +---@param kind "issues" | "pull_requests" +---@param callback fun(list: cmp_git.CompletionList) +---@param git_info cmp_git.GitInfo +---@param trigger_char string +---@param bufnr? integer +function GiteaApi:_complete_list(capability, kind, callback, git_info, trigger_char, bufnr) + if not self:is_valid_host(git_info) then + return false + end + + -- The buffer of the request, also when the lookup ends in another buffer + bufnr = bufnr or vim.api.nvim_get_current_buf() + local entry = self:_entry(kind, git_info) + local job = self:_with_adapter(git_info, bufnr, entry:get() ~= nil, function(adapter) + local request = self:_list_job(capability, kind, adapter, callback, git_info, trigger_char, entry) + if request then + request:start() + end + return request + end) + self:_refresh(entry, capability, kind, git_info, trigger_char, bufnr) + return true, job +end + +---@param callback fun(list: cmp_git.CompletionList) +---@param git_info cmp_git.GitInfo +---@param trigger_char string +---@param bufnr? integer The buffer of the request, by default the current one. +function GiteaApi:complete_issues(callback, git_info, trigger_char, bufnr) + return self:_complete_list(issues, "issues", callback, git_info, trigger_char, bufnr) +end + +---@param callback fun(list: cmp_git.CompletionList) +---@param git_info cmp_git.GitInfo +---@param trigger_char string +---@param bufnr? integer The buffer of the request, by default the current one. +function GiteaApi:complete_change_requests(callback, git_info, trigger_char, bufnr) + return self:_complete_list(change_requests, "pull_requests", callback, git_info, trigger_char, bufnr) +end + +---@param callback fun(list: cmp_git.CompletionList) +---@param git_info cmp_git.GitInfo +---@param trigger_char string +---@param bufnr? integer The buffer of the request, by default the current one. +function GiteaApi:complete_issues_and_change_requests(callback, git_info, trigger_char, bufnr) + if not self:is_valid_host(git_info) then + return false + end + + bufnr = bufnr or vim.api.nvim_get_current_buf() + local issues_entry = self:_entry("issues", git_info) + local change_requests_entry = self:_entry("pull_requests", git_info) + local cached = issues_entry:get() ~= nil and change_requests_entry:get() ~= nil + local job = self:_with_adapter(git_info, bufnr, cached, function(adapter) + return change_requests.with_issues(callback, function(on_list) + return self:_list_job(issues, "issues", adapter, on_list, git_info, trigger_char, issues_entry) + end, function(on_list) + return self:_list_job( + change_requests, + "pull_requests", + adapter, + on_list, + git_info, + trigger_char, + change_requests_entry + ) + end) + end) + self:_refresh(issues_entry, issues, "issues", git_info, trigger_char, bufnr) + self:_refresh(change_requests_entry, change_requests, "pull_requests", git_info, trigger_char, bufnr) + return true, job +end + +---@param callback fun(list: cmp_git.CompletionList) +---@param git_info cmp_git.GitInfo +---@param trigger_char string +---@param bufnr? integer The buffer of the request, by default the current one. +function GiteaApi:complete_mentions(callback, git_info, trigger_char, bufnr) + if not self:is_valid_host(git_info) then + return false + end + + bufnr = bufnr or vim.api.nvim_get_current_buf() + local entry = self:_entry("mentions", git_info) + -- A request during a fetch joins it, so it needs no adapter either + local job = self:_with_adapter(git_info, bufnr, entry:get() ~= nil or entry.fetch ~= nil, function(adapter) + return mentions.complete({ + adapter = adapter, + cache = entry, + callback = callback, + config = self.config.mentions, + git_info = git_info, + trigger_char = trigger_char, + }) + end) + self:_refresh(entry, mentions, "mentions", git_info, trigger_char, bufnr) + return true, job +end + +return GiteaApi diff --git a/lua/cmp_git/utils.lua b/lua/cmp_git/utils.lua index d42b20c..1ba06c9 100644 --- a/lua/cmp_git/utils.lua +++ b/lua/cmp_git/utils.lua @@ -135,6 +135,13 @@ function M.parse_github_date(d) return parse_date(d) end +---For Gitea and Forgejo, which give dates with a UTC offset, such as `2024-01-02T03:04:05+02:00`. +---@param d string|vim.NIL|nil A JSON null is `vim.NIL`. +---@return integer timestamp 0 when the value is not a date. +function M.parse_gitea_date(d) + return parse_date(d) +end + ---Replace the missing and null text fields that the formats read. JSON null is `vim.NIL`, which a format would show as ---the text "vim.NIL". ---@generic T: table diff --git a/tests/helpers.lua b/tests/helpers.lua index 8f4da7f..7ea6f62 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -95,6 +95,8 @@ end local ENV_NAMES = { "GITHUB_API_TOKEN", "GITLAB_TOKEN", + "GITEA_TOKEN", + "FORGEJO_TOKEN", "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_CEILING_DIRECTORIES", diff --git a/tests/provider/adapters/test_gitea_api.lua b/tests/provider/adapters/test_gitea_api.lua new file mode 100644 index 0000000..9a1db82 --- /dev/null +++ b/tests/provider/adapters/test_gitea_api.lua @@ -0,0 +1,140 @@ +local helpers = require("tests.helpers") +local config = require("cmp_git.config") +local gitea_api = require("cmp_git.provider.adapters.gitea_api") + +local eq = helpers.eq + +local T = helpers.new_set() + +local remote = { host = "gitea.com", owner = "owner", repo = "repo" } +local defaults = config.normalize({}).gitea + +---An adapter whose provider gives one command before curl, and records the paths that it gets. +---@return table adapter +---@return string[] paths +local function adapter() + local paths = {} + return gitea_api.adapter({ + token_env = "GITEA_TOKEN", + commands = function(_, path) + table.insert(paths, path) + return { { exec = "tea", args = { "api", "/" .. path } } } + end, + }), + paths +end + +---@param spec cmp_git.CommandSpec +---@return string url The last argument. +local function url(spec) + return spec.args[#spec.args] +end + +T["api_url"] = function() + local cases = { + { remote, "https://gitea.com/api/v1/x", "an SSH remote uses HTTPS" }, + { vim.tbl_extend("force", remote, { scheme = "https" }), "https://gitea.com/api/v1/x", "an https remote" }, + { { host = "127.0.0.1:3000", scheme = "http" }, "http://127.0.0.1:3000/api/v1/x", "an http remote" }, + { { host = "example.com/gitea", scheme = "https" }, "https://example.com/gitea/api/v1/x", "a path prefix" }, + { { host = "example.com:8443/gitea" }, "https://example.com:8443/gitea/api/v1/x", "a port and a prefix" }, + } + + for _, case in ipairs(cases) do + eq(gitea_api.api_url(case[1], "x"), case[2], case[3]) + end +end + +T["issues_request"] = function() + local gitea, paths = adapter() + local request = gitea.issues_request(remote, "#", defaults.issues) + + local path = "repos/owner/repo/issues?state=open&type=issues&limit=50&page=1" + eq(paths, { path }, "the provider gets the API path") + eq(#request.commands, 2, "the provider command, then curl without a token") + eq(request.commands[1].args, { "api", "/" .. path }, "the provider command comes first") + eq(request.commands[2], { + exec = "curl", + args = { "-s", "-S", "--fail", "-L", "https://gitea.com/api/v1/" .. path }, + }, "curl without a token has no header and no stdin") +end + +T["change_requests_request"] = function() + local gitea = adapter() + local request = gitea.change_requests_request( + { host = "127.0.0.1:3000", owner = "owner", repo = "repo", scheme = "http" }, + "!", + config.normalize({ gitea = { pull_requests = { state = "all", limit = 7 } } }).gitea.pull_requests + ) + + eq( + url(request.commands[2]), + "http://127.0.0.1:3000/api/v1/repos/owner/repo/issues?state=all&type=pulls&limit=7&page=1", + "the pull requests of the issues API, with the state and the limit of the config" + ) +end + +T["the_token_variable_adds_curl_with_the_token"] = function() + vim.env.GITEA_TOKEN = "secret" + local gitea = adapter() + local requests = { + issues = gitea.issues_request(remote, "#", defaults.issues), + pull_requests = gitea.change_requests_request(remote, "#", defaults.pull_requests), + mentions = gitea.mentions_request(remote, "@", defaults.mentions), + } + + for name, request in pairs(requests) do + local curl = request.commands[2] + eq(curl.stdin, "Authorization: token secret\n", name .. ": curl reads the token header from stdin") + eq(vim.list_slice(curl.args, 1, 6), { "-s", "-S", "--fail", "-L", "-H", "@-" }, name .. ": headers from stdin") + for _, arg in ipairs(curl.args) do + eq(arg:find("secret", 1, true), nil, name .. ": the token is not an argument") + end + end + eq(#requests.issues.commands, 3, "issues: then curl without a token") + eq(requests.issues.commands[3].stdin, nil, "the last command has no token") + eq(#requests.mentions.commands, 2, "mentions: no curl without a token") +end + +T["an_empty_token_variable_is_not_a_token"] = function() + vim.env.GITEA_TOKEN = "" + local request = adapter().issues_request(remote, "#", defaults.issues) + + eq(#request.commands, 2, "the provider command and curl without a token") + eq(request.commands[2].stdin, nil, "no empty token header") +end + +T["mentions_request"] = function() + local gitea = adapter() + local request = gitea.mentions_request(remote, "@", defaults.mentions, { page = 1, page_size = 100 }) + + eq(request.commands[1].args, { "api", "/repos/owner/repo/assignees" }, "the assignees of the repository") + eq(#request.commands, 1, "the assignees API needs a token, so there is no curl without one") + eq(gitea.mentions_has_more(), false, "one page of mentions") + eq( + request.handle_item({ login = "ada", full_name = vim.NIL }).documentation.value, + "# ada\n\n", + "a null full name is empty" + ) +end + +T["the_owner_and_the_repository_are_url_encoded"] = function() + local gitea, paths = adapter() + gitea.issues_request({ host = "gitea.com", owner = "a/b", repo = "r#1" }, "#", defaults.issues) + + eq(paths[1]:match("^repos/[^?]+"), "repos/a%2Fb/r%231/issues", "the owner and the repository") +end + +T["handle_item_fills_null_text_and_removes_carriage_returns"] = function() + local request = adapter().issues_request(remote, "#", defaults.issues) + + local item = request.handle_item({ number = 1, title = vim.NIL, body = vim.NIL, updated_at = vim.NIL }) + for _, text in ipairs({ item.label, item.filterText, item.insertText, item.documentation.value }) do + eq(text:find("vim.NIL", 1, true), nil, "no vim.NIL text in " .. vim.inspect(text)) + end + eq(item.label, "#1: ", "a null title is empty") + + local crlf = request.handle_item({ number = 2, title = "t", body = "line 1\r\nline 2", updated_at = "" }) + eq(crlf.data.body, "line 1\nline 2", "the body has no carriage returns") +end + +return T diff --git a/tests/sources/fake_gitea_api.lua b/tests/sources/fake_gitea_api.lua new file mode 100644 index 0000000..3080d17 --- /dev/null +++ b/tests/sources/fake_gitea_api.lua @@ -0,0 +1,47 @@ +-- A fake curl for the Gitea and Forgejo source cases +local helpers = require("tests.helpers") + +local M = {} + +local ISSUES = + '[{"number":1,"title":"An issue","body":"","updated_at":"2024-01-01T00:00:00+02:00","pull_request":null}]' +local PULLS = + '[{"number":2,"title":"A pull request","body":null,"updated_at":"2024-01-02T00:00:00Z","pull_request":{}}]' +local USERS = '[{"login":"ada","full_name":"Ada Lovelace"}]' + +---Make PATH a directory with only a fake curl, until the end of the case. It answers the issue `#1`, the pull request +---`#2`, and the assignee `ada`. A token `bad` fails, as a 401 fails with `--fail`. +---@param opts? { fail?: boolean } Fail each request. +---@return fun(): string[] log A line `
` for each run so far. +function M.curl(opts) + local log = helpers.tempdir() .. "/curl.log" + local answers = opts and opts.fail and "exit 22" + or table.concat({ + 'case "$url" in', + string.format("*type=issues*) printf '%%s' '%s' ;;", ISSUES), + string.format("*type=pulls*) printf '%%s' '%s' ;;", PULLS), + string.format("*/assignees) printf '%%s' '%s' ;;", USERS), + "*) exit 22 ;;", + "esac", + }, "\n") + helpers.fake_commands({ + curl = table.concat({ + 'for arg in "$@"; do url=$arg; done', + "header=", + -- The header with the token comes on stdin + 'case " $* " in *" @- "*) IFS= read -r header ;; esac', + string.format("printf '%%s %%s\\n' \"$url\" \"$header\" >> '%s'", log), + 'case "$header" in *" bad") exit 22 ;; esac', + answers, + }, "\n"), + }) + + return function() + if vim.fn.filereadable(log) == 0 then + return {} + end + return vim.fn.readfile(log) + end +end + +return M diff --git a/tests/sources/test_forgejo.lua b/tests/sources/test_forgejo.lua new file mode 100644 index 0000000..6775835 --- /dev/null +++ b/tests/sources/test_forgejo.lua @@ -0,0 +1,74 @@ +local helpers = require("tests.helpers") +local fake_gitea_api = require("tests.sources.fake_gitea_api") +local config = require("cmp_git.config") +local Forgejo = require("cmp_git.sources.forgejo") + +local eq = helpers.eq +local ok = helpers.ok + +local T = helpers.new_set() + +local REMOTE = { host = "codeberg.org", owner = "owner", repo = "repo" } +local API = "https://codeberg.org/api/v1/repos/owner/repo/" +local ISSUES_URL = API .. "issues?state=open&type=issues&limit=50&page=1" +local PULLS_URL = API .. "issues?state=open&type=pulls&limit=50&page=1" + +---@param overrides? table +---@return cmp_git.Source.GiteaApi +local function source(overrides) + return Forgejo.new(config.normalize({ forgejo = overrides }).forgejo) +end + +---Run a request of the source and return the labels of its final list. +---@param forgejo cmp_git.Source.GiteaApi +---@param method string +---@param trigger_char string +---@return string[] +local function complete(forgejo, method, trigger_char) + local callback, lists = helpers.recorder() + eq(forgejo[method](forgejo, callback, REMOTE, trigger_char), true, method .. " handles the host") + helpers.wait_for(function() + return #lists > 0 + end, method .. " answers") + return vim.tbl_map(function(item) + return item.label + end, lists[1].items) +end + +T["forgejo_hosts"] = function() + local forgejo = source({ hosts = { "forgejo.example.com:3000" } }) + + ok(forgejo:is_valid_host(REMOTE), "codeberg.org by default") + ok(forgejo:is_valid_host({ host = "forgejo.example.com:3000", owner = "o", repo = "r" }), "a host with a port") + ok(not forgejo:is_valid_host({ host = "gitea.com", owner = "o", repo = "r" }), "a Gitea host") +end + +T["the_forgejo_token_variable_gives_the_items"] = function() + vim.env.FORGEJO_TOKEN = "secret" + vim.env.GITEA_TOKEN = "gitea" + local log = fake_gitea_api.curl() + local forgejo = source() + + eq(complete(forgejo, "complete_issues_and_change_requests", "#"), { "#1: An issue", "#2: A pull request" }, "#") + eq(complete(forgejo, "complete_mentions", "@"), { "@ada" }, "the assignees") + + local runs = log() + table.sort(runs) + eq(runs, { + API .. "assignees Authorization: token secret", + ISSUES_URL .. " Authorization: token secret", + PULLS_URL .. " Authorization: token secret", + }, "curl sends FORGEJO_TOKEN, not GITEA_TOKEN") + ok(forgejo:_entry("issues", REMOTE).path:find("/forgejo%-issues%-") ~= nil, "the items are in the forgejo files") +end + +T["without_a_token_the_public_items_come_from_curl"] = function() + local log = fake_gitea_api.curl() + local forgejo = source() + + eq(complete(forgejo, "complete_change_requests", "!"), { "!2: A pull request" }, "the pull requests") + eq(complete(forgejo, "complete_mentions", "@"), {}, "no mentions without a token") + eq(log(), { PULLS_URL .. " " }, "one request without a token, and none for the mentions") +end + +return T diff --git a/tests/sources/test_gitea.lua b/tests/sources/test_gitea.lua new file mode 100644 index 0000000..0af1307 --- /dev/null +++ b/tests/sources/test_gitea.lua @@ -0,0 +1,159 @@ +local helpers = require("tests.helpers") +local fake_gitea_api = require("tests.sources.fake_gitea_api") +local config = require("cmp_git.config") +local Gitea = require("cmp_git.sources.gitea") + +local eq = helpers.eq +local ok = helpers.ok + +local T = helpers.new_set() + +local EMPTY = { items = {}, isIncomplete = false } + +local REMOTE = { host = "gitea.com", owner = "owner", repo = "repo" } +local API = "https://gitea.com/api/v1/repos/owner/repo/" +local ISSUES_URL = API .. "issues?state=open&type=issues&limit=50&page=1" +local PULLS_URL = API .. "issues?state=open&type=pulls&limit=50&page=1" + +---@param overrides? table +---@return cmp_git.Source.GiteaApi +local function source(overrides) + return Gitea.new(config.normalize({ gitea = overrides }).gitea) +end + +---Run a request of the source and return its final list. +---@param gitea cmp_git.Source.GiteaApi +---@param method string +---@param trigger_char string +---@param git_info? cmp_git.GitInfo +---@return cmp_git.CompletionList +local function complete(gitea, method, trigger_char, git_info) + local callback, lists = helpers.recorder() + eq(gitea[method](gitea, callback, git_info or REMOTE, trigger_char), true, method .. " handles the host") + helpers.wait_for(function() + return #lists > 0 + end, method .. " answers") + helpers.flush() + eq(#lists, 1, method .. " gives one list") + return lists[1] +end + +---@param list cmp_git.CompletionList +---@return string[] +local function labels(list) + return vim.tbl_map(function(item) + return item.label + end, list.items) +end + +T["gitea_hosts"] = function() + local gitea = source({ hosts = { "example.com/gitea" } }) + + ok(gitea:is_valid_host(REMOTE), "gitea.com by default") + ok(gitea:is_valid_host({ host = "example.com/gitea", owner = "o", repo = "r" }), "a host with a path prefix") + ok(not gitea:is_valid_host({ host = "codeberg.org", owner = "o", repo = "r" }), "a Forgejo host") + ok(not gitea:is_valid_host({ host = "gitea.com" }), "a remote without a repository") +end + +T["issues_and_pull_requests_come_from_curl"] = function() + local log = fake_gitea_api.curl() + local gitea = source() + + local list = complete(gitea, "complete_issues_and_change_requests", "#") + + eq(labels(list), { "#1: An issue", "#2: A pull request" }, "the issues, then the pull requests") + eq(list.items[2].data.body, "", "a null body is empty") + local runs = log() + table.sort(runs) + eq(runs, { ISSUES_URL .. " ", PULLS_URL .. " " }, "one request for each list, without a token") + eq(#gitea:_entry("issues", REMOTE):get(), 1, "the issues are cached") + ok(gitea:_entry("pull_requests", REMOTE).path:find("/gitea%-pull_requests%-") ~= nil, "in the gitea files") + + eq(labels(complete(gitea, "complete_issues_and_change_requests", "#")), labels(list), "a cache hit") + eq(#log(), 2, "a cache hit runs no command") +end + +T["pull_requests_under_the_bang"] = function() + fake_gitea_api.curl() + + eq(labels(complete(source(), "complete_change_requests", "!")), { "!2: A pull request" }, "the pull requests") + eq(labels(complete(source(), "complete_issues", "#")), { "#1: An issue" }, "the issues alone") +end + +T["failed_commands_give_an_empty_list_that_is_not_cached"] = function() + vim.env.GITEA_TOKEN = "secret" + local log = fake_gitea_api.curl({ fail = true }) + local gitea = source() + + for _, method in ipairs({ "complete_issues", "complete_change_requests", "complete_issues_and_change_requests" }) do + eq(complete(gitea, method, "#"), EMPTY, method) + end + eq(complete(gitea, "complete_mentions", "@"), EMPTY, "mentions") + for _, kind in ipairs({ "issues", "pull_requests", "mentions" }) do + eq(gitea:_entry(kind, REMOTE):get(), nil, kind .. " are not cached") + end + eq(#log(), 9, "curl with the token, then without, for each list, and with the token for the mentions") +end + +T["missing_commands_give_an_empty_list"] = function() + helpers.fake_commands({}) + local gitea = source() + + eq(complete(gitea, "complete_issues_and_change_requests", "#"), EMPTY, "issues and pull requests") + eq(complete(gitea, "complete_change_requests", "!"), EMPTY, "pull requests") +end + +T["mentions_need_a_token"] = function() + local log = fake_gitea_api.curl() + local gitea = source() + + eq(complete(gitea, "complete_mentions", "@"), EMPTY, "no mentions without a token") + eq(log(), {}, "and no process") + + vim.env.GITEA_TOKEN = "secret" + local list = complete(gitea, "complete_mentions", "@") + + eq(labels(list), { "@ada" }, "the assignees") + eq(list.items[1].documentation.value, "# ada\n\nAda Lovelace", "with the full name") + eq(log(), { API .. "assignees Authorization: token secret" }, "curl sends the token") + eq(#gitea:_entry("mentions", REMOTE):get(), 1, "the mentions are cached") +end + +T["an_invalid_token_falls_back_to_curl_without_a_token"] = function() + vim.env.GITEA_TOKEN = "bad" + local log = fake_gitea_api.curl() + + eq(labels(complete(source(), "complete_issues", "#")), { "#1: An issue" }, "the public issues") + eq(log(), { ISSUES_URL .. " Authorization: token bad", ISSUES_URL .. " " }, "with the token, then without") +end + +T["an_http_remote_uses_plain_http"] = function() + local log = fake_gitea_api.curl() + local remote = { host = "127.0.0.1:3000", owner = "owner", repo = "repo", scheme = "http" } + + complete(source({ hosts = { "127.0.0.1:3000" } }), "complete_issues", "#", remote) + + eq( + log(), + { "http://127.0.0.1:3000/api/v1/repos/owner/repo/issues?state=open&type=issues&limit=50&page=1 " }, + "the API URL of an http remote" + ) +end + +T["stale_items_refresh_in_the_background"] = function() + local log = fake_gitea_api.curl() + local gitea = Gitea.new(config.normalize({}).gitea, { ttl = 60 }) + local entry = gitea:_entry("issues", REMOTE) + entry:set({ { number = 9, title = "An old issue", body = "", updated_at = "2024-01-01T00:00:00Z" } }) + entry.fetched_at = os.time() - 60 + + eq(labels(complete(gitea, "complete_issues", "#")), { "#9: An old issue" }, "the stale items at once") + helpers.wait_for(function() + return not entry.refreshing + end, "the refresh ends") + + eq(log(), { ISSUES_URL .. " " }, "the refresh runs curl") + eq(labels(complete(gitea, "complete_issues", "#")), { "#1: An issue" }, "the next request gets the new items") +end + +return T diff --git a/tests/test_config.lua b/tests/test_config.lua index 4e943bd..ff33c3f 100644 --- a/tests/test_config.lua +++ b/tests/test_config.lua @@ -25,6 +25,52 @@ T["config_normalize_fills_defaults_and_hosts"] = function() ok(vim.tbl_contains(normalized.gitlab.hosts, "gitlab.com"), "normalized config adds gitlab host") end +T["config_gitea_and_forgejo_defaults"] = function() + local normalized = config.normalize({}) + + for _, provider in ipairs({ "gitea", "forgejo" }) do + local defaults = normalized[provider] + eq( + { defaults.issues.limit, defaults.issues.state }, + { 50, "open" }, + provider .. " issues: one page of the servers" + ) + eq({ defaults.pull_requests.limit, defaults.pull_requests.state }, { 50, "open" }, provider .. " pull requests") + eq(defaults.mentions.limit, 100, provider .. " mentions") + eq(type(defaults.pull_requests.format.label), "function", provider .. " formats") + end + eq(normalized.gitea.hosts, { "gitea.com" }, "the default Gitea host") + eq(normalized.forgejo.hosts, { "codeberg.org" }, "the default Forgejo host") + eq( + config.normalize({ gitea = { hosts = { "example.com/gitea" } } }).gitea.hosts, + { "example.com/gitea", "gitea.com" }, + "a Gitea host with a path prefix" + ) + neq(normalized.gitea.issues.format, normalized.forgejo.issues.format, "each provider has its own formats") +end + +T["config_default_trigger_actions_of_the_hosted_providers"] = function() + local actions = {} + for _, entry in ipairs(config.normalize({}).trigger_actions) do + if entry.trigger_character then + actions[entry.trigger_character] = entry.actions + end + end + + eq(actions["#"], { + "gitlab_issues", + "github_issues_and_change_requests", + "gitea_issues_and_change_requests", + "forgejo_issues_and_change_requests", + }, "# completes issues and pull requests") + eq(actions["@"], { "gitlab_mentions", "github_mentions", "gitea_mentions", "forgejo_mentions" }, "@ mentions") + eq( + actions["!"], + { "gitlab_change_requests", "gitea_change_requests", "forgejo_change_requests" }, + "! completes merge requests and pull requests" + ) +end + T["config_normalize_fills_the_cache_ttl"] = function() eq(config.normalize({}).cache.ttl, 300, "the default TTL is five minutes") eq(config.normalize({ cache = { ttl = 0 } }).cache.ttl, 0, "a user TTL") @@ -65,6 +111,10 @@ T["config_normalize_provider_filter_alias"] = function() return "gitlab" end + local gitea_filter = function() + return "gitea" + end + local normalized = config.normalize({ git = { filter_fn = git_filter, @@ -75,6 +125,12 @@ T["config_normalize_provider_filter_alias"] = function() gitlab = { filter_fn = gitlab_filter, }, + gitea = { + filter_fn = gitea_filter, + }, + forgejo = { + filter_fn = gitea_filter, + }, }) eq(normalized.git.commits.format.filterText, git_filter, "git filter alias applies to commits") @@ -88,6 +144,16 @@ T["config_normalize_provider_filter_alias"] = function() gitlab_filter, "gitlab filter alias applies to merge requests" ) + for _, provider in ipairs({ "gitea", "forgejo" }) do + for _, capability in ipairs({ "issues", "mentions", "pull_requests" }) do + eq( + normalized[provider][capability].format.filterText, + gitea_filter, + provider .. " filter alias applies to " .. capability + ) + end + end + neq(format.gitea.issues.filterText, gitea_filter, "filter alias does not change the Gitea formats") end T["config_normalize_provider_filter_alias_overrides_nested_filter"] = function() diff --git a/tests/test_core.lua b/tests/test_core.lua index 5ff088c..2b52fa0 100644 --- a/tests/test_core.lua +++ b/tests/test_core.lua @@ -499,8 +499,8 @@ T["complete_answers_when_no_action_handles_the_request"] = function() core:complete(trigger_context("#"), callback) expect_one_empty_list(lists, "outside a git repository") - -- Neither GitHub nor GitLab handles these hosts, so the sources decline without a process - for _, git_info in ipairs({ { host = "gitea.com", owner = "owner", repo = "repo" }, {} }) do + -- No provider handles these hosts, so the sources decline without a process + for _, git_info in ipairs({ { host = "git.example.org", owner = "owner", repo = "repo" }, {} }) do stub_repository(git_info) for _, trigger in ipairs({ "#", "@", "!" }) do callback, lists = helpers.recorder() @@ -721,7 +721,7 @@ end T["sources_load_on_first_use"] = function() local core = Core.new({}) - for _, name in ipairs({ "git", "github", "gitlab" }) do + for _, name in ipairs({ "git", "github", "gitlab", "gitea", "forgejo" }) do eq(rawget(core.sources, name), nil, name .. " does not load with core") end @@ -732,6 +732,84 @@ T["sources_load_on_first_use"] = function() ) eq(core.sources.github, github, "the source loads once") eq(rawget(core.sources, "gitlab"), nil, "the other sources stay unloaded") + + ok(core.sources.gitea:is_valid_host({ host = "gitea.com", owner = "o", repo = "r" }), "the gitea config") + ok(core.sources.forgejo:is_valid_host({ host = "codeberg.org", owner = "o", repo = "r" }), "the forgejo config") + ok(not core.sources.forgejo:is_valid_host({ host = "gitea.com", owner = "o", repo = "r" }), "each own hosts") +end + +---Run the default actions of a trigger for a remote, with sources that record the methods that run. Each source +---handles the request when the host is its own. +---@param trigger string +---@param git_info cmp_git.GitInfo +---@return string[] calls `.` of each call. +---@return table core +local function route_trigger(trigger, git_info) + local core = Core.new({}) + local calls = {} + for _, name in ipairs({ "github", "gitlab", "gitea", "forgejo" }) do + local methods = {} + for _, method in ipairs({ + "complete_issues", + "complete_change_requests", + "complete_issues_and_change_requests", + "complete_mentions", + }) do + methods[method] = function(_, callback) + table.insert(calls, name .. "." .. method) + callback({ items = {}, isIncomplete = false }) + return true + end + end + rawset(core.sources, name, methods) + end + + local callback, lists = helpers.recorder() + core:_run_trigger_actions(trigger, callback, trigger_context(trigger), git_info) + eq(#lists, 1, trigger .. " answers once") + return calls, core +end + +T["gitea_and_forgejo_hosts_route_to_their_sources"] = function() + local gitea = { host = "gitea.com", owner = "owner", repo = "repo" } + local codeberg = { host = "codeberg.org", owner = "owner", repo = "repo" } + + eq(route_trigger("#", gitea), { "gitea.complete_issues_and_change_requests" }, "# on gitea.com") + eq(route_trigger("!", gitea), { "gitea.complete_change_requests" }, "! on gitea.com") + eq(route_trigger("@", gitea), { "gitea.complete_mentions" }, "@ on gitea.com") + eq(route_trigger("#", codeberg), { "forgejo.complete_issues_and_change_requests" }, "# on codeberg.org") + eq(route_trigger("!", codeberg), { "forgejo.complete_change_requests" }, "! on codeberg.org") + eq(route_trigger("@", codeberg), { "forgejo.complete_mentions" }, "@ on codeberg.org") +end + +T["github_and_gitlab_hosts_keep_their_routes"] = function() + local github = { host = "github.com", owner = "owner", repo = "repo" } + local gitlab = { host = "gitlab.com", owner = "group", repo = "project" } + + eq(route_trigger("#", github), { "github.complete_issues_and_change_requests" }, "# on github.com") + eq(route_trigger("@", github), { "github.complete_mentions" }, "@ on github.com") + eq(route_trigger("!", github), {}, "! on github.com") + eq(route_trigger("#", gitlab), { "gitlab.complete_issues" }, "# on gitlab.com") + eq(route_trigger("@", gitlab), { "gitlab.complete_mentions" }, "@ on gitlab.com") + eq(route_trigger("!", gitlab), { "gitlab.complete_change_requests" }, "! on gitlab.com") +end + +T["a_github_remote_does_not_load_the_gitea_and_forgejo_sources"] = function() + local core = Core.new({}) + rawset(core.sources, "github", { + complete_issues_and_change_requests = function(_, callback) + callback({ items = { { label = "#1: an issue" } }, isIncomplete = false }) + return true + end, + }) + local callback, lists = helpers.recorder() + + core:_run_trigger_actions("#", callback, trigger_context("#"), { host = "github.com", owner = "o", repo = "r" }) + + eq(lists[1].items[1].label, "#1: an issue", "the GitHub items") + for _, name in ipairs({ "gitlab", "gitea", "forgejo" }) do + eq(rawget(core.sources, name), nil, name .. " does not load") + end end T["setup_and_a_declined_request_load_only_the_core_modules"] = function() @@ -774,10 +852,12 @@ T["setup_and_a_declined_request_load_only_the_core_modules"] = function() }, "the request answers without the sources, the repository, or the trailer parsers") end -T["complete_passes_the_github_and_gitlab_hosts_to_discovery"] = function() +T["complete_passes_the_hosts_of_all_providers_to_discovery"] = function() local core = Core.new({ github = { hosts = { "github.example.com" } }, gitlab = { hosts = { "example.com/gitlab" } }, + gitea = { hosts = { "example.com/gitea" } }, + forgejo = { hosts = { "forgejo.example.com:3000" } }, }) local hosts helpers.stub(repository, "is_git_repo", function(on_result) @@ -791,11 +871,16 @@ T["complete_passes_the_github_and_gitlab_hosts_to_discovery"] = function() local callback, lists = helpers.recorder() core:complete(trigger_context("#"), callback) - eq( - hosts, - { "github.example.com", "github.com", "example.com/gitlab", "gitlab.com" }, - "the GitHub hosts, then the GitLab hosts" - ) + eq(hosts, { + "github.example.com", + "github.com", + "example.com/gitlab", + "gitlab.com", + "example.com/gitea", + "gitea.com", + "forgejo.example.com:3000", + "codeberg.org", + }, "the GitHub, GitLab, Gitea, and Forgejo hosts") eq(core.config.github.hosts, { "github.example.com", "github.com" }, "the GitHub hosts do not change") expect_one_empty_list(lists, "no source handles a request without a repository") end diff --git a/tests/test_format.lua b/tests/test_format.lua index 927589f..f979e35 100644 --- a/tests/test_format.lua +++ b/tests/test_format.lua @@ -130,4 +130,56 @@ T["gitlab_merge_requests"] = function() }) end +local GITEA_ISSUE = { + number = 3, + title = "Crash on start", + body = "Steps", + updated_at = "2024-01-01T00:00:00+02:00", +} + +T["gitea_and_forgejo_issues"] = function() + for _, provider in ipairs({ "gitea", "forgejo" }) do + eq(fields(format.item(defaults[provider].issues, "#", GITEA_ISSUE)), { + label = "#3: Crash on start", + insertText = "#3", + filterText = "# 3 Crash on start", + documentation = "# Crash on start\n\nSteps", + }, provider .. " issue") + end +end + +T["gitea_and_forgejo_pull_requests"] = function() + local pr = { number = 4, title = "Fix crash", body = "Fixes #3", updated_at = "2024-01-01T00:00:00Z" } + + for _, provider in ipairs({ "gitea", "forgejo" }) do + eq(fields(format.item(defaults[provider].pull_requests, "#", pr)), { + label = "#4: Fix crash", + insertText = "#4", + filterText = "# 4 Fix crash", + documentation = "# Fix crash\n\nFixes #3", + }, provider .. " pull request under #") + eq(fields(format.item(defaults[provider].pull_requests, "!", pr)), { + label = "!4: Fix crash", + insertText = "!4", + filterText = "! 4 Fix crash", + documentation = "# Fix crash\n\nFixes #3", + }, provider .. " pull request under !") + eq(format.item(defaults[provider].pull_requests, nil, pr).insertText, "#4", provider .. " without a trigger") + end +end + +T["gitea_and_forgejo_mentions"] = function() + for _, provider in ipairs({ "gitea", "forgejo" }) do + local item = format.item(defaults[provider].mentions, "@", { login = "ada", full_name = "Ada Lovelace" }) + + eq(fields(item), { + label = "@ada", + insertText = "@ada", + filterText = "@ ada", + documentation = "# ada\n\nAda Lovelace", + }, provider .. " mention") + eq(item.sortText, nil, provider .. " mentions have no sort text") + end +end + return T diff --git a/tests/test_sort.lua b/tests/test_sort.lua index bb89f58..ed8977c 100644 --- a/tests/test_sort.lua +++ b/tests/test_sort.lua @@ -63,4 +63,26 @@ T["default_provider_sorts"] = function() eq(sort.gitlab.mentions, nil, "GitLab mentions keep the API order") end +-- The sorts read only the date of an item +---@diagnostic disable: missing-fields +T["gitea_and_forgejo_sorts"] = function() + local sorts = { + ["gitea issues"] = sort.gitea.issues, + ["gitea pull requests"] = sort.gitea.pull_requests, + ["forgejo issues"] = sort.forgejo.issues, + ["forgejo pull requests"] = sort.forgejo.pull_requests, + } + + for name, sort_fn in pairs(sorts) do + -- The servers give the time with the offset of the server + local older = sort_fn({ updated_at = "2024-01-01T12:00:00+02:00" }) + local newer = sort_fn({ updated_at = "2024-01-01T11:00:00Z" }) + eq(#older, 10, name .. " sort text width") + ok(newer < older, name .. " sort the last updated first") + ok(sort_fn({}) > older, name .. " without a date sort last") + end + eq(sort.gitea.mentions, nil, "Gitea mentions keep the API order") + eq(sort.forgejo.mentions, nil, "Forgejo mentions keep the API order") +end + return T From 40f27440d25f78c8be1424d1e20c4bd286ca1564 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Fri, 23 Oct 2026 16:44:48 +1100 Subject: [PATCH 08/13] fix: run the next command after an output that is not a list A list command that exits with 0 and prints an error object, or other output that is not a list, now fails as a command with an exit code that is not 0. The fallback then runs the next command, and the callback gets one list. Before, the error object ended the fallback with an empty list. `tea api` exits with 0 on an HTTP error, so the Gitea provider needs this to fall back to curl. gh and glab exit with an error code on an HTTP error, so their requests do not change. --- lua/cmp_git/command.lua | 24 ++++++++++++++---------- tests/test_command.lua | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/lua/cmp_git/command.lua b/lua/cmp_git/command.lua index 90cc8c8..94530cf 100644 --- a/lua/cmp_git/command.lua +++ b/lua/cmp_git/command.lua @@ -19,8 +19,10 @@ local M = {} ---@param spec cmp_git.CommandSpec ---@param callback fun(result: string, success: boolean): nil +---@param accept? fun(result: string): boolean A result of exit code 0 that `accept` rejects is a failure, so that the +---next command of a fallback runs. ---@return cmp_git.SystemJob? -function M.build(spec, callback) +function M.build(spec, callback, accept) if vim.fn.executable(spec.exec) ~= 1 or not spec.args then log.fmt_debug("Can't work with %s for this call", spec.exec) return nil @@ -48,6 +50,9 @@ function M.build(spec, callback) if result.stderr and result.stderr ~= "" then log.fmt_debug("%s stderr: %s", spec.exec, result.stderr) end + elseif accept and not accept(result.stdout or "") then + success = false + log.fmt_debug("%s returned with a result that is not valid", spec.exec) else log.fmt_debug("%s returned with a result", spec.exec) end @@ -94,15 +99,14 @@ end ---@param handle_parsed? fun(parsed: any): TItem[]? A result that is not a list is a failure. ---@return cmp_git.SystemJob? function M.build_list(spec, callback, handle_item, handle_parsed) - return M.build(spec, function(result, success) - if not success then - callback({ items = {}, isIncomplete = false }, false) - return - end - - -- An output that is not a list, for example an error object, is a failure - local list, ok = response.completion_list(result, handle_item, handle_parsed) - callback(list, ok) + local list = nil + return M.build(spec, function(_, success) + callback(success and list or { items = {}, isIncomplete = false }, success) + end, function(result) + -- An output that is not a list, for example an error object of `tea api`, which exits with 0, is a failure + local ok + list, ok = response.completion_list(result, handle_item, handle_parsed) + return ok end) end diff --git a/tests/test_command.lua b/tests/test_command.lua index 4878790..c071356 100644 --- a/tests/test_command.lua +++ b/tests/test_command.lua @@ -178,6 +178,30 @@ T["build_list_reports_an_error_object"] = function() eq(calls, { { list = { items = {}, isIncomplete = false }, success = false } }, "an error object is a failure") end +T["fallback_list_runs_the_next_command_after_an_error_object"] = function() + local calls = {} + local job = helpers.not_nil(command.build_fallback_list({ + { exec = "sh", args = { "-c", 'printf \'{"message":"Bad credentials"}\'' } }, + { exec = "sh", args = { "-c", "printf '[\"item\"]'" } }, + }, function(list, success) + table.insert(calls, { + labels = vim.tbl_map(function(item) + return item.label + end, list.items), + success = success, + }) + end, function(item) + return { label = item } + end)) + job:start() + helpers.wait_for(function() + return #calls > 0 + end, "the fallback completes") + helpers.flush() + + eq(calls, { { labels = { "item" }, success = true } }, "the list of the second command, and no empty list") +end + T["build_list_reports_a_failure"] = function() local calls = {} local job = helpers.not_nil(command.build_list(exit_with(1), function(list, success) From b7e72293c2766f6f909492e729d93615214bb231 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Fri, 23 Oct 2026 16:44:48 +1100 Subject: [PATCH 09/13] feat: use the tea login of the host for Gitea The Gitea provider now asks `tea login list -o json` for the logins of tea once in the session, without blocking. A login matches when its URL without the scheme and the trailing `/` is the host of the remote. Of more than one match, the default login wins, and else the first one. With a matching login, each request runs `tea api -l ` first, then the curl commands. Without `-l`, tea can use the login of another server. Without a matching login, or without tea, no `tea api` process starts. Requests that wait for the lookup share it, and a cancelled request starts no command. `:CmpGitClearCache` drops the logins, so the next request that is not a cache hit reads them again. --- lua/cmp_git/commands.lua | 11 +- lua/cmp_git/provider/adapters/gitea.lua | 47 ++++++++ lua/cmp_git/sources/gitea.lua | 70 +++++++++++- tests/helpers.lua | 5 + tests/sources/fake_gitea_api.lua | 79 ++++++++++---- tests/sources/test_forgejo.lua | 4 +- tests/sources/test_gitea.lua | 138 ++++++++++++++++++++++-- 7 files changed, 323 insertions(+), 31 deletions(-) diff --git a/lua/cmp_git/commands.lua b/lua/cmp_git/commands.lua index a703c4d..a41cd46 100644 --- a/lua/cmp_git/commands.lua +++ b/lua/cmp_git/commands.lua @@ -16,6 +16,14 @@ local function clear_git_caches(bufnr) end end +---Drop the tea logins of the Gitea source, so that a login added during the session applies. +local function clear_tea_logins() + local Gitea = package.loaded["cmp_git.sources.gitea"] + if Gitea then + Gitea.clear_logins() + end +end + ---The remotes of the buffer, without repeats. Discovery runs with the config of each core, because the configs can ---give other hosts for one remote URL, for example with `ssh_aliases`. Without a core, it uses the default config. ---@param bufnr integer @@ -49,11 +57,12 @@ local function discover_remotes(bufnr, on_complete) end ---Clear the cache of the remote of the buffer, or of all remotes. Both forms also drop the cached commits and ----trailers, of the buffer or of all buffers. +---trailers, of the buffer or of all buffers, and the tea logins. ---@param all boolean ---@param bufnr? integer The buffer, by default the current one. function M.clear_cache(all, bufnr) local cache = require("cmp_git.cache") + clear_tea_logins() if all then cache.clear_all() clear_git_caches(nil) diff --git a/lua/cmp_git/provider/adapters/gitea.lua b/lua/cmp_git/provider/adapters/gitea.lua index e71758f..16fecff 100644 --- a/lua/cmp_git/provider/adapters/gitea.lua +++ b/lua/cmp_git/provider/adapters/gitea.lua @@ -10,4 +10,51 @@ M.curl = gitea_api.adapter({ end, }) +---The adapter of a request with a tea login: `tea api` with the login, then the commands of `M.curl`. Without `-l`, +---tea can use the login of another server. +---@param login string +---@return table adapter +function M.with_login(login) + return gitea_api.adapter({ + token_env = "GITEA_TOKEN", + commands = function(_, path) + -- `tea api` exits with 0 on an HTTP error. Its error body is an object, which is a failure of the request. + return { { exec = "tea", args = { "api", "-l", login, "/" .. path } } } + end, + }) +end + +---@param bufnr integer +---@return cmp_git.CommandSpec +function M.logins_command(bufnr) + return { exec = "tea", args = { "login", "list", "-o", "json" }, bufnr = bufnr } +end + +---The tea login of a host. A login matches when its URL without the scheme and the trailing `/` is the host. Of more +---than one match, the default login wins, and else the first one. +---@param output string The output of `tea login list -o json`. +---@param host string The host of the remote, with the port and the path prefix if any. +---@return string? name +function M.match_login(output, host) + local ok, logins = pcall(vim.json.decode, output) + if not ok or not vim.islist(logins) then + return nil + end + + local first = nil + for _, login in ipairs(logins) do + if type(login) == "table" and type(login.name) == "string" and type(login.url) == "string" then + local login_host = string.gsub(string.gsub(login.url, "^%a[%w+.-]*://", ""), "/+$", "") + if login_host == host then + -- tea 0.16.0 gives `default` as the string "true" or "false" + if login.default == true or login.default == "true" then + return login.name + end + first = first or login.name + end + end + end + return first +end + return M diff --git a/lua/cmp_git/sources/gitea.lua b/lua/cmp_git/sources/gitea.lua index f306923..bb06c0c 100644 --- a/lua/cmp_git/sources/gitea.lua +++ b/lua/cmp_git/sources/gitea.lua @@ -1,13 +1,81 @@ local adapter = require("cmp_git.provider.adapters.gitea") +local command = require("cmp_git.command") local GiteaApi = require("cmp_git.sources.gitea_api") local M = {} +---The output of `tea login list -o json`. Until it is `done`, the requests wait in `waiters`. +---@class cmp_git.Source.Gitea.Logins +---@field done boolean +---@field output? string nil when tea failed. +---@field waiters fun(output: string?)[] + +---The logins of tea are the same for all repositories, so one lookup serves the session. `M.clear_logins` drops it. +---@type cmp_git.Source.Gitea.Logins? +local logins = nil + +---Call `on_done` with the output of `tea login list -o json`, or with nil when tea fails or is not installed. tea runs +---at most once until `M.clear_logins`, and without blocking. +---@param bufnr integer +---@param on_done fun(output: string?) +local function with_logins(bufnr, on_done) + local lookup = logins + if lookup and lookup.done then + on_done(lookup.output) + return + end + if lookup then + table.insert(lookup.waiters, on_done) + return + end + + local job = command.build(adapter.logins_command(bufnr), function(result, success) + ---@cast lookup -nil + lookup.output = success and result or nil + lookup.done = true + local waiters = lookup.waiters + lookup.waiters = {} + for _, waiter in ipairs(waiters) do + waiter(lookup.output) + end + end) + if not job then + -- tea is optional + on_done(nil) + return + end + + lookup = { done = false, waiters = { on_done } } + logins = lookup + job:start() +end + +---Give the adapter of the tea login of the host, or the curl adapter when no login matches. +---@param git_info cmp_git.GitInfo +---@param bufnr integer +---@param on_adapter fun(adapter: table) +local function with_adapter(git_info, bufnr, on_adapter) + with_logins(bufnr, function(output) + local login = output and adapter.match_login(output, git_info.host) + on_adapter(login and adapter.with_login(login) or adapter.curl) + end) +end + ---@param overrides cmp_git.Config.Gitea ---@param cache_config? cmp_git.Config.Cache ---@return cmp_git.Source.GiteaApi function M.new(overrides, cache_config) - return GiteaApi.new({ name = "gitea", adapter = adapter.curl }, overrides, cache_config) + return GiteaApi.new( + { name = "gitea", adapter = adapter.curl, with_adapter = with_adapter }, + overrides, + cache_config + ) +end + +---Drop the tea logins, so that the next request that is not a cache hit reads them again. A lookup that runs still +---answers the requests that wait for it. +function M.clear_logins() + logins = nil end return M diff --git a/tests/helpers.lua b/tests/helpers.lua index 7ea6f62..1c88c12 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -250,6 +250,11 @@ local function teardown_case() if cache then cache.forget() end + -- The tea logins are shared by the Gitea sources of the session + local gitea = package.loaded["cmp_git.sources.gitea"] + if gitea then + gitea.clear_logins() + end for _, name in ipairs(ENV_NAMES) do -- A nil value unsets the variable diff --git a/tests/sources/fake_gitea_api.lua b/tests/sources/fake_gitea_api.lua index 3080d17..926121d 100644 --- a/tests/sources/fake_gitea_api.lua +++ b/tests/sources/fake_gitea_api.lua @@ -1,4 +1,4 @@ --- A fake curl for the Gitea and Forgejo source cases +-- Fake curl and tea commands for the Gitea and Forgejo source cases local helpers = require("tests.helpers") local M = {} @@ -9,33 +9,62 @@ local PULLS = '[{"number":2,"title":"A pull request","body":null,"updated_at":"2024-01-02T00:00:00Z","pull_request":{}}]' local USERS = '[{"login":"ada","full_name":"Ada Lovelace"}]' ----Make PATH a directory with only a fake curl, until the end of the case. It answers the issue `#1`, the pull request ----`#2`, and the assignee `ada`. A token `bad` fails, as a 401 fails with `--fail`. ----@param opts? { fail?: boolean } Fail each request. ----@return fun(): string[] log A line `
` for each run so far. -function M.curl(opts) - local log = helpers.tempdir() .. "/curl.log" - local answers = opts and opts.fail and "exit 22" - or table.concat({ - 'case "$url" in', - string.format("*type=issues*) printf '%%s' '%s' ;;", ISSUES), - string.format("*type=pulls*) printf '%%s' '%s' ;;", PULLS), - string.format("*/assignees) printf '%%s' '%s' ;;", USERS), - "*) exit 22 ;;", - "esac", - }, "\n") - helpers.fake_commands({ +---The answer to the API path in `$url`. +---@param fail string The script for a failed request. +---@return string script +local function answers(fail) + return table.concat({ + 'case "$url" in', + string.format("*type=issues*) printf '%%s' '%s' ;;", ISSUES), + string.format("*type=pulls*) printf '%%s' '%s' ;;", PULLS), + string.format("*/assignees) printf '%%s' '%s' ;;", USERS), + "*) " .. fail .. " ;;", + "esac", + }, "\n") +end + +---@class tests.FakeGiteaApi.Options +---@field fail? boolean Fail each API request of curl and tea. +---@field logins? table[] The logins of `tea login list -o json`. Without them, PATH has no tea. +---@field tea_logins_fail? boolean `tea login list` fails. +---@field tea_api_fails? boolean `tea api` gives an error object. + +---Make PATH a directory with only a fake curl, and a fake tea with `logins`, until the end of the case. They answer +---the issue `#1`, the pull request `#2`, and the assignee `ada`. A token `bad` fails, as a 401 fails with `--fail`. As +---the real tea, the fake `tea api` exits with 0 and prints an error object on an HTTP error. +---@param opts? tests.FakeGiteaApi.Options +---@return fun(): string[] log A line for each run so far: `
` for curl, `tea ` for tea. +function M.commands(opts) + opts = opts or {} + local log = helpers.tempdir() .. "/commands.log" + local append = string.format(">> '%s'", log) + local scripts = { curl = table.concat({ 'for arg in "$@"; do url=$arg; done', "header=", -- The header with the token comes on stdin 'case " $* " in *" @- "*) IFS= read -r header ;; esac', - string.format("printf '%%s %%s\\n' \"$url\" \"$header\" >> '%s'", log), + 'printf \'%s %s\\n\' "$url" "$header" ' .. append, 'case "$header" in *" bad") exit 22 ;; esac', - answers, + opts.fail and "exit 22" or answers("exit 22"), }, "\n"), - }) + } + if opts.logins then + local not_found = [[printf '%s' '{"message":"not found"}']] + scripts.tea = table.concat({ + "printf 'tea %s\\n' \"$*\" " .. append, + 'if [ "$1 $2 $3 $4" = "login list -o json" ]; then', + opts.tea_logins_fail and "exit 1" or string.format("printf '%%s' '%s'", vim.json.encode(opts.logins)), + "exit 0", + "fi", + 'if [ "$1 $2" != "api -l" ]; then exit 1; fi', + "url=$4", + (opts.fail or opts.tea_api_fails) and not_found or answers(not_found), + }, "\n") + end + + helpers.fake_commands(scripts) return function() if vim.fn.filereadable(log) == 0 then return {} @@ -44,4 +73,14 @@ function M.curl(opts) end end +---A login as `tea login list -o json` gives it. +---@param name string +---@param url string +---@param default? boolean +---@return table +function M.login(name, url, default) + local host = string.match(url, "^%a+://([^/]+)") + return { name = name, url = url, ssh_host = host, user = "ada", default = default and "true" or "false" } +end + return M diff --git a/tests/sources/test_forgejo.lua b/tests/sources/test_forgejo.lua index 6775835..e2d7d8a 100644 --- a/tests/sources/test_forgejo.lua +++ b/tests/sources/test_forgejo.lua @@ -46,7 +46,7 @@ end T["the_forgejo_token_variable_gives_the_items"] = function() vim.env.FORGEJO_TOKEN = "secret" vim.env.GITEA_TOKEN = "gitea" - local log = fake_gitea_api.curl() + local log = fake_gitea_api.commands() local forgejo = source() eq(complete(forgejo, "complete_issues_and_change_requests", "#"), { "#1: An issue", "#2: A pull request" }, "#") @@ -63,7 +63,7 @@ T["the_forgejo_token_variable_gives_the_items"] = function() end T["without_a_token_the_public_items_come_from_curl"] = function() - local log = fake_gitea_api.curl() + local log = fake_gitea_api.commands() local forgejo = source() eq(complete(forgejo, "complete_change_requests", "!"), { "!2: A pull request" }, "the pull requests") diff --git a/tests/sources/test_gitea.lua b/tests/sources/test_gitea.lua index 0af1307..2350404 100644 --- a/tests/sources/test_gitea.lua +++ b/tests/sources/test_gitea.lua @@ -1,5 +1,7 @@ local helpers = require("tests.helpers") local fake_gitea_api = require("tests.sources.fake_gitea_api") +local cache = require("cmp_git.cache") +local commands = require("cmp_git.commands") local config = require("cmp_git.config") local Gitea = require("cmp_git.sources.gitea") @@ -56,7 +58,7 @@ T["gitea_hosts"] = function() end T["issues_and_pull_requests_come_from_curl"] = function() - local log = fake_gitea_api.curl() + local log = fake_gitea_api.commands() local gitea = source() local list = complete(gitea, "complete_issues_and_change_requests", "#") @@ -74,7 +76,7 @@ T["issues_and_pull_requests_come_from_curl"] = function() end T["pull_requests_under_the_bang"] = function() - fake_gitea_api.curl() + fake_gitea_api.commands() eq(labels(complete(source(), "complete_change_requests", "!")), { "!2: A pull request" }, "the pull requests") eq(labels(complete(source(), "complete_issues", "#")), { "#1: An issue" }, "the issues alone") @@ -82,7 +84,7 @@ end T["failed_commands_give_an_empty_list_that_is_not_cached"] = function() vim.env.GITEA_TOKEN = "secret" - local log = fake_gitea_api.curl({ fail = true }) + local log = fake_gitea_api.commands({ fail = true }) local gitea = source() for _, method in ipairs({ "complete_issues", "complete_change_requests", "complete_issues_and_change_requests" }) do @@ -104,7 +106,7 @@ T["missing_commands_give_an_empty_list"] = function() end T["mentions_need_a_token"] = function() - local log = fake_gitea_api.curl() + local log = fake_gitea_api.commands() local gitea = source() eq(complete(gitea, "complete_mentions", "@"), EMPTY, "no mentions without a token") @@ -121,14 +123,14 @@ end T["an_invalid_token_falls_back_to_curl_without_a_token"] = function() vim.env.GITEA_TOKEN = "bad" - local log = fake_gitea_api.curl() + local log = fake_gitea_api.commands() eq(labels(complete(source(), "complete_issues", "#")), { "#1: An issue" }, "the public issues") eq(log(), { ISSUES_URL .. " Authorization: token bad", ISSUES_URL .. " " }, "with the token, then without") end T["an_http_remote_uses_plain_http"] = function() - local log = fake_gitea_api.curl() + local log = fake_gitea_api.commands() local remote = { host = "127.0.0.1:3000", owner = "owner", repo = "repo", scheme = "http" } complete(source({ hosts = { "127.0.0.1:3000" } }), "complete_issues", "#", remote) @@ -141,7 +143,7 @@ T["an_http_remote_uses_plain_http"] = function() end T["stale_items_refresh_in_the_background"] = function() - local log = fake_gitea_api.curl() + local log = fake_gitea_api.commands() local gitea = Gitea.new(config.normalize({}).gitea, { ttl = 60 }) local entry = gitea:_entry("issues", REMOTE) entry:set({ { number = 9, title = "An old issue", body = "", updated_at = "2024-01-01T00:00:00Z" } }) @@ -156,4 +158,126 @@ T["stale_items_refresh_in_the_background"] = function() eq(labels(complete(gitea, "complete_issues", "#")), { "#1: An issue" }, "the next request gets the new items") end +local LOGINS = "tea login list -o json" +local WORK = fake_gitea_api.login("work", "https://gitea.com/") +local TEA_ISSUES = "tea api -l work /repos/owner/repo/issues?state=open&type=issues&limit=50&page=1" +local TEA_PULLS = "tea api -l work /repos/owner/repo/issues?state=open&type=pulls&limit=50&page=1" + +T["tea_api_runs_with_the_login_of_the_host"] = function() + local log = fake_gitea_api.commands({ logins = { fake_gitea_api.login("other", "https://example.com"), WORK } }) + local gitea = source() + + eq(labels(complete(gitea, "complete_issues", "#")), { "#1: An issue" }, "the issues") + eq(log(), { LOGINS, TEA_ISSUES }, "tea looks up the logins, then asks with the login of the host") + + eq(labels(complete(gitea, "complete_mentions", "@")), { "@ada" }, "the mentions") + eq(log()[3], "tea api -l work /repos/owner/repo/assignees", "tea gives the mentions without GITEA_TOKEN") +end + +T["without_a_matching_login_tea_api_does_not_run"] = function() + local log = fake_gitea_api.commands({ + logins = { + fake_gitea_api.login("other", "https://example.com"), + fake_gitea_api.login("port", "https://gitea.com:3000"), + fake_gitea_api.login("prefix", "https://gitea.com/sub"), + }, + }) + + eq(labels(complete(source(), "complete_issues", "#")), { "#1: An issue" }, "the issues") + eq(log(), { LOGINS, ISSUES_URL .. " " }, "curl without tea api") +end + +T["of_more_than_one_matching_login_the_default_wins"] = function() + local log = fake_gitea_api.commands({ + logins = { WORK, fake_gitea_api.login("home", "http://gitea.com", true) }, + }) + complete(source(), "complete_issues", "#") + eq(log()[2]:match("^tea api %-l (%S+)"), "home", "the default login") + + cache.clear_all() + Gitea.clear_logins() + log = fake_gitea_api.commands({ logins = { WORK, fake_gitea_api.login("home", "http://gitea.com") } }) + complete(source(), "complete_issues", "#") + eq(log()[2]:match("^tea api %-l (%S+)"), "work", "without a default login, the first one") +end + +T["a_failed_tea_falls_back_to_curl"] = function() + vim.env.GITEA_TOKEN = "secret" + local log = fake_gitea_api.commands({ logins = { WORK }, tea_logins_fail = true }) + eq(labels(complete(source(), "complete_issues", "#")), { "#1: An issue" }, "a failed lookup") + eq(log(), { LOGINS, ISSUES_URL .. " Authorization: token secret" }, "curl after the failed lookup") + + cache.clear_all() + Gitea.clear_logins() + log = fake_gitea_api.commands({ logins = { WORK }, tea_api_fails = true }) + eq(labels(complete(source(), "complete_issues", "#")), { "#1: An issue" }, "an error object of tea api") + eq(log(), { LOGINS, TEA_ISSUES, ISSUES_URL .. " Authorization: token secret" }, "curl after tea api") +end + +T["concurrent_requests_share_one_lookup"] = function() + local log = fake_gitea_api.commands({ logins = { WORK } }) + local gitea = source() + local callback, lists = helpers.recorder() + + gitea:complete_issues_and_change_requests(callback, REMOTE, "#") + gitea:complete_mentions(callback, REMOTE, "@") + eq(#lists, 0, "the requests do not wait for tea") + helpers.wait_for(function() + return #lists == 2 + end, "both requests answer") + + local runs = log() + eq(#vim.tbl_filter(function(line) + return line == LOGINS + end, runs), 1, "one lookup: " .. vim.inspect(runs)) + eq(#runs, 4, "the lookup and three tea api requests: " .. vim.inspect(runs)) +end + +T["a_cancel_during_the_lookup_starts_no_request"] = function() + local log = fake_gitea_api.commands({ logins = { WORK } }) + local gitea = source() + local cancelled_callback, cancelled_lists = helpers.recorder() + local callback, lists = helpers.recorder() + + local _, controller = gitea:complete_issues(cancelled_callback, REMOTE, "#") + helpers.not_nil(controller):cancel() + gitea:complete_change_requests(callback, REMOTE, "!") + helpers.wait_for(function() + return #lists == 1 + end, "the other request answers after the lookup") + helpers.flush() + + eq(log(), { LOGINS, TEA_PULLS }, "the cancelled request starts no command") + eq(cancelled_lists, {}, "a cancelled request gets no list") +end + +T["a_cache_hit_runs_no_tea"] = function() + local log = fake_gitea_api.commands({ logins = { WORK } }) + local gitea = source() + for _, kind in ipairs({ "issues", "pull_requests", "mentions" }) do + gitea:_entry(kind, REMOTE):set({}) + end + local callback, lists = helpers.recorder() + + for _, method in ipairs({ "complete_issues_and_change_requests", "complete_change_requests", "complete_mentions" }) do + gitea[method](gitea, callback, REMOTE, "#") + end + + eq(#lists, 3, "each cache hit answers at once") + eq(log(), {}, "no command runs") +end + +T["the_logins_stay_for_the_session_until_the_cache_is_cleared"] = function() + local log = fake_gitea_api.commands({ logins = { WORK } }) + + complete(source(), "complete_issues", "#") + cache.clear_all() + complete(source(), "complete_issues", "#") + eq(log(), { LOGINS, TEA_ISSUES, TEA_ISSUES }, "a new source uses the logins of the session") + + commands.clear_cache(true) + complete(source(), "complete_issues", "#") + eq(log(), { LOGINS, TEA_ISSUES, TEA_ISSUES, LOGINS, TEA_ISSUES }, ":CmpGitClearCache! looks up the logins again") +end + return T From 1a32024cbd8508713a20edd758e526454c4e3819 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Fri, 23 Oct 2026 16:47:06 +1100 Subject: [PATCH 10/13] feat: use the fj token of the host for Forgejo The Forgejo provider now reads the token that fj keeps for the host from fj's `keys.json`, and sends it with curl first. Then curl sends `FORGEJO_TOKEN`, and then asks without a token. - The file is `keys.json` in the data directory of fj: `$XDG_DATA_HOME/forgejo-cli` or `~/.local/share/forgejo-cli` on Linux, `~/Library/Application Support/forgejo-cli.forgejo-cli` on macOS, and `%APPDATA%\forgejo-cli\forgejo-cli\data` on Windows. - The host key has the port and the path prefix, as fj writes it. - The token of an `Application` or an `OAuth` entry is used. fj refreshes an OAuth token only when fj runs, so an expired token fails, and the next command runs. - The file is read for each request, so a new `fj auth` login applies to the next request that is not a cache hit. A missing file, invalid JSON, or another shape gives no token. The test cases clear `XDG_DATA_HOME` and `APPDATA`, so that the keys of the developer do not apply. --- lua/cmp_git/provider/adapters/forgejo.lua | 71 +++++++++-- tests/helpers.lua | 3 + tests/provider/adapters/test_forgejo.lua | 146 ++++++++++++++++++++++ tests/sources/test_forgejo.lua | 29 +++++ 4 files changed, 242 insertions(+), 7 deletions(-) create mode 100644 tests/provider/adapters/test_forgejo.lua diff --git a/lua/cmp_git/provider/adapters/forgejo.lua b/lua/cmp_git/provider/adapters/forgejo.lua index f387dfd..aa07b67 100644 --- a/lua/cmp_git/provider/adapters/forgejo.lua +++ b/lua/cmp_git/provider/adapters/forgejo.lua @@ -1,9 +1,66 @@ local gitea_api = require("cmp_git.provider.adapters.gitea_api") +local log = require("cmp_git.log") ----The adapter of a request: curl with `FORGEJO_TOKEN`, then curl without a token. -return gitea_api.adapter({ - token_env = "FORGEJO_TOKEN", - commands = function() - return {} - end, -}) +local M = {} + +---The key file of fj: `keys.json` in `data_dir()` of `ProjectDirs::from("", "forgejo-cli", "forgejo-cli")` of the +---`directories` crate. +---@return string? path nil when the OS gives no directory. +function M.keys_path() + local sysname = vim.uv.os_uname().sysname + local home = vim.uv.os_homedir() + if sysname == "Darwin" then + return home and home .. "/Library/Application Support/forgejo-cli.forgejo-cli/keys.json" + end + if sysname == "Windows_NT" then + local appdata = vim.env.APPDATA + return appdata and appdata .. "/forgejo-cli/forgejo-cli/data/keys.json" + end + + -- As in the XDG spec, a relative path is not valid + local data = vim.env.XDG_DATA_HOME + if not data or data:sub(1, 1) ~= "/" then + data = home and home .. "/.local/share" + end + return data and data .. "/forgejo-cli/keys.json" +end + +---The token that fj keeps for a host, from an `Application` or an `OAuth` entry. fj refreshes an OAuth token only when +---fj runs, so the token can be expired. Then the request fails, and the next command runs. +---@param host string The host with the port and the path prefix, as in the keys of fj. +---@return string? token nil when the file is missing or has no token for the host. +function M.fj_token(host) + local path = M.keys_path() + local file = path and io.open(path, "rb") + if not file then + return nil + end + local content = file:read("*a") + file:close() + + local ok, keys = pcall(vim.json.decode, content or "") + local entry = ok and type(keys) == "table" and type(keys.hosts) == "table" and keys.hosts[host] + local token = type(entry) == "table" and entry.token + if type(token) ~= "string" or token == "" then + log.fmt_debug("No fj token for %s in %s", host, path) + return nil + end + return token +end + +---The adapter of a request: curl with the fj token of the host, then curl with `FORGEJO_TOKEN`, then curl without a +---token. The key file is read for each request, so a new `fj auth` login applies to the next request. +return vim.tbl_extend( + "error", + M, + gitea_api.adapter({ + token_env = "FORGEJO_TOKEN", + commands = function(git_info, path) + local token = M.fj_token(git_info.host) + if not token then + return {} + end + return { gitea_api.curl_command(gitea_api.api_url(git_info, path), token) } + end, + }) +) diff --git a/tests/helpers.lua b/tests/helpers.lua index 1c88c12..29ec3a6 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -108,6 +108,9 @@ local ENV_NAMES = { "HOME", "XDG_CACHE_HOME", "XDG_CONFIG_HOME", + -- fj keeps its tokens in `keys.json` of the data directory + "XDG_DATA_HOME", + "APPDATA", } local temp_root = vim.fn.tempname() diff --git a/tests/provider/adapters/test_forgejo.lua b/tests/provider/adapters/test_forgejo.lua new file mode 100644 index 0000000..e08f864 --- /dev/null +++ b/tests/provider/adapters/test_forgejo.lua @@ -0,0 +1,146 @@ +local helpers = require("tests.helpers") +local config = require("cmp_git.config") +local forgejo = require("cmp_git.provider.adapters.forgejo") + +local eq = helpers.eq + +local T = helpers.new_set() + +local remote = { host = "codeberg.org", owner = "owner", repo = "repo" } +local defaults = config.normalize({}).forgejo + +---Write the key file of fj to the default Linux path. +---@param content string +local function write_keys(content) + vim.env.XDG_DATA_HOME = helpers.tempdir() + vim.fn.mkdir(vim.env.XDG_DATA_HOME .. "/forgejo-cli", "p") + vim.fn.writefile(vim.split(content, "\n"), vim.env.XDG_DATA_HOME .. "/forgejo-cli/keys.json") +end + +---@param hosts table +local function write_hosts(hosts) + write_keys(vim.json.encode({ hosts = hosts, aliases = vim.empty_dict(), default_ssh = {} })) +end + +---@param sysname string +local function on(sysname) + helpers.stub(vim.uv, "os_uname", function() + return { sysname = sysname, release = "", version = "", machine = "x86_64" } + end) +end + +T["keys_path_on_linux"] = function() + on("Linux") + local home = vim.uv.os_homedir() + + eq(forgejo.keys_path(), home .. "/.local/share/forgejo-cli/keys.json", "without XDG_DATA_HOME") + vim.env.XDG_DATA_HOME = "/data" + eq(forgejo.keys_path(), "/data/forgejo-cli/keys.json", "an absolute XDG_DATA_HOME") + vim.env.XDG_DATA_HOME = "data" + eq(forgejo.keys_path(), home .. "/.local/share/forgejo-cli/keys.json", "a relative XDG_DATA_HOME is not valid") +end + +T["keys_path_on_macos_and_windows"] = function() + vim.env.XDG_DATA_HOME = "/data" + + on("Darwin") + eq( + forgejo.keys_path(), + vim.uv.os_homedir() .. "/Library/Application Support/forgejo-cli.forgejo-cli/keys.json", + "macOS ignores XDG_DATA_HOME" + ) + + on("Windows_NT") + vim.env.APPDATA = "C:\\Users\\ada\\AppData\\Roaming" + eq( + forgejo.keys_path(), + "C:\\Users\\ada\\AppData\\Roaming/forgejo-cli/forgejo-cli/data/keys.json", + "Windows uses APPDATA" + ) + vim.env.APPDATA = nil + eq(forgejo.keys_path(), nil, "Windows without APPDATA") +end + +T["a_missing_or_invalid_key_file_gives_no_token"] = function() + eq(forgejo.fj_token("codeberg.org"), nil, "no file") + + local files = { + "{", + "null", + "[]", + '{"hosts":[]}', + '{"hosts":{"codeberg.org":"secret"}}', + '{"hosts":{"codeberg.org":null}}', + '{"hosts":{"codeberg.org":{"type":"Application"}}}', + '{"hosts":{"codeberg.org":{"type":"Application","token":5}}}', + '{"hosts":{"codeberg.org":{"type":"Application","token":""}}}', + '{"hosts":{"gitea.com":{"type":"Application","token":"secret"}}}', + } + for _, content in ipairs(files) do + write_keys(content) + eq(forgejo.fj_token("codeberg.org"), nil, content) + end +end + +T["application_and_oauth_entries_give_their_tokens"] = function() + write_hosts({ + ["codeberg.org"] = { type = "Application", token = "app" }, + ["forgejo.example.com"] = { + type = "OAuth", + token = "oauth", + refresh_token = "refresh", + expires_at = "2020-01-01T00:00:00Z", + }, + }) + + eq(forgejo.fj_token("codeberg.org"), "app", "an Application entry") + eq(forgejo.fj_token("forgejo.example.com"), "oauth", "an OAuth entry, also when it expired") +end + +T["the_host_key_has_the_port_and_the_path_prefix"] = function() + write_hosts({ + ["127.0.0.1:3000"] = { type = "Application", token = "port" }, + ["example.com:8443/forgejo"] = { type = "Application", token = "prefix" }, + }) + + eq(forgejo.fj_token("127.0.0.1:3000"), "port", "a host with a port") + eq(forgejo.fj_token("example.com:8443/forgejo"), "prefix", "a host with a port and a path prefix") + eq(forgejo.fj_token("127.0.0.1"), nil, "the host without the port") + eq(forgejo.fj_token("example.com:8443"), nil, "the host without the path prefix") +end + +T["the_fj_token_comes_before_the_token_variable"] = function() + write_hosts({ ["codeberg.org"] = { type = "Application", token = "fj" } }) + vim.env.FORGEJO_TOKEN = "secret" + + local issues = forgejo.issues_request(remote, "#", defaults.issues) + eq( + vim.tbl_map(function(spec) + return spec.stdin or "" + end, issues.commands), + { + "Authorization: token fj\n", + "Authorization: token secret\n", + "", + }, + "the fj token, FORGEJO_TOKEN, then no token" + ) + eq( + issues.commands[1].args[#issues.commands[1].args], + "https://codeberg.org/api/v1/repos/owner/repo/issues?state=open&type=issues&limit=50&page=1", + "curl with the fj token asks the API of the host" + ) + + local mentions = forgejo.mentions_request(remote, "@", defaults.mentions) + eq(#mentions.commands, 2, "the mentions: the fj token and FORGEJO_TOKEN") +end + +T["the_key_file_is_read_for_each_request"] = function() + eq(#forgejo.mentions_request(remote, "@", defaults.mentions).commands, 0, "no token, no command") + + write_hosts({ ["codeberg.org"] = { type = "Application", token = "fj" } }) + local mentions = forgejo.mentions_request(remote, "@", defaults.mentions) + eq(mentions.commands[1].stdin, "Authorization: token fj\n", "a new login applies to the next request") +end + +return T diff --git a/tests/sources/test_forgejo.lua b/tests/sources/test_forgejo.lua index e2d7d8a..e41b07b 100644 --- a/tests/sources/test_forgejo.lua +++ b/tests/sources/test_forgejo.lua @@ -35,6 +35,15 @@ local function complete(forgejo, method, trigger_char) end, lists[1].items) end +---Give fj a login for codeberg.org with `token`. +---@param token string +local function fj_login(token) + vim.env.XDG_DATA_HOME = helpers.tempdir() + vim.fn.mkdir(vim.env.XDG_DATA_HOME .. "/forgejo-cli", "p") + local keys = { hosts = { ["codeberg.org"] = { type = "Application", token = token } } } + vim.fn.writefile({ vim.json.encode(keys) }, vim.env.XDG_DATA_HOME .. "/forgejo-cli/keys.json") +end + T["forgejo_hosts"] = function() local forgejo = source({ hosts = { "forgejo.example.com:3000" } }) @@ -71,4 +80,24 @@ T["without_a_token_the_public_items_come_from_curl"] = function() eq(log(), { PULLS_URL .. " " }, "one request without a token, and none for the mentions") end +T["the_fj_token_gives_the_items"] = function() + fj_login("fj") + local log = fake_gitea_api.commands() + + eq(complete(source(), "complete_mentions", "@"), { "@ada" }, "the assignees") + eq(log(), { API .. "assignees Authorization: token fj" }, "curl sends the fj token") +end + +T["an_expired_fj_token_falls_back_to_the_token_variable"] = function() + fj_login("bad") + vim.env.FORGEJO_TOKEN = "secret" + local log = fake_gitea_api.commands() + + eq(complete(source(), "complete_issues", "#"), { "#1: An issue" }, "the issues") + eq(log(), { + ISSUES_URL .. " Authorization: token bad", + ISSUES_URL .. " Authorization: token secret", + }, "the fj token fails, then FORGEJO_TOKEN gives the items") +end + return T From 65575d81e5128a2afad82b56e5dfe2f878c987e9 Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Fri, 23 Oct 2026 16:54:29 +1100 Subject: [PATCH 11/13] test: run the Gitea and Forgejo sources against real servers `integration.forge("gitea")` and `integration.forge("forgejo")` start a real server from the integration shell once for each test file, in the first case that needs it, and `post_once` stops it. The server runs on a free port of 127.0.0.1 in its own temporary directory, with SQLite, no SSH, no mail, and no network. It gets the admin `root` with an access token, and the seed data: `root/public` and the private `root/private`, each with an issue, a pull request from a branch, and the collaborator `ada`. - The start time goes to the notes of the case, for CI. A server starts and seeds in about 3 s. - The log of the server goes to the notes of a case that fails. - A server with a path prefix in ROOT_URL runs behind the fake server as a proxy, which removes the prefix. - The Forgejo cases always skip on aarch64-darwin, where nixpkgs marks forgejo as broken. The Gitea cases run the real tea with a login, curl with `GITEA_TOKEN`, an invalid token on a public repository, `@` without credentials, a server with a path prefix, and an `http://` remote through discovery. The Forgejo cases run curl with the token that `fj auth add-token` writes, with `FORGEJO_TOKEN`, and without a token. --- tests/integration/helpers.lua | 325 +++++++++++++++++++++++++++++ tests/integration/test_forgejo.lua | 87 ++++++++ tests/integration/test_gitea.lua | 155 ++++++++++++++ 3 files changed, 567 insertions(+) create mode 100644 tests/integration/test_forgejo.lua create mode 100644 tests/integration/test_gitea.lua diff --git a/tests/integration/helpers.lua b/tests/integration/helpers.lua index 8d86865..e1672a4 100644 --- a/tests/integration/helpers.lua +++ b/tests/integration/helpers.lua @@ -1,5 +1,6 @@ -- Helpers for the tests that run the real CLIs. The cases must not use the network: gh and glab talk to a fake HTTP -- server in this process, and tea, the fj token, and curl talk to Gitea and Forgejo servers on 127.0.0.1. +local MiniTest = require("mini.test") local helpers = require("tests.helpers") local uv = vim.uv @@ -274,6 +275,330 @@ function M.http_server(opts) return self end +---@return integer port A port of 127.0.0.1 that no server uses now. +local function free_port() + local tcp = assert(uv.new_tcp()) + assert(tcp:bind("127.0.0.1", 0)) + local port = tcp:getsockname().port + tcp:close() + return port +end + +local PASSWORD = "Password1!" + +---The settings of a server that starts fast, in its own directory, and does not use the network. +---@param dir string +---@param port integer +---@param root_url string +---@return string[] +local function app_ini(dir, port, root_url) + return { + "APP_NAME = cmp-git test", + "RUN_MODE = prod", + "WORK_PATH = " .. dir, + "[database]", + "DB_TYPE = sqlite3", + "PATH = " .. dir .. "/data/forge.db", + "[repository]", + "ROOT = " .. dir .. "/repos", + "[server]", + "HTTP_ADDR = 127.0.0.1", + "HTTP_PORT = " .. port, + "ROOT_URL = " .. root_url, + "DISABLE_SSH = true", + "OFFLINE_MODE = true", + "APP_DATA_PATH = " .. dir .. "/data", + "LFS_START_SERVER = false", + "[security]", + "INSTALL_LOCK = true", + "SECRET_KEY = cmp-git-test-secret", + "INTERNAL_TOKEN = cmp-git-test-internal-token", + "[oauth2]", + "ENABLED = false", + "[service]", + "DISABLE_REGISTRATION = true", + "[actions]", + "ENABLED = false", + "[packages]", + "ENABLED = false", + "[log]", + "MODE = console", + "LEVEL = Info", + "[cron]", + "ENABLED = false", + "[indexer]", + "ISSUE_INDEXER_TYPE = db", + "REPO_INDEXER_ENABLED = false", + "[mailer]", + "ENABLED = false", + } +end + +---A real Gitea or Forgejo server for the cases of one file, on 127.0.0.1 in its own temporary directory. The first +---case that calls `start` starts it, and `stop` in `post_once` ends it. +---@class tests.integration.Forge +---@field bin "gitea" | "forgejo" +---@field prefix? string +---@field url? string The URL for cmp-git and the CLIs, with the path prefix, such as `http://127.0.0.1:3000`. +---@field host? string The host for cmp-git, with the port and the path prefix, such as `127.0.0.1:3000`. +---@field token? string An access token of the admin user `root`. +---@field port? integer The port of the server. +---@field dir? string +---@field proxy? tests.integration.HttpServer The proxy that removes the path prefix. +---@field process? vim.SystemObj +---@field exit_code? integer +---@field output string[] The output of the server so far. +---@field failure? string The error of a start that failed. The next cases fail with it at once. +---@field exe? string +---@field curl? string +---@field env? table +local Forge = {} +Forge.__index = Forge + +---@param bin "gitea" | "forgejo" +---@param opts? { prefix?: string } A path prefix in ROOT_URL. A proxy in front of the server removes it, because +---the server does not serve its routes under the prefix. +---@return tests.integration.Forge +function M.forge(bin, opts) + opts = opts or {} + return setmetatable({ bin = bin, prefix = opts.prefix, output = {} }, Forge) +end + +---Run a command of the server binary, and fail with its output when it fails. +---@param args string[] +---@return string stdout +function Forge:run(args) + local dir = assert(self.dir, "the server has no directory") + local command = vim.list_extend({ self.exe }, args) + vim.list_extend(command, { "--config", dir .. "/app.ini", "--work-path", dir }) + local result = vim.system(command, { env = self.env, clear_env = true, text = true }):wait(120000) + if result.code ~= 0 then + error(string.format("`%s %s` failed:\n%s%s", self.bin, args[1], result.stdout, result.stderr), 0) + end + return result.stdout +end + +---Send a request to the API of the server as `root`. +---@param method string +---@param path string The path after `/api/v1`. +---@param body? table Sent as JSON. +---@return any response The decoded JSON, or nil for an empty response. +function Forge:api(method, path, body) + local command = { self.curl, "-sS", "--fail-with-body", "-X", method } + vim.list_extend(command, { "-H", "Authorization: token " .. self.token, "-H", "Content-Type: application/json" }) + if body then + vim.list_extend(command, { "--data-binary", vim.json.encode(body) }) + end + table.insert(command, string.format("http://127.0.0.1:%d/api/v1%s", self.port, path)) + + local result = vim.system(command, { text = true }):wait(30000) + if result.code ~= 0 then + error(string.format("%s %s failed: %s%s", method, path, result.stdout, result.stderr), 2) + end + return result.stdout ~= "" and vim.json.decode(result.stdout) or nil +end + +---Wait until the API answers, or fail with the output of the server. +function Forge:wait_until_ready() + local version = string.format("http://127.0.0.1:%d/api/v1/version", self.port) + local deadline = uv.hrtime() + 120e9 + while vim.system({ self.curl, "-sf", version }):wait(5000).code ~= 0 do + if self.exit_code ~= nil then + error(string.format("%s exited with %d:\n%s", self.bin, self.exit_code, self:log()), 0) + end + if uv.hrtime() > deadline then + error(string.format("%s did not answer within 120 s:\n%s", self.bin, self:log()), 0) + end + vim.wait(100) + end +end + +---The repositories of `root`: `root/public` and the private `root/private`. Each has the issue #1, the pull request +---#2 from the branch `feature`, and the collaborator `ada`, so that the assignees are `ada` and `root`. +function Forge:seed() + self:api("POST", "/admin/users", { + username = "ada", + full_name = "Ada Lovelace", + email = "ada@example.com", + password = PASSWORD, + must_change_password = false, + }) + for _, name in ipairs({ "public", "private" }) do + local repo = "/repos/root/" .. name + self:api("POST", "/user/repos", { name = name, private = name == "private", auto_init = true }) + self:api("POST", repo .. "/issues", { title = "A " .. name .. " issue", body = "The body of the issue" }) + self:api("POST", repo .. "/contents/feature.txt", { + content = vim.base64.encode("feature\n"), + message = "Add a feature", + new_branch = "feature", + }) + self:api("POST", repo .. "/pulls", { + head = "feature", + base = "main", + title = "A " .. name .. " pull request", + body = "Fixes #1", + }) + self:api("PUT", repo .. "/collaborators/ada", { permission = "write" }) + end +end + +function Forge:launch() + local started = uv.hrtime() + self.exe = vim.fn.exepath(self.bin) + self.curl = vim.fn.exepath("curl") + -- Outside the temporary directory of the case, which the case hooks delete + self.dir = vim.fn.tempname() + vim.fn.mkdir(self.dir, "p") + self.port = free_port() + + local root_url = string.format("http://127.0.0.1:%d/", self.port) + if self.prefix then + self.proxy = M.http_server({ upstream = { port = self.port, prefix = "/" .. self.prefix } }) + root_url = string.format("%s/%s/", self.proxy.url, self.prefix) + end + self.url = string.gsub(root_url, "/$", "") + self.host = string.gsub(self.url, "^http://", "") + assert( + vim.fn.writefile(app_ini(self.dir, self.port, root_url), self.dir .. "/app.ini") == 0, + "cannot write app.ini" + ) + + -- Only these variables, so that the GITEA__* and FORGEJO__* variables of the developer do not change the settings. + -- The server runs git from PATH. + self.env = { HOME = self.dir, PATH = vim.env.PATH, USER = vim.env.USER or "cmp-git" } + self:run({ "migrate" }) + local created = self:run({ + "admin", + "user", + "create", + "--admin", + "--username", + "root", + "--password", + PASSWORD, + "--email", + "root@example.com", + "--access-token", + "--must-change-password=false", + }) + self.token = string.match(created, "Access token was successfully created%.%.%. (%x+)") + assert(self.token, "no access token in the output: " .. created) + + local function collect(_, data) + if data then + table.insert(self.output, data) + end + end + local web = vim.list_extend({ self.exe, "web" }, { "--config", self.dir .. "/app.ini", "--work-path", self.dir }) + self.process = vim.system(web, { + env = self.env, + clear_env = true, + text = true, + stdout = collect, + stderr = collect, + }, function(result) + self.exit_code = result.code + end) + self:wait_until_ready() + self:seed() + + MiniTest.add_note(string.format("%s started and seeded in %.1f s", self.bin, (uv.hrtime() - started) / 1e9)) +end + +---Start the server once for the file, and give it to the case. Call it before the case changes PATH. Forgejo always +---skips on aarch64-darwin, where nixpkgs marks it as broken. When the case fails, the server log goes to its notes. +---@return tests.integration.Forge +function Forge:start() + local uname = uv.os_uname() + if self.bin == "forgejo" and uname.sysname == "Darwin" and uname.machine == "arm64" then + helpers.skip("nixpkgs has no forgejo for aarch64-darwin") + end + M.require_binaries({ self.bin, "curl", "git" }) + if self.failure then + error(self.failure, 0) + end + if not self.process then + local ok, err = pcall(self.launch, self) + if not ok then + self.failure = tostring(err) + error(self.failure, 0) + end + end + + helpers.defer(function() + local case = MiniTest.current.case + if case and #case.exec.fails > 0 then + MiniTest.add_note(string.format("The log of %s:\n%s", self.bin, self:log())) + end + end) + return self +end + +---@return string lines The last lines of the server output. +function Forge:log() + local lines = vim.split(table.concat(self.output), "\n", { trimempty = true }) + return table.concat(vim.list_slice(lines, math.max(1, #lines - 40)), "\n") +end + +function Forge:stop() + if self.process and self.exit_code == nil then + self.process:kill(15) + if not vim.wait(10000, function() + return self.exit_code ~= nil + end, 50) then + self.process:kill(9) + vim.wait(5000, function() + return self.exit_code ~= nil + end, 50) + end + end + if self.proxy then + self.proxy:close() + end + if self.dir then + vim.fn.delete(self.dir, "rf") + end +end + +---Run a CLI with the arguments, and fail with its output when it fails. +---@param command string[] +local function run_cli(command) + local result = vim.system(command, { text = true, stdin = "" }):wait(30000) + if result.code ~= 0 then + error(string.format("`%s` failed:\n%s%s", table.concat(command, " ", 1, 3), result.stdout, result.stderr), 2) + end +end + +---Give tea one login for the server, in a new config directory, until the end of the case. +---@param server tests.integration.Forge +---@param name string +function M.use_tea(server, name) + helpers.stub(vim.env, "XDG_CONFIG_HOME", helpers.tempdir()) + run_cli({ + "tea", + "login", + "add", + "--name", + name, + "--url", + assert(server.url, "start the server first"), + "--token", + server.token, + }) +end + +---Give tea a new config directory without logins until the end of the case. +function M.without_tea_logins() + helpers.stub(vim.env, "XDG_CONFIG_HOME", helpers.tempdir()) +end + +---Give fj the token of the server with `fj auth add-token`, in a new data directory, until the end of the case. +---@param server tests.integration.Forge +function M.use_fj(server) + helpers.stub(vim.env, "XDG_DATA_HOME", helpers.tempdir()) + run_cli({ "fj", "--host", assert(server.url, "start the server first"), "auth", "add-token", server.token }) +end + ---@param path string ---@param lines string[] local function write_file(path, lines) diff --git a/tests/integration/test_forgejo.lua b/tests/integration/test_forgejo.lua new file mode 100644 index 0000000..87648ed --- /dev/null +++ b/tests/integration/test_forgejo.lua @@ -0,0 +1,87 @@ +-- The fj token and curl against a real Forgejo server, which starts once for the file. The server has the +-- repositories `root/public` and `root/private`, each with the issue #1, the pull request #2, and the assignees `ada` +-- and `root`. +local helpers = require("tests.helpers") +local integration = require("tests.integration.helpers") +local config = require("cmp_git.config") +local Forgejo = require("cmp_git.sources.forgejo") + +local eq = helpers.eq + +local forgejo = integration.forge("forgejo") + +local T = helpers.new_set({ + post_once = function() + forgejo:stop() + end, +}) + +---@param repo string +---@return cmp_git.GitInfo +local function remote(repo) + return { host = assert(forgejo.host, "start the server first"), owner = "root", repo = repo, scheme = "http" } +end + +---@return cmp_git.Source.GiteaApi +local function source() + return Forgejo.new(config.normalize({ forgejo = { hosts = { forgejo.host } } }).forgejo) +end + +---Run a request of the source and return the labels of its list. +---@param forgejo_source cmp_git.Source.GiteaApi +---@param method string +---@param git_info cmp_git.GitInfo +---@param trigger_char string +---@return string[] +local function complete(forgejo_source, method, git_info, trigger_char) + local bufnr = vim.api.nvim_get_current_buf() + local callback, lists = helpers.recorder() + eq(forgejo_source[method](forgejo_source, callback, git_info, trigger_char, bufnr), true, method .. " handles it") + helpers.wait_for(function() + return #lists > 0 + end, method .. " answers") + return vim.tbl_map(function(item) + return item.label + end, lists[#lists].items) +end + +T["the_fj_token_gives_the_private_items"] = function() + forgejo:start() + integration.use_fj(forgejo) + integration.edit_outside_repository() + local private = remote("private") + + eq( + complete(source(), "complete_issues_and_change_requests", private, "#"), + { "#1: A private issue", "#2: A private pull request" }, + "#" + ) + eq(complete(source(), "complete_change_requests", private, "!"), { "!2: A private pull request" }, "!") + eq(complete(source(), "complete_mentions", private, "@"), { "@ada", "@root" }, "@") +end + +T["the_forgejo_token_gives_the_private_items"] = function() + forgejo:start() + integration.edit_outside_repository() + vim.env.FORGEJO_TOKEN = forgejo.token + local private = remote("private") + + eq(complete(source(), "complete_issues", private, "#"), { "#1: A private issue" }, "#") + eq(complete(source(), "complete_mentions", private, "@"), { "@ada", "@root" }, "@") +end + +T["without_credentials_curl_gives_the_public_items"] = function() + forgejo:start() + integration.edit_outside_repository() + local public = remote("public") + + eq( + complete(source(), "complete_issues_and_change_requests", public, "#"), + { "#1: A public issue", "#2: A public pull request" }, + "#" + ) + eq(complete(source(), "complete_issues", remote("private"), "#"), {}, "no private items") + eq(complete(source(), "complete_mentions", public, "@"), {}, "the assignees API needs a token") +end + +return T diff --git a/tests/integration/test_gitea.lua b/tests/integration/test_gitea.lua new file mode 100644 index 0000000..5fbe4b6 --- /dev/null +++ b/tests/integration/test_gitea.lua @@ -0,0 +1,155 @@ +-- The real tea and curl against a real Gitea server, which starts once for the file. The server has the +-- repositories `root/public` and `root/private`, each with the issue #1, the pull request #2, and the assignees `ada` +-- and `root`. +local helpers = require("tests.helpers") +local integration = require("tests.integration.helpers") +local config = require("cmp_git.config") +local Core = require("cmp_git.core") +local Gitea = require("cmp_git.sources.gitea") + +local eq = helpers.eq + +local gitea = integration.forge("gitea") +-- A second server with `/gitea/` in ROOT_URL, behind a proxy that removes the prefix +local prefixed = integration.forge("gitea", { prefix = "gitea" }) + +local T = helpers.new_set({ + post_once = function() + gitea:stop() + prefixed:stop() + end, +}) + +---@param server tests.integration.Forge +---@param repo string +---@return cmp_git.GitInfo +local function remote(server, repo) + return { host = assert(server.host, "start the server first"), owner = "root", repo = repo, scheme = "http" } +end + +---@param server tests.integration.Forge +---@return cmp_git.Source.GiteaApi +local function source(server) + return Gitea.new(config.normalize({ gitea = { hosts = { server.host } } }).gitea) +end + +---Run a request of the source and return the labels of its list. +---@param gitea_source cmp_git.Source.GiteaApi +---@param method string +---@param git_info cmp_git.GitInfo +---@param trigger_char string +---@return string[] +local function complete(gitea_source, method, git_info, trigger_char) + local bufnr = vim.api.nvim_get_current_buf() + local callback, lists = helpers.recorder() + eq(gitea_source[method](gitea_source, callback, git_info, trigger_char, bufnr), true, method .. " handles the host") + helpers.wait_for(function() + return #lists > 0 + end, method .. " answers") + return vim.tbl_map(function(item) + return item.label + end, lists[#lists].items) +end + +local PRIVATE = { "#1: A private issue", "#2: A private pull request" } + +T["tea_api_with_a_login_gives_the_private_items"] = function() + gitea:start() + integration.use_tea(gitea, "local") + integration.edit_outside_repository() + -- A failed tea would fall back to curl, which then fails too + integration.failing_commands({ curl = 7 }) + local private = remote(gitea, "private") + + eq(complete(source(gitea), "complete_issues_and_change_requests", private, "#"), PRIVATE, "#") + eq(complete(source(gitea), "complete_change_requests", private, "!"), { "!2: A private pull request" }, "!") + eq(complete(source(gitea), "complete_mentions", private, "@"), { "@ada", "@root" }, "@") +end + +T["without_a_tea_login_curl_sends_the_gitea_token"] = function() + gitea:start() + integration.without_tea_logins() + integration.edit_outside_repository() + vim.env.GITEA_TOKEN = gitea.token + local private = remote(gitea, "private") + + eq(complete(source(gitea), "complete_issues", private, "#"), { "#1: A private issue" }, "#") + eq(complete(source(gitea), "complete_mentions", private, "@"), { "@ada", "@root" }, "@") +end + +T["an_invalid_gitea_token_falls_back_to_curl_without_a_token"] = function() + gitea:start() + integration.without_tea_logins() + integration.edit_outside_repository() + vim.env.GITEA_TOKEN = "0000000000000000000000000000000000000000" + + eq( + complete(source(gitea), "complete_issues_and_change_requests", remote(gitea, "public"), "#"), + { "#1: A public issue", "#2: A public pull request" }, + "the public items without a token" + ) + eq(complete(source(gitea), "complete_issues", remote(gitea, "private"), "#"), {}, "no private items") +end + +T["without_credentials_the_mentions_are_empty"] = function() + gitea:start() + integration.without_tea_logins() + integration.edit_outside_repository() + local public = remote(gitea, "public") + + eq(complete(source(gitea), "complete_change_requests", public, "!"), { "!2: A public pull request" }, "!") + eq(complete(source(gitea), "complete_mentions", public, "@"), {}, "the assignees API needs a token") +end + +T["a_path_prefix_reaches_the_server"] = function() + prefixed:start() + local proxy = assert(prefixed.proxy, "a server with a prefix has a proxy") + eq(prefixed.host, "127.0.0.1:" .. proxy.port .. "/gitea", "the host has the path prefix") + integration.edit_outside_repository() + local private = remote(prefixed, "private") + + integration.use_tea(prefixed, "prefixed") + eq(complete(source(prefixed), "complete_issues_and_change_requests", private, "#"), PRIVATE, "tea") + + integration.without_tea_logins() + Gitea.clear_logins() + vim.env.GITEA_TOKEN = prefixed.token + eq(complete(source(prefixed), "complete_mentions", private, "@"), { "@ada", "@root" }, "curl") + + local paths = vim.tbl_map(function(request) + return request.path + end, proxy.requests) + eq(vim.tbl_contains(paths, "/gitea/api/v1/repos/root/private/assignees"), true, "curl sends the prefix") + eq(vim.tbl_contains(paths, "/gitea/api/v1/repos/root/private/issues"), true, "tea sends the prefix") +end + +T["an_http_remote_reaches_the_server_through_discovery"] = function() + gitea:start() + integration.without_tea_logins() + local repo = helpers.git_repo({ remotes = { { "origin", gitea.url .. "/root/public.git" } } }) + vim.cmd.edit(vim.fn.fnameescape(repo.dir .. "/COMMIT_EDITMSG")) + local line = "see #" + vim.api.nvim_buf_set_lines(0, 0, -1, false, { line }) + local core = Core.new({ gitea = { hosts = { gitea.host } } }) + + local callback, lists = helpers.recorder() + core:complete({ + trigger_character = "#", + cursor = { row = 1, character = #line }, + line = line, + bufnr = vim.api.nvim_get_current_buf(), + }, callback) + helpers.wait_for(function() + return #lists > 0 + end, "the completion answers") + + eq( + vim.tbl_map(function(item) + return item.label + end, lists[#lists].items), + { "#1: A public issue", "#2: A public pull request" }, + "discovery keeps the http scheme, and curl asks the server with plain HTTP" + ) +end + +return T From 1b1d397ec887ba452fc522a2d6c9f4eb5524504a Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Fri, 23 Oct 2026 16:58:46 +1100 Subject: [PATCH 12/13] docs: describe the Gitea and Forgejo providers and the integration tests The README now lists the Gitea and Forgejo features, tea and fj as optional requirements, the setup for private repositories with the token scopes, and the config defaults with the new trigger actions. `docs/configuration.md` describes: - The new actions, the new sources of callback actions, and the `scheme` field of the git info. - The item fields and the sort of the new capabilities. - The command chains of Gitea and Forgejo, the tea login match, the key file of fj on each OS, the refresh of an expired fj OAuth token, the 50-item cap, and the mentions that need a token. - The Gitea and Forgejo hosts, their path prefixes, and the plain HTTP of an `http://` remote, with a warning. - The cache of the new items, and the tea logins that `:CmpGitClearCache` drops. The sections "GitHub CLI, GitLab CLI, and curl" and "Hosted GitHub and GitLab instances" are now "CLIs, tokens, and curl" and "Hosted instances". CONTRIBUTING describes the `integration` shell, `make test-integration`, the integration helpers, the `Integration` CI job, and the new isolation of each case. --- CONTRIBUTING.md | 54 +++++++++++++-- README.md | 81 ++++++++++++++++++++-- benchmarks/cases/e2e.lua | 4 +- docs/configuration.md | 142 +++++++++++++++++++++++++++++++-------- 4 files changed, 237 insertions(+), 44 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e1d7b54..cb0c60d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,13 +21,17 @@ To load the default shell when you enter the repository: 2. Install direnv and hook it into your shell. 3. In the repository, run `direnv allow`. -The flake has three shells. Each shell also has StyLua, Selene, lua-language-server, make, and git. +The flake has four shells. Each shell also has StyLua, Selene, lua-language-server, make, and git. | Shell | Neovim | Command | | ------------- | -------------------------------------------------------- | --------------------------- | | `default` | The stable release in the locked nixpkgs | `nix develop` | | `nvim-0_12_0` | 0.12.0, the minimum supported version | `nix develop .#nvim-0_12_0` | | `nightly` | The nightly build in the locked `neovim-nightly-overlay` | `nix develop .#nightly` | +| `integration` | The stable release, with the CLIs and the servers | `nix develop .#integration` | + +The `integration` shell also has curl, `gh`, `glab`, `tea`, `fj`, and the `gitea` and `forgejo` servers. nixpkgs marks +`forgejo` as broken on `aarch64-darwin`, so the shell has no `forgejo` there. The nightly builds are in the nix-community binary cache. Without this cache, Nix builds Neovim from source. To use the cache, add these lines to your Nix config: @@ -41,8 +45,9 @@ extra-trusted-public-keys = nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq | Target | Action | | ------------------------------ | --------------------------------------------------------------------------------------------------- | -| `make test` | Runs all tests. | +| `make test` | Runs all tests, except the tests in `tests/integration/`. | | `make test-file FILE=` | Runs one test file. | +| `make test-integration` | Runs the tests in `tests/integration/`. Use the `integration` shell. | | `make format` | Formats `lua/`, `tests/`, and `benchmarks/` with StyLua. | | `make format-check` | Checks the formatting. | | `make lint` | Lints `lua/`, `tests/`, and `benchmarks/` with Selene. Warnings fail the target. | @@ -63,8 +68,9 @@ that version. ## Tests -The tests use [mini.test](https://github.com/nvim-mini/mini.nvim/blob/main/readmes/mini-test.md). The files in -`tests/` mirror the modules in `lua/cmp_git/`. mini.test collects the files that match `tests/**/test_*.lua`. +The tests use [mini.test](https://github.com/nvim-mini/mini.nvim/blob/main/readmes/mini-test.md). The files in `tests/` +mirror the modules in `lua/cmp_git/`. mini.test collects the files that match `tests/**/test_*.lua`. `make test` +collects the files outside `tests/integration/`, and `make test-integration` collects the files in it. - `tests/minimal_init.lua` adds the repository and mini.nvim to `runtimepath`. - `tests/helpers.lua` has the expectations, the fakes, and the hooks for each case. @@ -73,13 +79,18 @@ Start each test file with `local T = helpers.new_set()`. The hooks of this set i - They capture `vim.notify`, so log messages do not print. To read the messages, call `helpers.flush()`, and then read `helpers.notifications`. -- They unset `GITHUB_API_TOKEN` and `GITLAB_TOKEN`. +- They unset `GITHUB_API_TOKEN`, `GITLAB_TOKEN`, `GITEA_TOKEN`, and `FORGEJO_TOKEN`. +- They unset `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, and `APPDATA`, and set `HOME` to a temporary directory. Thus the + tea logins and the `fj` tokens of your user do not apply. - They isolate git from your user config and your system config. -- They set `XDG_CACHE_HOME` to a temporary directory, so that the cache of the GitHub and GitLab items is empty. +- They set `XDG_CACHE_HOME` to a temporary directory, so that the cache of the items is empty. +- They clear the tea logins that the Gitea source read. - They restore the cwd, the buffers, and the values that `helpers.stub` replaced. - They delete the directories from `helpers.tempdir` and `helpers.git_repo`. -Do not use the network in tests. Use fake runners and fake adapters in place of `gh`, `glab`, and `curl`. +Do not use the network in tests. Use fake runners and fake adapters in place of `gh`, `glab`, `tea`, and `curl`. +`helpers.fake_commands` puts fake commands on `PATH` for one case. `tests/sources/fake_gitea_api.lua` has a fake curl +and a fake tea for the Gitea and Forgejo sources. To run one test file: @@ -104,6 +115,30 @@ nix develop .#nightly --no-write-lock-file \ --override-input neovim-nightly-overlay "github:nix-community/neovim-nightly-overlay/$rev" -c make test ``` +### Integration tests + +The tests in `tests/integration/` run the real CLIs. They do not use the network either: + +- `gh` and `glab` send their requests to a fake HTTP server in the Neovim of the tests. `integration.http_server` starts + it, and `integration.use_gh` and `integration.use_glab` give the CLIs an isolated config for it. The canned GraphQL + answers for `gh` must match the queries of the locked `gh` version. +- tea, the `fj` token, and curl send their requests to real `gitea` and `forgejo` servers on `127.0.0.1`. + `integration.forge("gitea")` gives a server for the test file. The first case that calls `start` starts it, and + `stop` in `post_once` ends it. `integration.use_tea` and `integration.use_fj` give the CLIs a login for the server. + +Each server starts in a temporary directory, with a free port, SQLite, and seed data: the admin `root`, the +repositories `root/public` and the private `root/private`, and the collaborator `ada`. The start time of each server +goes to the notes of the case. The log of the server goes to the notes of a case that fails. + +To run the integration tests: + +```sh +nix develop .#integration -c make test-integration +``` + +A case skips when a binary that it needs is not on `PATH`. With `CMP_GIT_INTEGRATION_REQUIRE=1`, as in CI, a missing +binary fails the case. The Forgejo server cases always skip on `aarch64-darwin`. + ## Benchmarks The benchmarks in `benchmarks/` measure the two costs that users feel: the work that runs while they type, and the time @@ -170,6 +205,7 @@ has these jobs: | `Neovim 0.12.0` | `make test` in the `nvim-0_12_0` shell | Yes | | `Nightly cache` | `scripts/nightly-cache.sh check` (the locked nightly Neovim is in the cache for each system) | Yes | | `Neovim nightly` | `make test` with the newest `neovim-nightly-overlay` commit that is in the cache, not the locked revision | No | +| `Integration` | `make test-integration` in the `integration` shell, with `CMP_GIT_INTEGRATION_REQUIRE=1` | Yes | | `Benchmarks` | Pull requests only. `scripts/bench-compare.sh` against the base branch, with the report in the job summary | No | A weekly schedule (Monday 06:00 UTC) runs only the nightly job. GitHub disables scheduled workflows after 60 days @@ -201,6 +237,10 @@ changes that you pushed to the branch. To check the locked nightly Neovim on your computer, run `scripts/nightly-cache.sh check`. +A nixpkgs update can also change the versions of `gh`, `glab`, `tea`, `fj`, `gitea`, and `forgejo`. If the +`Integration` job then fails, run `nix develop .#integration -c make test-integration` on the Dependabot branch. Then +fix the code or the canned answers of the fake server. + Dependabot does not change the Neovim 0.12.0 pin in `flake.nix`. ### Update mini.nvim diff --git a/README.md b/README.md index aaeef04..0e16fa6 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,12 @@ Git source for [hrsh7th/nvim-cmp](https://github.com/hrsh7th/nvim-cmp) and [Sagh | Mentions | @ | | Merge Requests | ! | +| Gitea and Forgejo | Trigger | +| ----------------- | ------- | +| Issues | # | +| Mentions | @ | +| Pull Requests | #, ! | + ## Requirements - Neovim >= 0.12 @@ -30,6 +36,9 @@ Git source for [hrsh7th/nvim-cmp](https://github.com/hrsh7th/nvim-cmp) and [Sagh - curl (>= 7.55) - [GitHub CLI](https://cli.github.com/) (optional, cmp-git uses curl if it is not available) - [GitLab CLI](https://gitlab.com/gitlab-org/cli) (optional, cmp-git uses curl if it is not available) +- [tea](https://gitea.com/gitea/tea) >= 0.12.0 (optional, for Gitea, cmp-git uses curl if it is not available) +- [forgejo-cli](https://codeberg.org/forgejo-contrib/forgejo-cli) (`fj`) (optional, for Forgejo, cmp-git reads the + token of `fj` and sends it with curl) ### GitHub private repositories @@ -43,6 +52,21 @@ Git source for [hrsh7th/nvim-cmp](https://github.com/hrsh7th/nvim-cmp) and [Sagh `GITLAB_TOKEN` environment variable to the token. - `GitLab CLI`: Run [glab auth login](https://glab.readthedocs.io/en/latest/auth/login.html). +### Gitea private repositories + +- `curl`: Generate an access token in Settings > Applications on the server, with the `read:issue` and + `read:repository` scopes. Set the `GITEA_TOKEN` environment variable to the token. +- `tea`: Run `tea login add` for the server. cmp-git uses the login whose URL is the host of the remote. + +### Forgejo private repositories + +- `curl`: Generate an access token in Settings > Applications on the server, with the `read:issue` and + `read:repository` scopes. Set the `FORGEJO_TOKEN` environment variable to the token. +- `forgejo-cli`: Run `fj auth login` or `fj auth add-token` for the server. cmp-git reads the token that `fj` keeps for + the host. + +Without a token, `@` shows no Gitea and Forgejo mentions, also in a public repository. The assignees API needs a token. + ## Installation [vim-plug](https://github.com/junegunn/vim-plug) @@ -135,7 +159,7 @@ require("cmp_git").setup({ enableRemoteUrlRewrites = false, -- enable git url rewrites, see https://git-scm.com/docs/git-config#Documentation/git-config.txt-urlltbasegtinsteadOf ssh_aliases = {}, -- SSH host aliases and their real hosts, such as { ["github-work"] = "github.com" } cache = { - ttl = 300, -- seconds after which a request also gets the GitHub and GitLab items again in the background + ttl = 300, -- seconds after which a request also gets the items again in the background }, git = { commits = { @@ -217,6 +241,46 @@ require("cmp_git").setup({ format = format.gitlab.merge_requests, }, }, + gitea = { + hosts = {}, -- list of private instances of gitea, with the port and the path prefix if any, such as "example.com/gitea" + issues = { + limit = 50, -- the servers give at most 50 items + state = "open", -- open, closed, all + sort_by = sort.gitea.issues, + format = format.gitea.issues, + }, + mentions = { + limit = 100, + sort_by = sort.gitea.mentions, + format = format.gitea.mentions, + }, + pull_requests = { + limit = 50, -- the servers give at most 50 items + state = "open", -- open, closed, all + sort_by = sort.gitea.pull_requests, + format = format.gitea.pull_requests, + }, + }, + forgejo = { + hosts = {}, -- list of private instances of forgejo, with the port and the path prefix if any + issues = { + limit = 50, -- the servers give at most 50 items + state = "open", -- open, closed, all + sort_by = sort.forgejo.issues, + format = format.forgejo.issues, + }, + mentions = { + limit = 100, + sort_by = sort.forgejo.mentions, + format = format.forgejo.mentions, + }, + pull_requests = { + limit = 50, -- the servers give at most 50 items + state = "open", -- open, closed, all + sort_by = sort.forgejo.pull_requests, + format = format.forgejo.pull_requests, + }, + }, trigger_actions = { { pattern = [[^[[:alnum:]-]\+]], @@ -233,15 +297,20 @@ require("cmp_git").setup({ }, { trigger_character = "#", - actions = { "gitlab_issues", "github_issues_and_change_requests" }, + actions = { + "gitlab_issues", + "github_issues_and_change_requests", + "gitea_issues_and_change_requests", + "forgejo_issues_and_change_requests", + }, }, { trigger_character = "@", - actions = { "gitlab_mentions", "github_mentions" }, + actions = { "gitlab_mentions", "github_mentions", "gitea_mentions", "forgejo_mentions" }, }, { trigger_character = "!", - actions = { "gitlab_change_requests" }, + actions = { "gitlab_change_requests", "gitea_change_requests", "forgejo_change_requests" }, }, }, } @@ -255,8 +324,8 @@ require("cmp_git").setup({ - [Format and sort items](docs/configuration.md#format-and-sort-items) - [Filetypes](docs/configuration.md#filetypes) - [Remotes and SSH aliases](docs/configuration.md#remotes-and-ssh-aliases) -- [GitHub CLI, GitLab CLI, and curl](docs/configuration.md#github-cli-gitlab-cli-and-curl) -- [Hosted GitHub and GitLab instances](docs/configuration.md#hosted-github-and-gitlab-instances) +- [CLIs, tokens, and curl](docs/configuration.md#clis-tokens-and-curl) +- [Hosted instances](docs/configuration.md#hosted-instances) - [Cache](docs/configuration.md#cache), and the `:CmpGitClearCache` command - [Troubleshooting](docs/configuration.md#troubleshooting) diff --git a/benchmarks/cases/e2e.lua b/benchmarks/cases/e2e.lua index b4f8624..97873ad 100644 --- a/benchmarks/cases/e2e.lua +++ b/benchmarks/cases/e2e.lua @@ -26,7 +26,7 @@ local REQUESTS = { github_mentions = { row = 3, line = "See @", trigger_character = "@" }, } ----Remove the cached GitHub and GitLab items from memory and from disk. Revisions before the disk cache have no cache +---Remove the cached items of the providers from memory and from disk. Revisions before the disk cache have no cache ---module, and a new source is cold there. local function clear_cache() local ok, cache = pcall(require, "cmp_git.cache") @@ -95,7 +95,7 @@ for _, name in ipairs({ table.insert(cases, request_case(name, "cold")) table.insert(cases, request_case(name, "warm")) end --- Only the GitHub and GitLab items have cache files +-- Only the items of the providers have cache files, not the commits and the trailers for _, name in ipairs({ "github_issues_and_prs", "github_mentions" }) do table.insert(cases, request_case(name, "disk")) end diff --git a/docs/configuration.md b/docs/configuration.md index 932485b..6c62237 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -34,9 +34,16 @@ commits. ### Built-in actions -The built-in actions are `git_commits`, `git_trailer_keys`, `git_trailer_identities`, `git_trailer_values`, -`gitlab_issues`, `gitlab_mentions`, `gitlab_change_requests`, `github_issues`, `github_change_requests`, -`github_issues_and_change_requests`, and `github_mentions`. The `git_trailer_*` actions need a `pattern` entry. +The built-in actions are: + +- `git_commits`, `git_trailer_keys`, `git_trailer_identities`, and `git_trailer_values`. These actions need a + `pattern` entry, except `git_commits`. +- `gitlab_issues`, `gitlab_mentions`, and `gitlab_change_requests`. +- `github_issues`, `github_change_requests`, `github_issues_and_change_requests`, and `github_mentions`. +- `gitea_issues`, `gitea_change_requests`, `gitea_issues_and_change_requests`, and `gitea_mentions`. +- `forgejo_issues`, `forgejo_change_requests`, `forgejo_issues_and_change_requests`, and `forgejo_mentions`. + +Each provider action handles the request only when the host of the remote is in the `hosts` of its provider. Two compatibility aliases are also available: @@ -48,11 +55,12 @@ Two compatibility aliases are also available: For compatibility, cmp-git also supports legacy callback actions. A callback entry has `trigger_character` and `action`. The `action` function receives these arguments, in this order: -1. The sources (currently `git`, `gitlab`, and `github`). +1. The sources: `git`, `gitlab`, `github`, `gitea`, and `forgejo`. 2. The trigger character. 3. The completion callback. 4. The parameters that `nvim-cmp` passes to `complete`. -5. The current git info. +5. The current git info. It has `host`, `owner`, and `repo`. For an `http://` or `https://` remote, it also has + `scheme`, which is `"http"` or `"https"`. We recommend named `actions` for new configuration. @@ -143,12 +151,17 @@ For compatibility, cmp-git also supports `filter_fn` on a provider, as an alias | `github.mentions` | `login` | | `gitlab.issues`, `gitlab.merge_requests` | The GitLab API fields, such as `iid`, `title`, `description`, and `updated_at` | | `gitlab.mentions` | `username`, `name` | +| `gitea.issues`, `gitea.pull_requests` | The Gitea API fields, such as `number`, `title`, `body`, `state`, and `updated_at` | +| `gitea.mentions` | The Gitea API fields, such as `login` and `full_name` | +| `forgejo.*` | The same fields as `gitea.*` | - `sha` has `git.commits.sha_length` characters (default 7). The default `insertText` inserts `sha`. The default `label` shows the first 7 characters. - `author_name` and `author_mail` are the name and the email of the author, with the `.mailmap` applied. `commit_timestamp` is the commit date. - GitHub issues and pull requests always have `body` and `updatedAt`. +- A Gitea or Forgejo pull request also has `pull_request`. Under `#`, the default format shows it as `#N`, and under + `!` as `!N`. A `null` title, body, or full name is an empty string. - `diff` is the number of seconds since the commit, or since the most recent use of the identity or the value. - `count` is the number of uses in the scanned commits. - `builtin_index` is the position of the key in `git.trailers.keys.builtin`. It is `nil` for a key from history. @@ -166,7 +179,7 @@ field name, such as `"title"`. For a field name, cmp-git uses the value of that | `git.trailers.identities` | Most recent first. Your git identity comes first if it is not in history. | | `git.trailers.values` | Most used first, then most recent | | Issues, pull requests, and merge requests | Last updated first | -| `github.mentions`, `gitlab.mentions` | No `sort_by`, so the items have no `sortText` | +| Mentions | No `sort_by`, so the items have no `sortText` | ### Sorting in nvim-cmp @@ -237,10 +250,13 @@ cmp-git does not use the default repository in an `octo` buffer. In an `octo` buffer, cmp-git gets the owner and the repository from the buffer name. It gets the host from `github_hostname` in the Octo config. The default host is `github.com`. -## GitHub CLI, GitLab CLI, and curl +## CLIs, tokens, and curl For GitHub, cmp-git uses the GitHub CLI (`gh`). For GitLab, it uses the GitLab CLI (`glab`). If the CLI is not -installed, or if the CLI command fails, cmp-git uses curl. +installed, or if the CLI command fails, cmp-git uses curl. For Gitea and Forgejo, see +[Gitea and Forgejo](#gitea-and-forgejo). + +### GitHub and GitLab The results are different in these cases: @@ -261,9 +277,56 @@ GitHub mentions come from the collaborators of the repository when your token ca the contributors. cmp-git gets more pages until it has `github.mentions.limit` mentions. To get all mentions, set `github.mentions.limit = math.huge`. -## Hosted GitHub and GitLab instances +### Gitea and Forgejo + +cmp-git sends the Gitea and Forgejo requests to `/api/v1` of the server. Each request runs these commands in order, +and stops at the first command that gives the items. + +For Gitea: + +1. `tea api -l `, if tea has a login for the host. +2. curl with the token in `GITEA_TOKEN`, if the variable is set. +3. curl without a token. + +For Forgejo: + +1. curl with the token that `fj` keeps for the host, if there is one. +2. curl with the token in `FORGEJO_TOKEN`, if the variable is set. +3. curl without a token. + +A token of one host gives an error on another host, also for a public repository. Thus the last command, curl without +a token, still gets the public items. curl reads the token from its standard input, so the token is not in the list of +processes. + +A tea login matches when its URL, without the scheme and the trailing `/`, is the host of the remote. If more than one +login matches, cmp-git uses the default login, and else the first login. Without a matching login, or without tea, +cmp-git starts no `tea api` process. cmp-git reads the tea logins once in a session, without blocking. +`:CmpGitClearCache` and `:CmpGitClearCache!` make cmp-git read them again. + +cmp-git reads the token of `fj` from the `keys.json` file of `fj`. It reads the file for each request that is not a +cache hit, so a new `fj` login applies to the next request. The file is in these directories: + +- Linux: `$XDG_DATA_HOME/forgejo-cli`, or `~/.local/share/forgejo-cli` if `XDG_DATA_HOME` is not set. +- macOS: `~/Library/Application Support/forgejo-cli.forgejo-cli`. +- Windows: `%APPDATA%\forgejo-cli\forgejo-cli\data`. -To use a hosted instance of GitHub Enterprise or GitLab, add its host to the `hosts` list of the provider: +`fj` refreshes an expired OAuth token of `fj auth login` only when `fj` runs. cmp-git does not refresh the token. If +the token expires, run an `fj` command that uses the server, such as `fj whoami`, to refresh it. Until then, the request +fails with this token and continues with the next command. + +The results have these limits: + +- `issues` and `pull_requests` get one page of at most `limit` items. The servers give at most 50 items for each page. + A self-hosted server can set a lower maximum with `MAX_RESPONSE_ITEMS`. The servers sort the page by creation time. + Thus, if a repository has more open items than the limit, cmp-git shows the newest items. +- The mentions come from the assignees of the repository: the users that can get an issue assigned. The assignees API + needs a token, so without credentials, `@` shows no items, also in a public repository. cmp-git then starts no + request for the mentions. + +## Hosted instances + +To use a hosted instance of GitHub Enterprise, GitLab, Gitea, or Forgejo, add its host to the `hosts` list of the +provider: ```lua require("cmp_git").setup({ @@ -272,16 +335,23 @@ require("cmp_git").setup({ }, gitlab = { hosts = { "gitlab.mycompany.com", } - } + }, + gitea = { + hosts = { "gitea.mycompany.com", } + }, + forgejo = { + hosts = { "forgejo.mycompany.com:3000", } + }, }) ``` -Each entry must be the same as the host in the URL of the remote. A GitLab entry can also have a path prefix. -cmp-git always adds `github.com` and `gitlab.com`. With curl, cmp-git uses `https:///api/v3` for GitHub -Enterprise and `https:///api/v4` for GitLab. +Each entry must be the same as the host in the URL of the remote, with the port if the URL has one. A GitLab, Gitea, +or Forgejo entry can also have a path prefix. cmp-git always adds `github.com`, `gitlab.com`, `gitea.com`, and +`codeberg.org`, which runs Forgejo. With curl, cmp-git uses `https:///api/v3` for GitHub Enterprise, +`https:///api/v4` for GitLab, and `https:///api/v1` for Gitea and Forgejo. -A GitLab instance can have a path prefix in its URL, for example `https://example.com/gitlab`. For this instance, add -the host and the path prefix to `gitlab.hosts`: +An instance can have a path prefix in its URL, for example `https://example.com/gitlab`. For this instance, add the +host and the path prefix to the `hosts` list of the provider, here `gitlab.hosts`: ```lua require("cmp_git").setup({ @@ -291,25 +361,34 @@ require("cmp_git").setup({ }) ``` -With this setup, cmp-git reads the remote `https://example.com/gitlab/group/repo.git` as the project `group/repo` on -the instance `example.com/gitlab`. With curl, cmp-git uses `https://example.com/gitlab/api/v4`. A path prefix can -have more than one segment. If more than one entry matches the URL, cmp-git uses the entry with the longest path -prefix. +With this setup, cmp-git reads the remote `https://example.com/gitlab/group/repo.git` as the project `group/repo` on the +instance `example.com/gitlab`. With curl, cmp-git uses `https://example.com/gitlab/api/v4`. For Gitea and Forgejo, the +API is at `https:////api/v1`. A path prefix can have more than one segment. If more than one entry matches +the URL, cmp-git uses the entry with the longest path prefix. The path of an SSH remote, such as `git@example.com:group/repo.git`, has no path prefix. cmp-git uses the entry with the path prefix for this remote when these conditions are true: -- `github.hosts` and `gitlab.hosts` have only one entry with a path prefix for `example.com`. -- `github.hosts` and `gitlab.hosts` do not have `example.com` without a path prefix. +- The `hosts` lists of all providers have only one entry with a path prefix for `example.com`. +- The `hosts` lists of all providers do not have `example.com` without a path prefix. + +GitHub Enterprise does not use a path prefix. + +### Plain HTTP remotes -Path prefixes are only for GitLab. GitHub Enterprise does not use a path prefix. +Warning: With an `http://` remote, the Gitea and Forgejo requests and their tokens go over the network without +encryption. + +For a remote with an `http://` URL, cmp-git sends the Gitea and Forgejo requests to `http:///api/v1`, the same +way as git sends the credentials of that remote. For all other remotes, cmp-git uses HTTPS. The GitHub and GitLab +requests always use HTTPS. ## Cache -cmp-git keeps the GitHub and GitLab items in a cache on disk: the issues, the pull requests, the merge requests, and -the mentions. The cache is in `stdpath("cache")/cmp-git`, usually `~/.cache/nvim/cmp-git`, with a directory for each -remote, such as `github.com/owner/repo`. All buffers of a remote use the same items, also in a new Neovim. So the first -`#`, `!`, or `@` in a new commit message shows the items without a wait for the network. +cmp-git keeps the GitHub, GitLab, Gitea, and Forgejo items in a cache on disk: the issues, the pull requests, the merge +requests, and the mentions. The cache is in `stdpath("cache")/cmp-git`, usually `~/.cache/nvim/cmp-git`, with a +directory for each remote, such as `github.com/owner/repo`. All buffers of a remote use the same items, also in a new +Neovim. So the first `#`, `!`, or `@` in a new commit message shows the items without a wait for the network. The remote is the first remote in `remotes` that has a supported URL. For GitHub, the items come from the repository that `gh repo set-default` chose, if any. A change of the default repository shows after the next refresh. @@ -340,6 +419,8 @@ To clear the cache, use these commands: trailers of the buffer. - `:CmpGitClearCache!` clears the items of all remotes, and the commits and the trailers of all buffers. +Both commands also make cmp-git read the tea logins again for the next Gitea request. + The commits and the trailers come from the local repository. cmp-git keeps them in memory for each buffer and does not refresh them. To get new commits and trailers, run `:CmpGitClearCache`, or wipe the buffer with `:bwipeout` and open the file again. @@ -350,8 +431,11 @@ If no items show, make sure that these conditions are true: - The filetype of the buffer is in `filetypes`. - The file is in a git repository. -- For GitHub and GitLab items, the host of the remote is in `github.hosts` or `gitlab.hosts`. -- For GitHub and GitLab items, `gh`, `glab`, or `curl` is installed. +- For GitHub, GitLab, Gitea, and Forgejo items, the host of the remote is in the `hosts` list of the provider. +- For GitHub and GitLab items, `gh`, `glab`, or `curl` is installed. For Gitea and Forgejo items, `curl` is + installed. +- For a Gitea or Forgejo private repository, and for Gitea and Forgejo mentions, tea, `fj`, `GITEA_TOKEN`, or + `FORGEJO_TOKEN` gives a token for the host. See [Gitea and Forgejo](#gitea-and-forgejo). To log missing executables, and the exit code and stderr of failed commands, set `vim.g.cmp_git_debug = true`. cmp-git sends the messages with `vim.notify`. From 917153d1fb175b63504c93dc0b3609ee98f1921e Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Fri, 23 Oct 2026 17:05:32 +1100 Subject: [PATCH 13/13] fix: type the XDG data directory of fj as optional The types of Neovim 0.12 give `vim.env.XDG_DATA_HOME` the type `string`, so lua-language-server rejected the fallback to nil when `HOME` is not known. The variable is now `string?`. --- lua/cmp_git/provider/adapters/forgejo.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/cmp_git/provider/adapters/forgejo.lua b/lua/cmp_git/provider/adapters/forgejo.lua index aa07b67..ddd2abd 100644 --- a/lua/cmp_git/provider/adapters/forgejo.lua +++ b/lua/cmp_git/provider/adapters/forgejo.lua @@ -18,7 +18,7 @@ function M.keys_path() end -- As in the XDG spec, a relative path is not valid - local data = vim.env.XDG_DATA_HOME + local data = vim.env.XDG_DATA_HOME ---@type string? if not data or data:sub(1, 1) ~= "/" then data = home and home .. "/.local/share" end