Compare commits

..

No commits in common. "main" and "v0.0.1" have entirely different histories.
main ... v0.0.1

14 changed files with 971 additions and 1146 deletions

View file

@ -1,14 +1,15 @@
name: luarocks-dev name: luarocks-dev
on: on:
workflow_run: push:
workflows: [quality]
types: [completed]
branches: [main] branches: [main]
jobs: jobs:
quality:
uses: ./.github/workflows/quality.yaml
publish: publish:
if: ${{ github.event.workflow_run.conclusion == 'success' }} needs: quality
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:

2
.gitignore vendored
View file

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

366
README.md
View file

@ -2,12 +2,13 @@
**Forge-agnostic git workflow for Neovim** **Forge-agnostic git workflow for Neovim**
PR, issue, and CI workflows across GitHub, GitLab, and Codeberg/Gitea/Forgejo — PR, issue, and CI workflows across GitHub, GitLab, and more — without leaving
without leaving your editor. your editor.
## Features ## 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, - PR lifecycle: list, create (compose buffer with template discovery, diff stat,
reviewers), checkout, worktree, review diff, merge, approve, close/reopen, reviewers), checkout, worktree, review diff, merge, approve, close/reopen,
draft toggle draft toggle
@ -16,52 +17,27 @@ without leaving your editor.
- Code review via [diffs.nvim](https://github.com/barrettruth/diffs.nvim) with - Code review via [diffs.nvim](https://github.com/barrettruth/diffs.nvim) with
unified/split toggle and quickfix navigation unified/split toggle and quickfix navigation
- Commit and branch browsing with checkout, diff, and URL generation - 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 - [fzf-lua](https://github.com/ibhagwan/fzf-lua) pickers with contextual
keybinds keybinds
- Pluggable source registration for custom or self-hosted forges - Pluggable source registration for custom or self-hosted forges
## Requirements ## Dependencies
- Neovim 0.10.0+ - Neovim 0.10.0+
- [fzf-lua](https://github.com/ibhagwan/fzf-lua) - [fzf-lua](https://github.com/ibhagwan/fzf-lua)
- At least one forge CLI: [`gh`](https://cli.github.com/), - At least one forge CLI:
[`glab`](https://gitlab.com/gitlab-org/cli), or - [`gh`](https://cli.github.com/) for GitHub
[`tea`](https://gitea.com/gitea/tea) - [`glab`](https://gitlab.com/gitlab-org/cli) for GitLab
- (Optional) [diffs.nvim](https://github.com/barrettruth/diffs.nvim) for review - [`tea`](https://gitea.com/gitea/tea) for Codeberg/Gitea/Forgejo
mode - [vim-fugitive](https://github.com/tpope/vim-fugitive) (optional, for fugitive
- (Optional) [vim-fugitive](https://github.com/tpope/vim-fugitive) for split keymaps and split diff)
diff and fugitive keymaps - [diffs.nvim](https://github.com/barrettruth/diffs.nvim) (optional, for review
mode)
## Installation ## Installation
Install with your package manager of choice or via ### [lazy.nvim](https://github.com/folke/lazy.nvim)
[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 ```lua
{ {
@ -70,19 +46,309 @@ vim.g.forge = {
} }
``` ```
**Q: How do I create a PR?** ### [mini.deps](https://github.com/echasnovski/mini.deps)
`<c-g>` to open the picker, select Pull Requests, then `ctrl-a` to compose. Or ```lua
from a fugitive buffer: `cpr` (compose), `cpd` (draft), `cpf` (instant from MiniDeps.add({
commits), `cpw` (push and open web). 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 ```sh
custom `sources.<name>.hosts` entries. The first match wins, and the CLI must be git clone https://github.com/barrettruth/forge.nvim \
in `$PATH`. ~/.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.

View file

@ -9,10 +9,7 @@ delegates to the appropriate CLI (`gh`, `glab`, or `tea`).
Requirements: ~ Requirements: ~
- Neovim 0.10.0+ - Neovim 0.10.0+
- At least one picker backend: - fzf-lua (required)
- fzf-lua
- telescope.nvim
- snacks.nvim
- One or more forge CLIs: - One or more forge CLIs:
- `gh` for GitHub - `gh` for GitHub
- `glab` for GitLab - `glab` for GitLab
@ -35,15 +32,6 @@ unset keys use defaults. >lua
Top-level keys: ~ 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` *forge-config-ci*
`ci.lines` `integer` (default `10000`) `ci.lines` `integer` (default `10000`)
Maximum number of log lines fetched for CI/check log output. Maximum number of log lines fetched for CI/check log output.
@ -65,8 +53,7 @@ Top-level keys: ~
`table|false` (default shown below) `table|false` (default shown below)
Per-picker action bindings. Set to `false` to disable all picker-level Per-picker action bindings. Set to `false` to disable all picker-level
actions. Use `"<cr>"` to bind to enter. Values use vim keymap notation actions. Use `"<cr>"` to bind to enter. Values use vim keymap notation
(e.g. `"<c-d>"`), which is translated to the appropriate format for (e.g. `"<c-d>"`), which is translated to fzf-lua format internally.
each picker backend.
Defaults: >lua Defaults: >lua
keys = { keys = {
@ -588,7 +575,7 @@ HEALTH *forge-health*
Reports on: ~ Reports on: ~
- `git` availability - `git` availability
- Forge CLI availability (`gh`, `glab`, `tea`) - 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) - `diffs.nvim` installation (review mode)
- Custom registered sources and their CLI availability - Custom registered sources and their CLI availability
@ -712,11 +699,12 @@ PUBLIC API: `require('forge.pickers')` ~
`pr_manage(f, num)` `pr_manage(f, num)`
Opens the management picker for PR `num`. Opens the management picker for PR `num`.
*forge.pickers.pr_action_fns()* *forge.pickers.pr_actions()*
`pr_action_fns(f, num)` `pr_actions(f, num)`
Returns `table<string, function>`. Action functions for PR `num`, Returns `table<string, function>`. Action functions for PR `num`,
keyed by action name (`"checkout"`, `"diff"`, `"worktree"`, keyed by fzf binding. Also has `_by_name` table keyed by action
`"browse"`, `"ci"`, `"manage"`). name (`"checkout"`, `"diff"`, `"worktree"`, `"browse"`, `"ci"`,
`"manage"`).
*forge.pickers.issue_close()* *forge.pickers.issue_close()*
`issue_close(f, num)` `issue_close(f, num)`

View file

@ -24,21 +24,22 @@
devShells = forEachSystem ( devShells = forEachSystem (
pkgs: pkgs:
let let
vimdoc-ls = vimdoc-language-server.packages.${pkgs.system}.default;
commonPackages = [ commonPackages = [
pkgs.prettier pkgs.prettier
pkgs.stylua pkgs.stylua
pkgs.selene pkgs.selene
pkgs.lua-language-server pkgs.lua-language-server
vimdoc-language-server.packages.${pkgs.system}.default vimdoc-ls
(pkgs.luajit.withPackages (ps: [
ps.busted
ps.nlua
]))
]; ];
in in
{ {
default = pkgs.mkShell { packages = commonPackages; }; default = pkgs.mkShell {
ci = pkgs.mkShell { packages = commonPackages ++ [ pkgs.neovim ]; }; packages = commonPackages;
};
ci = pkgs.mkShell {
packages = commonPackages;
};
} }
); );
}; };

View file

@ -22,18 +22,11 @@ function M.check()
end end
end end
local picker_mod = require('forge.picker') local has_fzf = pcall(require, 'fzf-lua')
local backend = picker_mod.backend() if has_fzf then
local found_any = false vim.health.ok('fzf-lua found')
for _, name in ipairs(picker_mod.detect_order) do else
if pcall(require, name) then vim.health.error('fzf-lua not found (required)')
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 end
local has_diffs = pcall(require, 'diffs') local has_diffs = pcall(require, 'diffs')

View file

@ -1,7 +1,6 @@
local M = {} local M = {}
---@class forge.Config ---@class forge.Config
---@field picker 'fzf-lua'|'telescope'|'snacks'|'auto'
---@field ci forge.CIConfig ---@field ci forge.CIConfig
---@field sources table<string, forge.SourceConfig> ---@field sources table<string, forge.SourceConfig>
---@field keys forge.KeysConfig|false ---@field keys forge.KeysConfig|false
@ -84,7 +83,6 @@ local M = {}
---@type forge.Config ---@type forge.Config
local DEFAULTS = { local DEFAULTS = {
picker = 'auto',
ci = { lines = 10000 }, ci = { lines = 10000 },
sources = {}, sources = {},
keys = { keys = {
@ -540,25 +538,15 @@ local function extract_author(entry, field)
return tostring(v or '') return tostring(v or '')
end end
---@param secs integer local function hl(group, text)
---@return string local utils = require('fzf-lua.utils')
local function format_duration(secs) return utils.ansi_from_hl(group, text)
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 end
---@param entry table ---@param entry table
---@param fields table ---@param fields table
---@param show_state boolean ---@param show_state boolean
---@return forge.Segment[] ---@return string
function M.format_pr(entry, fields, show_state) function M.format_pr(entry, fields, show_state)
local display = M.config().display local display = M.config().display
local icons = display.icons local icons = display.icons
@ -567,7 +555,7 @@ function M.format_pr(entry, fields, show_state)
local title = entry[fields.title] or '' local title = entry[fields.title] or ''
local author = extract_author(entry, fields.author) local author = extract_author(entry, fields.author)
local age = relative_time(entry[fields.created_at]) local age = relative_time(entry[fields.created_at])
local segments = {} local prefix = ''
if show_state then if show_state then
local state = (entry[fields.state] or ''):lower() local state = (entry[fields.state] or ''):lower()
local icon, group local icon, group
@ -578,22 +566,20 @@ function M.format_pr(entry, fields, show_state)
else else
icon, group = icons.closed, 'ForgeClosed' icon, group = icons.closed, 'ForgeClosed'
end end
table.insert(segments, { icon, group }) prefix = hl(group, icon) .. ' '
table.insert(segments, { ' ' })
end end
table.insert(segments, { ('#%-5s'):format(num), 'ForgeNumber' }) return prefix
table.insert(segments, { ' ' .. pad_or_truncate(title, widths.title) .. ' ' }) .. hl('ForgeNumber', ('#%-5s'):format(num))
table.insert(segments, { .. ' '
pad_or_truncate(author, widths.author) .. (' %3s'):format(age), .. pad_or_truncate(title, widths.title)
'ForgeDim', .. ' '
}) .. hl('ForgeDim', pad_or_truncate(author, widths.author) .. (' %3s'):format(age))
return segments
end end
---@param entry table ---@param entry table
---@param fields table ---@param fields table
---@param show_state boolean ---@param show_state boolean
---@return forge.Segment[] ---@return string
function M.format_issue(entry, fields, show_state) function M.format_issue(entry, fields, show_state)
local display = M.config().display local display = M.config().display
local icons = display.icons local icons = display.icons
@ -602,7 +588,7 @@ function M.format_issue(entry, fields, show_state)
local title = entry[fields.title] or '' local title = entry[fields.title] or ''
local author = extract_author(entry, fields.author) local author = extract_author(entry, fields.author)
local age = relative_time(entry[fields.created_at]) local age = relative_time(entry[fields.created_at])
local segments = {} local prefix = ''
if show_state then if show_state then
local state = (entry[fields.state] or ''):lower() local state = (entry[fields.state] or ''):lower()
local icon, group local icon, group
@ -611,20 +597,18 @@ function M.format_issue(entry, fields, show_state)
else else
icon, group = icons.closed, 'ForgeClosed' icon, group = icons.closed, 'ForgeClosed'
end end
table.insert(segments, { icon, group }) prefix = hl(group, icon) .. ' '
table.insert(segments, { ' ' })
end end
table.insert(segments, { ('#%-5s'):format(num), 'ForgeNumber' }) return prefix
table.insert(segments, { ' ' .. pad_or_truncate(title, widths.title) .. ' ' }) .. hl('ForgeNumber', ('#%-5s'):format(num))
table.insert(segments, { .. ' '
pad_or_truncate(author, widths.author) .. (' %3s'):format(age), .. pad_or_truncate(title, widths.title)
'ForgeDim', .. ' '
}) .. hl('ForgeDim', pad_or_truncate(author, widths.author) .. (' %3s'):format(age))
return segments
end end
---@param check table ---@param check table
---@return forge.Segment[] ---@return string
function M.format_check(check) function M.format_check(check)
local display = M.config().display local display = M.config().display
local icons = display.icons 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_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) 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 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 end
return { end
{ icon, group }, return hl(group, icon)
{ ' ' .. pad_or_truncate(name, widths.name) .. ' ' }, .. ' '
{ elapsed, 'ForgeDim' }, .. pad_or_truncate(name, widths.name)
} .. ' '
.. hl('ForgeDim', elapsed)
end end
---@param run forge.CIRun ---@param run forge.CIRun
---@return forge.Segment[] ---@return string
function M.format_run(run) function M.format_run(run)
local display = M.config().display local display = M.config().display
local icons = display.icons local icons = display.icons
@ -681,18 +670,19 @@ function M.format_run(run)
local age = relative_time(run.created_at) local age = relative_time(run.created_at)
if run.branch ~= '' then if run.branch ~= '' then
local name_w = widths.name - widths.branch + 10 local name_w = widths.name - widths.branch + 10
return { return hl(group, icon)
{ icon, group }, .. ' '
{ ' ' .. pad_or_truncate(run.name, name_w) .. ' ' }, .. pad_or_truncate(run.name, name_w)
{ pad_or_truncate(run.branch, widths.branch), 'ForgeBranch' }, .. ' '
{ ' ' .. ('%-6s'):format(event) .. ' ' .. age, 'ForgeDim' }, .. hl('ForgeBranch', pad_or_truncate(run.branch, widths.branch))
} .. ' '
.. hl('ForgeDim', ('%-6s'):format(event) .. ' ' .. age)
end end
return { return hl(group, icon)
{ icon, group }, .. ' '
{ ' ' .. pad_or_truncate(run.name, widths.name) .. ' ' }, .. pad_or_truncate(run.name, widths.name)
{ ('%-6s'):format(event) .. ' ' .. age, 'ForgeDim' }, .. ' '
} .. hl('ForgeDim', ('%-6s'):format(event) .. ' ' .. age)
end end
---@param checks table[] ---@param checks table[]
@ -725,10 +715,6 @@ function M.config()
cfg.keys = false cfg.keys = false
end 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.sources', cfg.sources, 'table')
vim.validate('forge.keys', cfg.keys, function(v) vim.validate('forge.keys', cfg.keys, function(v)
return v == false or type(v) == 'table' return v == false or type(v) == 'table'

View file

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

View file

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

View file

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

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

View file

@ -1,7 +1,5 @@
local M = {} local M = {}
local picker = require('forge.picker')
---@param result { code: integer, stdout: string?, stderr: string? } ---@param result { code: integer, stdout: string?, stderr: string? }
---@param fallback string ---@param fallback string
---@return string ---@return string
@ -17,6 +15,36 @@ local function cmd_error(result, fallback)
return msg return msg
end end
local fzf_args = (vim.env.FZF_DEFAULT_OPTS or '')
:gsub('%-%-bind=[^%s]+', '')
:gsub('%-%-color=[^%s]+', '')
local function to_fzf_key(key)
if key == '<cr>' then
return 'default'
end
return key:gsub('<c%-(%a)>', 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 kind string
---@param num string ---@param num string
---@param label string ---@param label string
@ -51,10 +79,13 @@ end
---@param f forge.Forge ---@param f forge.Forge
---@param num string ---@param num string
---@return table<string, function> ---@return table<string, function>
local function pr_action_fns(f, num) local function pr_actions(f, num)
local kind = f.labels.pr_one local kind = f.labels.pr_one
return {
checkout = function() local defs = {
{
name = 'checkout',
fn = function()
local forge_mod = require('forge') local forge_mod = require('forge')
forge_mod.log_now(('checking out %s #%s...'):format(kind, num)) forge_mod.log_now(('checking out %s #%s...'):format(kind, num))
vim.system(f:checkout_cmd(num), { text = true }, function(result) vim.system(f:checkout_cmd(num), { text = true }, function(result)
@ -68,10 +99,16 @@ local function pr_action_fns(f, num)
end) end)
end) end)
end, end,
browse = function() },
{
name = 'browse',
fn = function()
f:view_web(f.kinds.pr, num) f:view_web(f.kinds.pr, num)
end, end,
worktree = function() },
{
name = 'worktree',
fn = function()
local forge_mod = require('forge') local forge_mod = require('forge')
local fetch_cmd = f:fetch_pr(num) local fetch_cmd = f:fetch_pr(num)
local branch = fetch_cmd[#fetch_cmd]:match(':(.+)$') local branch = fetch_cmd[#fetch_cmd]:match(':(.+)$')
@ -82,19 +119,29 @@ local function pr_action_fns(f, num)
local wt_path = vim.fs.normalize(root .. '/../' .. branch) local wt_path = vim.fs.normalize(root .. '/../' .. branch)
forge_mod.log_now(('fetching %s #%s into worktree...'):format(kind, num)) forge_mod.log_now(('fetching %s #%s into worktree...'):format(kind, num))
vim.system(fetch_cmd, { text = true }, function() vim.system(fetch_cmd, { text = true }, function()
vim.system({ 'git', 'worktree', 'add', wt_path, branch }, { text = true }, function(result) vim.system(
{ 'git', 'worktree', 'add', wt_path, branch },
{ text = true },
function(result)
vim.schedule(function() vim.schedule(function()
if result.code == 0 then if result.code == 0 then
vim.notify(('[forge]: worktree at %s'):format(wt_path)) vim.notify(('[forge]: worktree at %s'):format(wt_path))
else else
vim.notify('[forge]: ' .. cmd_error(result, 'worktree failed'), vim.log.levels.ERROR) vim.notify(
'[forge]: ' .. cmd_error(result, 'worktree failed'),
vim.log.levels.ERROR
)
end end
vim.cmd.redraw() vim.cmd.redraw()
end) end)
end) end
)
end) end)
end, end,
diff = function() },
{
name = 'diff',
fn = function()
local forge_mod = require('forge') local forge_mod = require('forge')
local review = require('forge.review') local review = require('forge.review')
local repo_root = vim.trim(vim.fn.system('git rev-parse --show-toplevel')) local repo_root = vim.trim(vim.fn.system('git rev-parse --show-toplevel'))
@ -124,7 +171,10 @@ local function pr_action_fns(f, num)
end) end)
end) end)
end, end,
ci = function() },
{
name = 'ci',
fn = function()
if f.capabilities.per_pr_checks then if f.capabilities.per_pr_checks then
M.checks(f, num) M.checks(f, num)
else else
@ -134,10 +184,25 @@ local function pr_action_fns(f, num)
M.ci(f) M.ci(f)
end end
end, end,
manage = function() },
{
name = 'manage',
fn = function()
M.pr_manage(f, num) M.pr_manage(f, num)
end, end,
},
} }
---@type table<string, function>
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<string, function>
actions._by_name = name_to_fn
return actions
end end
---@param f forge.Forge ---@param f forge.Forge
@ -158,10 +223,7 @@ local function pr_manage_picker(f, num)
local action_map = {} local action_map = {}
local function add(label, fn) local function add(label, fn)
table.insert(entries, { table.insert(entries, label)
display = { { label } },
value = label,
})
action_map[label] = fn action_map[label] = fn
end end
@ -205,20 +267,17 @@ local function pr_manage_picker(f, num)
end) end)
end end
picker.pick({ require('fzf-lua').fzf_exec(entries, {
fzf_args = fzf_args,
prompt = ('%s #%s Actions> '):format(kind, num), prompt = ('%s #%s Actions> '):format(kind, num),
entries = entries, fzf_opts = { ['--no-multi'] = '' },
actions = { actions = {
{ ['default'] = function(selected)
name = 'default', if selected[1] and action_map[selected[1]] then
fn = function(entry) action_map[selected[1]]()
if entry and action_map[entry.value] then
action_map[entry.value]()
end end
end, end,
}, },
},
picker_name = '_menu',
}) })
end end
@ -232,13 +291,18 @@ function M.checks(f, num, filter, cached_checks)
local function open_picker(checks) local function open_picker(checks)
local filtered = forge_mod.filter_checks(checks, filter) local filtered = forge_mod.filter_checks(checks, filter)
local entries = {} local lines = {}
for _, c in ipairs(filtered) do for i, c in ipairs(filtered) do
table.insert(entries, { local line = ('%d\t%s'):format(i, forge_mod.format_check(c))
display = forge_mod.format_check(c), table.insert(lines, line)
value = c, end
ordinal = c.name or '',
}) 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 end
local labels = { local labels = {
@ -248,17 +312,14 @@ function M.checks(f, num, filter, cached_checks)
pending = 'running', pending = 'running',
} }
picker.pick({ local check_actions = build_actions('ci', {
prompt = ('Checks (#%s, %s)> '):format(num, labels[filter] or filter),
entries = entries,
actions = {
{ {
name = 'log', name = 'log',
fn = function(entry) fn = function(selected)
if not entry then local c = get_check(selected)
if not c then
return return
end end
local c = entry.value
local run_id = (c.link or ''):match('/actions/runs/(%d+)') local run_id = (c.link or ''):match('/actions/runs/(%d+)')
if not run_id then if not run_id then
return return
@ -285,9 +346,10 @@ function M.checks(f, num, filter, cached_checks)
}, },
{ {
name = 'browse', name = 'browse',
fn = function(entry) fn = function(selected)
if entry and entry.value.link then local c = get_check(selected)
vim.ui.open(entry.value.link) if c and c.link then
vim.ui.open(c.link)
end end
end, end,
}, },
@ -315,8 +377,18 @@ function M.checks(f, num, filter, cached_checks)
M.checks(f, num, 'all', checks) M.checks(f, num, 'all', checks)
end, 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',
}, },
picker_name = 'ci', actions = check_actions,
}) })
end end
@ -340,7 +412,16 @@ function M.checks(f, num, filter, cached_checks)
end) end)
end) end)
else 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
end end
@ -349,32 +430,33 @@ end
function M.ci(f, branch) function M.ci(f, branch)
local forge_mod = require('forge') local forge_mod = require('forge')
local function open_ci_picker(runs) local function open_picker(runs)
local normalized = {} local normalized = {}
for _, entry in ipairs(runs) do for _, entry in ipairs(runs) do
table.insert(normalized, f:normalize_run(entry)) table.insert(normalized, f:normalize_run(entry))
end end
local entries = {} local lines = {}
for _, run in ipairs(normalized) do for i, run in ipairs(normalized) do
table.insert(entries, { table.insert(lines, ('%d\t%s'):format(i, forge_mod.format_run(run)))
display = forge_mod.format_run(run),
value = run,
ordinal = run.name .. ' ' .. run.branch,
})
end end
picker.pick({ local function get_run(selected)
prompt = ('%s (%s)> '):format(f.labels.ci, branch or 'all'), if not selected[1] then
entries = entries, return nil
actions = { end
local idx = tonumber(selected[1]:match('^(%d+)'))
return idx and normalized[idx] or nil
end
local ci_actions = build_actions('ci', {
{ {
name = 'log', name = 'log',
fn = function(entry) fn = function(selected)
if not entry then local run = get_run(selected)
if not run then
return return
end end
local run = entry.value
forge_mod.log_now('fetching CI/CD logs...') forge_mod.log_now('fetching CI/CD logs...')
local s = run.status:lower() local s = run.status:lower()
local cmd local cmd
@ -399,9 +481,10 @@ function M.ci(f, branch)
}, },
{ {
name = 'browse', name = 'browse',
fn = function(entry) fn = function(selected)
if entry and entry.value.url ~= '' then local run = get_run(selected)
vim.ui.open(entry.value.url) if run and run.url ~= '' then
vim.ui.open(run.url)
end end
end, end,
}, },
@ -411,8 +494,18 @@ function M.ci(f, branch)
M.ci(f, branch) M.ci(f, branch)
end, 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',
}, },
picker_name = 'ci', actions = ci_actions,
}) })
end end
@ -422,7 +515,7 @@ function M.ci(f, branch)
vim.schedule(function() vim.schedule(function()
local ok, runs = pcall(vim.json.decode, result.stdout or '[]') local ok, runs = pcall(vim.json.decode, result.stdout or '[]')
if ok and runs and #runs > 0 then if ok and runs and #runs > 0 then
open_ci_picker(runs) open_picker(runs)
else else
vim.notify('[forge]: no CI runs found', vim.log.levels.INFO) vim.notify('[forge]: no CI runs found', vim.log.levels.INFO)
vim.cmd.redraw() vim.cmd.redraw()
@ -430,7 +523,11 @@ function M.ci(f, branch)
end) end)
end) end)
elseif f.list_runs_cmd then 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
end end
@ -438,27 +535,6 @@ end
function M.commits(f) function M.commits(f)
local forge_mod = require('forge') local forge_mod = require('forge')
local review = require('forge.review') 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 == '<cr>' then
return 'default'
end
return key:gsub('<c%-(%a)>', 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 = local log_cmd =
'git log --color --pretty=format:"%C(yellow)%h%Creset %Cgreen(%><(12)%cr%><|(12))%Creset %s %C(blue)<%an>%Creset"' 'git log --color --pretty=format:"%C(yellow)%h%Creset %Cgreen(%><(12)%cr%><|(12))%Creset %s %C(blue)<%an>%Creset"'
@ -469,9 +545,10 @@ function M.commits(f)
end end
end end
local fzf_actions = {} local defs = {
if keys.checkout then {
fzf_actions[to_fzf_key(keys.checkout)] = function(selected) name = 'checkout',
fn = function(selected)
with_sha(selected, function(sha) with_sha(selected, function(sha)
forge_mod.log_now('checking out ' .. sha .. '...') forge_mod.log_now('checking out ' .. sha .. '...')
vim.system({ 'git', 'checkout', sha }, { text = true }, function(result) vim.system({ 'git', 'checkout', sha }, { text = true }, function(result)
@ -479,16 +556,20 @@ function M.commits(f)
if result.code == 0 then if result.code == 0 then
vim.notify(('[forge]: checked out %s (detached)'):format(sha)) vim.notify(('[forge]: checked out %s (detached)'):format(sha))
else else
vim.notify('[forge]: ' .. cmd_error(result, 'checkout failed'), vim.log.levels.ERROR) vim.notify(
'[forge]: ' .. cmd_error(result, 'checkout failed'),
vim.log.levels.ERROR
)
end end
vim.cmd.redraw() vim.cmd.redraw()
end) end)
end) end)
end) end)
end end,
end },
if keys.diff then {
fzf_actions[to_fzf_key(keys.diff)] = function(selected) name = 'diff',
fn = function(selected)
with_sha(selected, function(sha) with_sha(selected, function(sha)
local range = sha .. '^..' .. sha local range = sha .. '^..' .. sha
review.start(range) review.start(range)
@ -498,25 +579,30 @@ function M.commits(f)
end end
forge_mod.log_now('reviewing ' .. sha) forge_mod.log_now('reviewing ' .. sha)
end) end)
end end,
end },
if keys.browse then {
fzf_actions[to_fzf_key(keys.browse)] = function(selected) name = 'browse',
fn = function(selected)
with_sha(selected, function(sha) with_sha(selected, function(sha)
if f then if f then
f:browse_commit(sha) f:browse_commit(sha)
end end
end) end)
end end,
end },
if keys.yank then {
fzf_actions[to_fzf_key(keys.yank)] = function(selected) name = 'yank',
fn = function(selected)
with_sha(selected, function(sha) with_sha(selected, function(sha)
vim.fn.setreg('+', sha) vim.fn.setreg('+', sha)
vim.notify('[forge]: copied ' .. sha) vim.notify('[forge]: copied ' .. sha)
end) end)
end end,
end },
}
local commit_actions = build_actions('commits', defs)
require('fzf-lua').fzf_exec(log_cmd, { require('fzf-lua').fzf_exec(log_cmd, {
fzf_args = fzf_args, fzf_args = fzf_args,
@ -526,7 +612,7 @@ function M.commits(f)
['--no-multi'] = '', ['--no-multi'] = '',
['--preview'] = 'git show --color {1}', ['--preview'] = 'git show --color {1}',
}, },
actions = fzf_actions, actions = commit_actions,
}) })
end end
@ -535,26 +621,10 @@ function M.branches(f)
local forge_mod = require('forge') local forge_mod = require('forge')
local review = require('forge.review') local review = require('forge.review')
if picker.backend() ~= 'fzf-lua' then local defs = {
vim.notify('[forge]: branches picker requires fzf-lua', vim.log.levels.WARN) {
return name = 'diff',
end fn = function(selected)
local function to_fzf_key(key)
if key == '<cr>' then
return 'default'
end
return key:gsub('<c%-(%a)>', 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 if not selected[1] then
return return
end end
@ -568,10 +638,11 @@ function M.branches(f)
commands.greview(br) commands.greview(br)
end end
forge_mod.log_now('reviewing ' .. br) forge_mod.log_now('reviewing ' .. br)
end end,
end },
if keys.browse then {
fzf_actions[to_fzf_key(keys.browse)] = function(selected) name = 'browse',
fn = function(selected)
if not selected[1] then if not selected[1] then
return return
end end
@ -579,10 +650,12 @@ function M.branches(f)
if br and f then if br and f then
f:browse_branch(br) f:browse_branch(br)
end end
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 end
---@param state 'all'|'open'|'closed' ---@param state 'all'|'open'|'closed'
@ -596,66 +669,64 @@ function M.pr(state, f)
local show_state = state ~= 'open' local show_state = state ~= 'open'
local function open_pr_list(prs) local function open_pr_list(prs)
local entries = {} local lines = {}
for _, pr in ipairs(prs) do for _, pr in ipairs(prs) do
local num = tostring(pr[pr_fields.number] or '') table.insert(lines, forge_mod.format_pr(pr, pr_fields, show_state))
table.insert(entries, { end
display = forge_mod.format_pr(pr, pr_fields, show_state), local function with_pr_num(selected, fn)
value = num, local num = selected[1] and selected[1]:match('[#!](%d+)')
ordinal = (pr[pr_fields.title] or '') .. ' #' .. num, if num then
}) fn(num)
end
end end
picker.pick({ local list_actions = build_actions('pr', {
prompt = ('%s (%s)> '):format(f.labels.pr, state),
entries = entries,
actions = {
{ {
name = 'checkout', name = 'checkout',
fn = function(entry) fn = function(selected)
if entry then with_pr_num(selected, function(num)
pr_action_fns(f, entry.value).checkout() pr_actions(f, num)._by_name['checkout']()
end end)
end, end,
}, },
{ {
name = 'diff', name = 'diff',
fn = function(entry) fn = function(selected)
if entry then with_pr_num(selected, function(num)
pr_action_fns(f, entry.value).diff() pr_actions(f, num)._by_name['diff']()
end end)
end, end,
}, },
{ {
name = 'worktree', name = 'worktree',
fn = function(entry) fn = function(selected)
if entry then with_pr_num(selected, function(num)
pr_action_fns(f, entry.value).worktree() pr_actions(f, num)._by_name['worktree']()
end end)
end, end,
}, },
{ {
name = 'ci', name = 'ci',
fn = function(entry) fn = function(selected)
if entry then with_pr_num(selected, function(num)
pr_action_fns(f, entry.value).ci() pr_actions(f, num)._by_name['ci']()
end end)
end, end,
}, },
{ {
name = 'browse', name = 'browse',
fn = function(entry) fn = function(selected)
if entry then with_pr_num(selected, function(num)
f:view_web(cli_kind, entry.value) f:view_web(cli_kind, num)
end end)
end, end,
}, },
{ {
name = 'manage', name = 'manage',
fn = function(entry) fn = function(selected)
if entry then with_pr_num(selected, function(num)
pr_action_fns(f, entry.value).manage() pr_actions(f, num)._by_name['manage']()
end end)
end, end,
}, },
{ {
@ -677,8 +748,16 @@ function M.pr(state, f)
M.pr(state, f) M.pr(state, f)
end, end,
}, },
})
require('fzf-lua').fzf_exec(lines, {
fzf_args = fzf_args,
prompt = ('%s (%s)> '):format(f.labels.pr, state),
fzf_opts = {
['--ansi'] = '',
['--no-multi'] = '',
}, },
picker_name = 'pr', actions = list_actions,
}) })
end end
@ -716,36 +795,35 @@ function M.issue(state, f)
end) end)
local state_field = issue_fields.state local state_field = issue_fields.state
local state_map = {} local state_map = {}
local entries = {} local lines = {}
for _, issue in ipairs(issues) do for _, issue in ipairs(issues) do
local n = tostring(issue[num_field] or '') local n = tostring(issue[num_field] or '')
local s = (issue[state_field] or ''):lower() local s = (issue[state_field] or ''):lower()
state_map[n] = s == 'open' or s == 'opened' state_map[n] = s == 'open' or s == 'opened'
table.insert(entries, { table.insert(lines, forge_mod.format_issue(issue, issue_fields, issue_show_state))
display = forge_mod.format_issue(issue, issue_fields, issue_show_state), end
value = n, local function with_issue_num(selected, fn)
ordinal = (issue[issue_fields.title] or '') .. ' #' .. n, local num = selected[1] and selected[1]:match('[#!](%d+)')
}) if num then
fn(num)
end
end end
picker.pick({ local issue_actions = build_actions('issue', {
prompt = ('%s (%s)> '):format(f.labels.issue, state),
entries = entries,
actions = {
{ {
name = 'browse', name = 'browse',
fn = function(entry) fn = function(selected)
if entry then with_issue_num(selected, function(num)
f:view_web(cli_kind, entry.value) f:view_web(cli_kind, num)
end end)
end, end,
}, },
{ {
name = 'close', name = 'close',
fn = function(entry) fn = function(selected)
if entry then with_issue_num(selected, function(num)
issue_toggle_state(f, entry.value, state_map[entry.value] ~= false) issue_toggle_state(f, num, state_map[num] ~= false)
end end)
end, end,
}, },
{ {
@ -761,8 +839,16 @@ function M.issue(state, f)
M.issue(state, f) M.issue(state, f)
end, end,
}, },
})
require('fzf-lua').fzf_exec(lines, {
fzf_args = fzf_args,
prompt = ('%s (%s)> '):format(f.labels.issue, state),
fzf_opts = {
['--ansi'] = '',
['--no-multi'] = '',
}, },
picker_name = 'issue', actions = issue_actions,
}) })
end end
@ -805,7 +891,7 @@ end
---@param num string ---@param num string
---@return table<string, function> ---@return table<string, function>
function M.pr_actions(f, num) function M.pr_actions(f, num)
return pr_action_fns(f, num) return pr_actions(f, num)
end end
function M.git() function M.git()
@ -827,14 +913,11 @@ function M.git()
local branch = vim.trim(vim.fn.system('git branch --show-current')) local branch = vim.trim(vim.fn.system('git branch --show-current'))
local items = {} local items = {}
local action_map = {} local actions = {}
local function add(label, action) local function add(label, action)
table.insert(items, { table.insert(items, label)
display = { { label } }, actions[label] = action
value = label,
})
action_map[label] = action
end end
if f then if f then
@ -885,29 +968,21 @@ function M.git()
end) end)
add('Worktrees', function() add('Worktrees', function()
if picker.backend() == 'fzf-lua' then
require('fzf-lua').git_worktrees() require('fzf-lua').git_worktrees()
else
vim.notify('[forge]: worktrees picker requires fzf-lua', vim.log.levels.WARN)
end
end) end)
local prompt = f and (f.name:sub(1, 1):upper() .. f.name:sub(2)) .. '> ' or 'Git> ' 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, prompt = prompt,
entries = items,
actions = { actions = {
{ ['default'] = function(selected)
name = 'default', if selected[1] and actions[selected[1]] then
fn = function(entry) actions[selected[1]]()
if entry and action_map[entry.value] then
action_map[entry.value]()
end end
end, end,
}, },
},
picker_name = '_menu',
}) })
end end

