Compare commits

..

11 commits
v0.0.1 ... main

Author SHA1 Message Date
Barrett Ruth
09463c08b7
ci: add neovim and busted to CI shell
Problem: the CI flake shell lacked `neovim`, so `vimdoc-language-server`
could not resolve builtin Neovim help tags like `|:checkhealth|`.
Also missing `busted`/`nlua` lua packages.

Solution: add `pkgs.neovim` to the `ci` shell and `busted`/`nlua` via
`luajit.withPackages` to both shells, matching the pattern used in
canola.nvim. Revert the vimdoc workaround from the previous commit.
2026-03-28 18:40:16 -04:00
Barrett Ruth
d6a4766aac
feat: gitignore demo files 2026-03-28 18:38:09 -04:00
Barrett Ruth
be29458072
docs: fix unresolved vimdoc tag reference
Problem: `|:checkhealth|` is not a valid vimdoc tag, causing the
vimdoc-language-server check to fail in CI.

Solution: use inline code (\`:checkhealth forge\`) instead of a tag
reference.
2026-03-28 18:37:30 -04:00
Barrett Ruth
f8ec4acf19
ci: deduplicate quality checks in luarocks-dev
Problem: `luarocks-dev` called the `quality` reusable workflow on every
push to main, duplicating every check that `quality.yaml` already runs
independently on the same trigger.

Solution: switch `luarocks-dev` to `workflow_run` trigger, firing only
after the `quality` workflow completes successfully on main.
2026-03-28 18:37:26 -04:00
Barrett Ruth
0e892b0b52
ci: format 2026-03-28 18:35:17 -04:00
Barrett Ruth
ac8db6851f
refactor(picker): canonicalize backend names into single table
Problem: picker backend names were hard-coded as string literals in
`picker/init.lua`, `health.lua`, and `init.lua` validation, making
them easy to get out of sync.

Solution: export `backends` and `detect_order` from `forge.picker` and
reference them in health check and config validation instead of
duplicating the name strings.
2026-03-28 17:50:11 -04:00
Barrett Ruth
97698c2af1
docs: update vimdoc and health check for picker backends
Problem: docs listed fzf-lua as the only required dependency, and the
health check only verified fzf-lua.

Solution: update requirements to list all three picker backends, add
`picker` config option to vimdoc, update health check to report all
detected backends and mark the active one.
2026-03-28 17:48:45 -04:00
Barrett Ruth
830442f856
test: update format tests for segment-based output
Problem: format functions now return `forge.Segment[]` instead of
strings, so all tests calling `:find()` and `:match()` on results
were failing.

Solution: add `flatten()` helper that concatenates segment text, and
wrap all format assertions with it. Also fix stylua formatting in
`sources_spec.lua`.
2026-03-28 17:48:35 -04:00
Barrett Ruth
fa7cab89af
feat(picker): add multi-backend picker abstraction
Problem: all pickers were tightly coupled to fzf-lua via ANSI strings
and fzf-specific action tables, making it impossible to use telescope
or snacks.nvim.

Solution: introduce `forge.picker` dispatcher with `fzf`, `telescope`,
and `snacks` backends. Format functions now return `forge.Segment[]`
instead of ANSI strings. `pickers.lua` builds backend-agnostic
`forge.PickerEntry[]` and delegates to `forge.picker.pick()`. Backend
auto-detection tries fzf-lua, snacks, telescope in order. Commits,
branches, and worktree pickers remain fzf-only with graceful fallback.
2026-03-28 17:48:24 -04:00
Barrett Ruth
354c5000c0
doc: minimize readme 2026-03-28 15:18:01 -04:00
Barrett Ruth
2658c47af1
ci: tests 2026-03-28 15:10:08 -04:00
14 changed files with 1142 additions and 967 deletions

View file

@ -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:

2
.gitignore vendored
View file

@ -11,3 +11,5 @@ result
result-*
.direnv/
.envrc
scripts/demo

366
README.md
View file

@ -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' },
})
```
`<c-g>` 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 `<c-g>`
picker.
`:Forge` with no arguments (or `<c-g>`) 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 = '<c-g>',
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 |
| ----------- | ---- | -------------------------------- |
| `<c-g>` | 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?** `<c-g>` -> 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.<name>.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.<name>.hosts` entries. The first match wins, and the CLI must be
in `$PATH`.

View file

@ -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 `"<cr>"` to bind to enter. Values use vim keymap notation
(e.g. `"<c-d>"`), which is translated to fzf-lua format internally.
(e.g. `"<c-d>"`), 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<string, function>`. 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)`

View file

@ -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 ]; };
}
);
};

View file

@ -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')

View file

@ -1,6 +1,7 @@
local M = {}
---@class forge.Config
---@field picker 'fzf-lua'|'telescope'|'snacks'|'auto'
---@field ci forge.CIConfig
---@field sources table<string, forge.SourceConfig>
---@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'

76
lua/forge/picker/fzf.lua Normal file
View file

@ -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 == '<cr>' then
return 'default'
end
local result = key:gsub('<c%-(%a)>', 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 '<cr>' 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

77
lua/forge/picker/init.lua Normal file
View file

@ -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

View file

@ -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 '<cr>' 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 == '<cr>' 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

View file

@ -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 '<cr>' 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 == '<cr>' 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

File diff suppressed because it is too large Load diff

View file

@ -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)

189
spec/sources_spec.lua Normal file
View file

@ -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)