diff --git a/.github/workflows/luarocks-dev.yaml b/.github/workflows/luarocks-dev.yaml index cc6faab..e7121ac 100644 --- a/.github/workflows/luarocks-dev.yaml +++ b/.github/workflows/luarocks-dev.yaml @@ -1,15 +1,14 @@ name: luarocks-dev on: - push: + workflow_run: + workflows: [quality] + types: [completed] branches: [main] jobs: - quality: - uses: ./.github/workflows/quality.yaml - publish: - needs: quality + if: ${{ github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 93ac2c5..ec4a4f7 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ result result-* .direnv/ .envrc + +scripts/demo diff --git a/README.md b/README.md index e24ad54..faa000a 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,12 @@ **Forge-agnostic git workflow for Neovim** -PR, issue, and CI workflows across GitHub, GitLab, and more — without leaving -your editor. +PR, issue, and CI workflows across GitHub, GitLab, and Codeberg/Gitea/Forgejo — +without leaving your editor. ## Features -- Automatic forge detection from git remote (GitHub via `gh`, GitLab via `glab`, - Codeberg/Gitea/Forgejo via `tea`) +- Automatic forge detection from git remote (`gh`, `glab`, `tea`) - PR lifecycle: list, create (compose buffer with template discovery, diff stat, reviewers), checkout, worktree, review diff, merge, approve, close/reopen, draft toggle @@ -17,27 +16,52 @@ your editor. - Code review via [diffs.nvim](https://github.com/barrettruth/diffs.nvim) with unified/split toggle and quickfix navigation - Commit and branch browsing with checkout, diff, and URL generation -- File/line permalink generation and yanking (commit and branch URLs) +- File/line permalink generation and yanking - [fzf-lua](https://github.com/ibhagwan/fzf-lua) pickers with contextual keybinds - Pluggable source registration for custom or self-hosted forges -## Dependencies +## Requirements - Neovim 0.10.0+ - [fzf-lua](https://github.com/ibhagwan/fzf-lua) -- At least one forge CLI: - - [`gh`](https://cli.github.com/) for GitHub - - [`glab`](https://gitlab.com/gitlab-org/cli) for GitLab - - [`tea`](https://gitea.com/gitea/tea) for Codeberg/Gitea/Forgejo -- [vim-fugitive](https://github.com/tpope/vim-fugitive) (optional, for fugitive - keymaps and split diff) -- [diffs.nvim](https://github.com/barrettruth/diffs.nvim) (optional, for review - mode) +- At least one forge CLI: [`gh`](https://cli.github.com/), + [`glab`](https://gitlab.com/gitlab-org/cli), or + [`tea`](https://gitea.com/gitea/tea) +- (Optional) [diffs.nvim](https://github.com/barrettruth/diffs.nvim) for review + mode +- (Optional) [vim-fugitive](https://github.com/tpope/vim-fugitive) for split + diff and fugitive keymaps ## Installation -### [lazy.nvim](https://github.com/folke/lazy.nvim) +Install with your package manager of choice or via +[luarocks](https://luarocks.org/modules/barrettruth/forge.nvim): + +``` +luarocks install forge.nvim +``` + +## Documentation + +```vim +:help forge.nvim +``` + +## FAQ + +**Q: How do I configure forge.nvim?** + +Configure via `vim.g.forge` before the plugin loads. All fields are optional: + +```lua +vim.g.forge = { + sources = { gitlab = { hosts = { 'gitlab.mycompany.com' } } }, + display = { icons = { open = '', merged = '', closed = '' } }, +} +``` + +**Q: How do I install with lazy.nvim?** ```lua { @@ -46,309 +70,19 @@ your editor. } ``` -### [mini.deps](https://github.com/echasnovski/mini.deps) +**Q: How do I create a PR?** -```lua -MiniDeps.add({ - source = 'barrettruth/forge.nvim', - depends = { 'ibhagwan/fzf-lua' }, -}) -``` +`` to open the picker, select Pull Requests, then `ctrl-a` to compose. Or +from a fugitive buffer: `cpr` (compose), `cpd` (draft), `cpf` (instant from +commits), `cpw` (push and open web). -### [luarocks](https://luarocks.org/modules/barrettruth/forge.nvim) +**Q: Does review mode require diffs.nvim?** -``` -luarocks install forge.nvim -``` +Yes. Without [diffs.nvim](https://github.com/barrettruth/diffs.nvim), diff +actions and review toggling are unavailable. -### Manual +**Q: How does forge detection work?** -```sh -git clone https://github.com/barrettruth/forge.nvim \ - ~/.local/share/nvim/site/pack/plugins/start/forge.nvim -``` - -## Usage - -forge.nvim works through two entry points: the `:Forge` command and the `` -picker. - -`:Forge` with no arguments (or ``) opens the top-level picker — PRs, -issues, CI, commits, branches, worktrees, and browse actions. Each sub-picker -has contextual keybinds shown in the fzf header. - -PR creation opens a compose buffer (markdown) pre-filled from commit messages -and repo templates. First line is the title, everything after the blank line is -the body. Draft, reviewers, and base branch are set in the HTML comment block -below. Write (`:w`) to push and create. - -## Configuration - -Configure via `vim.g.forge`. All fields are optional — defaults shown below. - -```lua -vim.g.forge = { - ci = { lines = 10000 }, - sources = {}, - keys = { - picker = '', - next_qf = ']q', prev_qf = '[q', - next_loc = ']l', prev_loc = '[l', - review_toggle = 's', - terminal_open = 'gx', - fugitive = { - create = 'cpr', create_draft = 'cpd', - create_fill = 'cpf', create_web = 'cpw', - }, - }, - picker_keys = { - pr = { - checkout = 'default', diff = 'ctrl-d', worktree = 'ctrl-w', - checks = 'ctrl-t', browse = 'ctrl-x', manage = 'ctrl-e', - create = 'ctrl-a', toggle = 'ctrl-o', refresh = 'ctrl-r', - }, - issue = { browse = 'default', close_reopen = 'ctrl-s', toggle = 'ctrl-o', refresh = 'ctrl-r' }, - checks = { log = 'default', browse = 'ctrl-x', failed = 'ctrl-f', passed = 'ctrl-p', running = 'ctrl-n', all = 'ctrl-a' }, - ci = { log = 'default', browse = 'ctrl-x', refresh = 'ctrl-r' }, - commits = { checkout = 'default', diff = 'ctrl-d', browse = 'ctrl-x', yank = 'ctrl-y' }, - branches = { diff = 'ctrl-d', browse = 'ctrl-x' }, - }, - display = { - icons = { open = '+', merged = 'm', closed = 'x', pass = '*', fail = 'x', pending = '~', skip = '-', unknown = '?' }, - widths = { title = 45, author = 15, name = 35, branch = 25 }, - limits = { pulls = 100, issues = 100, runs = 30 }, - }, -} -``` - -Set `keys = false` to disable all keymaps. Set `picker_keys = false` to disable -all picker keybinds. Set any individual key to `false` to disable it. - -### Examples - -Disable quickfix/loclist keymaps: - -```lua -vim.g.forge = { - keys = { next_qf = false, prev_qf = false, next_loc = false, prev_loc = false }, -} -``` - -Nerd font icons: - -```lua -vim.g.forge = { - display = { - icons = { open = '', merged = '', closed = '', pass = '', fail = '', pending = '', skip = '', unknown = '' }, - }, -} -``` - -Self-hosted GitLab: - -```lua -vim.g.forge = { - sources = { gitlab = { hosts = { 'gitlab.mycompany.com' } } }, -} -``` - -Override PR picker bindings: - -```lua -vim.g.forge = { - picker_keys = { pr = { checkout = 'ctrl-o', diff = 'default' } }, -} -``` - -## Commands - -`:Forge` with no arguments opens the top-level picker. Subcommands: - -| Command | Description | -| --------------------------------------------- | --------------------------------- | -| `:Forge pr` | List open PRs | -| `:Forge pr --state={open,closed,all}` | List PRs by state | -| `:Forge pr create [--draft] [--fill] [--web]` | Create PR | -| `:Forge pr checkout {num}` | Checkout PR branch | -| `:Forge pr diff {num}` | Review PR diff | -| `:Forge pr worktree {num}` | Fetch PR into worktree | -| `:Forge pr checks {num}` | Show PR checks | -| `:Forge pr browse {num}` | Open PR in browser | -| `:Forge pr manage {num}` | Merge/approve/close/draft actions | -| `:Forge issue` | List all issues | -| `:Forge issue --state={open,closed,all}` | List issues by state | -| `:Forge issue browse {num}` | Open issue in browser | -| `:Forge issue close {num}` | Close issue | -| `:Forge issue reopen {num}` | Reopen issue | -| `:Forge ci` | CI runs for current branch | -| `:Forge ci --all` | CI runs for all branches | -| `:Forge commit` | Browse commits | -| `:Forge commit checkout {sha}` | Checkout commit | -| `:Forge commit diff {sha}` | Review commit diff | -| `:Forge commit browse {sha}` | Open commit in browser | -| `:Forge branch` | Browse branches | -| `:Forge branch diff {name}` | Review branch diff | -| `:Forge branch browse {name}` | Open branch in browser | -| `:Forge worktree` | List worktrees | -| `:Forge browse [--root] [--commit]` | Open file/repo/commit in browser | -| `:Forge yank [--commit]` | Yank permalink for file/line | -| `:Forge review end` | End review session | -| `:Forge review toggle` | Toggle split/unified review | -| `:Forge cache clear` | Clear all caches | - -## Keymaps - -### Global - -| Key | Mode | Description | -| ----------- | ---- | -------------------------------- | -| `` | n, v | Open forge picker | -| `]q` / `[q` | n | Next/prev quickfix entry (wraps) | -| `]l` / `[l` | n | Next/prev loclist entry (wraps) | - -### Fugitive buffer - -Active in `fugitive` filetype buffers when a forge is detected. - -| Key | Description | -| ----- | ----------------------------------- | -| `cpr` | Create PR (compose buffer) | -| `cpd` | Create draft PR | -| `cpf` | Create PR from commits (no compose) | -| `cpw` | Push and open web creation | - -### Review - -Active during a review session. - -| Key | Description | -| --- | ------------------------- | -| `s` | Toggle unified/split diff | - -### Terminal (log buffers) - -Active on CI/check log terminals when a URL is available. - -| Key | Description | -| ---- | ------------------------- | -| `gx` | Open run/check in browser | - -## Picker Actions - -Keybinds shown in the fzf header. `default` = `enter`. - -| Picker | Key | Action | -| ------------ | ------------------------------ | ---------------------------------- | -| **PR** | `enter` | Checkout | -| | `ctrl-d` | Review diff | -| | `ctrl-w` | Worktree | -| | `ctrl-t` | Checks | -| | `ctrl-x` | Browse | -| | `ctrl-e` | Manage (merge/approve/close/draft) | -| | `ctrl-a` | Create new | -| | `ctrl-o` | Cycle state (open/closed/all) | -| | `ctrl-r` | Refresh | -| **Issue** | `enter` | Browse | -| | `ctrl-s` | Close/reopen | -| | `ctrl-o` | Cycle state | -| | `ctrl-r` | Refresh | -| **Checks** | `enter` | View log (tails if running) | -| | `ctrl-x` | Browse | -| | `ctrl-f` / `ctrl-p` / `ctrl-n` | Filter: failed / passed / running | -| | `ctrl-a` | Show all | -| **CI** | `enter` | View log (tails if running) | -| | `ctrl-x` | Browse | -| | `ctrl-r` | Refresh | -| **Commits** | `enter` | Checkout (detached) | -| | `ctrl-d` | Review diff | -| | `ctrl-x` | Browse | -| | `ctrl-y` | Yank hash | -| **Branches** | `ctrl-d` | Review diff | -| | `ctrl-x` | Browse | - -## Custom Sources - -Register a custom forge source for self-hosted or alternative platforms: - -```lua -require('forge').register('mygitea', require('my_gitea_source')) -``` - -Route remotes to your source by host: - -```lua -vim.g.forge = { - sources = { mygitea = { hosts = { 'gitea.internal.dev' } } }, -} -``` - -A source is a table implementing the `forge.Forge` interface. Required fields: -`name` (string), `cli` (string, checked via `executable()`), `kinds` -(`{ issue, pr }`), and `labels` (`{ issue, pr, pr_one, pr_full, ci }`). - -Required methods (all receive `self`): `list_pr_json_cmd`, -`list_issue_json_cmd`, `pr_json_fields`, `issue_json_fields`, `view_web`, -`browse`, `browse_root`, `browse_branch`, `browse_commit`, `checkout_cmd`, -`yank_branch`, `yank_commit`, `fetch_pr`, `pr_base_cmd`, `pr_for_branch_cmd`, -`checks_cmd`, `check_log_cmd`, `check_tail_cmd`, `list_runs_json_cmd`, -`list_runs_cmd`, `normalize_run`, `run_log_cmd`, `run_tail_cmd`, `merge_cmd`, -`approve_cmd`, `repo_info`, `pr_state`, `close_cmd`, `reopen_cmd`, -`close_issue_cmd`, `reopen_issue_cmd`, `draft_toggle_cmd`, `create_pr_cmd`, -`create_pr_web_cmd`, `default_branch_cmd`, `template_paths`. - -See `lua/forge/github.lua`, `lua/forge/gitlab.lua`, or `lua/forge/codeberg.lua` -for complete implementations. The `forge.Forge` class definition with full type -annotations is in `lua/forge/init.lua`. - -### Skeleton - -```lua -local M = { - name = 'mygitea', - cli = 'tea', - kinds = { issue = 'issues', pr = 'pulls' }, - labels = { issue = 'Issues', pr = 'PRs', pr_one = 'PR', pr_full = 'Pull Requests', ci = 'CI/CD' }, -} - -function M:list_pr_json_cmd(state) - return { 'tea', 'pr', 'list', '--state', state, '--output', 'json' } -end - -function M:pr_json_fields() - return { number = 'number', title = 'title', branch = 'head', state = 'state', author = 'poster', created_at = 'created_at' } -end - -return M -``` - -## Health - -Run `:checkhealth forge` to verify your setup. Checks for `git`, forge CLIs -(`gh`, `glab`, `tea`), required plugins (`fzf-lua`), optional plugins -(`diffs.nvim`, `vim-fugitive`), and any registered custom sources. - -## FAQ - -**Q: How do I create a PR?** `` -> Pull Requests -> `ctrl-a` to compose. Or -from fugitive: `cpr` (compose), `cpd` (draft), `cpf` (instant), `cpw` (web). - -**Q: Does review mode require diffs.nvim?** Yes. Without -[diffs.nvim](https://github.com/barrettruth/diffs.nvim), the diff action and -review toggling are unavailable. - -**Q: How does forge detection work?** forge.nvim reads the `origin` remote URL -and matches against known hosts and any custom `sources..hosts`. The first -match wins, and the CLI must be in `$PATH`. - -**Q: Can I use this with self-hosted GitLab/Gitea?** Yes. Add your host to -`vim.g.forge.sources`. See the [examples](#examples). - -**Q: What does `ctrl-o` do in pickers?** Cycles the state filter: open -> closed --> all -> open. - -**Q: How do I merge/approve/close a PR?** `ctrl-e` on a PR in the picker opens -the manage picker. Available actions depend on your repository permissions. - -**Q: Does this work without a forge remote?** Partially. Commits, branches, and -worktrees work in any git repo. PRs, issues, CI, and browse require a detected -forge. +forge.nvim reads the `origin` remote URL and matches against known hosts and any +custom `sources..hosts` entries. The first match wins, and the CLI must be +in `$PATH`. diff --git a/doc/forge.nvim.txt b/doc/forge.nvim.txt index b37004a..e49a8de 100644 --- a/doc/forge.nvim.txt +++ b/doc/forge.nvim.txt @@ -9,7 +9,10 @@ delegates to the appropriate CLI (`gh`, `glab`, or `tea`). Requirements: ~ - Neovim 0.10.0+ -- fzf-lua (required) +- At least one picker backend: + - fzf-lua + - telescope.nvim + - snacks.nvim - One or more forge CLIs: - `gh` for GitHub - `glab` for GitLab @@ -32,6 +35,15 @@ unset keys use defaults. >lua Top-level keys: ~ +`picker` *forge-config-picker* + `string` (default `"auto"`) + Picker backend to use. One of `"auto"`, `"fzf-lua"`, `"telescope"`, or + `"snacks"`. When `"auto"`, forge.nvim tries fzf-lua, then snacks, then + telescope. + + Note: commits, branches, and worktree pickers currently require fzf-lua. + Other backends show a notification for these pickers. + `ci` *forge-config-ci* `ci.lines` `integer` (default `10000`) Maximum number of log lines fetched for CI/check log output. @@ -53,7 +65,8 @@ Top-level keys: ~ `table|false` (default shown below) Per-picker action bindings. Set to `false` to disable all picker-level actions. Use `""` to bind to enter. Values use vim keymap notation - (e.g. `""`), which is translated to fzf-lua format internally. + (e.g. `""`), which is translated to the appropriate format for + each picker backend. Defaults: >lua keys = { @@ -575,7 +588,7 @@ HEALTH *forge-health* Reports on: ~ - `git` availability - Forge CLI availability (`gh`, `glab`, `tea`) -- `fzf-lua` installation (required) +- Picker backends (fzf-lua, telescope, snacks) and which is active - `diffs.nvim` installation (review mode) - Custom registered sources and their CLI availability @@ -699,12 +712,11 @@ PUBLIC API: `require('forge.pickers')` ~ `pr_manage(f, num)` Opens the management picker for PR `num`. - *forge.pickers.pr_actions()* -`pr_actions(f, num)` + *forge.pickers.pr_action_fns()* +`pr_action_fns(f, num)` Returns `table`. Action functions for PR `num`, - keyed by fzf binding. Also has `_by_name` table keyed by action - name (`"checkout"`, `"diff"`, `"worktree"`, `"browse"`, `"ci"`, - `"manage"`). + keyed by action name (`"checkout"`, `"diff"`, `"worktree"`, + `"browse"`, `"ci"`, `"manage"`). *forge.pickers.issue_close()* `issue_close(f, num)` diff --git a/flake.nix b/flake.nix index e11545a..6a16865 100644 --- a/flake.nix +++ b/flake.nix @@ -24,22 +24,21 @@ devShells = forEachSystem ( pkgs: let - vimdoc-ls = vimdoc-language-server.packages.${pkgs.system}.default; commonPackages = [ pkgs.prettier pkgs.stylua pkgs.selene pkgs.lua-language-server - vimdoc-ls + vimdoc-language-server.packages.${pkgs.system}.default + (pkgs.luajit.withPackages (ps: [ + ps.busted + ps.nlua + ])) ]; in { - default = pkgs.mkShell { - packages = commonPackages; - }; - ci = pkgs.mkShell { - packages = commonPackages; - }; + default = pkgs.mkShell { packages = commonPackages; }; + ci = pkgs.mkShell { packages = commonPackages ++ [ pkgs.neovim ]; }; } ); }; diff --git a/lua/forge/health.lua b/lua/forge/health.lua index 4bbc1cd..da9b093 100644 --- a/lua/forge/health.lua +++ b/lua/forge/health.lua @@ -22,11 +22,18 @@ function M.check() end end - local has_fzf = pcall(require, 'fzf-lua') - if has_fzf then - vim.health.ok('fzf-lua found') - else - vim.health.error('fzf-lua not found (required)') + local picker_mod = require('forge.picker') + local backend = picker_mod.backend() + local found_any = false + for _, name in ipairs(picker_mod.detect_order) do + if pcall(require, name) then + local suffix = backend == name and ' (active)' or '' + vim.health.ok(name .. ' found' .. suffix) + found_any = true + end + end + if not found_any then + vim.health.error('no picker backend found (install fzf-lua, telescope.nvim, or snacks.nvim)') end local has_diffs = pcall(require, 'diffs') diff --git a/lua/forge/init.lua b/lua/forge/init.lua index 894abb4..d26df49 100644 --- a/lua/forge/init.lua +++ b/lua/forge/init.lua @@ -1,6 +1,7 @@ local M = {} ---@class forge.Config +---@field picker 'fzf-lua'|'telescope'|'snacks'|'auto' ---@field ci forge.CIConfig ---@field sources table ---@field keys forge.KeysConfig|false @@ -83,6 +84,7 @@ local M = {} ---@type forge.Config local DEFAULTS = { + picker = 'auto', ci = { lines = 10000 }, sources = {}, keys = { @@ -538,15 +540,25 @@ local function extract_author(entry, field) return tostring(v or '') end -local function hl(group, text) - local utils = require('fzf-lua.utils') - return utils.ansi_from_hl(group, text) +---@param secs integer +---@return string +local function format_duration(secs) + if secs < 0 then + secs = 0 + end + if secs >= 3600 then + return ('%dh%dm'):format(math.floor(secs / 3600), math.floor(secs % 3600 / 60)) + end + if secs >= 60 then + return ('%dm%ds'):format(math.floor(secs / 60), secs % 60) + end + return ('%ds'):format(secs) end ---@param entry table ---@param fields table ---@param show_state boolean ----@return string +---@return forge.Segment[] function M.format_pr(entry, fields, show_state) local display = M.config().display local icons = display.icons @@ -555,7 +567,7 @@ function M.format_pr(entry, fields, show_state) local title = entry[fields.title] or '' local author = extract_author(entry, fields.author) local age = relative_time(entry[fields.created_at]) - local prefix = '' + local segments = {} if show_state then local state = (entry[fields.state] or ''):lower() local icon, group @@ -566,20 +578,22 @@ function M.format_pr(entry, fields, show_state) else icon, group = icons.closed, 'ForgeClosed' end - prefix = hl(group, icon) .. ' ' + table.insert(segments, { icon, group }) + table.insert(segments, { ' ' }) end - return prefix - .. hl('ForgeNumber', ('#%-5s'):format(num)) - .. ' ' - .. pad_or_truncate(title, widths.title) - .. ' ' - .. hl('ForgeDim', pad_or_truncate(author, widths.author) .. (' %3s'):format(age)) + table.insert(segments, { ('#%-5s'):format(num), 'ForgeNumber' }) + table.insert(segments, { ' ' .. pad_or_truncate(title, widths.title) .. ' ' }) + table.insert(segments, { + pad_or_truncate(author, widths.author) .. (' %3s'):format(age), + 'ForgeDim', + }) + return segments end ---@param entry table ---@param fields table ---@param show_state boolean ----@return string +---@return forge.Segment[] function M.format_issue(entry, fields, show_state) local display = M.config().display local icons = display.icons @@ -588,7 +602,7 @@ function M.format_issue(entry, fields, show_state) local title = entry[fields.title] or '' local author = extract_author(entry, fields.author) local age = relative_time(entry[fields.created_at]) - local prefix = '' + local segments = {} if show_state then local state = (entry[fields.state] or ''):lower() local icon, group @@ -597,18 +611,20 @@ function M.format_issue(entry, fields, show_state) else icon, group = icons.closed, 'ForgeClosed' end - prefix = hl(group, icon) .. ' ' + table.insert(segments, { icon, group }) + table.insert(segments, { ' ' }) end - return prefix - .. hl('ForgeNumber', ('#%-5s'):format(num)) - .. ' ' - .. pad_or_truncate(title, widths.title) - .. ' ' - .. hl('ForgeDim', pad_or_truncate(author, widths.author) .. (' %3s'):format(age)) + table.insert(segments, { ('#%-5s'):format(num), 'ForgeNumber' }) + table.insert(segments, { ' ' .. pad_or_truncate(title, widths.title) .. ' ' }) + table.insert(segments, { + pad_or_truncate(author, widths.author) .. (' %3s'):format(age), + 'ForgeDim', + }) + return segments end ---@param check table ----@return string +---@return forge.Segment[] function M.format_check(check) local display = M.config().display local icons = display.icons @@ -632,23 +648,18 @@ function M.format_check(check) local ok_s, ts = pcall(vim.fn.strptime, '%Y-%m-%dT%H:%M:%SZ', check.startedAt) local ok_e, te = pcall(vim.fn.strptime, '%Y-%m-%dT%H:%M:%SZ', check.completedAt) if ok_s and ok_e and ts > 0 and te > 0 then - local secs = te - ts - if secs >= 60 then - elapsed = ('%dm%ds'):format(math.floor(secs / 60), secs % 60) - else - elapsed = ('%ds'):format(secs) - end + elapsed = format_duration(te - ts) end end - return hl(group, icon) - .. ' ' - .. pad_or_truncate(name, widths.name) - .. ' ' - .. hl('ForgeDim', elapsed) + return { + { icon, group }, + { ' ' .. pad_or_truncate(name, widths.name) .. ' ' }, + { elapsed, 'ForgeDim' }, + } end ---@param run forge.CIRun ----@return string +---@return forge.Segment[] function M.format_run(run) local display = M.config().display local icons = display.icons @@ -670,19 +681,18 @@ function M.format_run(run) local age = relative_time(run.created_at) if run.branch ~= '' then local name_w = widths.name - widths.branch + 10 - return hl(group, icon) - .. ' ' - .. pad_or_truncate(run.name, name_w) - .. ' ' - .. hl('ForgeBranch', pad_or_truncate(run.branch, widths.branch)) - .. ' ' - .. hl('ForgeDim', ('%-6s'):format(event) .. ' ' .. age) + return { + { icon, group }, + { ' ' .. pad_or_truncate(run.name, name_w) .. ' ' }, + { pad_or_truncate(run.branch, widths.branch), 'ForgeBranch' }, + { ' ' .. ('%-6s'):format(event) .. ' ' .. age, 'ForgeDim' }, + } end - return hl(group, icon) - .. ' ' - .. pad_or_truncate(run.name, widths.name) - .. ' ' - .. hl('ForgeDim', ('%-6s'):format(event) .. ' ' .. age) + return { + { icon, group }, + { ' ' .. pad_or_truncate(run.name, widths.name) .. ' ' }, + { ('%-6s'):format(event) .. ' ' .. age, 'ForgeDim' }, + } end ---@param checks table[] @@ -715,6 +725,10 @@ function M.config() cfg.keys = false end + local picker_backends = require('forge.picker').backends + vim.validate('forge.picker', cfg.picker, function(v) + return v == 'auto' or picker_backends[v] ~= nil + end, "'auto', 'fzf-lua', 'telescope', or 'snacks'") vim.validate('forge.sources', cfg.sources, 'table') vim.validate('forge.keys', cfg.keys, function(v) return v == false or type(v) == 'table' diff --git a/lua/forge/picker/fzf.lua b/lua/forge/picker/fzf.lua new file mode 100644 index 0000000..d4d3a05 --- /dev/null +++ b/lua/forge/picker/fzf.lua @@ -0,0 +1,76 @@ +local M = {} + +local fzf_args = (vim.env.FZF_DEFAULT_OPTS or '') + :gsub('%-%-bind=[^%s]+', '') + :gsub('%-%-color=[^%s]+', '') + +---@param key string +---@return string +local function to_fzf_key(key) + if key == '' then + return 'default' + end + local result = key:gsub('', function(ch) + return 'ctrl-' .. ch:lower() + end) + return result +end + +---@param segments forge.Segment[] +---@return string +local function render(segments) + local utils = require('fzf-lua.utils') + local parts = {} + for _, seg in ipairs(segments) do + if seg[2] then + table.insert(parts, utils.ansi_from_hl(seg[2], seg[1])) + else + table.insert(parts, seg[1]) + end + end + return table.concat(parts) +end + +---@param opts forge.PickerOpts +function M.pick(opts) + local cfg = require('forge').config() + local keys = cfg.keys + if keys == false then + keys = {} + end + local bindings = keys[opts.picker_name] or {} + + local lines = {} + for i, entry in ipairs(opts.entries) do + lines[i] = ('%d\t%s'):format(i, render(entry.display)) + end + + local fzf_actions = {} + for _, def in ipairs(opts.actions) do + local key = def.name == 'default' and '' or bindings[def.name] + if key then + fzf_actions[to_fzf_key(key)] = function(selected) + if not selected[1] then + def.fn(nil) + return + end + local idx = tonumber(selected[1]:match('^(%d+)')) + def.fn(idx and opts.entries[idx] or nil) + end + end + end + + require('fzf-lua').fzf_exec(lines, { + fzf_args = fzf_args, + prompt = opts.prompt or '', + fzf_opts = { + ['--ansi'] = '', + ['--no-multi'] = '', + ['--with-nth'] = '2..', + ['--delimiter'] = '\t', + }, + actions = fzf_actions, + }) +end + +return M diff --git a/lua/forge/picker/init.lua b/lua/forge/picker/init.lua new file mode 100644 index 0000000..7ce309d --- /dev/null +++ b/lua/forge/picker/init.lua @@ -0,0 +1,77 @@ +local M = {} + +---@alias forge.Segment {[1]: string, [2]: string?} + +---@class forge.PickerEntry +---@field display forge.Segment[] +---@field value any +---@field ordinal string? + +---@class forge.PickerActionDef +---@field name string +---@field fn fun(entry: forge.PickerEntry?) + +---@class forge.PickerOpts +---@field prompt string? +---@field entries forge.PickerEntry[] +---@field actions forge.PickerActionDef[] +---@field picker_name string + +M.backends = { + ['fzf-lua'] = 'forge.picker.fzf', + telescope = 'forge.picker.telescope', + snacks = 'forge.picker.snacks', +} + +M.detect_order = { 'fzf-lua', 'snacks', 'telescope' } + +---@return string +local function detect() + local cfg = require('forge').config() + local name = cfg.picker or 'auto' + if name ~= 'auto' then + return name + end + for _, backend in ipairs(M.detect_order) do + if pcall(require, backend) then + return backend + end + end + return M.detect_order[1] +end + +---@param entry forge.PickerEntry +---@return string +function M.ordinal(entry) + if entry.ordinal then + return entry.ordinal + end + local parts = {} + for _, seg in ipairs(entry.display) do + table.insert(parts, seg[1]) + end + return table.concat(parts) +end + +---@return string +function M.backend() + return detect() +end + +---@param opts forge.PickerOpts +function M.pick(opts) + local name = detect() + local mod_path = M.backends[name] + if not mod_path then + vim.notify('[forge]: unknown picker backend: ' .. name, vim.log.levels.ERROR) + return + end + local ok, backend = pcall(require, mod_path) + if not ok then + vim.notify('[forge]: picker backend ' .. name .. ' not available', vim.log.levels.ERROR) + return + end + backend.pick(opts) +end + +return M diff --git a/lua/forge/picker/snacks.lua b/lua/forge/picker/snacks.lua new file mode 100644 index 0000000..72c6501 --- /dev/null +++ b/lua/forge/picker/snacks.lua @@ -0,0 +1,64 @@ +local M = {} + +---@param opts forge.PickerOpts +function M.pick(opts) + local Snacks = require('snacks') + local picker_mod = require('forge.picker') + + local cfg = require('forge').config() + local keys = cfg.keys + if keys == false then + keys = {} + end + local bindings = keys[opts.picker_name] or {} + + local items = {} + for i, entry in ipairs(opts.entries) do + items[i] = { + idx = i, + text = picker_mod.ordinal(entry), + value = entry, + } + end + + local snacks_actions = {} + local input_keys = {} + local list_keys = {} + for _, def in ipairs(opts.actions) do + local key = def.name == 'default' and '' or bindings[def.name] + if key then + local action_name = 'forge_' .. def.name + snacks_actions[action_name] = function(picker) + local item = picker:current() + picker:close() + def.fn(item and item.value or nil) + end + if key == '' then + snacks_actions['confirm'] = snacks_actions[action_name] + else + -- selene: allow(mixed_table) + input_keys[key] = { action_name, mode = { 'i', 'n' } } + list_keys[key] = action_name + end + end + end + + Snacks.picker({ + items = items, + prompt = opts.prompt, + format = function(item) + local ret = {} + for _, seg in ipairs(item.value.display) do + table.insert(ret, { seg[1], seg[2] or 'Normal' }) + end + return ret + end, + actions = snacks_actions, + win = { + input = { keys = input_keys }, + list = { keys = list_keys }, + }, + }) +end + +return M diff --git a/lua/forge/picker/telescope.lua b/lua/forge/picker/telescope.lua new file mode 100644 index 0000000..026ae3c --- /dev/null +++ b/lua/forge/picker/telescope.lua @@ -0,0 +1,69 @@ +local M = {} + +---@param opts forge.PickerOpts +function M.pick(opts) + local pickers = require('telescope.pickers') + local finders = require('telescope.finders') + local conf = require('telescope.config').values + local actions = require('telescope.actions') + local action_state = require('telescope.actions.state') + local picker_mod = require('forge.picker') + + local cfg = require('forge').config() + local keys = cfg.keys + if keys == false then + keys = {} + end + local bindings = keys[opts.picker_name] or {} + + local finder = finders.new_table({ + results = opts.entries, + entry_maker = function(entry) + return { + value = entry, + ordinal = picker_mod.ordinal(entry), + display = function(tbl) + local text = '' + local hl_list = {} + for _, seg in ipairs(tbl.value.display) do + local start = #text + text = text .. seg[1] + if seg[2] then + table.insert(hl_list, { { start, #text }, seg[2] }) + end + end + return text, hl_list + end, + } + end, + }) + + pickers + .new({}, { + prompt_title = (opts.prompt or ''):gsub('[>%s]+$', ''), + finder = finder, + sorter = conf.generic_sorter({}), + attach_mappings = function(prompt_bufnr, map) + for _, def in ipairs(opts.actions) do + local key = def.name == 'default' and '' or bindings[def.name] + if key then + local function action_fn() + local entry = action_state.get_selected_entry() + actions.close(prompt_bufnr) + def.fn(entry and entry.value or nil) + end + if key == '' then + actions.select_default:replace(action_fn) + else + map('i', key, action_fn) + map('n', key, action_fn) + end + end + end + return true + end, + }) + :find() +end + +return M diff --git a/lua/forge/pickers.lua b/lua/forge/pickers.lua index 3377035..60b2139 100644 --- a/lua/forge/pickers.lua +++ b/lua/forge/pickers.lua @@ -1,5 +1,7 @@ local M = {} +local picker = require('forge.picker') + ---@param result { code: integer, stdout: string?, stderr: string? } ---@param fallback string ---@return string @@ -15,36 +17,6 @@ local function cmd_error(result, fallback) return msg end -local fzf_args = (vim.env.FZF_DEFAULT_OPTS or '') - :gsub('%-%-bind=[^%s]+', '') - :gsub('%-%-color=[^%s]+', '') - -local function to_fzf_key(key) - if key == '' then - return 'default' - end - return key:gsub('', function(ch) - return 'ctrl-' .. ch:lower() - end) -end - -local function build_actions(picker_name, action_defs) - local cfg = require('forge').config() - local keys = cfg.keys - if keys == false then - keys = {} - end - local bindings = keys[picker_name] or {} - local actions = {} - for _, def in ipairs(action_defs) do - local key = bindings[def.name] - if key then - actions[to_fzf_key(key)] = def.fn - end - end - return actions -end - ---@param kind string ---@param num string ---@param label string @@ -79,130 +51,93 @@ end ---@param f forge.Forge ---@param num string ---@return table -local function pr_actions(f, num) +local function pr_action_fns(f, num) local kind = f.labels.pr_one - - local defs = { - { - name = 'checkout', - fn = function() - local forge_mod = require('forge') - forge_mod.log_now(('checking out %s #%s...'):format(kind, num)) - vim.system(f:checkout_cmd(num), { text = true }, function(result) + return { + checkout = function() + local forge_mod = require('forge') + forge_mod.log_now(('checking out %s #%s...'):format(kind, num)) + vim.system(f:checkout_cmd(num), { text = true }, function(result) + vim.schedule(function() + if result.code == 0 then + vim.notify(('[forge]: checked out %s #%s'):format(kind, num)) + else + vim.notify('[forge]: ' .. cmd_error(result, 'checkout failed'), vim.log.levels.ERROR) + end + vim.cmd.redraw() + end) + end) + end, + browse = function() + f:view_web(f.kinds.pr, num) + end, + worktree = function() + local forge_mod = require('forge') + local fetch_cmd = f:fetch_pr(num) + local branch = fetch_cmd[#fetch_cmd]:match(':(.+)$') + if not branch then + return + end + local root = vim.trim(vim.fn.system('git rev-parse --show-toplevel')) + local wt_path = vim.fs.normalize(root .. '/../' .. branch) + forge_mod.log_now(('fetching %s #%s into worktree...'):format(kind, num)) + vim.system(fetch_cmd, { text = true }, function() + vim.system({ 'git', 'worktree', 'add', wt_path, branch }, { text = true }, function(result) vim.schedule(function() if result.code == 0 then - vim.notify(('[forge]: checked out %s #%s'):format(kind, num)) + vim.notify(('[forge]: worktree at %s'):format(wt_path)) else - vim.notify('[forge]: ' .. cmd_error(result, 'checkout failed'), vim.log.levels.ERROR) + vim.notify('[forge]: ' .. cmd_error(result, 'worktree failed'), vim.log.levels.ERROR) end vim.cmd.redraw() end) end) - end, - }, - { - name = 'browse', - fn = function() - f:view_web(f.kinds.pr, num) - end, - }, - { - name = 'worktree', - fn = function() - local forge_mod = require('forge') - local fetch_cmd = f:fetch_pr(num) - local branch = fetch_cmd[#fetch_cmd]:match(':(.+)$') - if not branch then - return + end) + end, + diff = function() + local forge_mod = require('forge') + local review = require('forge.review') + local repo_root = vim.trim(vim.fn.system('git rev-parse --show-toplevel')) + + forge_mod.log_now(('reviewing %s #%s...'):format(kind, num)) + vim.system(f:checkout_cmd(num), { text = true }, function(co_result) + if co_result.code ~= 0 then + vim.schedule(function() + forge_mod.log('checkout skipped, proceeding with diff') + end) end - local root = vim.trim(vim.fn.system('git rev-parse --show-toplevel')) - local wt_path = vim.fs.normalize(root .. '/../' .. branch) - forge_mod.log_now(('fetching %s #%s into worktree...'):format(kind, num)) - vim.system(fetch_cmd, { text = true }, function() - vim.system( - { 'git', 'worktree', 'add', wt_path, branch }, - { text = true }, - function(result) - vim.schedule(function() - if result.code == 0 then - vim.notify(('[forge]: worktree at %s'):format(wt_path)) - else - vim.notify( - '[forge]: ' .. cmd_error(result, 'worktree failed'), - vim.log.levels.ERROR - ) - end - vim.cmd.redraw() - end) + + vim.system(f:pr_base_cmd(num), { text = true }, function(base_result) + vim.schedule(function() + local base = vim.trim(base_result.stdout or '') + if base == '' or base_result.code ~= 0 then + base = 'main' end - ) - end) - end, - }, - { - name = 'diff', - fn = function() - local forge_mod = require('forge') - local review = require('forge.review') - local repo_root = vim.trim(vim.fn.system('git rev-parse --show-toplevel')) - - forge_mod.log_now(('reviewing %s #%s...'):format(kind, num)) - vim.system(f:checkout_cmd(num), { text = true }, function(co_result) - if co_result.code ~= 0 then - vim.schedule(function() - forge_mod.log('checkout skipped, proceeding with diff') - end) - end - - vim.system(f:pr_base_cmd(num), { text = true }, function(base_result) - vim.schedule(function() - local base = vim.trim(base_result.stdout or '') - if base == '' or base_result.code ~= 0 then - base = 'main' - end - local range = 'origin/' .. base - review.start(range) - local ok, commands = pcall(require, 'diffs.commands') - if ok then - commands.greview(range, { repo_root = repo_root }) - end - forge_mod.log(('review ready for %s #%s against %s'):format(kind, num, base)) - end) + local range = 'origin/' .. base + review.start(range) + local ok, commands = pcall(require, 'diffs.commands') + if ok then + commands.greview(range, { repo_root = repo_root }) + end + forge_mod.log(('review ready for %s #%s against %s'):format(kind, num, base)) end) end) - end, - }, - { - name = 'ci', - fn = function() - if f.capabilities.per_pr_checks then - M.checks(f, num) - else - require('forge').log( - ('per-%s checks unavailable on %s, showing repo CI'):format(kind, f.name) - ) - M.ci(f) - end - end, - }, - { - name = 'manage', - fn = function() - M.pr_manage(f, num) - end, - }, + end) + end, + ci = function() + if f.capabilities.per_pr_checks then + M.checks(f, num) + else + require('forge').log( + ('per-%s checks unavailable on %s, showing repo CI'):format(kind, f.name) + ) + M.ci(f) + end + end, + manage = function() + M.pr_manage(f, num) + end, } - - ---@type table - local name_to_fn = {} - for _, def in ipairs(defs) do - name_to_fn[def.name] = def.fn - end - - local actions = build_actions('pr', defs) - ---@type table - actions._by_name = name_to_fn - return actions end ---@param f forge.Forge @@ -223,7 +158,10 @@ local function pr_manage_picker(f, num) local action_map = {} local function add(label, fn) - table.insert(entries, label) + table.insert(entries, { + display = { { label } }, + value = label, + }) action_map[label] = fn end @@ -267,17 +205,20 @@ local function pr_manage_picker(f, num) end) end - require('fzf-lua').fzf_exec(entries, { - fzf_args = fzf_args, + picker.pick({ prompt = ('%s #%s Actions> '):format(kind, num), - fzf_opts = { ['--no-multi'] = '' }, + entries = entries, actions = { - ['default'] = function(selected) - if selected[1] and action_map[selected[1]] then - action_map[selected[1]]() - end - end, + { + name = 'default', + fn = function(entry) + if entry and action_map[entry.value] then + action_map[entry.value]() + end + end, + }, }, + picker_name = '_menu', }) end @@ -291,18 +232,13 @@ function M.checks(f, num, filter, cached_checks) local function open_picker(checks) local filtered = forge_mod.filter_checks(checks, filter) - local lines = {} - for i, c in ipairs(filtered) do - local line = ('%d\t%s'):format(i, forge_mod.format_check(c)) - table.insert(lines, line) - end - - local function get_check(selected) - if not selected[1] then - return nil - end - local idx = tonumber(selected[1]:match('^(%d+)')) - return idx and filtered[idx] or nil + local entries = {} + for _, c in ipairs(filtered) do + table.insert(entries, { + display = forge_mod.format_check(c), + value = c, + ordinal = c.name or '', + }) end local labels = { @@ -312,83 +248,75 @@ function M.checks(f, num, filter, cached_checks) pending = 'running', } - local check_actions = build_actions('ci', { - { - name = 'log', - fn = function(selected) - local c = get_check(selected) - if not c then - return - end - local run_id = (c.link or ''):match('/actions/runs/(%d+)') - if not run_id then - return - end - forge_mod.log_now('fetching check logs...') - local bucket = (c.bucket or ''):lower() - local cmd - if bucket == 'pending' then - cmd = f:check_tail_cmd(run_id) - else - cmd = f:check_log_cmd(run_id, bucket == 'fail') - end - vim.cmd('noautocmd botright new') - vim.fn.termopen(cmd) - vim.api.nvim_feedkeys( - vim.api.nvim_replace_termcodes('G', true, false, true), - 'n', - false - ) - if c.link then - vim.b.forge_check_url = c.link - end - end, - }, - { - name = 'browse', - fn = function(selected) - local c = get_check(selected) - if c and c.link then - vim.ui.open(c.link) - end - end, - }, - { - name = 'failed', - fn = function() - M.checks(f, num, 'fail', checks) - end, - }, - { - name = 'passed', - fn = function() - M.checks(f, num, 'pass', checks) - end, - }, - { - name = 'running', - fn = function() - M.checks(f, num, 'pending', checks) - end, - }, - { - name = 'all', - fn = function() - M.checks(f, num, 'all', checks) - end, - }, - }) - - require('fzf-lua').fzf_exec(lines, { - fzf_args = fzf_args, + picker.pick({ prompt = ('Checks (#%s, %s)> '):format(num, labels[filter] or filter), - fzf_opts = { - ['--ansi'] = '', - ['--no-multi'] = '', - ['--with-nth'] = '2..', - ['--delimiter'] = '\t', + entries = entries, + actions = { + { + name = 'log', + fn = function(entry) + if not entry then + return + end + local c = entry.value + local run_id = (c.link or ''):match('/actions/runs/(%d+)') + if not run_id then + return + end + forge_mod.log_now('fetching check logs...') + local bucket = (c.bucket or ''):lower() + local cmd + if bucket == 'pending' then + cmd = f:check_tail_cmd(run_id) + else + cmd = f:check_log_cmd(run_id, bucket == 'fail') + end + vim.cmd('noautocmd botright new') + vim.fn.termopen(cmd) + vim.api.nvim_feedkeys( + vim.api.nvim_replace_termcodes('G', true, false, true), + 'n', + false + ) + if c.link then + vim.b.forge_check_url = c.link + end + end, + }, + { + name = 'browse', + fn = function(entry) + if entry and entry.value.link then + vim.ui.open(entry.value.link) + end + end, + }, + { + name = 'failed', + fn = function() + M.checks(f, num, 'fail', checks) + end, + }, + { + name = 'passed', + fn = function() + M.checks(f, num, 'pass', checks) + end, + }, + { + name = 'running', + fn = function() + M.checks(f, num, 'pending', checks) + end, + }, + { + name = 'all', + fn = function() + M.checks(f, num, 'all', checks) + end, + }, }, - actions = check_actions, + picker_name = 'ci', }) end @@ -412,16 +340,7 @@ function M.checks(f, num, filter, cached_checks) end) end) else - require('fzf-lua').fzf_exec(f:checks_cmd(num), { - fzf_args = fzf_args, - prompt = ('Checks (#%s)> '):format(num), - fzf_opts = { ['--ansi'] = '' }, - actions = { - ['ctrl-r'] = function() - M.checks(f, num, filter) - end, - }, - }) + vim.notify('[forge]: structured checks not available for this forge', vim.log.levels.INFO) end end @@ -430,82 +349,70 @@ end function M.ci(f, branch) local forge_mod = require('forge') - local function open_picker(runs) + local function open_ci_picker(runs) local normalized = {} for _, entry in ipairs(runs) do table.insert(normalized, f:normalize_run(entry)) end - local lines = {} - for i, run in ipairs(normalized) do - table.insert(lines, ('%d\t%s'):format(i, forge_mod.format_run(run))) + local entries = {} + for _, run in ipairs(normalized) do + table.insert(entries, { + display = forge_mod.format_run(run), + value = run, + ordinal = run.name .. ' ' .. run.branch, + }) end - local function get_run(selected) - if not selected[1] then - return nil - end - local idx = tonumber(selected[1]:match('^(%d+)')) - return idx and normalized[idx] or nil - end - - local ci_actions = build_actions('ci', { - { - name = 'log', - fn = function(selected) - local run = get_run(selected) - if not run then - return - end - forge_mod.log_now('fetching CI/CD logs...') - local s = run.status:lower() - local cmd - if s == 'in_progress' or s == 'running' or s == 'pending' or s == 'queued' then - cmd = f:run_tail_cmd(run.id) - elseif s == 'failure' or s == 'failed' then - cmd = f:run_log_cmd(run.id, true) - else - cmd = f:run_log_cmd(run.id, false) - end - vim.cmd('noautocmd botright new') - vim.fn.termopen(cmd) - vim.api.nvim_feedkeys( - vim.api.nvim_replace_termcodes('G', true, false, true), - 'n', - false - ) - if run.url ~= '' then - vim.b.forge_run_url = run.url - end - end, - }, - { - name = 'browse', - fn = function(selected) - local run = get_run(selected) - if run and run.url ~= '' then - vim.ui.open(run.url) - end - end, - }, - { - name = 'refresh', - fn = function() - M.ci(f, branch) - end, - }, - }) - - require('fzf-lua').fzf_exec(lines, { - fzf_args = fzf_args, + picker.pick({ prompt = ('%s (%s)> '):format(f.labels.ci, branch or 'all'), - fzf_opts = { - ['--ansi'] = '', - ['--no-multi'] = '', - ['--with-nth'] = '2..', - ['--delimiter'] = '\t', + entries = entries, + actions = { + { + name = 'log', + fn = function(entry) + if not entry then + return + end + local run = entry.value + forge_mod.log_now('fetching CI/CD logs...') + local s = run.status:lower() + local cmd + if s == 'in_progress' or s == 'running' or s == 'pending' or s == 'queued' then + cmd = f:run_tail_cmd(run.id) + elseif s == 'failure' or s == 'failed' then + cmd = f:run_log_cmd(run.id, true) + else + cmd = f:run_log_cmd(run.id, false) + end + vim.cmd('noautocmd botright new') + vim.fn.termopen(cmd) + vim.api.nvim_feedkeys( + vim.api.nvim_replace_termcodes('G', true, false, true), + 'n', + false + ) + if run.url ~= '' then + vim.b.forge_run_url = run.url + end + end, + }, + { + name = 'browse', + fn = function(entry) + if entry and entry.value.url ~= '' then + vim.ui.open(entry.value.url) + end + end, + }, + { + name = 'refresh', + fn = function() + M.ci(f, branch) + end, + }, }, - actions = ci_actions, + picker_name = 'ci', }) end @@ -515,7 +422,7 @@ function M.ci(f, branch) vim.schedule(function() local ok, runs = pcall(vim.json.decode, result.stdout or '[]') if ok and runs and #runs > 0 then - open_picker(runs) + open_ci_picker(runs) else vim.notify('[forge]: no CI runs found', vim.log.levels.INFO) vim.cmd.redraw() @@ -523,11 +430,7 @@ function M.ci(f, branch) end) end) elseif f.list_runs_cmd then - require('fzf-lua').fzf_exec(f:list_runs_cmd(branch), { - fzf_args = fzf_args, - prompt = f.labels.ci .. '> ', - fzf_opts = { ['--ansi'] = '' }, - }) + vim.notify('[forge]: structured CI data not available for this forge', vim.log.levels.INFO) end end @@ -535,6 +438,27 @@ end function M.commits(f) local forge_mod = require('forge') local review = require('forge.review') + + if picker.backend() ~= 'fzf-lua' then + vim.notify('[forge]: commits picker requires fzf-lua', vim.log.levels.WARN) + return + end + + local fzf_args = (vim.env.FZF_DEFAULT_OPTS or '') + :gsub('%-%-bind=[^%s]+', '') + :gsub('%-%-color=[^%s]+', '') + + local function to_fzf_key(key) + if key == '' then + return 'default' + end + return key:gsub('', function(ch) + return 'ctrl-' .. ch:lower() + end) + end + + local cfg = require('forge').config() + local keys = type(cfg.keys) == 'table' and cfg.keys.commits or {} local log_cmd = 'git log --color --pretty=format:"%C(yellow)%h%Creset %Cgreen(%><(12)%cr%><|(12))%Creset %s %C(blue)<%an>%Creset"' @@ -545,64 +469,54 @@ function M.commits(f) end end - local defs = { - { - name = 'checkout', - fn = function(selected) - with_sha(selected, function(sha) - forge_mod.log_now('checking out ' .. sha .. '...') - vim.system({ 'git', 'checkout', sha }, { text = true }, function(result) - vim.schedule(function() - if result.code == 0 then - vim.notify(('[forge]: checked out %s (detached)'):format(sha)) - else - vim.notify( - '[forge]: ' .. cmd_error(result, 'checkout failed'), - vim.log.levels.ERROR - ) - end - vim.cmd.redraw() - end) + local fzf_actions = {} + if keys.checkout then + fzf_actions[to_fzf_key(keys.checkout)] = function(selected) + with_sha(selected, function(sha) + forge_mod.log_now('checking out ' .. sha .. '...') + vim.system({ 'git', 'checkout', sha }, { text = true }, function(result) + vim.schedule(function() + if result.code == 0 then + vim.notify(('[forge]: checked out %s (detached)'):format(sha)) + else + vim.notify('[forge]: ' .. cmd_error(result, 'checkout failed'), vim.log.levels.ERROR) + end + vim.cmd.redraw() end) end) - end, - }, - { - name = 'diff', - fn = function(selected) - with_sha(selected, function(sha) - local range = sha .. '^..' .. sha - review.start(range) - local ok, commands = pcall(require, 'diffs.commands') - if ok then - commands.greview(range) - end - forge_mod.log_now('reviewing ' .. sha) - end) - end, - }, - { - name = 'browse', - fn = function(selected) - with_sha(selected, function(sha) - if f then - f:browse_commit(sha) - end - end) - end, - }, - { - name = 'yank', - fn = function(selected) - with_sha(selected, function(sha) - vim.fn.setreg('+', sha) - vim.notify('[forge]: copied ' .. sha) - end) - end, - }, - } - - local commit_actions = build_actions('commits', defs) + end) + end + end + if keys.diff then + fzf_actions[to_fzf_key(keys.diff)] = function(selected) + with_sha(selected, function(sha) + local range = sha .. '^..' .. sha + review.start(range) + local ok, commands = pcall(require, 'diffs.commands') + if ok then + commands.greview(range) + end + forge_mod.log_now('reviewing ' .. sha) + end) + end + end + if keys.browse then + fzf_actions[to_fzf_key(keys.browse)] = function(selected) + with_sha(selected, function(sha) + if f then + f:browse_commit(sha) + end + end) + end + end + if keys.yank then + fzf_actions[to_fzf_key(keys.yank)] = function(selected) + with_sha(selected, function(sha) + vim.fn.setreg('+', sha) + vim.notify('[forge]: copied ' .. sha) + end) + end + end require('fzf-lua').fzf_exec(log_cmd, { fzf_args = fzf_args, @@ -612,7 +526,7 @@ function M.commits(f) ['--no-multi'] = '', ['--preview'] = 'git show --color {1}', }, - actions = commit_actions, + actions = fzf_actions, }) end @@ -621,41 +535,54 @@ function M.branches(f) local forge_mod = require('forge') local review = require('forge.review') - local defs = { - { - name = 'diff', - fn = function(selected) - if not selected[1] then - return - end - local br = selected[1]:match('%s-[%+%*]?%s+([^ ]+)') - if not br then - return - end - review.start(br) - local ok, commands = pcall(require, 'diffs.commands') - if ok then - commands.greview(br) - end - forge_mod.log_now('reviewing ' .. br) - end, - }, - { - name = 'browse', - fn = function(selected) - if not selected[1] then - return - end - local br = selected[1]:match('%s-[%+%*]?%s+([^ ]+)') - if br and f then - f:browse_branch(br) - end - end, - }, - } + if picker.backend() ~= 'fzf-lua' then + vim.notify('[forge]: branches picker requires fzf-lua', vim.log.levels.WARN) + return + end - local branch_actions = build_actions('branches', defs) - require('fzf-lua').git_branches({ actions = branch_actions }) + local function to_fzf_key(key) + if key == '' then + return 'default' + end + return key:gsub('', function(ch) + return 'ctrl-' .. ch:lower() + end) + end + + local cfg = require('forge').config() + local keys = type(cfg.keys) == 'table' and cfg.keys.branches or {} + local fzf_actions = {} + + if keys.diff then + fzf_actions[to_fzf_key(keys.diff)] = function(selected) + if not selected[1] then + return + end + local br = selected[1]:match('%s-[%+%*]?%s+([^ ]+)') + if not br then + return + end + review.start(br) + local ok, commands = pcall(require, 'diffs.commands') + if ok then + commands.greview(br) + end + forge_mod.log_now('reviewing ' .. br) + end + end + if keys.browse then + fzf_actions[to_fzf_key(keys.browse)] = function(selected) + if not selected[1] then + return + end + local br = selected[1]:match('%s-[%+%*]?%s+([^ ]+)') + if br and f then + f:browse_branch(br) + end + end + end + + require('fzf-lua').git_branches({ actions = fzf_actions }) end ---@param state 'all'|'open'|'closed' @@ -669,95 +596,89 @@ function M.pr(state, f) local show_state = state ~= 'open' local function open_pr_list(prs) - local lines = {} + local entries = {} for _, pr in ipairs(prs) do - table.insert(lines, forge_mod.format_pr(pr, pr_fields, show_state)) - end - local function with_pr_num(selected, fn) - local num = selected[1] and selected[1]:match('[#!](%d+)') - if num then - fn(num) - end + local num = tostring(pr[pr_fields.number] or '') + table.insert(entries, { + display = forge_mod.format_pr(pr, pr_fields, show_state), + value = num, + ordinal = (pr[pr_fields.title] or '') .. ' #' .. num, + }) end - local list_actions = build_actions('pr', { - { - name = 'checkout', - fn = function(selected) - with_pr_num(selected, function(num) - pr_actions(f, num)._by_name['checkout']() - end) - end, - }, - { - name = 'diff', - fn = function(selected) - with_pr_num(selected, function(num) - pr_actions(f, num)._by_name['diff']() - end) - end, - }, - { - name = 'worktree', - fn = function(selected) - with_pr_num(selected, function(num) - pr_actions(f, num)._by_name['worktree']() - end) - end, - }, - { - name = 'ci', - fn = function(selected) - with_pr_num(selected, function(num) - pr_actions(f, num)._by_name['ci']() - end) - end, - }, - { - name = 'browse', - fn = function(selected) - with_pr_num(selected, function(num) - f:view_web(cli_kind, num) - end) - end, - }, - { - name = 'manage', - fn = function(selected) - with_pr_num(selected, function(num) - pr_actions(f, num)._by_name['manage']() - end) - end, - }, - { - name = 'create', - fn = function() - forge_mod.create_pr() - end, - }, - { - name = 'filter', - fn = function() - M.pr(next_state, f) - end, - }, - { - name = 'refresh', - fn = function() - forge_mod.clear_list(cache_key) - M.pr(state, f) - end, - }, - }) - - require('fzf-lua').fzf_exec(lines, { - fzf_args = fzf_args, + picker.pick({ prompt = ('%s (%s)> '):format(f.labels.pr, state), - fzf_opts = { - ['--ansi'] = '', - ['--no-multi'] = '', + entries = entries, + actions = { + { + name = 'checkout', + fn = function(entry) + if entry then + pr_action_fns(f, entry.value).checkout() + end + end, + }, + { + name = 'diff', + fn = function(entry) + if entry then + pr_action_fns(f, entry.value).diff() + end + end, + }, + { + name = 'worktree', + fn = function(entry) + if entry then + pr_action_fns(f, entry.value).worktree() + end + end, + }, + { + name = 'ci', + fn = function(entry) + if entry then + pr_action_fns(f, entry.value).ci() + end + end, + }, + { + name = 'browse', + fn = function(entry) + if entry then + f:view_web(cli_kind, entry.value) + end + end, + }, + { + name = 'manage', + fn = function(entry) + if entry then + pr_action_fns(f, entry.value).manage() + end + end, + }, + { + name = 'create', + fn = function() + forge_mod.create_pr() + end, + }, + { + name = 'filter', + fn = function() + M.pr(next_state, f) + end, + }, + { + name = 'refresh', + fn = function() + forge_mod.clear_list(cache_key) + M.pr(state, f) + end, + }, }, - actions = list_actions, + picker_name = 'pr', }) end @@ -795,60 +716,53 @@ function M.issue(state, f) end) local state_field = issue_fields.state local state_map = {} - local lines = {} + local entries = {} for _, issue in ipairs(issues) do local n = tostring(issue[num_field] or '') local s = (issue[state_field] or ''):lower() state_map[n] = s == 'open' or s == 'opened' - table.insert(lines, forge_mod.format_issue(issue, issue_fields, issue_show_state)) - end - local function with_issue_num(selected, fn) - local num = selected[1] and selected[1]:match('[#!](%d+)') - if num then - fn(num) - end + table.insert(entries, { + display = forge_mod.format_issue(issue, issue_fields, issue_show_state), + value = n, + ordinal = (issue[issue_fields.title] or '') .. ' #' .. n, + }) end - local issue_actions = build_actions('issue', { - { - name = 'browse', - fn = function(selected) - with_issue_num(selected, function(num) - f:view_web(cli_kind, num) - end) - end, - }, - { - name = 'close', - fn = function(selected) - with_issue_num(selected, function(num) - issue_toggle_state(f, num, state_map[num] ~= false) - end) - end, - }, - { - name = 'filter', - fn = function() - M.issue(next_state, f) - end, - }, - { - name = 'refresh', - fn = function() - forge_mod.clear_list(cache_key) - M.issue(state, f) - end, - }, - }) - - require('fzf-lua').fzf_exec(lines, { - fzf_args = fzf_args, + picker.pick({ prompt = ('%s (%s)> '):format(f.labels.issue, state), - fzf_opts = { - ['--ansi'] = '', - ['--no-multi'] = '', + entries = entries, + actions = { + { + name = 'browse', + fn = function(entry) + if entry then + f:view_web(cli_kind, entry.value) + end + end, + }, + { + name = 'close', + fn = function(entry) + if entry then + issue_toggle_state(f, entry.value, state_map[entry.value] ~= false) + end + end, + }, + { + name = 'filter', + fn = function() + M.issue(next_state, f) + end, + }, + { + name = 'refresh', + fn = function() + forge_mod.clear_list(cache_key) + M.issue(state, f) + end, + }, }, - actions = issue_actions, + picker_name = 'issue', }) end @@ -891,7 +805,7 @@ end ---@param num string ---@return table function M.pr_actions(f, num) - return pr_actions(f, num) + return pr_action_fns(f, num) end function M.git() @@ -913,11 +827,14 @@ function M.git() local branch = vim.trim(vim.fn.system('git branch --show-current')) local items = {} - local actions = {} + local action_map = {} local function add(label, action) - table.insert(items, label) - actions[label] = action + table.insert(items, { + display = { { label } }, + value = label, + }) + action_map[label] = action end if f then @@ -968,21 +885,29 @@ function M.git() end) add('Worktrees', function() - require('fzf-lua').git_worktrees() + if picker.backend() == 'fzf-lua' then + require('fzf-lua').git_worktrees() + else + vim.notify('[forge]: worktrees picker requires fzf-lua', vim.log.levels.WARN) + end end) local prompt = f and (f.name:sub(1, 1):upper() .. f.name:sub(2)) .. '> ' or 'Git> ' - require('fzf-lua').fzf_exec(items, { - fzf_args = fzf_args, + picker.pick({ prompt = prompt, + entries = items, actions = { - ['default'] = function(selected) - if selected[1] and actions[selected[1]] then - actions[selected[1]]() - end - end, + { + name = 'default', + fn = function(entry) + if entry and action_map[entry.value] then + action_map[entry.value]() + end + end, + }, }, + picker_name = '_menu', }) end diff --git a/spec/init_spec.lua b/spec/init_spec.lua index 5702349..2e9fb5d 100644 --- a/spec/init_spec.lua +++ b/spec/init_spec.lua @@ -10,6 +10,14 @@ end local forge = require('forge') +local function flatten(segments) + local parts = {} + for _, seg in ipairs(segments) do + parts[#parts + 1] = seg[1] + end + return table.concat(parts) +end + describe('config', function() after_each(function() vim.g.forge = nil @@ -53,7 +61,7 @@ describe('format_pr', function() it('formats open PR with state icon', function() local entry = { number = 42, title = 'fix bug', state = 'OPEN', login = 'alice', created_at = '' } - local result = forge.format_pr(entry, fields, true) + local result = flatten(forge.format_pr(entry, fields, true)) assert.truthy(result:find('+')) assert.truthy(result:find('#42')) assert.truthy(result:find('fix bug')) @@ -62,20 +70,20 @@ describe('format_pr', function() it('formats merged PR', function() local entry = { number = 7, title = 'add feature', state = 'MERGED', login = 'bob', created_at = '' } - local result = forge.format_pr(entry, fields, true) + local result = flatten(forge.format_pr(entry, fields, true)) assert.truthy(result:find('m')) assert.truthy(result:find('#7')) end) it('formats closed PR', function() local entry = { number = 3, title = 'stale', state = 'CLOSED', login = 'eve', created_at = '' } - local result = forge.format_pr(entry, fields, true) + local result = flatten(forge.format_pr(entry, fields, true)) assert.truthy(result:find('x')) end) it('omits state prefix when show_state is false', function() local entry = { number = 1, title = 'no state', state = 'OPEN', login = 'dev', created_at = '' } - local result = forge.format_pr(entry, fields, false) + local result = flatten(forge.format_pr(entry, fields, false)) assert.truthy(result:find('#1')) assert.falsy(result:match('^+')) end) @@ -83,14 +91,14 @@ describe('format_pr', function() it('truncates long titles', function() local long_title = string.rep('a', 100) local entry = { number = 9, title = long_title, state = 'OPEN', login = 'x', created_at = '' } - local result = forge.format_pr(entry, fields, false) + local result = flatten(forge.format_pr(entry, fields, false)) assert.falsy(result:find(long_title)) end) it('extracts author from table with login field', function() local entry = { number = 5, title = 't', state = 'OPEN', login = { login = 'nested' }, created_at = '' } - local result = forge.format_pr(entry, fields, false) + local result = flatten(forge.format_pr(entry, fields, false)) assert.truthy(result:find('nested')) end) end) @@ -107,59 +115,59 @@ describe('format_issue', function() it('formats open issue', function() local entry = { number = 10, title = 'bug report', state = 'open', author = 'alice', created_at = '' } - local result = forge.format_issue(entry, fields, true) + local result = flatten(forge.format_issue(entry, fields, true)) assert.truthy(result:find('+')) assert.truthy(result:find('#10')) end) it('formats closed issue', function() local entry = { number = 11, title = 'done', state = 'closed', author = 'bob', created_at = '' } - local result = forge.format_issue(entry, fields, true) + local result = flatten(forge.format_issue(entry, fields, true)) assert.truthy(result:find('x')) end) it('handles opened state (GitLab)', function() local entry = { number = 12, title = 'mr issue', state = 'opened', author = 'c', created_at = '' } - local result = forge.format_issue(entry, fields, true) + local result = flatten(forge.format_issue(entry, fields, true)) assert.truthy(result:find('+')) end) end) describe('format_check', function() it('maps pass bucket', function() - local result = forge.format_check({ name = 'lint', bucket = 'pass' }) + local result = flatten(forge.format_check({ name = 'lint', bucket = 'pass' })) assert.truthy(result:find('%*')) assert.truthy(result:find('lint')) end) it('maps fail bucket', function() - local result = forge.format_check({ name = 'build', bucket = 'fail' }) + local result = flatten(forge.format_check({ name = 'build', bucket = 'fail' })) assert.truthy(result:find('x')) end) it('maps pending bucket', function() - local result = forge.format_check({ name = 'test', bucket = 'pending' }) + local result = flatten(forge.format_check({ name = 'test', bucket = 'pending' })) assert.truthy(result:find('~')) end) it('maps skipping bucket', function() - local result = forge.format_check({ name = 'optional', bucket = 'skipping' }) + local result = flatten(forge.format_check({ name = 'optional', bucket = 'skipping' })) assert.truthy(result:find('%-')) end) it('maps cancel bucket', function() - local result = forge.format_check({ name = 'cancelled', bucket = 'cancel' }) + local result = flatten(forge.format_check({ name = 'cancelled', bucket = 'cancel' })) assert.truthy(result:find('%-')) end) it('maps unknown bucket', function() - local result = forge.format_check({ name = 'mystery', bucket = 'something_else' }) + local result = flatten(forge.format_check({ name = 'mystery', bucket = 'something_else' })) assert.truthy(result:find('%?')) end) it('defaults to pending when bucket is nil', function() - local result = forge.format_check({ name = 'none' }) + local result = flatten(forge.format_check({ name = 'none' })) assert.truthy(result:find('~')) end) end) @@ -168,7 +176,7 @@ describe('format_run', function() it('formats successful run with branch', function() local run = { name = 'CI', branch = 'main', status = 'success', event = 'push', created_at = '' } - local result = forge.format_run(run) + local result = flatten(forge.format_run(run)) assert.truthy(result:find('%*')) assert.truthy(result:find('CI')) assert.truthy(result:find('main')) @@ -183,7 +191,7 @@ describe('format_run', function() event = 'workflow_dispatch', created_at = '', } - local result = forge.format_run(run) + local result = flatten(forge.format_run(run)) assert.truthy(result:find('x')) assert.truthy(result:find('manual')) end) @@ -191,13 +199,13 @@ describe('format_run', function() it('maps in_progress status', function() local run = { name = 'Test', branch = '', status = 'in_progress', event = 'push', created_at = '' } - local result = forge.format_run(run) + local result = flatten(forge.format_run(run)) assert.truthy(result:find('~')) end) it('maps cancelled status', function() local run = { name = 'Old', branch = '', status = 'cancelled', event = 'push', created_at = '' } - local result = forge.format_run(run) + local result = flatten(forge.format_run(run)) assert.truthy(result:find('%-')) end) end) @@ -242,39 +250,39 @@ describe('relative_time via format_pr', function() it('shows minutes for recent timestamps', function() local ts = os.date('%Y-%m-%dT%H:%M:%SZ', os.time() - 120) local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = ts } - local result = forge.format_pr(entry, fields, false) + local result = flatten(forge.format_pr(entry, fields, false)) assert.truthy(result:match('%d+m')) end) it('shows hours', function() local ts = os.date('%Y-%m-%dT%H:%M:%SZ', os.time() - 7200) local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = ts } - local result = forge.format_pr(entry, fields, false) + local result = flatten(forge.format_pr(entry, fields, false)) assert.truthy(result:match('%d+h')) end) it('shows days', function() local ts = os.date('%Y-%m-%dT%H:%M:%SZ', os.time() - 172800) local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = ts } - local result = forge.format_pr(entry, fields, false) + local result = flatten(forge.format_pr(entry, fields, false)) assert.truthy(result:match('%d+d')) end) it('returns empty for nil timestamp', function() local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = nil } - local result = forge.format_pr(entry, fields, false) + local result = flatten(forge.format_pr(entry, fields, false)) assert.truthy(result) end) it('returns empty for empty string timestamp', function() local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = '' } - local result = forge.format_pr(entry, fields, false) + local result = flatten(forge.format_pr(entry, fields, false)) assert.truthy(result) end) it('returns empty for garbage timestamp', function() local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = 'not-a-date' } - local result = forge.format_pr(entry, fields, false) + local result = flatten(forge.format_pr(entry, fields, false)) assert.truthy(result) end) end) diff --git a/spec/sources_spec.lua b/spec/sources_spec.lua new file mode 100644 index 0000000..802b4d8 --- /dev/null +++ b/spec/sources_spec.lua @@ -0,0 +1,189 @@ +vim.opt.runtimepath:prepend(vim.fn.getcwd()) + +package.preload['fzf-lua.utils'] = function() + return { + ansi_from_hl = function(_, text) + return text + end, + } +end + +describe('github', function() + local gh = require('forge.github') + + it('has correct metadata', function() + assert.equals('gh', gh.cli) + assert.equals('github', gh.name) + assert.equals('pr', gh.kinds.pr) + assert.equals('issue', gh.kinds.issue) + end) + + it('builds list_pr_json_cmd', function() + local cmd = gh:list_pr_json_cmd('open') + assert.equals('gh', cmd[1]) + assert.truthy(vim.tbl_contains(cmd, '--state')) + assert.truthy(vim.tbl_contains(cmd, 'open')) + assert.truthy(vim.tbl_contains(cmd, '--json')) + end) + + it('builds merge_cmd with method flag', function() + assert.same({ 'gh', 'pr', 'merge', '42', '--squash' }, gh:merge_cmd('42', 'squash')) + assert.same({ 'gh', 'pr', 'merge', '10', '--rebase' }, gh:merge_cmd('10', 'rebase')) + end) + + it('builds create_pr_cmd', function() + local cmd = gh:create_pr_cmd('title', 'body', 'main', false, nil) + assert.truthy(vim.tbl_contains(cmd, '--title')) + assert.truthy(vim.tbl_contains(cmd, '--base')) + assert.falsy(vim.tbl_contains(cmd, '--draft')) + end) + + it('adds draft flag to create_pr_cmd', function() + assert.truthy(vim.tbl_contains(gh:create_pr_cmd('t', 'b', 'main', true, nil), '--draft')) + end) + + it('adds reviewers to create_pr_cmd', function() + local cmd = gh:create_pr_cmd('t', 'b', 'main', false, { 'alice', 'bob' }) + local count = 0 + for _, v in ipairs(cmd) do + if v == '--reviewer' then + count = count + 1 + end + end + assert.equals(2, count) + end) + + it('builds checkout_cmd', function() + assert.same({ 'gh', 'pr', 'checkout', '5' }, gh:checkout_cmd('5')) + end) + + it('builds close/reopen commands', function() + assert.same({ 'gh', 'pr', 'close', '3' }, gh:close_cmd('3')) + assert.same({ 'gh', 'pr', 'reopen', '3' }, gh:reopen_cmd('3')) + end) + + it('returns correct pr_json_fields', function() + local f = gh:pr_json_fields() + assert.equals('number', f.number) + assert.equals('headRefName', f.branch) + assert.equals('createdAt', f.created_at) + end) + + it('normalizes completed run to conclusion', function() + local run = gh:normalize_run({ + databaseId = 123, + name = 'CI', + headBranch = 'main', + status = 'completed', + conclusion = 'success', + event = 'push', + url = 'https://example.com', + createdAt = '2025-01-01T00:00:00Z', + }) + assert.equals('123', run.id) + assert.equals('success', run.status) + assert.equals('main', run.branch) + end) + + it('preserves in_progress status in normalize_run', function() + assert.equals( + 'in_progress', + gh:normalize_run({ databaseId = 1, status = 'in_progress' }).status + ) + end) +end) + +describe('gitlab', function() + local gl = require('forge.gitlab') + + it('has correct metadata', function() + assert.equals('glab', gl.cli) + assert.equals('gitlab', gl.name) + assert.equals('mr', gl.kinds.pr) + end) + + it('builds list_pr_json_cmd with state variants', function() + local cmd = gl:list_pr_json_cmd('open') + assert.equals('glab', cmd[1]) + assert.equals('mr', cmd[2]) + assert.truthy(vim.tbl_contains(gl:list_pr_json_cmd('closed'), '--closed')) + assert.truthy(vim.tbl_contains(gl:list_pr_json_cmd('all'), '--all')) + end) + + it('builds merge_cmd with method flags', function() + assert.same({ 'glab', 'mr', 'merge', '5', '--squash' }, gl:merge_cmd('5', 'squash')) + assert.same({ 'glab', 'mr', 'merge', '5', '--rebase' }, gl:merge_cmd('5', 'rebase')) + assert.same({ 'glab', 'mr', 'merge', '5' }, gl:merge_cmd('5', 'merge')) + end) + + it('builds create_pr_cmd with --description and --target-branch', function() + local cmd = gl:create_pr_cmd('title', 'desc', 'develop', false, nil) + assert.truthy(vim.tbl_contains(cmd, '--description')) + assert.truthy(vim.tbl_contains(cmd, '--target-branch')) + assert.truthy(vim.tbl_contains(cmd, '--yes')) + end) + + it('returns correct pr_json_fields', function() + local f = gl:pr_json_fields() + assert.equals('iid', f.number) + assert.equals('source_branch', f.branch) + assert.equals('created_at', f.created_at) + end) + + it('extracts MR number from ref in normalize_run', function() + local run = gl:normalize_run({ + id = 456, + ref = 'refs/merge-requests/10/head', + status = 'success', + source = 'push', + web_url = 'https://example.com', + created_at = '2025-01-01T00:00:00Z', + }) + assert.equals('456', run.id) + assert.equals('!10', run.name) + end) + + it('uses ref as name for non-MR refs', function() + assert.equals('main', gl:normalize_run({ id = 1, ref = 'main', status = 'running' }).name) + end) +end) + +describe('codeberg', function() + local cb = require('forge.codeberg') + + it('has correct metadata', function() + assert.equals('tea', cb.cli) + assert.equals('codeberg', cb.name) + assert.equals('pulls', cb.kinds.pr) + assert.equals('issues', cb.kinds.issue) + end) + + it('builds list_pr_json_cmd with --fields', function() + local cmd = cb:list_pr_json_cmd('open') + assert.equals('tea', cmd[1]) + assert.truthy(vim.tbl_contains(cmd, '--fields')) + end) + + it('builds merge_cmd with --style', function() + assert.same({ 'tea', 'pr', 'merge', '7', '--style', 'squash' }, cb:merge_cmd('7', 'squash')) + end) + + it('ignores draft and reviewers in create_pr_cmd', function() + local cmd = cb:create_pr_cmd('title', 'body', 'main', true, { 'alice' }) + assert.falsy(vim.tbl_contains(cmd, '--draft')) + assert.falsy(vim.tbl_contains(cmd, '--reviewer')) + assert.truthy(vim.tbl_contains(cmd, '--base')) + end) + + it('returns correct pr_json_fields', function() + local f = cb:pr_json_fields() + assert.equals('index', f.number) + assert.equals('head', f.branch) + assert.equals('poster', f.author) + end) + + it('returns nil from draft_toggle_cmd', function() + assert.is_nil(cb:draft_toggle_cmd('1', true)) + assert.is_nil(cb:draft_toggle_cmd('1', false)) + end) +end)