diff --git a/.github/workflows/luarocks-dev.yaml b/.github/workflows/luarocks-dev.yaml index e7121ac..cc6faab 100644 --- a/.github/workflows/luarocks-dev.yaml +++ b/.github/workflows/luarocks-dev.yaml @@ -1,14 +1,15 @@ name: luarocks-dev on: - workflow_run: - workflows: [quality] - types: [completed] + push: branches: [main] jobs: + quality: + uses: ./.github/workflows/quality.yaml + publish: - if: ${{ github.event.workflow_run.conclusion == 'success' }} + needs: quality runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index ec4a4f7..93ac2c5 100644 --- a/.gitignore +++ b/.gitignore @@ -11,5 +11,3 @@ result result-* .direnv/ .envrc - -scripts/demo diff --git a/README.md b/README.md index faa000a..e24ad54 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,13 @@ **Forge-agnostic git workflow for Neovim** -PR, issue, and CI workflows across GitHub, GitLab, and Codeberg/Gitea/Forgejo — -without leaving your editor. +PR, issue, and CI workflows across GitHub, GitLab, and more — without leaving +your editor. ## Features -- Automatic forge detection from git remote (`gh`, `glab`, `tea`) +- Automatic forge detection from git remote (GitHub via `gh`, GitLab via `glab`, + Codeberg/Gitea/Forgejo via `tea`) - PR lifecycle: list, create (compose buffer with template discovery, diff stat, reviewers), checkout, worktree, review diff, merge, approve, close/reopen, draft toggle @@ -16,52 +17,27 @@ without leaving 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 +- File/line permalink generation and yanking (commit and branch URLs) - [fzf-lua](https://github.com/ibhagwan/fzf-lua) pickers with contextual keybinds - Pluggable source registration for custom or self-hosted forges -## Requirements +## Dependencies - Neovim 0.10.0+ - [fzf-lua](https://github.com/ibhagwan/fzf-lua) -- 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 +- 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) ## Installation -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?** +### [lazy.nvim](https://github.com/folke/lazy.nvim) ```lua { @@ -70,19 +46,309 @@ vim.g.forge = { } ``` -**Q: How do I create a PR?** +### [mini.deps](https://github.com/echasnovski/mini.deps) -`` 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). +```lua +MiniDeps.add({ + source = 'barrettruth/forge.nvim', + depends = { 'ibhagwan/fzf-lua' }, +}) +``` -**Q: Does review mode require diffs.nvim?** +### [luarocks](https://luarocks.org/modules/barrettruth/forge.nvim) -Yes. Without [diffs.nvim](https://github.com/barrettruth/diffs.nvim), diff -actions and review toggling are unavailable. +``` +luarocks install forge.nvim +``` -**Q: How does forge detection work?** +### Manual -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`. +```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. diff --git a/doc/forge.nvim.txt b/doc/forge.nvim.txt index e49a8de..b37004a 100644 --- a/doc/forge.nvim.txt +++ b/doc/forge.nvim.txt @@ -9,10 +9,7 @@ delegates to the appropriate CLI (`gh`, `glab`, or `tea`). Requirements: ~ - Neovim 0.10.0+ -- At least one picker backend: - - fzf-lua - - telescope.nvim - - snacks.nvim +- fzf-lua (required) - One or more forge CLIs: - `gh` for GitHub - `glab` for GitLab @@ -35,15 +32,6 @@ 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. @@ -65,8 +53,7 @@ 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 the appropriate format for - each picker backend. + (e.g. `""`), which is translated to fzf-lua format internally. Defaults: >lua keys = { @@ -588,7 +575,7 @@ HEALTH *forge-health* Reports on: ~ - `git` availability - Forge CLI availability (`gh`, `glab`, `tea`) -- Picker backends (fzf-lua, telescope, snacks) and which is active +- `fzf-lua` installation (required) - `diffs.nvim` installation (review mode) - Custom registered sources and their CLI availability @@ -712,11 +699,12 @@ PUBLIC API: `require('forge.pickers')` ~ `pr_manage(f, num)` Opens the management picker for PR `num`. - *forge.pickers.pr_action_fns()* -`pr_action_fns(f, num)` + *forge.pickers.pr_actions()* +`pr_actions(f, num)` Returns `table`. Action functions for PR `num`, - keyed by action name (`"checkout"`, `"diff"`, `"worktree"`, - `"browse"`, `"ci"`, `"manage"`). + keyed by fzf binding. Also has `_by_name` table 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 6a16865..e11545a 100644 --- a/flake.nix +++ b/flake.nix @@ -24,21 +24,22 @@ 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-language-server.packages.${pkgs.system}.default - (pkgs.luajit.withPackages (ps: [ - ps.busted - ps.nlua - ])) + vimdoc-ls ]; in { - default = pkgs.mkShell { packages = commonPackages; }; - ci = pkgs.mkShell { packages = commonPackages ++ [ pkgs.neovim ]; }; + default = pkgs.mkShell { + packages = commonPackages; + }; + ci = pkgs.mkShell { + packages = commonPackages; + }; } ); }; diff --git a/lua/forge/health.lua b/lua/forge/health.lua index da9b093..4bbc1cd 100644 --- a/lua/forge/health.lua +++ b/lua/forge/health.lua @@ -22,18 +22,11 @@ function M.check() end end - 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)') + 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)') end local has_diffs = pcall(require, 'diffs') diff --git a/lua/forge/init.lua b/lua/forge/init.lua index d26df49..894abb4 100644 --- a/lua/forge/init.lua +++ b/lua/forge/init.lua @@ -1,7 +1,6 @@ local M = {} ---@class forge.Config ----@field picker 'fzf-lua'|'telescope'|'snacks'|'auto' ---@field ci forge.CIConfig ---@field sources table ---@field keys forge.KeysConfig|false @@ -84,7 +83,6 @@ local M = {} ---@type forge.Config local DEFAULTS = { - picker = 'auto', ci = { lines = 10000 }, sources = {}, keys = { @@ -540,25 +538,15 @@ local function extract_author(entry, field) return tostring(v or '') end ----@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) +local function hl(group, text) + local utils = require('fzf-lua.utils') + return utils.ansi_from_hl(group, text) end ---@param entry table ---@param fields table ---@param show_state boolean ----@return forge.Segment[] +---@return string function M.format_pr(entry, fields, show_state) local display = M.config().display local icons = display.icons @@ -567,7 +555,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 segments = {} + local prefix = '' if show_state then local state = (entry[fields.state] or ''):lower() local icon, group @@ -578,22 +566,20 @@ function M.format_pr(entry, fields, show_state) else icon, group = icons.closed, 'ForgeClosed' end - table.insert(segments, { icon, group }) - table.insert(segments, { ' ' }) + prefix = hl(group, icon) .. ' ' end - 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 + return prefix + .. hl('ForgeNumber', ('#%-5s'):format(num)) + .. ' ' + .. pad_or_truncate(title, widths.title) + .. ' ' + .. hl('ForgeDim', pad_or_truncate(author, widths.author) .. (' %3s'):format(age)) end ---@param entry table ---@param fields table ---@param show_state boolean ----@return forge.Segment[] +---@return string function M.format_issue(entry, fields, show_state) local display = M.config().display local icons = display.icons @@ -602,7 +588,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 segments = {} + local prefix = '' if show_state then local state = (entry[fields.state] or ''):lower() local icon, group @@ -611,20 +597,18 @@ function M.format_issue(entry, fields, show_state) else icon, group = icons.closed, 'ForgeClosed' end - table.insert(segments, { icon, group }) - table.insert(segments, { ' ' }) + prefix = hl(group, icon) .. ' ' end - 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 + return prefix + .. hl('ForgeNumber', ('#%-5s'):format(num)) + .. ' ' + .. pad_or_truncate(title, widths.title) + .. ' ' + .. hl('ForgeDim', pad_or_truncate(author, widths.author) .. (' %3s'):format(age)) end ---@param check table ----@return forge.Segment[] +---@return string function M.format_check(check) local display = M.config().display local icons = display.icons @@ -648,18 +632,23 @@ 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 - elapsed = format_duration(te - ts) + local secs = te - ts + if secs >= 60 then + elapsed = ('%dm%ds'):format(math.floor(secs / 60), secs % 60) + else + elapsed = ('%ds'):format(secs) + end end end - return { - { icon, group }, - { ' ' .. pad_or_truncate(name, widths.name) .. ' ' }, - { elapsed, 'ForgeDim' }, - } + return hl(group, icon) + .. ' ' + .. pad_or_truncate(name, widths.name) + .. ' ' + .. hl('ForgeDim', elapsed) end ---@param run forge.CIRun ----@return forge.Segment[] +---@return string function M.format_run(run) local display = M.config().display local icons = display.icons @@ -681,18 +670,19 @@ 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 { - { icon, group }, - { ' ' .. pad_or_truncate(run.name, name_w) .. ' ' }, - { pad_or_truncate(run.branch, widths.branch), 'ForgeBranch' }, - { ' ' .. ('%-6s'):format(event) .. ' ' .. age, 'ForgeDim' }, - } + 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) end - return { - { icon, group }, - { ' ' .. pad_or_truncate(run.name, widths.name) .. ' ' }, - { ('%-6s'):format(event) .. ' ' .. age, 'ForgeDim' }, - } + return hl(group, icon) + .. ' ' + .. pad_or_truncate(run.name, widths.name) + .. ' ' + .. hl('ForgeDim', ('%-6s'):format(event) .. ' ' .. age) end ---@param checks table[] @@ -725,10 +715,6 @@ 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 deleted file mode 100644 index d4d3a05..0000000 --- a/lua/forge/picker/fzf.lua +++ /dev/null @@ -1,76 +0,0 @@ -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 deleted file mode 100644 index 7ce309d..0000000 --- a/lua/forge/picker/init.lua +++ /dev/null @@ -1,77 +0,0 @@ -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 deleted file mode 100644 index 72c6501..0000000 --- a/lua/forge/picker/snacks.lua +++ /dev/null @@ -1,64 +0,0 @@ -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 deleted file mode 100644 index 026ae3c..0000000 --- a/lua/forge/picker/telescope.lua +++ /dev/null @@ -1,69 +0,0 @@ -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 60b2139..3377035 100644 --- a/lua/forge/pickers.lua +++ b/lua/forge/pickers.lua @@ -1,7 +1,5 @@ local M = {} -local picker = require('forge.picker') - ---@param result { code: integer, stdout: string?, stderr: string? } ---@param fallback string ---@return string @@ -17,6 +15,36 @@ 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 @@ -51,93 +79,130 @@ end ---@param f forge.Forge ---@param num string ---@return table -local function pr_action_fns(f, num) +local function pr_actions(f, num) local kind = f.labels.pr_one - 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) + + 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) vim.schedule(function() if result.code == 0 then - vim.notify(('[forge]: worktree at %s'):format(wt_path)) + vim.notify(('[forge]: checked out %s #%s'):format(kind, num)) else - vim.notify('[forge]: ' .. cmd_error(result, 'worktree failed'), vim.log.levels.ERROR) + vim.notify('[forge]: ' .. cmd_error(result, 'checkout failed'), vim.log.levels.ERROR) end vim.cmd.redraw() end) end) - 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, + }, + { + 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 + 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) + 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')) - 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)) + 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) end) 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, + 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, + }, } + + ---@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 @@ -158,10 +223,7 @@ local function pr_manage_picker(f, num) local action_map = {} local function add(label, fn) - table.insert(entries, { - display = { { label } }, - value = label, - }) + table.insert(entries, label) action_map[label] = fn end @@ -205,20 +267,17 @@ local function pr_manage_picker(f, num) end) end - picker.pick({ + require('fzf-lua').fzf_exec(entries, { + fzf_args = fzf_args, prompt = ('%s #%s Actions> '):format(kind, num), - entries = entries, + fzf_opts = { ['--no-multi'] = '' }, actions = { - { - name = 'default', - fn = function(entry) - if entry and action_map[entry.value] then - action_map[entry.value]() - end - end, - }, + ['default'] = function(selected) + if selected[1] and action_map[selected[1]] then + action_map[selected[1]]() + end + end, }, - picker_name = '_menu', }) end @@ -232,13 +291,18 @@ function M.checks(f, num, filter, cached_checks) local function open_picker(checks) local filtered = forge_mod.filter_checks(checks, filter) - local entries = {} - for _, c in ipairs(filtered) do - table.insert(entries, { - display = forge_mod.format_check(c), - value = c, - ordinal = c.name or '', - }) + 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 end local labels = { @@ -248,75 +312,83 @@ function M.checks(f, num, filter, cached_checks) pending = 'running', } - picker.pick({ - prompt = ('Checks (#%s, %s)> '):format(num, labels[filter] or filter), - 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, - }, + 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, }, - picker_name = 'ci', + { + 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, + prompt = ('Checks (#%s, %s)> '):format(num, labels[filter] or filter), + fzf_opts = { + ['--ansi'] = '', + ['--no-multi'] = '', + ['--with-nth'] = '2..', + ['--delimiter'] = '\t', + }, + actions = check_actions, }) end @@ -340,7 +412,16 @@ function M.checks(f, num, filter, cached_checks) end) end) else - vim.notify('[forge]: structured checks not available for this forge', vim.log.levels.INFO) + 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, + }, + }) end end @@ -349,70 +430,82 @@ end function M.ci(f, branch) local forge_mod = require('forge') - local function open_ci_picker(runs) + local function open_picker(runs) local normalized = {} for _, entry in ipairs(runs) do table.insert(normalized, f:normalize_run(entry)) end - local entries = {} - for _, run in ipairs(normalized) do - table.insert(entries, { - display = forge_mod.format_run(run), - value = run, - ordinal = run.name .. ' ' .. run.branch, - }) + local lines = {} + for i, run in ipairs(normalized) do + table.insert(lines, ('%d\t%s'):format(i, forge_mod.format_run(run))) end - picker.pick({ - prompt = ('%s (%s)> '):format(f.labels.ci, branch or 'all'), - 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, - }, + 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, }, - picker_name = 'ci', + { + 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, + prompt = ('%s (%s)> '):format(f.labels.ci, branch or 'all'), + fzf_opts = { + ['--ansi'] = '', + ['--no-multi'] = '', + ['--with-nth'] = '2..', + ['--delimiter'] = '\t', + }, + actions = ci_actions, }) end @@ -422,7 +515,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_ci_picker(runs) + open_picker(runs) else vim.notify('[forge]: no CI runs found', vim.log.levels.INFO) vim.cmd.redraw() @@ -430,7 +523,11 @@ function M.ci(f, branch) end) end) elseif f.list_runs_cmd then - vim.notify('[forge]: structured CI data not available for this forge', vim.log.levels.INFO) + require('fzf-lua').fzf_exec(f:list_runs_cmd(branch), { + fzf_args = fzf_args, + prompt = f.labels.ci .. '> ', + fzf_opts = { ['--ansi'] = '' }, + }) end end @@ -438,27 +535,6 @@ 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"' @@ -469,54 +545,64 @@ function M.commits(f) end 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() + 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) end) end) - 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 + 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) require('fzf-lua').fzf_exec(log_cmd, { fzf_args = fzf_args, @@ -526,7 +612,7 @@ function M.commits(f) ['--no-multi'] = '', ['--preview'] = 'git show --color {1}', }, - actions = fzf_actions, + actions = commit_actions, }) end @@ -535,54 +621,41 @@ function M.branches(f) local forge_mod = require('forge') local review = require('forge.review') - if picker.backend() ~= 'fzf-lua' then - vim.notify('[forge]: branches picker requires fzf-lua', vim.log.levels.WARN) - return - end + 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, + }, + } - 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 }) + local branch_actions = build_actions('branches', defs) + require('fzf-lua').git_branches({ actions = branch_actions }) end ---@param state 'all'|'open'|'closed' @@ -596,89 +669,95 @@ function M.pr(state, f) local show_state = state ~= 'open' local function open_pr_list(prs) - local entries = {} + local lines = {} for _, pr in ipairs(prs) do - 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, - }) + 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 end - picker.pick({ - prompt = ('%s (%s)> '):format(f.labels.pr, state), - 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, - }, + 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, }, - picker_name = 'pr', + { + 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, + prompt = ('%s (%s)> '):format(f.labels.pr, state), + fzf_opts = { + ['--ansi'] = '', + ['--no-multi'] = '', + }, + actions = list_actions, }) end @@ -716,53 +795,60 @@ function M.issue(state, f) end) local state_field = issue_fields.state local state_map = {} - local entries = {} + local lines = {} 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(entries, { - display = forge_mod.format_issue(issue, issue_fields, issue_show_state), - value = n, - ordinal = (issue[issue_fields.title] or '') .. ' #' .. n, - }) + 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 end - picker.pick({ - prompt = ('%s (%s)> '):format(f.labels.issue, state), - 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, - }, + 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, }, - picker_name = 'issue', + { + 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, + prompt = ('%s (%s)> '):format(f.labels.issue, state), + fzf_opts = { + ['--ansi'] = '', + ['--no-multi'] = '', + }, + actions = issue_actions, }) end @@ -805,7 +891,7 @@ end ---@param num string ---@return table function M.pr_actions(f, num) - return pr_action_fns(f, num) + return pr_actions(f, num) end function M.git() @@ -827,14 +913,11 @@ function M.git() local branch = vim.trim(vim.fn.system('git branch --show-current')) local items = {} - local action_map = {} + local actions = {} local function add(label, action) - table.insert(items, { - display = { { label } }, - value = label, - }) - action_map[label] = action + table.insert(items, label) + actions[label] = action end if f then @@ -885,29 +968,21 @@ function M.git() end) add('Worktrees', function() - 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 + require('fzf-lua').git_worktrees() end) local prompt = f and (f.name:sub(1, 1):upper() .. f.name:sub(2)) .. '> ' or 'Git> ' - picker.pick({ + require('fzf-lua').fzf_exec(items, { + fzf_args = fzf_args, prompt = prompt, - entries = items, actions = { - { - name = 'default', - fn = function(entry) - if entry and action_map[entry.value] then - action_map[entry.value]() - end - end, - }, + ['default'] = function(selected) + if selected[1] and actions[selected[1]] then + actions[selected[1]]() + end + end, }, - picker_name = '_menu', }) end diff --git a/spec/init_spec.lua b/spec/init_spec.lua index 2e9fb5d..5702349 100644 --- a/spec/init_spec.lua +++ b/spec/init_spec.lua @@ -10,14 +10,6 @@ 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 @@ -61,7 +53,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 = flatten(forge.format_pr(entry, fields, true)) + local result = forge.format_pr(entry, fields, true) assert.truthy(result:find('+')) assert.truthy(result:find('#42')) assert.truthy(result:find('fix bug')) @@ -70,20 +62,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 = flatten(forge.format_pr(entry, fields, true)) + local result = 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 = flatten(forge.format_pr(entry, fields, true)) + local result = 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 = flatten(forge.format_pr(entry, fields, false)) + local result = forge.format_pr(entry, fields, false) assert.truthy(result:find('#1')) assert.falsy(result:match('^+')) end) @@ -91,14 +83,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 = flatten(forge.format_pr(entry, fields, false)) + local result = 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 = flatten(forge.format_pr(entry, fields, false)) + local result = forge.format_pr(entry, fields, false) assert.truthy(result:find('nested')) end) end) @@ -115,59 +107,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 = flatten(forge.format_issue(entry, fields, true)) + local result = 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 = flatten(forge.format_issue(entry, fields, true)) + local result = 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 = flatten(forge.format_issue(entry, fields, true)) + local result = forge.format_issue(entry, fields, true) assert.truthy(result:find('+')) end) end) describe('format_check', function() it('maps pass bucket', function() - local result = flatten(forge.format_check({ name = 'lint', bucket = 'pass' })) + local result = forge.format_check({ name = 'lint', bucket = 'pass' }) assert.truthy(result:find('%*')) assert.truthy(result:find('lint')) end) it('maps fail bucket', function() - local result = flatten(forge.format_check({ name = 'build', bucket = 'fail' })) + local result = forge.format_check({ name = 'build', bucket = 'fail' }) assert.truthy(result:find('x')) end) it('maps pending bucket', function() - local result = flatten(forge.format_check({ name = 'test', bucket = 'pending' })) + local result = forge.format_check({ name = 'test', bucket = 'pending' }) assert.truthy(result:find('~')) end) it('maps skipping bucket', function() - local result = flatten(forge.format_check({ name = 'optional', bucket = 'skipping' })) + local result = forge.format_check({ name = 'optional', bucket = 'skipping' }) assert.truthy(result:find('%-')) end) it('maps cancel bucket', function() - local result = flatten(forge.format_check({ name = 'cancelled', bucket = 'cancel' })) + local result = forge.format_check({ name = 'cancelled', bucket = 'cancel' }) assert.truthy(result:find('%-')) end) it('maps unknown bucket', function() - local result = flatten(forge.format_check({ name = 'mystery', bucket = 'something_else' })) + local result = forge.format_check({ name = 'mystery', bucket = 'something_else' }) assert.truthy(result:find('%?')) end) it('defaults to pending when bucket is nil', function() - local result = flatten(forge.format_check({ name = 'none' })) + local result = forge.format_check({ name = 'none' }) assert.truthy(result:find('~')) end) end) @@ -176,7 +168,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 = flatten(forge.format_run(run)) + local result = forge.format_run(run) assert.truthy(result:find('%*')) assert.truthy(result:find('CI')) assert.truthy(result:find('main')) @@ -191,7 +183,7 @@ describe('format_run', function() event = 'workflow_dispatch', created_at = '', } - local result = flatten(forge.format_run(run)) + local result = forge.format_run(run) assert.truthy(result:find('x')) assert.truthy(result:find('manual')) end) @@ -199,13 +191,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 = flatten(forge.format_run(run)) + local result = 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 = flatten(forge.format_run(run)) + local result = forge.format_run(run) assert.truthy(result:find('%-')) end) end) @@ -250,39 +242,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 = flatten(forge.format_pr(entry, fields, false)) + local result = 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 = flatten(forge.format_pr(entry, fields, false)) + local result = 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 = flatten(forge.format_pr(entry, fields, false)) + local result = 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 = flatten(forge.format_pr(entry, fields, false)) + local result = 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 = flatten(forge.format_pr(entry, fields, false)) + local result = 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 = flatten(forge.format_pr(entry, fields, false)) + local result = forge.format_pr(entry, fields, false) assert.truthy(result) end) end) diff --git a/spec/sources_spec.lua b/spec/sources_spec.lua deleted file mode 100644 index 802b4d8..0000000 --- a/spec/sources_spec.lua +++ /dev/null @@ -1,189 +0,0 @@ -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)