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/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/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/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`. 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 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/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/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/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..ddd2abd --- /dev/null +++ b/lua/cmp_git/provider/adapters/forgejo.lua @@ -0,0 +1,66 @@ +local gitea_api = require("cmp_git.provider.adapters.gitea_api") +local log = require("cmp_git.log") + +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 ---@type string? + 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/lua/cmp_git/provider/adapters/gitea.lua b/lua/cmp_git/provider/adapters/gitea.lua new file mode 100644 index 0000000..16fecff --- /dev/null +++ b/lua/cmp_git/provider/adapters/gitea.lua @@ -0,0 +1,60 @@ +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, +}) + +---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/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/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/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/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/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..bb06c0c --- /dev/null +++ b/lua/cmp_git/sources/gitea.lua @@ -0,0 +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, 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/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/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..1ba06c9 100644 --- a/lua/cmp_git/utils.lua +++ b/lua/cmp_git/utils.lua @@ -135,4 +135,26 @@ 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 +---@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 4635404..29ec3a6 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", @@ -106,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() @@ -123,6 +128,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() @@ -132,6 +143,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. @@ -225,6 +253,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 @@ -235,12 +268,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..e1672a4 --- /dev/null +++ b/tests/integration/helpers.lua @@ -0,0 +1,673 @@ +-- 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 + +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 + +---@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) + 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 + +---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. +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_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_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 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 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 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(), }, 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/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/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") diff --git a/tests/sources/fake_gitea_api.lua b/tests/sources/fake_gitea_api.lua new file mode 100644 index 0000000..926121d --- /dev/null +++ b/tests/sources/fake_gitea_api.lua @@ -0,0 +1,86 @@ +-- Fake curl and tea commands 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"}]' + +---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', + 'printf \'%s %s\\n\' "$url" "$header" ' .. append, + 'case "$header" in *" bad") exit 22 ;; esac', + 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 {} + end + return vim.fn.readfile(log) + 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 new file mode 100644 index 0000000..e41b07b --- /dev/null +++ b/tests/sources/test_forgejo.lua @@ -0,0 +1,103 @@ +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 + +---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" } }) + + 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.commands() + 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.commands() + 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 + +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 diff --git a/tests/sources/test_gitea.lua b/tests/sources/test_gitea.lua new file mode 100644 index 0000000..2350404 --- /dev/null +++ b/tests/sources/test_gitea.lua @@ -0,0 +1,283 @@ +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") + +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.commands() + 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.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") +end + +T["failed_commands_give_an_empty_list_that_is_not_cached"] = function() + vim.env.GITEA_TOKEN = "secret" + 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 + 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.commands() + 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.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.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) + + 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.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" } }) + 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 + +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 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 }) 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) 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