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 name: luarocks-dev
on: on:
push: workflow_run:
workflows: [quality]
types: [completed]
branches: [main] branches: [main]
jobs: jobs:
quality:
uses: ./.github/workflows/quality.yaml
publish: publish:
needs: quality if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:

2
.gitignore vendored
View file

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

366
README.md
View file

@ -2,13 +2,12 @@
**Forge-agnostic git workflow for Neovim** **Forge-agnostic git workflow for Neovim**
PR, issue, and CI workflows across GitHub, GitLab, and more — without leaving PR, issue, and CI workflows across GitHub, GitLab, and Codeberg/Gitea/Forgejo —
your editor. without leaving your editor.
## Features ## Features
- Automatic forge detection from git remote (GitHub via `gh`, GitLab via `glab`, - Automatic forge detection from git remote (`gh`, `glab`, `tea`)
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
@ -17,27 +16,52 @@ 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 (commit and branch URLs) - File/line permalink generation and yanking
- [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
## Dependencies ## Requirements
- 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: - At least one forge CLI: [`gh`](https://cli.github.com/),
- [`gh`](https://cli.github.com/) for GitHub [`glab`](https://gitlab.com/gitlab-org/cli), or
- [`glab`](https://gitlab.com/gitlab-org/cli) for GitLab [`tea`](https://gitea.com/gitea/tea)
- [`tea`](https://gitea.com/gitea/tea) for Codeberg/Gitea/Forgejo - (Optional) [diffs.nvim](https://github.com/barrettruth/diffs.nvim) for review
- [vim-fugitive](https://github.com/tpope/vim-fugitive) (optional, for fugitive mode
keymaps and split diff) - (Optional) [vim-fugitive](https://github.com/tpope/vim-fugitive) for split
- [diffs.nvim](https://github.com/barrettruth/diffs.nvim) (optional, for review diff and fugitive keymaps
mode)
## Installation ## 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 ```lua
{ {
@ -46,309 +70,19 @@ your editor.
} }
``` ```
### [mini.deps](https://github.com/echasnovski/mini.deps) **Q: How do I create a PR?**
```lua `<c-g>` to open the picker, select Pull Requests, then `ctrl-a` to compose. Or
MiniDeps.add({ from a fugitive buffer: `cpr` (compose), `cpd` (draft), `cpf` (instant from
source = 'barrettruth/forge.nvim', commits), `cpw` (push and open web).
depends = { 'ibhagwan/fzf-lua' },
})
```
### [luarocks](https://luarocks.org/modules/barrettruth/forge.nvim) **Q: Does review mode require diffs.nvim?**
``` Yes. Without [diffs.nvim](https://github.com/barrettruth/diffs.nvim), diff
luarocks install forge.nvim actions and review toggling are unavailable.
```
### Manual **Q: How does forge detection work?**
```sh forge.nvim reads the `origin` remote URL and matches against known hosts and any
git clone https://github.com/barrettruth/forge.nvim \ custom `sources.<name>.hosts` entries. The first match wins, and the CLI must be
~/.local/share/nvim/site/pack/plugins/start/forge.nvim in `$PATH`.
```
## 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,7 +9,10 @@ delegates to the appropriate CLI (`gh`, `glab`, or `tea`).
Requirements: ~ Requirements: ~
- Neovim 0.10.0+ - Neovim 0.10.0+
- fzf-lua (required) - At least one picker backend:
- 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
@ -32,6 +35,15 @@ 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.
@ -53,7 +65,8 @@ 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 fzf-lua format internally. (e.g. `"<c-d>"`), which is translated to the appropriate format for
each picker backend.
Defaults: >lua Defaults: >lua
keys = { keys = {
@ -575,7 +588,7 @@ HEALTH *forge-health*
Reports on: ~ Reports on: ~
- `git` availability - `git` availability
- Forge CLI availability (`gh`, `glab`, `tea`) - 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) - `diffs.nvim` installation (review mode)
- Custom registered sources and their CLI availability - Custom registered sources and their CLI availability
@ -699,12 +712,11 @@ 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_actions()* *forge.pickers.pr_action_fns()*
`pr_actions(f, num)` `pr_action_fns(f, num)`
Returns `table<string, function>`. Action functions for PR `num`, Returns `table<string, function>`. Action functions for PR `num`,
keyed by fzf binding. Also has `_by_name` table keyed by action keyed by action name (`"checkout"`, `"diff"`, `"worktree"`,
name (`"checkout"`, `"diff"`, `"worktree"`, `"browse"`, `"ci"`, `"browse"`, `"ci"`, `"manage"`).
`"manage"`).
*forge.pickers.issue_close()* *forge.pickers.issue_close()*
`issue_close(f, num)` `issue_close(f, num)`

View file

@ -24,22 +24,21 @@
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-ls vimdoc-language-server.packages.${pkgs.system}.default
(pkgs.luajit.withPackages (ps: [
ps.busted
ps.nlua
]))
]; ];
in in
{ {
default = pkgs.mkShell { default = pkgs.mkShell { packages = commonPackages; };
packages = commonPackages; ci = pkgs.mkShell { packages = commonPackages ++ [ pkgs.neovim ]; };
};
ci = pkgs.mkShell {
packages = commonPackages;
};
} }
); );
}; };

View file

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

View file

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

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 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
@ -53,7 +61,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 = forge.format_pr(entry, fields, true) local result = flatten(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'))
@ -62,20 +70,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 = forge.format_pr(entry, fields, true) local result = flatten(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 = forge.format_pr(entry, fields, true) local result = flatten(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 = forge.format_pr(entry, fields, false) local result = flatten(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)
@ -83,14 +91,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 = forge.format_pr(entry, fields, false) local result = flatten(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 = forge.format_pr(entry, fields, false) local result = flatten(forge.format_pr(entry, fields, false))
assert.truthy(result:find('nested')) assert.truthy(result:find('nested'))
end) end)
end) end)
@ -107,59 +115,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 = forge.format_issue(entry, fields, true) local result = flatten(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 = forge.format_issue(entry, fields, true) local result = flatten(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 = forge.format_issue(entry, fields, true) local result = flatten(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 = 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('%*'))
assert.truthy(result:find('lint')) assert.truthy(result:find('lint'))
end) end)
it('maps fail bucket', function() 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')) assert.truthy(result:find('x'))
end) end)
it('maps pending bucket', function() 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('~')) assert.truthy(result:find('~'))
end) end)
it('maps skipping bucket', function() 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('%-')) assert.truthy(result:find('%-'))
end) end)
it('maps cancel bucket', function() 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('%-')) assert.truthy(result:find('%-'))
end) end)
it('maps unknown bucket', function() 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('%?')) 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 = forge.format_check({ name = 'none' }) local result = flatten(forge.format_check({ name = 'none' }))
assert.truthy(result:find('~')) assert.truthy(result:find('~'))
end) end)
end) end)
@ -168,7 +176,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 = forge.format_run(run) local result = flatten(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'))
@ -183,7 +191,7 @@ describe('format_run', function()
event = 'workflow_dispatch', event = 'workflow_dispatch',
created_at = '', created_at = '',
} }
local result = forge.format_run(run) local result = flatten(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)
@ -191,13 +199,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 = forge.format_run(run) local result = flatten(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 = forge.format_run(run) local result = flatten(forge.format_run(run))
assert.truthy(result:find('%-')) assert.truthy(result:find('%-'))
end) end)
end) end)
@ -242,39 +250,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 = forge.format_pr(entry, fields, false) local result = flatten(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 = forge.format_pr(entry, fields, false) local result = flatten(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 = forge.format_pr(entry, fields, false) local result = flatten(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 = forge.format_pr(entry, fields, false) local result = flatten(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 = forge.format_pr(entry, fields, false) local result = flatten(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 = forge.format_pr(entry, fields, false) local result = flatten(forge.format_pr(entry, fields, false))
assert.truthy(result) assert.truthy(result)
end) end)
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)