mirror of
https://github.com/harivansh-afk/forge.nvim.git
synced 2026-04-15 07:04:47 +00:00
Compare commits
11 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09463c08b7 | ||
|
|
d6a4766aac | ||
|
|
be29458072 | ||
|
|
f8ec4acf19 | ||
|
|
0e892b0b52 | ||
|
|
ac8db6851f | ||
|
|
97698c2af1 | ||
|
|
830442f856 | ||
|
|
fa7cab89af | ||
|
|
354c5000c0 | ||
|
|
2658c47af1 |
14 changed files with 1142 additions and 967 deletions
9
.github/workflows/luarocks-dev.yaml
vendored
9
.github/workflows/luarocks-dev.yaml
vendored
|
|
@ -1,15 +1,14 @@
|
|||
name: luarocks-dev
|
||||
|
||||
on:
|
||||
push:
|
||||
workflow_run:
|
||||
workflows: [quality]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
uses: ./.github/workflows/quality.yaml
|
||||
|
||||
publish:
|
||||
needs: quality
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -11,3 +11,5 @@ result
|
|||
result-*
|
||||
.direnv/
|
||||
.envrc
|
||||
|
||||
scripts/demo
|
||||
|
|
|
|||
366
README.md
366
README.md
|
|
@ -2,13 +2,12 @@
|
|||
|
||||
**Forge-agnostic git workflow for Neovim**
|
||||
|
||||
PR, issue, and CI workflows across GitHub, GitLab, and more — without leaving
|
||||
your editor.
|
||||
PR, issue, and CI workflows across GitHub, GitLab, and Codeberg/Gitea/Forgejo —
|
||||
without leaving your editor.
|
||||
|
||||
## Features
|
||||
|
||||
- Automatic forge detection from git remote (GitHub via `gh`, GitLab via `glab`,
|
||||
Codeberg/Gitea/Forgejo via `tea`)
|
||||
- Automatic forge detection from git remote (`gh`, `glab`, `tea`)
|
||||
- PR lifecycle: list, create (compose buffer with template discovery, diff stat,
|
||||
reviewers), checkout, worktree, review diff, merge, approve, close/reopen,
|
||||
draft toggle
|
||||
|
|
@ -17,27 +16,52 @@ your editor.
|
|||
- Code review via [diffs.nvim](https://github.com/barrettruth/diffs.nvim) with
|
||||
unified/split toggle and quickfix navigation
|
||||
- Commit and branch browsing with checkout, diff, and URL generation
|
||||
- File/line permalink generation and yanking (commit and branch URLs)
|
||||
- File/line permalink generation and yanking
|
||||
- [fzf-lua](https://github.com/ibhagwan/fzf-lua) pickers with contextual
|
||||
keybinds
|
||||
- Pluggable source registration for custom or self-hosted forges
|
||||
|
||||
## Dependencies
|
||||
## Requirements
|
||||
|
||||
- Neovim 0.10.0+
|
||||
- [fzf-lua](https://github.com/ibhagwan/fzf-lua)
|
||||
- At least one forge CLI:
|
||||
- [`gh`](https://cli.github.com/) for GitHub
|
||||
- [`glab`](https://gitlab.com/gitlab-org/cli) for GitLab
|
||||
- [`tea`](https://gitea.com/gitea/tea) for Codeberg/Gitea/Forgejo
|
||||
- [vim-fugitive](https://github.com/tpope/vim-fugitive) (optional, for fugitive
|
||||
keymaps and split diff)
|
||||
- [diffs.nvim](https://github.com/barrettruth/diffs.nvim) (optional, for review
|
||||
mode)
|
||||
- At least one forge CLI: [`gh`](https://cli.github.com/),
|
||||
[`glab`](https://gitlab.com/gitlab-org/cli), or
|
||||
[`tea`](https://gitea.com/gitea/tea)
|
||||
- (Optional) [diffs.nvim](https://github.com/barrettruth/diffs.nvim) for review
|
||||
mode
|
||||
- (Optional) [vim-fugitive](https://github.com/tpope/vim-fugitive) for split
|
||||
diff and fugitive keymaps
|
||||
|
||||
## Installation
|
||||
|
||||
### [lazy.nvim](https://github.com/folke/lazy.nvim)
|
||||
Install with your package manager of choice or via
|
||||
[luarocks](https://luarocks.org/modules/barrettruth/forge.nvim):
|
||||
|
||||
```
|
||||
luarocks install forge.nvim
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
```vim
|
||||
:help forge.nvim
|
||||
```
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: How do I configure forge.nvim?**
|
||||
|
||||
Configure via `vim.g.forge` before the plugin loads. All fields are optional:
|
||||
|
||||
```lua
|
||||
vim.g.forge = {
|
||||
sources = { gitlab = { hosts = { 'gitlab.mycompany.com' } } },
|
||||
display = { icons = { open = '', merged = '', closed = '' } },
|
||||
}
|
||||
```
|
||||
|
||||
**Q: How do I install with lazy.nvim?**
|
||||
|
||||
```lua
|
||||
{
|
||||
|
|
@ -46,309 +70,19 @@ your editor.
|
|||
}
|
||||
```
|
||||
|
||||
### [mini.deps](https://github.com/echasnovski/mini.deps)
|
||||
**Q: How do I create a PR?**
|
||||
|
||||
```lua
|
||||
MiniDeps.add({
|
||||
source = 'barrettruth/forge.nvim',
|
||||
depends = { 'ibhagwan/fzf-lua' },
|
||||
})
|
||||
```
|
||||
`<c-g>` to open the picker, select Pull Requests, then `ctrl-a` to compose. Or
|
||||
from a fugitive buffer: `cpr` (compose), `cpd` (draft), `cpf` (instant from
|
||||
commits), `cpw` (push and open web).
|
||||
|
||||
### [luarocks](https://luarocks.org/modules/barrettruth/forge.nvim)
|
||||
**Q: Does review mode require diffs.nvim?**
|
||||
|
||||
```
|
||||
luarocks install forge.nvim
|
||||
```
|
||||
Yes. Without [diffs.nvim](https://github.com/barrettruth/diffs.nvim), diff
|
||||
actions and review toggling are unavailable.
|
||||
|
||||
### Manual
|
||||
**Q: How does forge detection work?**
|
||||
|
||||
```sh
|
||||
git clone https://github.com/barrettruth/forge.nvim \
|
||||
~/.local/share/nvim/site/pack/plugins/start/forge.nvim
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
forge.nvim works through two entry points: the `:Forge` command and the `<c-g>`
|
||||
picker.
|
||||
|
||||
`:Forge` with no arguments (or `<c-g>`) opens the top-level picker — PRs,
|
||||
issues, CI, commits, branches, worktrees, and browse actions. Each sub-picker
|
||||
has contextual keybinds shown in the fzf header.
|
||||
|
||||
PR creation opens a compose buffer (markdown) pre-filled from commit messages
|
||||
and repo templates. First line is the title, everything after the blank line is
|
||||
the body. Draft, reviewers, and base branch are set in the HTML comment block
|
||||
below. Write (`:w`) to push and create.
|
||||
|
||||
## Configuration
|
||||
|
||||
Configure via `vim.g.forge`. All fields are optional — defaults shown below.
|
||||
|
||||
```lua
|
||||
vim.g.forge = {
|
||||
ci = { lines = 10000 },
|
||||
sources = {},
|
||||
keys = {
|
||||
picker = '<c-g>',
|
||||
next_qf = ']q', prev_qf = '[q',
|
||||
next_loc = ']l', prev_loc = '[l',
|
||||
review_toggle = 's',
|
||||
terminal_open = 'gx',
|
||||
fugitive = {
|
||||
create = 'cpr', create_draft = 'cpd',
|
||||
create_fill = 'cpf', create_web = 'cpw',
|
||||
},
|
||||
},
|
||||
picker_keys = {
|
||||
pr = {
|
||||
checkout = 'default', diff = 'ctrl-d', worktree = 'ctrl-w',
|
||||
checks = 'ctrl-t', browse = 'ctrl-x', manage = 'ctrl-e',
|
||||
create = 'ctrl-a', toggle = 'ctrl-o', refresh = 'ctrl-r',
|
||||
},
|
||||
issue = { browse = 'default', close_reopen = 'ctrl-s', toggle = 'ctrl-o', refresh = 'ctrl-r' },
|
||||
checks = { log = 'default', browse = 'ctrl-x', failed = 'ctrl-f', passed = 'ctrl-p', running = 'ctrl-n', all = 'ctrl-a' },
|
||||
ci = { log = 'default', browse = 'ctrl-x', refresh = 'ctrl-r' },
|
||||
commits = { checkout = 'default', diff = 'ctrl-d', browse = 'ctrl-x', yank = 'ctrl-y' },
|
||||
branches = { diff = 'ctrl-d', browse = 'ctrl-x' },
|
||||
},
|
||||
display = {
|
||||
icons = { open = '+', merged = 'm', closed = 'x', pass = '*', fail = 'x', pending = '~', skip = '-', unknown = '?' },
|
||||
widths = { title = 45, author = 15, name = 35, branch = 25 },
|
||||
limits = { pulls = 100, issues = 100, runs = 30 },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Set `keys = false` to disable all keymaps. Set `picker_keys = false` to disable
|
||||
all picker keybinds. Set any individual key to `false` to disable it.
|
||||
|
||||
### Examples
|
||||
|
||||
Disable quickfix/loclist keymaps:
|
||||
|
||||
```lua
|
||||
vim.g.forge = {
|
||||
keys = { next_qf = false, prev_qf = false, next_loc = false, prev_loc = false },
|
||||
}
|
||||
```
|
||||
|
||||
Nerd font icons:
|
||||
|
||||
```lua
|
||||
vim.g.forge = {
|
||||
display = {
|
||||
icons = { open = '', merged = '', closed = '', pass = '', fail = '', pending = '', skip = '', unknown = '' },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Self-hosted GitLab:
|
||||
|
||||
```lua
|
||||
vim.g.forge = {
|
||||
sources = { gitlab = { hosts = { 'gitlab.mycompany.com' } } },
|
||||
}
|
||||
```
|
||||
|
||||
Override PR picker bindings:
|
||||
|
||||
```lua
|
||||
vim.g.forge = {
|
||||
picker_keys = { pr = { checkout = 'ctrl-o', diff = 'default' } },
|
||||
}
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
`:Forge` with no arguments opens the top-level picker. Subcommands:
|
||||
|
||||
| Command | Description |
|
||||
| --------------------------------------------- | --------------------------------- |
|
||||
| `:Forge pr` | List open PRs |
|
||||
| `:Forge pr --state={open,closed,all}` | List PRs by state |
|
||||
| `:Forge pr create [--draft] [--fill] [--web]` | Create PR |
|
||||
| `:Forge pr checkout {num}` | Checkout PR branch |
|
||||
| `:Forge pr diff {num}` | Review PR diff |
|
||||
| `:Forge pr worktree {num}` | Fetch PR into worktree |
|
||||
| `:Forge pr checks {num}` | Show PR checks |
|
||||
| `:Forge pr browse {num}` | Open PR in browser |
|
||||
| `:Forge pr manage {num}` | Merge/approve/close/draft actions |
|
||||
| `:Forge issue` | List all issues |
|
||||
| `:Forge issue --state={open,closed,all}` | List issues by state |
|
||||
| `:Forge issue browse {num}` | Open issue in browser |
|
||||
| `:Forge issue close {num}` | Close issue |
|
||||
| `:Forge issue reopen {num}` | Reopen issue |
|
||||
| `:Forge ci` | CI runs for current branch |
|
||||
| `:Forge ci --all` | CI runs for all branches |
|
||||
| `:Forge commit` | Browse commits |
|
||||
| `:Forge commit checkout {sha}` | Checkout commit |
|
||||
| `:Forge commit diff {sha}` | Review commit diff |
|
||||
| `:Forge commit browse {sha}` | Open commit in browser |
|
||||
| `:Forge branch` | Browse branches |
|
||||
| `:Forge branch diff {name}` | Review branch diff |
|
||||
| `:Forge branch browse {name}` | Open branch in browser |
|
||||
| `:Forge worktree` | List worktrees |
|
||||
| `:Forge browse [--root] [--commit]` | Open file/repo/commit in browser |
|
||||
| `:Forge yank [--commit]` | Yank permalink for file/line |
|
||||
| `:Forge review end` | End review session |
|
||||
| `:Forge review toggle` | Toggle split/unified review |
|
||||
| `:Forge cache clear` | Clear all caches |
|
||||
|
||||
## Keymaps
|
||||
|
||||
### Global
|
||||
|
||||
| Key | Mode | Description |
|
||||
| ----------- | ---- | -------------------------------- |
|
||||
| `<c-g>` | n, v | Open forge picker |
|
||||
| `]q` / `[q` | n | Next/prev quickfix entry (wraps) |
|
||||
| `]l` / `[l` | n | Next/prev loclist entry (wraps) |
|
||||
|
||||
### Fugitive buffer
|
||||
|
||||
Active in `fugitive` filetype buffers when a forge is detected.
|
||||
|
||||
| Key | Description |
|
||||
| ----- | ----------------------------------- |
|
||||
| `cpr` | Create PR (compose buffer) |
|
||||
| `cpd` | Create draft PR |
|
||||
| `cpf` | Create PR from commits (no compose) |
|
||||
| `cpw` | Push and open web creation |
|
||||
|
||||
### Review
|
||||
|
||||
Active during a review session.
|
||||
|
||||
| Key | Description |
|
||||
| --- | ------------------------- |
|
||||
| `s` | Toggle unified/split diff |
|
||||
|
||||
### Terminal (log buffers)
|
||||
|
||||
Active on CI/check log terminals when a URL is available.
|
||||
|
||||
| Key | Description |
|
||||
| ---- | ------------------------- |
|
||||
| `gx` | Open run/check in browser |
|
||||
|
||||
## Picker Actions
|
||||
|
||||
Keybinds shown in the fzf header. `default` = `enter`.
|
||||
|
||||
| Picker | Key | Action |
|
||||
| ------------ | ------------------------------ | ---------------------------------- |
|
||||
| **PR** | `enter` | Checkout |
|
||||
| | `ctrl-d` | Review diff |
|
||||
| | `ctrl-w` | Worktree |
|
||||
| | `ctrl-t` | Checks |
|
||||
| | `ctrl-x` | Browse |
|
||||
| | `ctrl-e` | Manage (merge/approve/close/draft) |
|
||||
| | `ctrl-a` | Create new |
|
||||
| | `ctrl-o` | Cycle state (open/closed/all) |
|
||||
| | `ctrl-r` | Refresh |
|
||||
| **Issue** | `enter` | Browse |
|
||||
| | `ctrl-s` | Close/reopen |
|
||||
| | `ctrl-o` | Cycle state |
|
||||
| | `ctrl-r` | Refresh |
|
||||
| **Checks** | `enter` | View log (tails if running) |
|
||||
| | `ctrl-x` | Browse |
|
||||
| | `ctrl-f` / `ctrl-p` / `ctrl-n` | Filter: failed / passed / running |
|
||||
| | `ctrl-a` | Show all |
|
||||
| **CI** | `enter` | View log (tails if running) |
|
||||
| | `ctrl-x` | Browse |
|
||||
| | `ctrl-r` | Refresh |
|
||||
| **Commits** | `enter` | Checkout (detached) |
|
||||
| | `ctrl-d` | Review diff |
|
||||
| | `ctrl-x` | Browse |
|
||||
| | `ctrl-y` | Yank hash |
|
||||
| **Branches** | `ctrl-d` | Review diff |
|
||||
| | `ctrl-x` | Browse |
|
||||
|
||||
## Custom Sources
|
||||
|
||||
Register a custom forge source for self-hosted or alternative platforms:
|
||||
|
||||
```lua
|
||||
require('forge').register('mygitea', require('my_gitea_source'))
|
||||
```
|
||||
|
||||
Route remotes to your source by host:
|
||||
|
||||
```lua
|
||||
vim.g.forge = {
|
||||
sources = { mygitea = { hosts = { 'gitea.internal.dev' } } },
|
||||
}
|
||||
```
|
||||
|
||||
A source is a table implementing the `forge.Forge` interface. Required fields:
|
||||
`name` (string), `cli` (string, checked via `executable()`), `kinds`
|
||||
(`{ issue, pr }`), and `labels` (`{ issue, pr, pr_one, pr_full, ci }`).
|
||||
|
||||
Required methods (all receive `self`): `list_pr_json_cmd`,
|
||||
`list_issue_json_cmd`, `pr_json_fields`, `issue_json_fields`, `view_web`,
|
||||
`browse`, `browse_root`, `browse_branch`, `browse_commit`, `checkout_cmd`,
|
||||
`yank_branch`, `yank_commit`, `fetch_pr`, `pr_base_cmd`, `pr_for_branch_cmd`,
|
||||
`checks_cmd`, `check_log_cmd`, `check_tail_cmd`, `list_runs_json_cmd`,
|
||||
`list_runs_cmd`, `normalize_run`, `run_log_cmd`, `run_tail_cmd`, `merge_cmd`,
|
||||
`approve_cmd`, `repo_info`, `pr_state`, `close_cmd`, `reopen_cmd`,
|
||||
`close_issue_cmd`, `reopen_issue_cmd`, `draft_toggle_cmd`, `create_pr_cmd`,
|
||||
`create_pr_web_cmd`, `default_branch_cmd`, `template_paths`.
|
||||
|
||||
See `lua/forge/github.lua`, `lua/forge/gitlab.lua`, or `lua/forge/codeberg.lua`
|
||||
for complete implementations. The `forge.Forge` class definition with full type
|
||||
annotations is in `lua/forge/init.lua`.
|
||||
|
||||
### Skeleton
|
||||
|
||||
```lua
|
||||
local M = {
|
||||
name = 'mygitea',
|
||||
cli = 'tea',
|
||||
kinds = { issue = 'issues', pr = 'pulls' },
|
||||
labels = { issue = 'Issues', pr = 'PRs', pr_one = 'PR', pr_full = 'Pull Requests', ci = 'CI/CD' },
|
||||
}
|
||||
|
||||
function M:list_pr_json_cmd(state)
|
||||
return { 'tea', 'pr', 'list', '--state', state, '--output', 'json' }
|
||||
end
|
||||
|
||||
function M:pr_json_fields()
|
||||
return { number = 'number', title = 'title', branch = 'head', state = 'state', author = 'poster', created_at = 'created_at' }
|
||||
end
|
||||
|
||||
return M
|
||||
```
|
||||
|
||||
## Health
|
||||
|
||||
Run `:checkhealth forge` to verify your setup. Checks for `git`, forge CLIs
|
||||
(`gh`, `glab`, `tea`), required plugins (`fzf-lua`), optional plugins
|
||||
(`diffs.nvim`, `vim-fugitive`), and any registered custom sources.
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: How do I create a PR?** `<c-g>` -> Pull Requests -> `ctrl-a` to compose. Or
|
||||
from fugitive: `cpr` (compose), `cpd` (draft), `cpf` (instant), `cpw` (web).
|
||||
|
||||
**Q: Does review mode require diffs.nvim?** Yes. Without
|
||||
[diffs.nvim](https://github.com/barrettruth/diffs.nvim), the diff action and
|
||||
review toggling are unavailable.
|
||||
|
||||
**Q: How does forge detection work?** forge.nvim reads the `origin` remote URL
|
||||
and matches against known hosts and any custom `sources.<name>.hosts`. The first
|
||||
match wins, and the CLI must be in `$PATH`.
|
||||
|
||||
**Q: Can I use this with self-hosted GitLab/Gitea?** Yes. Add your host to
|
||||
`vim.g.forge.sources`. See the [examples](#examples).
|
||||
|
||||
**Q: What does `ctrl-o` do in pickers?** Cycles the state filter: open -> closed
|
||||
-> all -> open.
|
||||
|
||||
**Q: How do I merge/approve/close a PR?** `ctrl-e` on a PR in the picker opens
|
||||
the manage picker. Available actions depend on your repository permissions.
|
||||
|
||||
**Q: Does this work without a forge remote?** Partially. Commits, branches, and
|
||||
worktrees work in any git repo. PRs, issues, CI, and browse require a detected
|
||||
forge.
|
||||
forge.nvim reads the `origin` remote URL and matches against known hosts and any
|
||||
custom `sources.<name>.hosts` entries. The first match wins, and the CLI must be
|
||||
in `$PATH`.
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ delegates to the appropriate CLI (`gh`, `glab`, or `tea`).
|
|||
|
||||
Requirements: ~
|
||||
- Neovim 0.10.0+
|
||||
- fzf-lua (required)
|
||||
- At least one picker backend:
|
||||
- fzf-lua
|
||||
- telescope.nvim
|
||||
- snacks.nvim
|
||||
- One or more forge CLIs:
|
||||
- `gh` for GitHub
|
||||
- `glab` for GitLab
|
||||
|
|
@ -32,6 +35,15 @@ unset keys use defaults. >lua
|
|||
|
||||
Top-level keys: ~
|
||||
|
||||
`picker` *forge-config-picker*
|
||||
`string` (default `"auto"`)
|
||||
Picker backend to use. One of `"auto"`, `"fzf-lua"`, `"telescope"`, or
|
||||
`"snacks"`. When `"auto"`, forge.nvim tries fzf-lua, then snacks, then
|
||||
telescope.
|
||||
|
||||
Note: commits, branches, and worktree pickers currently require fzf-lua.
|
||||
Other backends show a notification for these pickers.
|
||||
|
||||
`ci` *forge-config-ci*
|
||||
`ci.lines` `integer` (default `10000`)
|
||||
Maximum number of log lines fetched for CI/check log output.
|
||||
|
|
@ -53,7 +65,8 @@ Top-level keys: ~
|
|||
`table|false` (default shown below)
|
||||
Per-picker action bindings. Set to `false` to disable all picker-level
|
||||
actions. Use `"<cr>"` to bind to enter. Values use vim keymap notation
|
||||
(e.g. `"<c-d>"`), which is translated to fzf-lua format internally.
|
||||
(e.g. `"<c-d>"`), which is translated to the appropriate format for
|
||||
each picker backend.
|
||||
|
||||
Defaults: >lua
|
||||
keys = {
|
||||
|
|
@ -575,7 +588,7 @@ HEALTH *forge-health*
|
|||
Reports on: ~
|
||||
- `git` availability
|
||||
- Forge CLI availability (`gh`, `glab`, `tea`)
|
||||
- `fzf-lua` installation (required)
|
||||
- Picker backends (fzf-lua, telescope, snacks) and which is active
|
||||
- `diffs.nvim` installation (review mode)
|
||||
- Custom registered sources and their CLI availability
|
||||
|
||||
|
|
@ -699,12 +712,11 @@ PUBLIC API: `require('forge.pickers')` ~
|
|||
`pr_manage(f, num)`
|
||||
Opens the management picker for PR `num`.
|
||||
|
||||
*forge.pickers.pr_actions()*
|
||||
`pr_actions(f, num)`
|
||||
*forge.pickers.pr_action_fns()*
|
||||
`pr_action_fns(f, num)`
|
||||
Returns `table<string, function>`. Action functions for PR `num`,
|
||||
keyed by fzf binding. Also has `_by_name` table keyed by action
|
||||
name (`"checkout"`, `"diff"`, `"worktree"`, `"browse"`, `"ci"`,
|
||||
`"manage"`).
|
||||
keyed by action name (`"checkout"`, `"diff"`, `"worktree"`,
|
||||
`"browse"`, `"ci"`, `"manage"`).
|
||||
|
||||
*forge.pickers.issue_close()*
|
||||
`issue_close(f, num)`
|
||||
|
|
|
|||
15
flake.nix
15
flake.nix
|
|
@ -24,22 +24,21 @@
|
|||
devShells = forEachSystem (
|
||||
pkgs:
|
||||
let
|
||||
vimdoc-ls = vimdoc-language-server.packages.${pkgs.system}.default;
|
||||
commonPackages = [
|
||||
pkgs.prettier
|
||||
pkgs.stylua
|
||||
pkgs.selene
|
||||
pkgs.lua-language-server
|
||||
vimdoc-ls
|
||||
vimdoc-language-server.packages.${pkgs.system}.default
|
||||
(pkgs.luajit.withPackages (ps: [
|
||||
ps.busted
|
||||
ps.nlua
|
||||
]))
|
||||
];
|
||||
in
|
||||
{
|
||||
default = pkgs.mkShell {
|
||||
packages = commonPackages;
|
||||
};
|
||||
ci = pkgs.mkShell {
|
||||
packages = commonPackages;
|
||||
};
|
||||
default = pkgs.mkShell { packages = commonPackages; };
|
||||
ci = pkgs.mkShell { packages = commonPackages ++ [ pkgs.neovim ]; };
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -22,11 +22,18 @@ function M.check()
|
|||
end
|
||||
end
|
||||
|
||||
local has_fzf = pcall(require, 'fzf-lua')
|
||||
if has_fzf then
|
||||
vim.health.ok('fzf-lua found')
|
||||
else
|
||||
vim.health.error('fzf-lua not found (required)')
|
||||
local picker_mod = require('forge.picker')
|
||||
local backend = picker_mod.backend()
|
||||
local found_any = false
|
||||
for _, name in ipairs(picker_mod.detect_order) do
|
||||
if pcall(require, name) then
|
||||
local suffix = backend == name and ' (active)' or ''
|
||||
vim.health.ok(name .. ' found' .. suffix)
|
||||
found_any = true
|
||||
end
|
||||
end
|
||||
if not found_any then
|
||||
vim.health.error('no picker backend found (install fzf-lua, telescope.nvim, or snacks.nvim)')
|
||||
end
|
||||
|
||||
local has_diffs = pcall(require, 'diffs')
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
local M = {}
|
||||
|
||||
---@class forge.Config
|
||||
---@field picker 'fzf-lua'|'telescope'|'snacks'|'auto'
|
||||
---@field ci forge.CIConfig
|
||||
---@field sources table<string, forge.SourceConfig>
|
||||
---@field keys forge.KeysConfig|false
|
||||
|
|
@ -83,6 +84,7 @@ local M = {}
|
|||
|
||||
---@type forge.Config
|
||||
local DEFAULTS = {
|
||||
picker = 'auto',
|
||||
ci = { lines = 10000 },
|
||||
sources = {},
|
||||
keys = {
|
||||
|
|
@ -538,15 +540,25 @@ local function extract_author(entry, field)
|
|||
return tostring(v or '')
|
||||
end
|
||||
|
||||
local function hl(group, text)
|
||||
local utils = require('fzf-lua.utils')
|
||||
return utils.ansi_from_hl(group, text)
|
||||
---@param secs integer
|
||||
---@return string
|
||||
local function format_duration(secs)
|
||||
if secs < 0 then
|
||||
secs = 0
|
||||
end
|
||||
if secs >= 3600 then
|
||||
return ('%dh%dm'):format(math.floor(secs / 3600), math.floor(secs % 3600 / 60))
|
||||
end
|
||||
if secs >= 60 then
|
||||
return ('%dm%ds'):format(math.floor(secs / 60), secs % 60)
|
||||
end
|
||||
return ('%ds'):format(secs)
|
||||
end
|
||||
|
||||
---@param entry table
|
||||
---@param fields table
|
||||
---@param show_state boolean
|
||||
---@return string
|
||||
---@return forge.Segment[]
|
||||
function M.format_pr(entry, fields, show_state)
|
||||
local display = M.config().display
|
||||
local icons = display.icons
|
||||
|
|
@ -555,7 +567,7 @@ function M.format_pr(entry, fields, show_state)
|
|||
local title = entry[fields.title] or ''
|
||||
local author = extract_author(entry, fields.author)
|
||||
local age = relative_time(entry[fields.created_at])
|
||||
local prefix = ''
|
||||
local segments = {}
|
||||
if show_state then
|
||||
local state = (entry[fields.state] or ''):lower()
|
||||
local icon, group
|
||||
|
|
@ -566,20 +578,22 @@ function M.format_pr(entry, fields, show_state)
|
|||
else
|
||||
icon, group = icons.closed, 'ForgeClosed'
|
||||
end
|
||||
prefix = hl(group, icon) .. ' '
|
||||
table.insert(segments, { icon, group })
|
||||
table.insert(segments, { ' ' })
|
||||
end
|
||||
return prefix
|
||||
.. hl('ForgeNumber', ('#%-5s'):format(num))
|
||||
.. ' '
|
||||
.. pad_or_truncate(title, widths.title)
|
||||
.. ' '
|
||||
.. hl('ForgeDim', pad_or_truncate(author, widths.author) .. (' %3s'):format(age))
|
||||
table.insert(segments, { ('#%-5s'):format(num), 'ForgeNumber' })
|
||||
table.insert(segments, { ' ' .. pad_or_truncate(title, widths.title) .. ' ' })
|
||||
table.insert(segments, {
|
||||
pad_or_truncate(author, widths.author) .. (' %3s'):format(age),
|
||||
'ForgeDim',
|
||||
})
|
||||
return segments
|
||||
end
|
||||
|
||||
---@param entry table
|
||||
---@param fields table
|
||||
---@param show_state boolean
|
||||
---@return string
|
||||
---@return forge.Segment[]
|
||||
function M.format_issue(entry, fields, show_state)
|
||||
local display = M.config().display
|
||||
local icons = display.icons
|
||||
|
|
@ -588,7 +602,7 @@ function M.format_issue(entry, fields, show_state)
|
|||
local title = entry[fields.title] or ''
|
||||
local author = extract_author(entry, fields.author)
|
||||
local age = relative_time(entry[fields.created_at])
|
||||
local prefix = ''
|
||||
local segments = {}
|
||||
if show_state then
|
||||
local state = (entry[fields.state] or ''):lower()
|
||||
local icon, group
|
||||
|
|
@ -597,18 +611,20 @@ function M.format_issue(entry, fields, show_state)
|
|||
else
|
||||
icon, group = icons.closed, 'ForgeClosed'
|
||||
end
|
||||
prefix = hl(group, icon) .. ' '
|
||||
table.insert(segments, { icon, group })
|
||||
table.insert(segments, { ' ' })
|
||||
end
|
||||
return prefix
|
||||
.. hl('ForgeNumber', ('#%-5s'):format(num))
|
||||
.. ' '
|
||||
.. pad_or_truncate(title, widths.title)
|
||||
.. ' '
|
||||
.. hl('ForgeDim', pad_or_truncate(author, widths.author) .. (' %3s'):format(age))
|
||||
table.insert(segments, { ('#%-5s'):format(num), 'ForgeNumber' })
|
||||
table.insert(segments, { ' ' .. pad_or_truncate(title, widths.title) .. ' ' })
|
||||
table.insert(segments, {
|
||||
pad_or_truncate(author, widths.author) .. (' %3s'):format(age),
|
||||
'ForgeDim',
|
||||
})
|
||||
return segments
|
||||
end
|
||||
|
||||
---@param check table
|
||||
---@return string
|
||||
---@return forge.Segment[]
|
||||
function M.format_check(check)
|
||||
local display = M.config().display
|
||||
local icons = display.icons
|
||||
|
|
@ -632,23 +648,18 @@ function M.format_check(check)
|
|||
local ok_s, ts = pcall(vim.fn.strptime, '%Y-%m-%dT%H:%M:%SZ', check.startedAt)
|
||||
local ok_e, te = pcall(vim.fn.strptime, '%Y-%m-%dT%H:%M:%SZ', check.completedAt)
|
||||
if ok_s and ok_e and ts > 0 and te > 0 then
|
||||
local secs = te - ts
|
||||
if secs >= 60 then
|
||||
elapsed = ('%dm%ds'):format(math.floor(secs / 60), secs % 60)
|
||||
else
|
||||
elapsed = ('%ds'):format(secs)
|
||||
end
|
||||
elapsed = format_duration(te - ts)
|
||||
end
|
||||
end
|
||||
return hl(group, icon)
|
||||
.. ' '
|
||||
.. pad_or_truncate(name, widths.name)
|
||||
.. ' '
|
||||
.. hl('ForgeDim', elapsed)
|
||||
return {
|
||||
{ icon, group },
|
||||
{ ' ' .. pad_or_truncate(name, widths.name) .. ' ' },
|
||||
{ elapsed, 'ForgeDim' },
|
||||
}
|
||||
end
|
||||
|
||||
---@param run forge.CIRun
|
||||
---@return string
|
||||
---@return forge.Segment[]
|
||||
function M.format_run(run)
|
||||
local display = M.config().display
|
||||
local icons = display.icons
|
||||
|
|
@ -670,19 +681,18 @@ function M.format_run(run)
|
|||
local age = relative_time(run.created_at)
|
||||
if run.branch ~= '' then
|
||||
local name_w = widths.name - widths.branch + 10
|
||||
return hl(group, icon)
|
||||
.. ' '
|
||||
.. pad_or_truncate(run.name, name_w)
|
||||
.. ' '
|
||||
.. hl('ForgeBranch', pad_or_truncate(run.branch, widths.branch))
|
||||
.. ' '
|
||||
.. hl('ForgeDim', ('%-6s'):format(event) .. ' ' .. age)
|
||||
return {
|
||||
{ icon, group },
|
||||
{ ' ' .. pad_or_truncate(run.name, name_w) .. ' ' },
|
||||
{ pad_or_truncate(run.branch, widths.branch), 'ForgeBranch' },
|
||||
{ ' ' .. ('%-6s'):format(event) .. ' ' .. age, 'ForgeDim' },
|
||||
}
|
||||
end
|
||||
return hl(group, icon)
|
||||
.. ' '
|
||||
.. pad_or_truncate(run.name, widths.name)
|
||||
.. ' '
|
||||
.. hl('ForgeDim', ('%-6s'):format(event) .. ' ' .. age)
|
||||
return {
|
||||
{ icon, group },
|
||||
{ ' ' .. pad_or_truncate(run.name, widths.name) .. ' ' },
|
||||
{ ('%-6s'):format(event) .. ' ' .. age, 'ForgeDim' },
|
||||
}
|
||||
end
|
||||
|
||||
---@param checks table[]
|
||||
|
|
@ -715,6 +725,10 @@ function M.config()
|
|||
cfg.keys = false
|
||||
end
|
||||
|
||||
local picker_backends = require('forge.picker').backends
|
||||
vim.validate('forge.picker', cfg.picker, function(v)
|
||||
return v == 'auto' or picker_backends[v] ~= nil
|
||||
end, "'auto', 'fzf-lua', 'telescope', or 'snacks'")
|
||||
vim.validate('forge.sources', cfg.sources, 'table')
|
||||
vim.validate('forge.keys', cfg.keys, function(v)
|
||||
return v == false or type(v) == 'table'
|
||||
|
|
|
|||
76
lua/forge/picker/fzf.lua
Normal file
76
lua/forge/picker/fzf.lua
Normal 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
77
lua/forge/picker/init.lua
Normal 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
|
||||
64
lua/forge/picker/snacks.lua
Normal file
64
lua/forge/picker/snacks.lua
Normal 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
|
||||
69
lua/forge/picker/telescope.lua
Normal file
69
lua/forge/picker/telescope.lua
Normal 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
|
|
@ -10,6 +10,14 @@ end
|
|||
|
||||
local forge = require('forge')
|
||||
|
||||
local function flatten(segments)
|
||||
local parts = {}
|
||||
for _, seg in ipairs(segments) do
|
||||
parts[#parts + 1] = seg[1]
|
||||
end
|
||||
return table.concat(parts)
|
||||
end
|
||||
|
||||
describe('config', function()
|
||||
after_each(function()
|
||||
vim.g.forge = nil
|
||||
|
|
@ -53,7 +61,7 @@ describe('format_pr', function()
|
|||
it('formats open PR with state icon', function()
|
||||
local entry =
|
||||
{ number = 42, title = 'fix bug', state = 'OPEN', login = 'alice', created_at = '' }
|
||||
local result = forge.format_pr(entry, fields, true)
|
||||
local result = flatten(forge.format_pr(entry, fields, true))
|
||||
assert.truthy(result:find('+'))
|
||||
assert.truthy(result:find('#42'))
|
||||
assert.truthy(result:find('fix bug'))
|
||||
|
|
@ -62,20 +70,20 @@ describe('format_pr', function()
|
|||
it('formats merged PR', function()
|
||||
local entry =
|
||||
{ number = 7, title = 'add feature', state = 'MERGED', login = 'bob', created_at = '' }
|
||||
local result = forge.format_pr(entry, fields, true)
|
||||
local result = flatten(forge.format_pr(entry, fields, true))
|
||||
assert.truthy(result:find('m'))
|
||||
assert.truthy(result:find('#7'))
|
||||
end)
|
||||
|
||||
it('formats closed PR', function()
|
||||
local entry = { number = 3, title = 'stale', state = 'CLOSED', login = 'eve', created_at = '' }
|
||||
local result = forge.format_pr(entry, fields, true)
|
||||
local result = flatten(forge.format_pr(entry, fields, true))
|
||||
assert.truthy(result:find('x'))
|
||||
end)
|
||||
|
||||
it('omits state prefix when show_state is false', function()
|
||||
local entry = { number = 1, title = 'no state', state = 'OPEN', login = 'dev', created_at = '' }
|
||||
local result = forge.format_pr(entry, fields, false)
|
||||
local result = flatten(forge.format_pr(entry, fields, false))
|
||||
assert.truthy(result:find('#1'))
|
||||
assert.falsy(result:match('^+'))
|
||||
end)
|
||||
|
|
@ -83,14 +91,14 @@ describe('format_pr', function()
|
|||
it('truncates long titles', function()
|
||||
local long_title = string.rep('a', 100)
|
||||
local entry = { number = 9, title = long_title, state = 'OPEN', login = 'x', created_at = '' }
|
||||
local result = forge.format_pr(entry, fields, false)
|
||||
local result = flatten(forge.format_pr(entry, fields, false))
|
||||
assert.falsy(result:find(long_title))
|
||||
end)
|
||||
|
||||
it('extracts author from table with login field', function()
|
||||
local entry =
|
||||
{ number = 5, title = 't', state = 'OPEN', login = { login = 'nested' }, created_at = '' }
|
||||
local result = forge.format_pr(entry, fields, false)
|
||||
local result = flatten(forge.format_pr(entry, fields, false))
|
||||
assert.truthy(result:find('nested'))
|
||||
end)
|
||||
end)
|
||||
|
|
@ -107,59 +115,59 @@ describe('format_issue', function()
|
|||
it('formats open issue', function()
|
||||
local entry =
|
||||
{ number = 10, title = 'bug report', state = 'open', author = 'alice', created_at = '' }
|
||||
local result = forge.format_issue(entry, fields, true)
|
||||
local result = flatten(forge.format_issue(entry, fields, true))
|
||||
assert.truthy(result:find('+'))
|
||||
assert.truthy(result:find('#10'))
|
||||
end)
|
||||
|
||||
it('formats closed issue', function()
|
||||
local entry = { number = 11, title = 'done', state = 'closed', author = 'bob', created_at = '' }
|
||||
local result = forge.format_issue(entry, fields, true)
|
||||
local result = flatten(forge.format_issue(entry, fields, true))
|
||||
assert.truthy(result:find('x'))
|
||||
end)
|
||||
|
||||
it('handles opened state (GitLab)', function()
|
||||
local entry =
|
||||
{ number = 12, title = 'mr issue', state = 'opened', author = 'c', created_at = '' }
|
||||
local result = forge.format_issue(entry, fields, true)
|
||||
local result = flatten(forge.format_issue(entry, fields, true))
|
||||
assert.truthy(result:find('+'))
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('format_check', function()
|
||||
it('maps pass bucket', function()
|
||||
local result = forge.format_check({ name = 'lint', bucket = 'pass' })
|
||||
local result = flatten(forge.format_check({ name = 'lint', bucket = 'pass' }))
|
||||
assert.truthy(result:find('%*'))
|
||||
assert.truthy(result:find('lint'))
|
||||
end)
|
||||
|
||||
it('maps fail bucket', function()
|
||||
local result = forge.format_check({ name = 'build', bucket = 'fail' })
|
||||
local result = flatten(forge.format_check({ name = 'build', bucket = 'fail' }))
|
||||
assert.truthy(result:find('x'))
|
||||
end)
|
||||
|
||||
it('maps pending bucket', function()
|
||||
local result = forge.format_check({ name = 'test', bucket = 'pending' })
|
||||
local result = flatten(forge.format_check({ name = 'test', bucket = 'pending' }))
|
||||
assert.truthy(result:find('~'))
|
||||
end)
|
||||
|
||||
it('maps skipping bucket', function()
|
||||
local result = forge.format_check({ name = 'optional', bucket = 'skipping' })
|
||||
local result = flatten(forge.format_check({ name = 'optional', bucket = 'skipping' }))
|
||||
assert.truthy(result:find('%-'))
|
||||
end)
|
||||
|
||||
it('maps cancel bucket', function()
|
||||
local result = forge.format_check({ name = 'cancelled', bucket = 'cancel' })
|
||||
local result = flatten(forge.format_check({ name = 'cancelled', bucket = 'cancel' }))
|
||||
assert.truthy(result:find('%-'))
|
||||
end)
|
||||
|
||||
it('maps unknown bucket', function()
|
||||
local result = forge.format_check({ name = 'mystery', bucket = 'something_else' })
|
||||
local result = flatten(forge.format_check({ name = 'mystery', bucket = 'something_else' }))
|
||||
assert.truthy(result:find('%?'))
|
||||
end)
|
||||
|
||||
it('defaults to pending when bucket is nil', function()
|
||||
local result = forge.format_check({ name = 'none' })
|
||||
local result = flatten(forge.format_check({ name = 'none' }))
|
||||
assert.truthy(result:find('~'))
|
||||
end)
|
||||
end)
|
||||
|
|
@ -168,7 +176,7 @@ describe('format_run', function()
|
|||
it('formats successful run with branch', function()
|
||||
local run =
|
||||
{ name = 'CI', branch = 'main', status = 'success', event = 'push', created_at = '' }
|
||||
local result = forge.format_run(run)
|
||||
local result = flatten(forge.format_run(run))
|
||||
assert.truthy(result:find('%*'))
|
||||
assert.truthy(result:find('CI'))
|
||||
assert.truthy(result:find('main'))
|
||||
|
|
@ -183,7 +191,7 @@ describe('format_run', function()
|
|||
event = 'workflow_dispatch',
|
||||
created_at = '',
|
||||
}
|
||||
local result = forge.format_run(run)
|
||||
local result = flatten(forge.format_run(run))
|
||||
assert.truthy(result:find('x'))
|
||||
assert.truthy(result:find('manual'))
|
||||
end)
|
||||
|
|
@ -191,13 +199,13 @@ describe('format_run', function()
|
|||
it('maps in_progress status', function()
|
||||
local run =
|
||||
{ name = 'Test', branch = '', status = 'in_progress', event = 'push', created_at = '' }
|
||||
local result = forge.format_run(run)
|
||||
local result = flatten(forge.format_run(run))
|
||||
assert.truthy(result:find('~'))
|
||||
end)
|
||||
|
||||
it('maps cancelled status', function()
|
||||
local run = { name = 'Old', branch = '', status = 'cancelled', event = 'push', created_at = '' }
|
||||
local result = forge.format_run(run)
|
||||
local result = flatten(forge.format_run(run))
|
||||
assert.truthy(result:find('%-'))
|
||||
end)
|
||||
end)
|
||||
|
|
@ -242,39 +250,39 @@ describe('relative_time via format_pr', function()
|
|||
it('shows minutes for recent timestamps', function()
|
||||
local ts = os.date('%Y-%m-%dT%H:%M:%SZ', os.time() - 120)
|
||||
local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = ts }
|
||||
local result = forge.format_pr(entry, fields, false)
|
||||
local result = flatten(forge.format_pr(entry, fields, false))
|
||||
assert.truthy(result:match('%d+m'))
|
||||
end)
|
||||
|
||||
it('shows hours', function()
|
||||
local ts = os.date('%Y-%m-%dT%H:%M:%SZ', os.time() - 7200)
|
||||
local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = ts }
|
||||
local result = forge.format_pr(entry, fields, false)
|
||||
local result = flatten(forge.format_pr(entry, fields, false))
|
||||
assert.truthy(result:match('%d+h'))
|
||||
end)
|
||||
|
||||
it('shows days', function()
|
||||
local ts = os.date('%Y-%m-%dT%H:%M:%SZ', os.time() - 172800)
|
||||
local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = ts }
|
||||
local result = forge.format_pr(entry, fields, false)
|
||||
local result = flatten(forge.format_pr(entry, fields, false))
|
||||
assert.truthy(result:match('%d+d'))
|
||||
end)
|
||||
|
||||
it('returns empty for nil timestamp', function()
|
||||
local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = nil }
|
||||
local result = forge.format_pr(entry, fields, false)
|
||||
local result = flatten(forge.format_pr(entry, fields, false))
|
||||
assert.truthy(result)
|
||||
end)
|
||||
|
||||
it('returns empty for empty string timestamp', function()
|
||||
local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = '' }
|
||||
local result = forge.format_pr(entry, fields, false)
|
||||
local result = flatten(forge.format_pr(entry, fields, false))
|
||||
assert.truthy(result)
|
||||
end)
|
||||
|
||||
it('returns empty for garbage timestamp', function()
|
||||
local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = 'not-a-date' }
|
||||
local result = forge.format_pr(entry, fields, false)
|
||||
local result = flatten(forge.format_pr(entry, fields, false))
|
||||
assert.truthy(result)
|
||||
end)
|
||||
end)
|
||||
|
|
|
|||
189
spec/sources_spec.lua
Normal file
189
spec/sources_spec.lua
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue