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

2
.gitignore vendored
View file

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

366
README.md
View file

@ -2,12 +2,13 @@
**Forge-agnostic git workflow for Neovim**
PR, issue, and CI workflows across GitHub, GitLab, and Codeberg/Gitea/Forgejo —
without leaving your editor.
PR, issue, and CI workflows across GitHub, GitLab, and more — without leaving
your editor.
## Features
- Automatic forge detection from git remote (`gh`, `glab`, `tea`)
- Automatic forge detection from git remote (GitHub via `gh`, GitLab via `glab`,
Codeberg/Gitea/Forgejo via `tea`)
- PR lifecycle: list, create (compose buffer with template discovery, diff stat,
reviewers), checkout, worktree, review diff, merge, approve, close/reopen,
draft toggle
@ -16,52 +17,27 @@ without leaving your editor.
- Code review via [diffs.nvim](https://github.com/barrettruth/diffs.nvim) with
unified/split toggle and quickfix navigation
- Commit and branch browsing with checkout, diff, and URL generation
- File/line permalink generation and yanking
- File/line permalink generation and yanking (commit and branch URLs)
- [fzf-lua](https://github.com/ibhagwan/fzf-lua) pickers with contextual
keybinds
- Pluggable source registration for custom or self-hosted forges
## Requirements
## Dependencies
- Neovim 0.10.0+
- [fzf-lua](https://github.com/ibhagwan/fzf-lua)
- At least one forge CLI: [`gh`](https://cli.github.com/),
[`glab`](https://gitlab.com/gitlab-org/cli), or
[`tea`](https://gitea.com/gitea/tea)
- (Optional) [diffs.nvim](https://github.com/barrettruth/diffs.nvim) for review
mode
- (Optional) [vim-fugitive](https://github.com/tpope/vim-fugitive) for split
diff and fugitive keymaps
- At least one forge CLI:
- [`gh`](https://cli.github.com/) for GitHub
- [`glab`](https://gitlab.com/gitlab-org/cli) for GitLab
- [`tea`](https://gitea.com/gitea/tea) for Codeberg/Gitea/Forgejo
- [vim-fugitive](https://github.com/tpope/vim-fugitive) (optional, for fugitive
keymaps and split diff)
- [diffs.nvim](https://github.com/barrettruth/diffs.nvim) (optional, for review
mode)
## Installation
Install with your package manager of choice or via
[luarocks](https://luarocks.org/modules/barrettruth/forge.nvim):
```
luarocks install forge.nvim
```
## Documentation
```vim
:help forge.nvim
```
## FAQ
**Q: How do I configure forge.nvim?**
Configure via `vim.g.forge` before the plugin loads. All fields are optional:
```lua
vim.g.forge = {
sources = { gitlab = { hosts = { 'gitlab.mycompany.com' } } },
display = { icons = { open = '', merged = '', closed = '' } },
}
```
**Q: How do I install with lazy.nvim?**
### [lazy.nvim](https://github.com/folke/lazy.nvim)
```lua
{
@ -70,19 +46,309 @@ vim.g.forge = {
}
```
**Q: How do I create a PR?**
### [mini.deps](https://github.com/echasnovski/mini.deps)
`<c-g>` to open the picker, select Pull Requests, then `ctrl-a` to compose. Or
from a fugitive buffer: `cpr` (compose), `cpd` (draft), `cpf` (instant from
commits), `cpw` (push and open web).
```lua
MiniDeps.add({
source = 'barrettruth/forge.nvim',
depends = { 'ibhagwan/fzf-lua' },
})
```
**Q: Does review mode require diffs.nvim?**
### [luarocks](https://luarocks.org/modules/barrettruth/forge.nvim)
Yes. Without [diffs.nvim](https://github.com/barrettruth/diffs.nvim), diff
actions and review toggling are unavailable.
```
luarocks install forge.nvim
```
**Q: How does forge detection work?**
### Manual
forge.nvim reads the `origin` remote URL and matches against known hosts and any
custom `sources.<name>.hosts` entries. The first match wins, and the CLI must be
in `$PATH`.
```sh
git clone https://github.com/barrettruth/forge.nvim \
~/.local/share/nvim/site/pack/plugins/start/forge.nvim
```
## Usage
forge.nvim works through two entry points: the `:Forge` command and the `<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: ~
- Neovim 0.10.0+
- At least one picker backend:
- fzf-lua
- telescope.nvim
- snacks.nvim
- fzf-lua (required)
- One or more forge CLIs:
- `gh` for GitHub
- `glab` for GitLab
@ -35,15 +32,6 @@ unset keys use defaults. >lua
Top-level keys: ~
`picker` *forge-config-picker*
`string` (default `"auto"`)
Picker backend to use. One of `"auto"`, `"fzf-lua"`, `"telescope"`, or
`"snacks"`. When `"auto"`, forge.nvim tries fzf-lua, then snacks, then
telescope.
Note: commits, branches, and worktree pickers currently require fzf-lua.
Other backends show a notification for these pickers.
`ci` *forge-config-ci*
`ci.lines` `integer` (default `10000`)
Maximum number of log lines fetched for CI/check log output.
@ -65,8 +53,7 @@ Top-level keys: ~
`table|false` (default shown below)
Per-picker action bindings. Set to `false` to disable all picker-level
actions. Use `"<cr>"` to bind to enter. Values use vim keymap notation
(e.g. `"<c-d>"`), which is translated to the appropriate format for
each picker backend.
(e.g. `"<c-d>"`), which is translated to fzf-lua format internally.
Defaults: >lua
keys = {
@ -588,7 +575,7 @@ HEALTH *forge-health*
Reports on: ~
- `git` availability
- Forge CLI availability (`gh`, `glab`, `tea`)
- Picker backends (fzf-lua, telescope, snacks) and which is active
- `fzf-lua` installation (required)
- `diffs.nvim` installation (review mode)
- Custom registered sources and their CLI availability
@ -712,11 +699,12 @@ PUBLIC API: `require('forge.pickers')` ~
`pr_manage(f, num)`
Opens the management picker for PR `num`.
*forge.pickers.pr_action_fns()*
`pr_action_fns(f, num)`
*forge.pickers.pr_actions()*
`pr_actions(f, num)`
Returns `table<string, function>`. Action functions for PR `num`,
keyed by action name (`"checkout"`, `"diff"`, `"worktree"`,
`"browse"`, `"ci"`, `"manage"`).
keyed by fzf binding. Also has `_by_name` table keyed by action
name (`"checkout"`, `"diff"`, `"worktree"`, `"browse"`, `"ci"`,
`"manage"`).
*forge.pickers.issue_close()*
`issue_close(f, num)`

View file

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

View file

@ -22,18 +22,11 @@ function M.check()
end
end
local picker_mod = require('forge.picker')
local backend = picker_mod.backend()
local found_any = false
for _, name in ipairs(picker_mod.detect_order) do
if pcall(require, name) then
local suffix = backend == name and ' (active)' or ''
vim.health.ok(name .. ' found' .. suffix)
found_any = true
end
end
if not found_any then
vim.health.error('no picker backend found (install fzf-lua, telescope.nvim, or snacks.nvim)')
local has_fzf = pcall(require, 'fzf-lua')
if has_fzf then
vim.health.ok('fzf-lua found')
else
vim.health.error('fzf-lua not found (required)')
end
local has_diffs = pcall(require, 'diffs')

View file

@ -1,7 +1,6 @@
local M = {}
---@class forge.Config
---@field picker 'fzf-lua'|'telescope'|'snacks'|'auto'
---@field ci forge.CIConfig
---@field sources table<string, forge.SourceConfig>
---@field keys forge.KeysConfig|false
@ -84,7 +83,6 @@ local M = {}
---@type forge.Config
local DEFAULTS = {
picker = 'auto',
ci = { lines = 10000 },
sources = {},
keys = {
@ -540,25 +538,15 @@ local function extract_author(entry, field)
return tostring(v or '')
end
---@param secs integer
---@return string
local function format_duration(secs)
if secs < 0 then
secs = 0
end
if secs >= 3600 then
return ('%dh%dm'):format(math.floor(secs / 3600), math.floor(secs % 3600 / 60))
end
if secs >= 60 then
return ('%dm%ds'):format(math.floor(secs / 60), secs % 60)
end
return ('%ds'):format(secs)
local function hl(group, text)
local utils = require('fzf-lua.utils')
return utils.ansi_from_hl(group, text)
end
---@param entry table
---@param fields table
---@param show_state boolean
---@return forge.Segment[]
---@return string
function M.format_pr(entry, fields, show_state)
local display = M.config().display
local icons = display.icons
@ -567,7 +555,7 @@ function M.format_pr(entry, fields, show_state)
local title = entry[fields.title] or ''
local author = extract_author(entry, fields.author)
local age = relative_time(entry[fields.created_at])
local segments = {}
local prefix = ''
if show_state then
local state = (entry[fields.state] or ''):lower()
local icon, group
@ -578,22 +566,20 @@ function M.format_pr(entry, fields, show_state)
else
icon, group = icons.closed, 'ForgeClosed'
end
table.insert(segments, { icon, group })
table.insert(segments, { ' ' })
prefix = hl(group, icon) .. ' '
end
table.insert(segments, { ('#%-5s'):format(num), 'ForgeNumber' })
table.insert(segments, { ' ' .. pad_or_truncate(title, widths.title) .. ' ' })
table.insert(segments, {
pad_or_truncate(author, widths.author) .. (' %3s'):format(age),
'ForgeDim',
})
return segments
return prefix
.. hl('ForgeNumber', ('#%-5s'):format(num))
.. ' '
.. pad_or_truncate(title, widths.title)
.. ' '
.. hl('ForgeDim', pad_or_truncate(author, widths.author) .. (' %3s'):format(age))
end
---@param entry table
---@param fields table
---@param show_state boolean
---@return forge.Segment[]
---@return string
function M.format_issue(entry, fields, show_state)
local display = M.config().display
local icons = display.icons
@ -602,7 +588,7 @@ function M.format_issue(entry, fields, show_state)
local title = entry[fields.title] or ''
local author = extract_author(entry, fields.author)
local age = relative_time(entry[fields.created_at])
local segments = {}
local prefix = ''
if show_state then
local state = (entry[fields.state] or ''):lower()
local icon, group
@ -611,20 +597,18 @@ function M.format_issue(entry, fields, show_state)
else
icon, group = icons.closed, 'ForgeClosed'
end
table.insert(segments, { icon, group })
table.insert(segments, { ' ' })
prefix = hl(group, icon) .. ' '
end
table.insert(segments, { ('#%-5s'):format(num), 'ForgeNumber' })
table.insert(segments, { ' ' .. pad_or_truncate(title, widths.title) .. ' ' })
table.insert(segments, {
pad_or_truncate(author, widths.author) .. (' %3s'):format(age),
'ForgeDim',
})
return segments
return prefix
.. hl('ForgeNumber', ('#%-5s'):format(num))
.. ' '
.. pad_or_truncate(title, widths.title)
.. ' '
.. hl('ForgeDim', pad_or_truncate(author, widths.author) .. (' %3s'):format(age))
end
---@param check table
---@return forge.Segment[]
---@return string
function M.format_check(check)
local display = M.config().display
local icons = display.icons
@ -648,18 +632,23 @@ function M.format_check(check)
local ok_s, ts = pcall(vim.fn.strptime, '%Y-%m-%dT%H:%M:%SZ', check.startedAt)
local ok_e, te = pcall(vim.fn.strptime, '%Y-%m-%dT%H:%M:%SZ', check.completedAt)
if ok_s and ok_e and ts > 0 and te > 0 then
elapsed = format_duration(te - ts)
local secs = te - ts
if secs >= 60 then
elapsed = ('%dm%ds'):format(math.floor(secs / 60), secs % 60)
else
elapsed = ('%ds'):format(secs)
end
end
end
return {
{ icon, group },
{ ' ' .. pad_or_truncate(name, widths.name) .. ' ' },
{ elapsed, 'ForgeDim' },
}
return hl(group, icon)
.. ' '
.. pad_or_truncate(name, widths.name)
.. ' '
.. hl('ForgeDim', elapsed)
end
---@param run forge.CIRun
---@return forge.Segment[]
---@return string
function M.format_run(run)
local display = M.config().display
local icons = display.icons
@ -681,18 +670,19 @@ function M.format_run(run)
local age = relative_time(run.created_at)
if run.branch ~= '' then
local name_w = widths.name - widths.branch + 10
return {
{ icon, group },
{ ' ' .. pad_or_truncate(run.name, name_w) .. ' ' },
{ pad_or_truncate(run.branch, widths.branch), 'ForgeBranch' },
{ ' ' .. ('%-6s'):format(event) .. ' ' .. age, 'ForgeDim' },
}
return hl(group, icon)
.. ' '
.. pad_or_truncate(run.name, name_w)
.. ' '
.. hl('ForgeBranch', pad_or_truncate(run.branch, widths.branch))
.. ' '
.. hl('ForgeDim', ('%-6s'):format(event) .. ' ' .. age)
end
return {
{ icon, group },
{ ' ' .. pad_or_truncate(run.name, widths.name) .. ' ' },
{ ('%-6s'):format(event) .. ' ' .. age, 'ForgeDim' },
}
return hl(group, icon)
.. ' '
.. pad_or_truncate(run.name, widths.name)
.. ' '
.. hl('ForgeDim', ('%-6s'):format(event) .. ' ' .. age)
end
---@param checks table[]
@ -725,10 +715,6 @@ function M.config()
cfg.keys = false
end
local picker_backends = require('forge.picker').backends
vim.validate('forge.picker', cfg.picker, function(v)
return v == 'auto' or picker_backends[v] ~= nil
end, "'auto', 'fzf-lua', 'telescope', or 'snacks'")
vim.validate('forge.sources', cfg.sources, 'table')
vim.validate('forge.keys', cfg.keys, function(v)
return v == false or type(v) == 'table'

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

File diff suppressed because it is too large Load diff

View file

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

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)