View file

@ -10,14 +10,6 @@ end
local forge = require('forge') 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() describe('config', function()
after_each(function() after_each(function()
vim.g.forge = nil vim.g.forge = nil
@ -61,7 +53,7 @@ describe('format_pr', function()
it('formats open PR with state icon', function() it('formats open PR with state icon', function()
local entry = local entry =
{ number = 42, title = 'fix bug', state = 'OPEN', login = 'alice', created_at = '' } { 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('+'))
assert.truthy(result:find('#42')) assert.truthy(result:find('#42'))
assert.truthy(result:find('fix bug')) assert.truthy(result:find('fix bug'))
@ -70,20 +62,20 @@ describe('format_pr', function()
it('formats merged PR', function() it('formats merged PR', function()
local entry = local entry =
{ number = 7, title = 'add feature', state = 'MERGED', login = 'bob', created_at = '' } { 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('m'))
assert.truthy(result:find('#7')) assert.truthy(result:find('#7'))
end) end)
it('formats closed PR', function() it('formats closed PR', function()
local entry = { number = 3, title = 'stale', state = 'CLOSED', login = 'eve', created_at = '' } 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')) assert.truthy(result:find('x'))
end) end)
it('omits state prefix when show_state is false', function() it('omits state prefix when show_state is false', function()
local entry = { number = 1, title = 'no state', state = 'OPEN', login = 'dev', created_at = '' } 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.truthy(result:find('#1'))
assert.falsy(result:match('^+')) assert.falsy(result:match('^+'))
end) end)
@ -91,14 +83,14 @@ describe('format_pr', function()
it('truncates long titles', function() it('truncates long titles', function()
local long_title = string.rep('a', 100) local long_title = string.rep('a', 100)
local entry = { number = 9, title = long_title, state = 'OPEN', login = 'x', created_at = '' } 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)) assert.falsy(result:find(long_title))
end) end)
it('extracts author from table with login field', function() it('extracts author from table with login field', function()
local entry = local entry =
{ number = 5, title = 't', state = 'OPEN', login = { login = 'nested' }, created_at = '' } { 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')) assert.truthy(result:find('nested'))
end) end)
end) end)
@ -115,59 +107,59 @@ describe('format_issue', function()
it('formats open issue', function() it('formats open issue', function()
local entry = local entry =
{ number = 10, title = 'bug report', state = 'open', author = 'alice', created_at = '' } { 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('+'))
assert.truthy(result:find('#10')) assert.truthy(result:find('#10'))
end) end)
it('formats closed issue', function() it('formats closed issue', function()
local entry = { number = 11, title = 'done', state = 'closed', author = 'bob', created_at = '' } 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')) assert.truthy(result:find('x'))
end) end)
it('handles opened state (GitLab)', function() it('handles opened state (GitLab)', function()
local entry = local entry =
{ number = 12, title = 'mr issue', state = 'opened', author = 'c', created_at = '' } { 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('+')) assert.truthy(result:find('+'))
end) end)
end) end)
describe('format_check', function() describe('format_check', function()
it('maps pass bucket', 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('%*'))
assert.truthy(result:find('lint')) assert.truthy(result:find('lint'))
end) end)
it('maps fail bucket', function() 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')) assert.truthy(result:find('x'))
end) end)
it('maps pending bucket', function() 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('~')) assert.truthy(result:find('~'))
end) end)
it('maps skipping bucket', function() 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('%-')) assert.truthy(result:find('%-'))
end) end)
it('maps cancel bucket', function() 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('%-')) assert.truthy(result:find('%-'))
end) end)
it('maps unknown bucket', function() 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('%?')) assert.truthy(result:find('%?'))
end) end)
it('defaults to pending when bucket is nil', function() 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('~')) assert.truthy(result:find('~'))
end) end)
end) end)
@ -176,7 +168,7 @@ describe('format_run', function()
it('formats successful run with branch', function() it('formats successful run with branch', function()
local run = local run =
{ name = 'CI', branch = 'main', status = 'success', event = 'push', created_at = '' } { 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('%*'))
assert.truthy(result:find('CI')) assert.truthy(result:find('CI'))
assert.truthy(result:find('main')) assert.truthy(result:find('main'))
@ -191,7 +183,7 @@ describe('format_run', function()
event = 'workflow_dispatch', event = 'workflow_dispatch',
created_at = '', created_at = '',
} }
local result = flatten(forge.format_run(run)) local result = forge.format_run(run)
assert.truthy(result:find('x')) assert.truthy(result:find('x'))
assert.truthy(result:find('manual')) assert.truthy(result:find('manual'))
end) end)
@ -199,13 +191,13 @@ describe('format_run', function()
it('maps in_progress status', function() it('maps in_progress status', function()
local run = local run =
{ name = 'Test', branch = '', status = 'in_progress', event = 'push', created_at = '' } { 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('~')) assert.truthy(result:find('~'))
end) end)
it('maps cancelled status', function() it('maps cancelled status', function()
local run = { name = 'Old', branch = '', status = 'cancelled', event = 'push', created_at = '' } 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('%-')) assert.truthy(result:find('%-'))
end) end)
end) end)
@ -250,39 +242,39 @@ describe('relative_time via format_pr', function()
it('shows minutes for recent timestamps', function() it('shows minutes for recent timestamps', function()
local ts = os.date('%Y-%m-%dT%H:%M:%SZ', os.time() - 120) 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 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')) assert.truthy(result:match('%d+m'))
end) end)
it('shows hours', function() it('shows hours', function()
local ts = os.date('%Y-%m-%dT%H:%M:%SZ', os.time() - 7200) 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 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')) assert.truthy(result:match('%d+h'))
end) end)
it('shows days', function() it('shows days', function()
local ts = os.date('%Y-%m-%dT%H:%M:%SZ', os.time() - 172800) 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 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')) assert.truthy(result:match('%d+d'))
end) end)
it('returns empty for nil timestamp', function() it('returns empty for nil timestamp', function()
local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = nil } 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) assert.truthy(result)
end) end)
it('returns empty for empty string timestamp', function() it('returns empty for empty string timestamp', function()
local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = '' } 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) assert.truthy(result)
end) end)
it('returns empty for garbage timestamp', function() it('returns empty for garbage timestamp', function()
local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = 'not-a-date' } 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) assert.truthy(result)
end) end)
end) end)

View file

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