mirror of
https://github.com/harivansh-afk/forge.nvim.git
synced 2026-04-15 17:01:00 +00:00
Compare commits
No commits in common. "main" and "v0.0.1" have entirely different histories.
14 changed files with 971 additions and 1146 deletions
9
.github/workflows/luarocks-dev.yaml
vendored
9
.github/workflows/luarocks-dev.yaml
vendored
|
|
@ -1,14 +1,15 @@
|
||||||
name: luarocks-dev
|
name: luarocks-dev
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_run:
|
push:
|
||||||
workflows: [quality]
|
|
||||||
types: [completed]
|
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
quality:
|
||||||
|
uses: ./.github/workflows/quality.yaml
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
needs: quality
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -11,5 +11,3 @@ result
|
||||||
result-*
|
result-*
|
||||||
.direnv/
|
.direnv/
|
||||||
.envrc
|
.envrc
|
||||||
|
|
||||||
scripts/demo
|
|
||||||
|
|
|
||||||
366
README.md
366
README.md
|
|
@ -2,12 +2,13 @@
|
||||||
|
|
||||||
**Forge-agnostic git workflow for Neovim**
|
**Forge-agnostic git workflow for Neovim**
|
||||||
|
|
||||||
PR, issue, and CI workflows across GitHub, GitLab, and Codeberg/Gitea/Forgejo —
|
PR, issue, and CI workflows across GitHub, GitLab, and more — without leaving
|
||||||
without leaving your editor.
|
your editor.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Automatic forge detection from git remote (`gh`, `glab`, `tea`)
|
- Automatic forge detection from git remote (GitHub via `gh`, GitLab via `glab`,
|
||||||
|
Codeberg/Gitea/Forgejo via `tea`)
|
||||||
- PR lifecycle: list, create (compose buffer with template discovery, diff stat,
|
- PR lifecycle: list, create (compose buffer with template discovery, diff stat,
|
||||||
reviewers), checkout, worktree, review diff, merge, approve, close/reopen,
|
reviewers), checkout, worktree, review diff, merge, approve, close/reopen,
|
||||||
draft toggle
|
draft toggle
|
||||||
|
|
@ -16,52 +17,27 @@ without leaving your editor.
|
||||||
- Code review via [diffs.nvim](https://github.com/barrettruth/diffs.nvim) with
|
- Code review via [diffs.nvim](https://github.com/barrettruth/diffs.nvim) with
|
||||||
unified/split toggle and quickfix navigation
|
unified/split toggle and quickfix navigation
|
||||||
- Commit and branch browsing with checkout, diff, and URL generation
|
- Commit and branch browsing with checkout, diff, and URL generation
|
||||||
- File/line permalink generation and yanking
|
- File/line permalink generation and yanking (commit and branch URLs)
|
||||||
- [fzf-lua](https://github.com/ibhagwan/fzf-lua) pickers with contextual
|
- [fzf-lua](https://github.com/ibhagwan/fzf-lua) pickers with contextual
|
||||||
keybinds
|
keybinds
|
||||||
- Pluggable source registration for custom or self-hosted forges
|
- Pluggable source registration for custom or self-hosted forges
|
||||||
|
|
||||||
## Requirements
|
## Dependencies
|
||||||
|
|
||||||
- Neovim 0.10.0+
|
- Neovim 0.10.0+
|
||||||
- [fzf-lua](https://github.com/ibhagwan/fzf-lua)
|
- [fzf-lua](https://github.com/ibhagwan/fzf-lua)
|
||||||
- At least one forge CLI: [`gh`](https://cli.github.com/),
|
- At least one forge CLI:
|
||||||
[`glab`](https://gitlab.com/gitlab-org/cli), or
|
- [`gh`](https://cli.github.com/) for GitHub
|
||||||
[`tea`](https://gitea.com/gitea/tea)
|
- [`glab`](https://gitlab.com/gitlab-org/cli) for GitLab
|
||||||
- (Optional) [diffs.nvim](https://github.com/barrettruth/diffs.nvim) for review
|
- [`tea`](https://gitea.com/gitea/tea) for Codeberg/Gitea/Forgejo
|
||||||
mode
|
- [vim-fugitive](https://github.com/tpope/vim-fugitive) (optional, for fugitive
|
||||||
- (Optional) [vim-fugitive](https://github.com/tpope/vim-fugitive) for split
|
keymaps and split diff)
|
||||||
diff and fugitive keymaps
|
- [diffs.nvim](https://github.com/barrettruth/diffs.nvim) (optional, for review
|
||||||
|
mode)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Install with your package manager of choice or via
|
### [lazy.nvim](https://github.com/folke/lazy.nvim)
|
||||||
[luarocks](https://luarocks.org/modules/barrettruth/forge.nvim):
|
|
||||||
|
|
||||||
```
|
|
||||||
luarocks install forge.nvim
|
|
||||||
```
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
```vim
|
|
||||||
:help forge.nvim
|
|
||||||
```
|
|
||||||
|
|
||||||
## FAQ
|
|
||||||
|
|
||||||
**Q: How do I configure forge.nvim?**
|
|
||||||
|
|
||||||
Configure via `vim.g.forge` before the plugin loads. All fields are optional:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
vim.g.forge = {
|
|
||||||
sources = { gitlab = { hosts = { 'gitlab.mycompany.com' } } },
|
|
||||||
display = { icons = { open = '', merged = '', closed = '' } },
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Q: How do I install with lazy.nvim?**
|
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
{
|
{
|
||||||
|
|
@ -70,19 +46,309 @@ vim.g.forge = {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Q: How do I create a PR?**
|
### [mini.deps](https://github.com/echasnovski/mini.deps)
|
||||||
|
|
||||||
`<c-g>` to open the picker, select Pull Requests, then `ctrl-a` to compose. Or
|
```lua
|
||||||
from a fugitive buffer: `cpr` (compose), `cpd` (draft), `cpf` (instant from
|
MiniDeps.add({
|
||||||
commits), `cpw` (push and open web).
|
source = 'barrettruth/forge.nvim',
|
||||||
|
depends = { 'ibhagwan/fzf-lua' },
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
**Q: Does review mode require diffs.nvim?**
|
### [luarocks](https://luarocks.org/modules/barrettruth/forge.nvim)
|
||||||
|
|
||||||
Yes. Without [diffs.nvim](https://github.com/barrettruth/diffs.nvim), diff
|
```
|
||||||
actions and review toggling are unavailable.
|
luarocks install forge.nvim
|
||||||
|
```
|
||||||
|
|
||||||
**Q: How does forge detection work?**
|
### Manual
|
||||||
|
|
||||||
forge.nvim reads the `origin` remote URL and matches against known hosts and any
|
```sh
|
||||||
custom `sources.<name>.hosts` entries. The first match wins, and the CLI must be
|
git clone https://github.com/barrettruth/forge.nvim \
|
||||||
in `$PATH`.
|
~/.local/share/nvim/site/pack/plugins/start/forge.nvim
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
forge.nvim works through two entry points: the `:Forge` command and the `<c-g>`
|
||||||
|
picker.
|
||||||
|
|
||||||
|
`:Forge` with no arguments (or `<c-g>`) opens the top-level picker — PRs,
|
||||||
|
issues, CI, commits, branches, worktrees, and browse actions. Each sub-picker
|
||||||
|
has contextual keybinds shown in the fzf header.
|
||||||
|
|
||||||
|
PR creation opens a compose buffer (markdown) pre-filled from commit messages
|
||||||
|
and repo templates. First line is the title, everything after the blank line is
|
||||||
|
the body. Draft, reviewers, and base branch are set in the HTML comment block
|
||||||
|
below. Write (`:w`) to push and create.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Configure via `vim.g.forge`. All fields are optional — defaults shown below.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
vim.g.forge = {
|
||||||
|
ci = { lines = 10000 },
|
||||||
|
sources = {},
|
||||||
|
keys = {
|
||||||
|
picker = '<c-g>',
|
||||||
|
next_qf = ']q', prev_qf = '[q',
|
||||||
|
next_loc = ']l', prev_loc = '[l',
|
||||||
|
review_toggle = 's',
|
||||||
|
terminal_open = 'gx',
|
||||||
|
fugitive = {
|
||||||
|
create = 'cpr', create_draft = 'cpd',
|
||||||
|
create_fill = 'cpf', create_web = 'cpw',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
picker_keys = {
|
||||||
|
pr = {
|
||||||
|
checkout = 'default', diff = 'ctrl-d', worktree = 'ctrl-w',
|
||||||
|
checks = 'ctrl-t', browse = 'ctrl-x', manage = 'ctrl-e',
|
||||||
|
create = 'ctrl-a', toggle = 'ctrl-o', refresh = 'ctrl-r',
|
||||||
|
},
|
||||||
|
issue = { browse = 'default', close_reopen = 'ctrl-s', toggle = 'ctrl-o', refresh = 'ctrl-r' },
|
||||||
|
checks = { log = 'default', browse = 'ctrl-x', failed = 'ctrl-f', passed = 'ctrl-p', running = 'ctrl-n', all = 'ctrl-a' },
|
||||||
|
ci = { log = 'default', browse = 'ctrl-x', refresh = 'ctrl-r' },
|
||||||
|
commits = { checkout = 'default', diff = 'ctrl-d', browse = 'ctrl-x', yank = 'ctrl-y' },
|
||||||
|
branches = { diff = 'ctrl-d', browse = 'ctrl-x' },
|
||||||
|
},
|
||||||
|
display = {
|
||||||
|
icons = { open = '+', merged = 'm', closed = 'x', pass = '*', fail = 'x', pending = '~', skip = '-', unknown = '?' },
|
||||||
|
widths = { title = 45, author = 15, name = 35, branch = 25 },
|
||||||
|
limits = { pulls = 100, issues = 100, runs = 30 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `keys = false` to disable all keymaps. Set `picker_keys = false` to disable
|
||||||
|
all picker keybinds. Set any individual key to `false` to disable it.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
Disable quickfix/loclist keymaps:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
vim.g.forge = {
|
||||||
|
keys = { next_qf = false, prev_qf = false, next_loc = false, prev_loc = false },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Nerd font icons:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
vim.g.forge = {
|
||||||
|
display = {
|
||||||
|
icons = { open = '', merged = '', closed = '', pass = '', fail = '', pending = '', skip = '', unknown = '' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Self-hosted GitLab:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
vim.g.forge = {
|
||||||
|
sources = { gitlab = { hosts = { 'gitlab.mycompany.com' } } },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Override PR picker bindings:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
vim.g.forge = {
|
||||||
|
picker_keys = { pr = { checkout = 'ctrl-o', diff = 'default' } },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
`:Forge` with no arguments opens the top-level picker. Subcommands:
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
| --------------------------------------------- | --------------------------------- |
|
||||||
|
| `:Forge pr` | List open PRs |
|
||||||
|
| `:Forge pr --state={open,closed,all}` | List PRs by state |
|
||||||
|
| `:Forge pr create [--draft] [--fill] [--web]` | Create PR |
|
||||||
|
| `:Forge pr checkout {num}` | Checkout PR branch |
|
||||||
|
| `:Forge pr diff {num}` | Review PR diff |
|
||||||
|
| `:Forge pr worktree {num}` | Fetch PR into worktree |
|
||||||
|
| `:Forge pr checks {num}` | Show PR checks |
|
||||||
|
| `:Forge pr browse {num}` | Open PR in browser |
|
||||||
|
| `:Forge pr manage {num}` | Merge/approve/close/draft actions |
|
||||||
|
| `:Forge issue` | List all issues |
|
||||||
|
| `:Forge issue --state={open,closed,all}` | List issues by state |
|
||||||
|
| `:Forge issue browse {num}` | Open issue in browser |
|
||||||
|
| `:Forge issue close {num}` | Close issue |
|
||||||
|
| `:Forge issue reopen {num}` | Reopen issue |
|
||||||
|
| `:Forge ci` | CI runs for current branch |
|
||||||
|
| `:Forge ci --all` | CI runs for all branches |
|
||||||
|
| `:Forge commit` | Browse commits |
|
||||||
|
| `:Forge commit checkout {sha}` | Checkout commit |
|
||||||
|
| `:Forge commit diff {sha}` | Review commit diff |
|
||||||
|
| `:Forge commit browse {sha}` | Open commit in browser |
|
||||||
|
| `:Forge branch` | Browse branches |
|
||||||
|
| `:Forge branch diff {name}` | Review branch diff |
|
||||||
|
| `:Forge branch browse {name}` | Open branch in browser |
|
||||||
|
| `:Forge worktree` | List worktrees |
|
||||||
|
| `:Forge browse [--root] [--commit]` | Open file/repo/commit in browser |
|
||||||
|
| `:Forge yank [--commit]` | Yank permalink for file/line |
|
||||||
|
| `:Forge review end` | End review session |
|
||||||
|
| `:Forge review toggle` | Toggle split/unified review |
|
||||||
|
| `:Forge cache clear` | Clear all caches |
|
||||||
|
|
||||||
|
## Keymaps
|
||||||
|
|
||||||
|
### Global
|
||||||
|
|
||||||
|
| Key | Mode | Description |
|
||||||
|
| ----------- | ---- | -------------------------------- |
|
||||||
|
| `<c-g>` | n, v | Open forge picker |
|
||||||
|
| `]q` / `[q` | n | Next/prev quickfix entry (wraps) |
|
||||||
|
| `]l` / `[l` | n | Next/prev loclist entry (wraps) |
|
||||||
|
|
||||||
|
### Fugitive buffer
|
||||||
|
|
||||||
|
Active in `fugitive` filetype buffers when a forge is detected.
|
||||||
|
|
||||||
|
| Key | Description |
|
||||||
|
| ----- | ----------------------------------- |
|
||||||
|
| `cpr` | Create PR (compose buffer) |
|
||||||
|
| `cpd` | Create draft PR |
|
||||||
|
| `cpf` | Create PR from commits (no compose) |
|
||||||
|
| `cpw` | Push and open web creation |
|
||||||
|
|
||||||
|
### Review
|
||||||
|
|
||||||
|
Active during a review session.
|
||||||
|
|
||||||
|
| Key | Description |
|
||||||
|
| --- | ------------------------- |
|
||||||
|
| `s` | Toggle unified/split diff |
|
||||||
|
|
||||||
|
### Terminal (log buffers)
|
||||||
|
|
||||||
|
Active on CI/check log terminals when a URL is available.
|
||||||
|
|
||||||
|
| Key | Description |
|
||||||
|
| ---- | ------------------------- |
|
||||||
|
| `gx` | Open run/check in browser |
|
||||||
|
|
||||||
|
## Picker Actions
|
||||||
|
|
||||||
|
Keybinds shown in the fzf header. `default` = `enter`.
|
||||||
|
|
||||||
|
| Picker | Key | Action |
|
||||||
|
| ------------ | ------------------------------ | ---------------------------------- |
|
||||||
|
| **PR** | `enter` | Checkout |
|
||||||
|
| | `ctrl-d` | Review diff |
|
||||||
|
| | `ctrl-w` | Worktree |
|
||||||
|
| | `ctrl-t` | Checks |
|
||||||
|
| | `ctrl-x` | Browse |
|
||||||
|
| | `ctrl-e` | Manage (merge/approve/close/draft) |
|
||||||
|
| | `ctrl-a` | Create new |
|
||||||
|
| | `ctrl-o` | Cycle state (open/closed/all) |
|
||||||
|
| | `ctrl-r` | Refresh |
|
||||||
|
| **Issue** | `enter` | Browse |
|
||||||
|
| | `ctrl-s` | Close/reopen |
|
||||||
|
| | `ctrl-o` | Cycle state |
|
||||||
|
| | `ctrl-r` | Refresh |
|
||||||
|
| **Checks** | `enter` | View log (tails if running) |
|
||||||
|
| | `ctrl-x` | Browse |
|
||||||
|
| | `ctrl-f` / `ctrl-p` / `ctrl-n` | Filter: failed / passed / running |
|
||||||
|
| | `ctrl-a` | Show all |
|
||||||
|
| **CI** | `enter` | View log (tails if running) |
|
||||||
|
| | `ctrl-x` | Browse |
|
||||||
|
| | `ctrl-r` | Refresh |
|
||||||
|
| **Commits** | `enter` | Checkout (detached) |
|
||||||
|
| | `ctrl-d` | Review diff |
|
||||||
|
| | `ctrl-x` | Browse |
|
||||||
|
| | `ctrl-y` | Yank hash |
|
||||||
|
| **Branches** | `ctrl-d` | Review diff |
|
||||||
|
| | `ctrl-x` | Browse |
|
||||||
|
|
||||||
|
## Custom Sources
|
||||||
|
|
||||||
|
Register a custom forge source for self-hosted or alternative platforms:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
require('forge').register('mygitea', require('my_gitea_source'))
|
||||||
|
```
|
||||||
|
|
||||||
|
Route remotes to your source by host:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
vim.g.forge = {
|
||||||
|
sources = { mygitea = { hosts = { 'gitea.internal.dev' } } },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A source is a table implementing the `forge.Forge` interface. Required fields:
|
||||||
|
`name` (string), `cli` (string, checked via `executable()`), `kinds`
|
||||||
|
(`{ issue, pr }`), and `labels` (`{ issue, pr, pr_one, pr_full, ci }`).
|
||||||
|
|
||||||
|
Required methods (all receive `self`): `list_pr_json_cmd`,
|
||||||
|
`list_issue_json_cmd`, `pr_json_fields`, `issue_json_fields`, `view_web`,
|
||||||
|
`browse`, `browse_root`, `browse_branch`, `browse_commit`, `checkout_cmd`,
|
||||||
|
`yank_branch`, `yank_commit`, `fetch_pr`, `pr_base_cmd`, `pr_for_branch_cmd`,
|
||||||
|
`checks_cmd`, `check_log_cmd`, `check_tail_cmd`, `list_runs_json_cmd`,
|
||||||
|
`list_runs_cmd`, `normalize_run`, `run_log_cmd`, `run_tail_cmd`, `merge_cmd`,
|
||||||
|
`approve_cmd`, `repo_info`, `pr_state`, `close_cmd`, `reopen_cmd`,
|
||||||
|
`close_issue_cmd`, `reopen_issue_cmd`, `draft_toggle_cmd`, `create_pr_cmd`,
|
||||||
|
`create_pr_web_cmd`, `default_branch_cmd`, `template_paths`.
|
||||||
|
|
||||||
|
See `lua/forge/github.lua`, `lua/forge/gitlab.lua`, or `lua/forge/codeberg.lua`
|
||||||
|
for complete implementations. The `forge.Forge` class definition with full type
|
||||||
|
annotations is in `lua/forge/init.lua`.
|
||||||
|
|
||||||
|
### Skeleton
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local M = {
|
||||||
|
name = 'mygitea',
|
||||||
|
cli = 'tea',
|
||||||
|
kinds = { issue = 'issues', pr = 'pulls' },
|
||||||
|
labels = { issue = 'Issues', pr = 'PRs', pr_one = 'PR', pr_full = 'Pull Requests', ci = 'CI/CD' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function M:list_pr_json_cmd(state)
|
||||||
|
return { 'tea', 'pr', 'list', '--state', state, '--output', 'json' }
|
||||||
|
end
|
||||||
|
|
||||||
|
function M:pr_json_fields()
|
||||||
|
return { number = 'number', title = 'title', branch = 'head', state = 'state', author = 'poster', created_at = 'created_at' }
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
|
```
|
||||||
|
|
||||||
|
## Health
|
||||||
|
|
||||||
|
Run `:checkhealth forge` to verify your setup. Checks for `git`, forge CLIs
|
||||||
|
(`gh`, `glab`, `tea`), required plugins (`fzf-lua`), optional plugins
|
||||||
|
(`diffs.nvim`, `vim-fugitive`), and any registered custom sources.
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
**Q: How do I create a PR?** `<c-g>` -> Pull Requests -> `ctrl-a` to compose. Or
|
||||||
|
from fugitive: `cpr` (compose), `cpd` (draft), `cpf` (instant), `cpw` (web).
|
||||||
|
|
||||||
|
**Q: Does review mode require diffs.nvim?** Yes. Without
|
||||||
|
[diffs.nvim](https://github.com/barrettruth/diffs.nvim), the diff action and
|
||||||
|
review toggling are unavailable.
|
||||||
|
|
||||||
|
**Q: How does forge detection work?** forge.nvim reads the `origin` remote URL
|
||||||
|
and matches against known hosts and any custom `sources.<name>.hosts`. The first
|
||||||
|
match wins, and the CLI must be in `$PATH`.
|
||||||
|
|
||||||
|
**Q: Can I use this with self-hosted GitLab/Gitea?** Yes. Add your host to
|
||||||
|
`vim.g.forge.sources`. See the [examples](#examples).
|
||||||
|
|
||||||
|
**Q: What does `ctrl-o` do in pickers?** Cycles the state filter: open -> closed
|
||||||
|
-> all -> open.
|
||||||
|
|
||||||
|
**Q: How do I merge/approve/close a PR?** `ctrl-e` on a PR in the picker opens
|
||||||
|
the manage picker. Available actions depend on your repository permissions.
|
||||||
|
|
||||||
|
**Q: Does this work without a forge remote?** Partially. Commits, branches, and
|
||||||
|
worktrees work in any git repo. PRs, issues, CI, and browse require a detected
|
||||||
|
forge.
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,7 @@ delegates to the appropriate CLI (`gh`, `glab`, or `tea`).
|
||||||
|
|
||||||
Requirements: ~
|
Requirements: ~
|
||||||
- Neovim 0.10.0+
|
- Neovim 0.10.0+
|
||||||
- At least one picker backend:
|
- fzf-lua (required)
|
||||||
- fzf-lua
|
|
||||||
- telescope.nvim
|
|
||||||
- snacks.nvim
|
|
||||||
- One or more forge CLIs:
|
- One or more forge CLIs:
|
||||||
- `gh` for GitHub
|
- `gh` for GitHub
|
||||||
- `glab` for GitLab
|
- `glab` for GitLab
|
||||||
|
|
@ -35,15 +32,6 @@ unset keys use defaults. >lua
|
||||||
|
|
||||||
Top-level keys: ~
|
Top-level keys: ~
|
||||||
|
|
||||||
`picker` *forge-config-picker*
|
|
||||||
`string` (default `"auto"`)
|
|
||||||
Picker backend to use. One of `"auto"`, `"fzf-lua"`, `"telescope"`, or
|
|
||||||
`"snacks"`. When `"auto"`, forge.nvim tries fzf-lua, then snacks, then
|
|
||||||
telescope.
|
|
||||||
|
|
||||||
Note: commits, branches, and worktree pickers currently require fzf-lua.
|
|
||||||
Other backends show a notification for these pickers.
|
|
||||||
|
|
||||||
`ci` *forge-config-ci*
|
`ci` *forge-config-ci*
|
||||||
`ci.lines` `integer` (default `10000`)
|
`ci.lines` `integer` (default `10000`)
|
||||||
Maximum number of log lines fetched for CI/check log output.
|
Maximum number of log lines fetched for CI/check log output.
|
||||||
|
|
@ -65,8 +53,7 @@ Top-level keys: ~
|
||||||
`table|false` (default shown below)
|
`table|false` (default shown below)
|
||||||
Per-picker action bindings. Set to `false` to disable all picker-level
|
Per-picker action bindings. Set to `false` to disable all picker-level
|
||||||
actions. Use `"<cr>"` to bind to enter. Values use vim keymap notation
|
actions. Use `"<cr>"` to bind to enter. Values use vim keymap notation
|
||||||
(e.g. `"<c-d>"`), which is translated to the appropriate format for
|
(e.g. `"<c-d>"`), which is translated to fzf-lua format internally.
|
||||||
each picker backend.
|
|
||||||
|
|
||||||
Defaults: >lua
|
Defaults: >lua
|
||||||
keys = {
|
keys = {
|
||||||
|
|
@ -588,7 +575,7 @@ HEALTH *forge-health*
|
||||||
Reports on: ~
|
Reports on: ~
|
||||||
- `git` availability
|
- `git` availability
|
||||||
- Forge CLI availability (`gh`, `glab`, `tea`)
|
- Forge CLI availability (`gh`, `glab`, `tea`)
|
||||||
- Picker backends (fzf-lua, telescope, snacks) and which is active
|
- `fzf-lua` installation (required)
|
||||||
- `diffs.nvim` installation (review mode)
|
- `diffs.nvim` installation (review mode)
|
||||||
- Custom registered sources and their CLI availability
|
- Custom registered sources and their CLI availability
|
||||||
|
|
||||||
|
|
@ -712,11 +699,12 @@ PUBLIC API: `require('forge.pickers')` ~
|
||||||
`pr_manage(f, num)`
|
`pr_manage(f, num)`
|
||||||
Opens the management picker for PR `num`.
|
Opens the management picker for PR `num`.
|
||||||
|
|
||||||
*forge.pickers.pr_action_fns()*
|
*forge.pickers.pr_actions()*
|
||||||
`pr_action_fns(f, num)`
|
`pr_actions(f, num)`
|
||||||
Returns `table<string, function>`. Action functions for PR `num`,
|
Returns `table<string, function>`. Action functions for PR `num`,
|
||||||
keyed by action name (`"checkout"`, `"diff"`, `"worktree"`,
|
keyed by fzf binding. Also has `_by_name` table keyed by action
|
||||||
`"browse"`, `"ci"`, `"manage"`).
|
name (`"checkout"`, `"diff"`, `"worktree"`, `"browse"`, `"ci"`,
|
||||||
|
`"manage"`).
|
||||||
|
|
||||||
*forge.pickers.issue_close()*
|
*forge.pickers.issue_close()*
|
||||||
`issue_close(f, num)`
|
`issue_close(f, num)`
|
||||||
|
|
|
||||||
15
flake.nix
15
flake.nix
|
|
@ -24,21 +24,22 @@
|
||||||
devShells = forEachSystem (
|
devShells = forEachSystem (
|
||||||
pkgs:
|
pkgs:
|
||||||
let
|
let
|
||||||
|
vimdoc-ls = vimdoc-language-server.packages.${pkgs.system}.default;
|
||||||
commonPackages = [
|
commonPackages = [
|
||||||
pkgs.prettier
|
pkgs.prettier
|
||||||
pkgs.stylua
|
pkgs.stylua
|
||||||
pkgs.selene
|
pkgs.selene
|
||||||
pkgs.lua-language-server
|
pkgs.lua-language-server
|
||||||
vimdoc-language-server.packages.${pkgs.system}.default
|
vimdoc-ls
|
||||||
(pkgs.luajit.withPackages (ps: [
|
|
||||||
ps.busted
|
|
||||||
ps.nlua
|
|
||||||
]))
|
|
||||||
];
|
];
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
default = pkgs.mkShell { packages = commonPackages; };
|
default = pkgs.mkShell {
|
||||||
ci = pkgs.mkShell { packages = commonPackages ++ [ pkgs.neovim ]; };
|
packages = commonPackages;
|
||||||
|
};
|
||||||
|
ci = pkgs.mkShell {
|
||||||
|
packages = commonPackages;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -22,18 +22,11 @@ function M.check()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local picker_mod = require('forge.picker')
|
local has_fzf = pcall(require, 'fzf-lua')
|
||||||
local backend = picker_mod.backend()
|
if has_fzf then
|
||||||
local found_any = false
|
vim.health.ok('fzf-lua found')
|
||||||
for _, name in ipairs(picker_mod.detect_order) do
|
else
|
||||||
if pcall(require, name) then
|
vim.health.error('fzf-lua not found (required)')
|
||||||
local suffix = backend == name and ' (active)' or ''
|
|
||||||
vim.health.ok(name .. ' found' .. suffix)
|
|
||||||
found_any = true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if not found_any then
|
|
||||||
vim.health.error('no picker backend found (install fzf-lua, telescope.nvim, or snacks.nvim)')
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local has_diffs = pcall(require, 'diffs')
|
local has_diffs = pcall(require, 'diffs')
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
---@class forge.Config
|
---@class forge.Config
|
||||||
---@field picker 'fzf-lua'|'telescope'|'snacks'|'auto'
|
|
||||||
---@field ci forge.CIConfig
|
---@field ci forge.CIConfig
|
||||||
---@field sources table<string, forge.SourceConfig>
|
---@field sources table<string, forge.SourceConfig>
|
||||||
---@field keys forge.KeysConfig|false
|
---@field keys forge.KeysConfig|false
|
||||||
|
|
@ -84,7 +83,6 @@ local M = {}
|
||||||
|
|
||||||
---@type forge.Config
|
---@type forge.Config
|
||||||
local DEFAULTS = {
|
local DEFAULTS = {
|
||||||
picker = 'auto',
|
|
||||||
ci = { lines = 10000 },
|
ci = { lines = 10000 },
|
||||||
sources = {},
|
sources = {},
|
||||||
keys = {
|
keys = {
|
||||||
|
|
@ -540,25 +538,15 @@ local function extract_author(entry, field)
|
||||||
return tostring(v or '')
|
return tostring(v or '')
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param secs integer
|
local function hl(group, text)
|
||||||
---@return string
|
local utils = require('fzf-lua.utils')
|
||||||
local function format_duration(secs)
|
return utils.ansi_from_hl(group, text)
|
||||||
if secs < 0 then
|
|
||||||
secs = 0
|
|
||||||
end
|
|
||||||
if secs >= 3600 then
|
|
||||||
return ('%dh%dm'):format(math.floor(secs / 3600), math.floor(secs % 3600 / 60))
|
|
||||||
end
|
|
||||||
if secs >= 60 then
|
|
||||||
return ('%dm%ds'):format(math.floor(secs / 60), secs % 60)
|
|
||||||
end
|
|
||||||
return ('%ds'):format(secs)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param entry table
|
---@param entry table
|
||||||
---@param fields table
|
---@param fields table
|
||||||
---@param show_state boolean
|
---@param show_state boolean
|
||||||
---@return forge.Segment[]
|
---@return string
|
||||||
function M.format_pr(entry, fields, show_state)
|
function M.format_pr(entry, fields, show_state)
|
||||||
local display = M.config().display
|
local display = M.config().display
|
||||||
local icons = display.icons
|
local icons = display.icons
|
||||||
|
|
@ -567,7 +555,7 @@ function M.format_pr(entry, fields, show_state)
|
||||||
local title = entry[fields.title] or ''
|
local title = entry[fields.title] or ''
|
||||||
local author = extract_author(entry, fields.author)
|
local author = extract_author(entry, fields.author)
|
||||||
local age = relative_time(entry[fields.created_at])
|
local age = relative_time(entry[fields.created_at])
|
||||||
local segments = {}
|
local prefix = ''
|
||||||
if show_state then
|
if show_state then
|
||||||
local state = (entry[fields.state] or ''):lower()
|
local state = (entry[fields.state] or ''):lower()
|
||||||
local icon, group
|
local icon, group
|
||||||
|
|
@ -578,22 +566,20 @@ function M.format_pr(entry, fields, show_state)
|
||||||
else
|
else
|
||||||
icon, group = icons.closed, 'ForgeClosed'
|
icon, group = icons.closed, 'ForgeClosed'
|
||||||
end
|
end
|
||||||
table.insert(segments, { icon, group })
|
prefix = hl(group, icon) .. ' '
|
||||||
table.insert(segments, { ' ' })
|
|
||||||
end
|
end
|
||||||
table.insert(segments, { ('#%-5s'):format(num), 'ForgeNumber' })
|
return prefix
|
||||||
table.insert(segments, { ' ' .. pad_or_truncate(title, widths.title) .. ' ' })
|
.. hl('ForgeNumber', ('#%-5s'):format(num))
|
||||||
table.insert(segments, {
|
.. ' '
|
||||||
pad_or_truncate(author, widths.author) .. (' %3s'):format(age),
|
.. pad_or_truncate(title, widths.title)
|
||||||
'ForgeDim',
|
.. ' '
|
||||||
})
|
.. hl('ForgeDim', pad_or_truncate(author, widths.author) .. (' %3s'):format(age))
|
||||||
return segments
|
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param entry table
|
---@param entry table
|
||||||
---@param fields table
|
---@param fields table
|
||||||
---@param show_state boolean
|
---@param show_state boolean
|
||||||
---@return forge.Segment[]
|
---@return string
|
||||||
function M.format_issue(entry, fields, show_state)
|
function M.format_issue(entry, fields, show_state)
|
||||||
local display = M.config().display
|
local display = M.config().display
|
||||||
local icons = display.icons
|
local icons = display.icons
|
||||||
|
|
@ -602,7 +588,7 @@ function M.format_issue(entry, fields, show_state)
|
||||||
local title = entry[fields.title] or ''
|
local title = entry[fields.title] or ''
|
||||||
local author = extract_author(entry, fields.author)
|
local author = extract_author(entry, fields.author)
|
||||||
local age = relative_time(entry[fields.created_at])
|
local age = relative_time(entry[fields.created_at])
|
||||||
local segments = {}
|
local prefix = ''
|
||||||
if show_state then
|
if show_state then
|
||||||
local state = (entry[fields.state] or ''):lower()
|
local state = (entry[fields.state] or ''):lower()
|
||||||
local icon, group
|
local icon, group
|
||||||
|
|
@ -611,20 +597,18 @@ function M.format_issue(entry, fields, show_state)
|
||||||
else
|
else
|
||||||
icon, group = icons.closed, 'ForgeClosed'
|
icon, group = icons.closed, 'ForgeClosed'
|
||||||
end
|
end
|
||||||
table.insert(segments, { icon, group })
|
prefix = hl(group, icon) .. ' '
|
||||||
table.insert(segments, { ' ' })
|
|
||||||
end
|
end
|
||||||
table.insert(segments, { ('#%-5s'):format(num), 'ForgeNumber' })
|
return prefix
|
||||||
table.insert(segments, { ' ' .. pad_or_truncate(title, widths.title) .. ' ' })
|
.. hl('ForgeNumber', ('#%-5s'):format(num))
|
||||||
table.insert(segments, {
|
.. ' '
|
||||||
pad_or_truncate(author, widths.author) .. (' %3s'):format(age),
|
.. pad_or_truncate(title, widths.title)
|
||||||
'ForgeDim',
|
.. ' '
|
||||||
})
|
.. hl('ForgeDim', pad_or_truncate(author, widths.author) .. (' %3s'):format(age))
|
||||||
return segments
|
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param check table
|
---@param check table
|
||||||
---@return forge.Segment[]
|
---@return string
|
||||||
function M.format_check(check)
|
function M.format_check(check)
|
||||||
local display = M.config().display
|
local display = M.config().display
|
||||||
local icons = display.icons
|
local icons = display.icons
|
||||||
|
|
@ -648,18 +632,23 @@ function M.format_check(check)
|
||||||
local ok_s, ts = pcall(vim.fn.strptime, '%Y-%m-%dT%H:%M:%SZ', check.startedAt)
|
local ok_s, ts = pcall(vim.fn.strptime, '%Y-%m-%dT%H:%M:%SZ', check.startedAt)
|
||||||
local ok_e, te = pcall(vim.fn.strptime, '%Y-%m-%dT%H:%M:%SZ', check.completedAt)
|
local ok_e, te = pcall(vim.fn.strptime, '%Y-%m-%dT%H:%M:%SZ', check.completedAt)
|
||||||
if ok_s and ok_e and ts > 0 and te > 0 then
|
if ok_s and ok_e and ts > 0 and te > 0 then
|
||||||
elapsed = format_duration(te - ts)
|
local secs = te - ts
|
||||||
|
if secs >= 60 then
|
||||||
|
elapsed = ('%dm%ds'):format(math.floor(secs / 60), secs % 60)
|
||||||
|
else
|
||||||
|
elapsed = ('%ds'):format(secs)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return {
|
end
|
||||||
{ icon, group },
|
return hl(group, icon)
|
||||||
{ ' ' .. pad_or_truncate(name, widths.name) .. ' ' },
|
.. ' '
|
||||||
{ elapsed, 'ForgeDim' },
|
.. pad_or_truncate(name, widths.name)
|
||||||
}
|
.. ' '
|
||||||
|
.. hl('ForgeDim', elapsed)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param run forge.CIRun
|
---@param run forge.CIRun
|
||||||
---@return forge.Segment[]
|
---@return string
|
||||||
function M.format_run(run)
|
function M.format_run(run)
|
||||||
local display = M.config().display
|
local display = M.config().display
|
||||||
local icons = display.icons
|
local icons = display.icons
|
||||||
|
|
@ -681,18 +670,19 @@ function M.format_run(run)
|
||||||
local age = relative_time(run.created_at)
|
local age = relative_time(run.created_at)
|
||||||
if run.branch ~= '' then
|
if run.branch ~= '' then
|
||||||
local name_w = widths.name - widths.branch + 10
|
local name_w = widths.name - widths.branch + 10
|
||||||
return {
|
return hl(group, icon)
|
||||||
{ icon, group },
|
.. ' '
|
||||||
{ ' ' .. pad_or_truncate(run.name, name_w) .. ' ' },
|
.. pad_or_truncate(run.name, name_w)
|
||||||
{ pad_or_truncate(run.branch, widths.branch), 'ForgeBranch' },
|
.. ' '
|
||||||
{ ' ' .. ('%-6s'):format(event) .. ' ' .. age, 'ForgeDim' },
|
.. hl('ForgeBranch', pad_or_truncate(run.branch, widths.branch))
|
||||||
}
|
.. ' '
|
||||||
|
.. hl('ForgeDim', ('%-6s'):format(event) .. ' ' .. age)
|
||||||
end
|
end
|
||||||
return {
|
return hl(group, icon)
|
||||||
{ icon, group },
|
.. ' '
|
||||||
{ ' ' .. pad_or_truncate(run.name, widths.name) .. ' ' },
|
.. pad_or_truncate(run.name, widths.name)
|
||||||
{ ('%-6s'):format(event) .. ' ' .. age, 'ForgeDim' },
|
.. ' '
|
||||||
}
|
.. hl('ForgeDim', ('%-6s'):format(event) .. ' ' .. age)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param checks table[]
|
---@param checks table[]
|
||||||
|
|
@ -725,10 +715,6 @@ function M.config()
|
||||||
cfg.keys = false
|
cfg.keys = false
|
||||||
end
|
end
|
||||||
|
|
||||||
local picker_backends = require('forge.picker').backends
|
|
||||||
vim.validate('forge.picker', cfg.picker, function(v)
|
|
||||||
return v == 'auto' or picker_backends[v] ~= nil
|
|
||||||
end, "'auto', 'fzf-lua', 'telescope', or 'snacks'")
|
|
||||||
vim.validate('forge.sources', cfg.sources, 'table')
|
vim.validate('forge.sources', cfg.sources, 'table')
|
||||||
vim.validate('forge.keys', cfg.keys, function(v)
|
vim.validate('forge.keys', cfg.keys, function(v)
|
||||||
return v == false or type(v) == 'table'
|
return v == false or type(v) == 'table'
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
local picker = require('forge.picker')
|
|
||||||
|
|
||||||
---@param result { code: integer, stdout: string?, stderr: string? }
|
---@param result { code: integer, stdout: string?, stderr: string? }
|
||||||
---@param fallback string
|
---@param fallback string
|
||||||
---@return string
|
---@return string
|
||||||
|
|
@ -17,6 +15,36 @@ local function cmd_error(result, fallback)
|
||||||
return msg
|
return msg
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local fzf_args = (vim.env.FZF_DEFAULT_OPTS or '')
|
||||||
|
:gsub('%-%-bind=[^%s]+', '')
|
||||||
|
:gsub('%-%-color=[^%s]+', '')
|
||||||
|
|
||||||
|
local function to_fzf_key(key)
|
||||||
|
if key == '<cr>' then
|
||||||
|
return 'default'
|
||||||
|
end
|
||||||
|
return key:gsub('<c%-(%a)>', function(ch)
|
||||||
|
return 'ctrl-' .. ch:lower()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function build_actions(picker_name, action_defs)
|
||||||
|
local cfg = require('forge').config()
|
||||||
|
local keys = cfg.keys
|
||||||
|
if keys == false then
|
||||||
|
keys = {}
|
||||||
|
end
|
||||||
|
local bindings = keys[picker_name] or {}
|
||||||
|
local actions = {}
|
||||||
|
for _, def in ipairs(action_defs) do
|
||||||
|
local key = bindings[def.name]
|
||||||
|
if key then
|
||||||
|
actions[to_fzf_key(key)] = def.fn
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return actions
|
||||||
|
end
|
||||||
|
|
||||||
---@param kind string
|
---@param kind string
|
||||||
---@param num string
|
---@param num string
|
||||||
---@param label string
|
---@param label string
|
||||||
|
|
@ -51,10 +79,13 @@ end
|
||||||
---@param f forge.Forge
|
---@param f forge.Forge
|
||||||
---@param num string
|
---@param num string
|
||||||
---@return table<string, function>
|
---@return table<string, function>
|
||||||
local function pr_action_fns(f, num)
|
local function pr_actions(f, num)
|
||||||
local kind = f.labels.pr_one
|
local kind = f.labels.pr_one
|
||||||
return {
|
|
||||||
checkout = function()
|
local defs = {
|
||||||
|
{
|
||||||
|
name = 'checkout',
|
||||||
|
fn = function()
|
||||||
local forge_mod = require('forge')
|
local forge_mod = require('forge')
|
||||||
forge_mod.log_now(('checking out %s #%s...'):format(kind, num))
|
forge_mod.log_now(('checking out %s #%s...'):format(kind, num))
|
||||||
vim.system(f:checkout_cmd(num), { text = true }, function(result)
|
vim.system(f:checkout_cmd(num), { text = true }, function(result)
|
||||||
|
|
@ -68,10 +99,16 @@ local function pr_action_fns(f, num)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
end,
|
end,
|
||||||
browse = function()
|
},
|
||||||
|
{
|
||||||
|
name = 'browse',
|
||||||
|
fn = function()
|
||||||
f:view_web(f.kinds.pr, num)
|
f:view_web(f.kinds.pr, num)
|
||||||
end,
|
end,
|
||||||
worktree = function()
|
},
|
||||||
|
{
|
||||||
|
name = 'worktree',
|
||||||
|
fn = function()
|
||||||
local forge_mod = require('forge')
|
local forge_mod = require('forge')
|
||||||
local fetch_cmd = f:fetch_pr(num)
|
local fetch_cmd = f:fetch_pr(num)
|
||||||
local branch = fetch_cmd[#fetch_cmd]:match(':(.+)$')
|
local branch = fetch_cmd[#fetch_cmd]:match(':(.+)$')
|
||||||
|
|
@ -82,19 +119,29 @@ local function pr_action_fns(f, num)
|
||||||
local wt_path = vim.fs.normalize(root .. '/../' .. branch)
|
local wt_path = vim.fs.normalize(root .. '/../' .. branch)
|
||||||
forge_mod.log_now(('fetching %s #%s into worktree...'):format(kind, num))
|
forge_mod.log_now(('fetching %s #%s into worktree...'):format(kind, num))
|
||||||
vim.system(fetch_cmd, { text = true }, function()
|
vim.system(fetch_cmd, { text = true }, function()
|
||||||
vim.system({ 'git', 'worktree', 'add', wt_path, branch }, { text = true }, function(result)
|
vim.system(
|
||||||
|
{ 'git', 'worktree', 'add', wt_path, branch },
|
||||||
|
{ text = true },
|
||||||
|
function(result)
|
||||||
vim.schedule(function()
|
vim.schedule(function()
|
||||||
if result.code == 0 then
|
if result.code == 0 then
|
||||||
vim.notify(('[forge]: worktree at %s'):format(wt_path))
|
vim.notify(('[forge]: worktree at %s'):format(wt_path))
|
||||||
else
|
else
|
||||||
vim.notify('[forge]: ' .. cmd_error(result, 'worktree failed'), vim.log.levels.ERROR)
|
vim.notify(
|
||||||
|
'[forge]: ' .. cmd_error(result, 'worktree failed'),
|
||||||
|
vim.log.levels.ERROR
|
||||||
|
)
|
||||||
end
|
end
|
||||||
vim.cmd.redraw()
|
vim.cmd.redraw()
|
||||||
end)
|
end)
|
||||||
end)
|
end
|
||||||
|
)
|
||||||
end)
|
end)
|
||||||
end,
|
end,
|
||||||
diff = function()
|
},
|
||||||
|
{
|
||||||
|
name = 'diff',
|
||||||
|
fn = function()
|
||||||
local forge_mod = require('forge')
|
local forge_mod = require('forge')
|
||||||
local review = require('forge.review')
|
local review = require('forge.review')
|
||||||
local repo_root = vim.trim(vim.fn.system('git rev-parse --show-toplevel'))
|
local repo_root = vim.trim(vim.fn.system('git rev-parse --show-toplevel'))
|
||||||
|
|
@ -124,7 +171,10 @@ local function pr_action_fns(f, num)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
end,
|
end,
|
||||||
ci = function()
|
},
|
||||||
|
{
|
||||||
|
name = 'ci',
|
||||||
|
fn = function()
|
||||||
if f.capabilities.per_pr_checks then
|
if f.capabilities.per_pr_checks then
|
||||||
M.checks(f, num)
|
M.checks(f, num)
|
||||||
else
|
else
|
||||||
|
|
@ -134,10 +184,25 @@ local function pr_action_fns(f, num)
|
||||||
M.ci(f)
|
M.ci(f)
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
manage = function()
|
},
|
||||||
|
{
|
||||||
|
name = 'manage',
|
||||||
|
fn = function()
|
||||||
M.pr_manage(f, num)
|
M.pr_manage(f, num)
|
||||||
end,
|
end,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
---@type table<string, function>
|
||||||
|
local name_to_fn = {}
|
||||||
|
for _, def in ipairs(defs) do
|
||||||
|
name_to_fn[def.name] = def.fn
|
||||||
|
end
|
||||||
|
|
||||||
|
local actions = build_actions('pr', defs)
|
||||||
|
---@type table<string, function>
|
||||||
|
actions._by_name = name_to_fn
|
||||||
|
return actions
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param f forge.Forge
|
---@param f forge.Forge
|
||||||
|
|
@ -158,10 +223,7 @@ local function pr_manage_picker(f, num)
|
||||||
local action_map = {}
|
local action_map = {}
|
||||||
|
|
||||||
local function add(label, fn)
|
local function add(label, fn)
|
||||||
table.insert(entries, {
|
table.insert(entries, label)
|
||||||
display = { { label } },
|
|
||||||
value = label,
|
|
||||||
})
|
|
||||||
action_map[label] = fn
|
action_map[label] = fn
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -205,20 +267,17 @@ local function pr_manage_picker(f, num)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
picker.pick({
|
require('fzf-lua').fzf_exec(entries, {
|
||||||
|
fzf_args = fzf_args,
|
||||||
prompt = ('%s #%s Actions> '):format(kind, num),
|
prompt = ('%s #%s Actions> '):format(kind, num),
|
||||||
entries = entries,
|
fzf_opts = { ['--no-multi'] = '' },
|
||||||
actions = {
|
actions = {
|
||||||
{
|
['default'] = function(selected)
|
||||||
name = 'default',
|
if selected[1] and action_map[selected[1]] then
|
||||||
fn = function(entry)
|
action_map[selected[1]]()
|
||||||
if entry and action_map[entry.value] then
|
|
||||||
action_map[entry.value]()
|
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
},
|
|
||||||
picker_name = '_menu',
|
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -232,13 +291,18 @@ function M.checks(f, num, filter, cached_checks)
|
||||||
|
|
||||||
local function open_picker(checks)
|
local function open_picker(checks)
|
||||||
local filtered = forge_mod.filter_checks(checks, filter)
|
local filtered = forge_mod.filter_checks(checks, filter)
|
||||||
local entries = {}
|
local lines = {}
|
||||||
for _, c in ipairs(filtered) do
|
for i, c in ipairs(filtered) do
|
||||||
table.insert(entries, {
|
local line = ('%d\t%s'):format(i, forge_mod.format_check(c))
|
||||||
display = forge_mod.format_check(c),
|
table.insert(lines, line)
|
||||||
value = c,
|
end
|
||||||
ordinal = c.name or '',
|
|
||||||
})
|
local function get_check(selected)
|
||||||
|
if not selected[1] then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local idx = tonumber(selected[1]:match('^(%d+)'))
|
||||||
|
return idx and filtered[idx] or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
local labels = {
|
local labels = {
|
||||||
|
|
@ -248,17 +312,14 @@ function M.checks(f, num, filter, cached_checks)
|
||||||
pending = 'running',
|
pending = 'running',
|
||||||
}
|
}
|
||||||
|
|
||||||
picker.pick({
|
local check_actions = build_actions('ci', {
|
||||||
prompt = ('Checks (#%s, %s)> '):format(num, labels[filter] or filter),
|
|
||||||
entries = entries,
|
|
||||||
actions = {
|
|
||||||
{
|
{
|
||||||
name = 'log',
|
name = 'log',
|
||||||
fn = function(entry)
|
fn = function(selected)
|
||||||
if not entry then
|
local c = get_check(selected)
|
||||||
|
if not c then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local c = entry.value
|
|
||||||
local run_id = (c.link or ''):match('/actions/runs/(%d+)')
|
local run_id = (c.link or ''):match('/actions/runs/(%d+)')
|
||||||
if not run_id then
|
if not run_id then
|
||||||
return
|
return
|
||||||
|
|
@ -285,9 +346,10 @@ function M.checks(f, num, filter, cached_checks)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name = 'browse',
|
name = 'browse',
|
||||||
fn = function(entry)
|
fn = function(selected)
|
||||||
if entry and entry.value.link then
|
local c = get_check(selected)
|
||||||
vim.ui.open(entry.value.link)
|
if c and c.link then
|
||||||
|
vim.ui.open(c.link)
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
|
|
@ -315,8 +377,18 @@ function M.checks(f, num, filter, cached_checks)
|
||||||
M.checks(f, num, 'all', checks)
|
M.checks(f, num, 'all', checks)
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
require('fzf-lua').fzf_exec(lines, {
|
||||||
|
fzf_args = fzf_args,
|
||||||
|
prompt = ('Checks (#%s, %s)> '):format(num, labels[filter] or filter),
|
||||||
|
fzf_opts = {
|
||||||
|
['--ansi'] = '',
|
||||||
|
['--no-multi'] = '',
|
||||||
|
['--with-nth'] = '2..',
|
||||||
|
['--delimiter'] = '\t',
|
||||||
},
|
},
|
||||||
picker_name = 'ci',
|
actions = check_actions,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -340,7 +412,16 @@ function M.checks(f, num, filter, cached_checks)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
else
|
else
|
||||||
vim.notify('[forge]: structured checks not available for this forge', vim.log.levels.INFO)
|
require('fzf-lua').fzf_exec(f:checks_cmd(num), {
|
||||||
|
fzf_args = fzf_args,
|
||||||
|
prompt = ('Checks (#%s)> '):format(num),
|
||||||
|
fzf_opts = { ['--ansi'] = '' },
|
||||||
|
actions = {
|
||||||
|
['ctrl-r'] = function()
|
||||||
|
M.checks(f, num, filter)
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -349,32 +430,33 @@ end
|
||||||
function M.ci(f, branch)
|
function M.ci(f, branch)
|
||||||
local forge_mod = require('forge')
|
local forge_mod = require('forge')
|
||||||
|
|
||||||
local function open_ci_picker(runs)
|
local function open_picker(runs)
|
||||||
local normalized = {}
|
local normalized = {}
|
||||||
for _, entry in ipairs(runs) do
|
for _, entry in ipairs(runs) do
|
||||||
table.insert(normalized, f:normalize_run(entry))
|
table.insert(normalized, f:normalize_run(entry))
|
||||||
end
|
end
|
||||||
|
|
||||||
local entries = {}
|
local lines = {}
|
||||||
for _, run in ipairs(normalized) do
|
for i, run in ipairs(normalized) do
|
||||||
table.insert(entries, {
|
table.insert(lines, ('%d\t%s'):format(i, forge_mod.format_run(run)))
|
||||||
display = forge_mod.format_run(run),
|
|
||||||
value = run,
|
|
||||||
ordinal = run.name .. ' ' .. run.branch,
|
|
||||||
})
|
|
||||||
end
|
end
|
||||||
|
|
||||||
picker.pick({
|
local function get_run(selected)
|
||||||
prompt = ('%s (%s)> '):format(f.labels.ci, branch or 'all'),
|
if not selected[1] then
|
||||||
entries = entries,
|
return nil
|
||||||
actions = {
|
end
|
||||||
|
local idx = tonumber(selected[1]:match('^(%d+)'))
|
||||||
|
return idx and normalized[idx] or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local ci_actions = build_actions('ci', {
|
||||||
{
|
{
|
||||||
name = 'log',
|
name = 'log',
|
||||||
fn = function(entry)
|
fn = function(selected)
|
||||||
if not entry then
|
local run = get_run(selected)
|
||||||
|
if not run then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local run = entry.value
|
|
||||||
forge_mod.log_now('fetching CI/CD logs...')
|
forge_mod.log_now('fetching CI/CD logs...')
|
||||||
local s = run.status:lower()
|
local s = run.status:lower()
|
||||||
local cmd
|
local cmd
|
||||||
|
|
@ -399,9 +481,10 @@ function M.ci(f, branch)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name = 'browse',
|
name = 'browse',
|
||||||
fn = function(entry)
|
fn = function(selected)
|
||||||
if entry and entry.value.url ~= '' then
|
local run = get_run(selected)
|
||||||
vim.ui.open(entry.value.url)
|
if run and run.url ~= '' then
|
||||||
|
vim.ui.open(run.url)
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
|
|
@ -411,8 +494,18 @@ function M.ci(f, branch)
|
||||||
M.ci(f, branch)
|
M.ci(f, branch)
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
require('fzf-lua').fzf_exec(lines, {
|
||||||
|
fzf_args = fzf_args,
|
||||||
|
prompt = ('%s (%s)> '):format(f.labels.ci, branch or 'all'),
|
||||||
|
fzf_opts = {
|
||||||
|
['--ansi'] = '',
|
||||||
|
['--no-multi'] = '',
|
||||||
|
['--with-nth'] = '2..',
|
||||||
|
['--delimiter'] = '\t',
|
||||||
},
|
},
|
||||||
picker_name = 'ci',
|
actions = ci_actions,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -422,7 +515,7 @@ function M.ci(f, branch)
|
||||||
vim.schedule(function()
|
vim.schedule(function()
|
||||||
local ok, runs = pcall(vim.json.decode, result.stdout or '[]')
|
local ok, runs = pcall(vim.json.decode, result.stdout or '[]')
|
||||||
if ok and runs and #runs > 0 then
|
if ok and runs and #runs > 0 then
|
||||||
open_ci_picker(runs)
|
open_picker(runs)
|
||||||
else
|
else
|
||||||
vim.notify('[forge]: no CI runs found', vim.log.levels.INFO)
|
vim.notify('[forge]: no CI runs found', vim.log.levels.INFO)
|
||||||
vim.cmd.redraw()
|
vim.cmd.redraw()
|
||||||
|
|
@ -430,7 +523,11 @@ function M.ci(f, branch)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
elseif f.list_runs_cmd then
|
elseif f.list_runs_cmd then
|
||||||
vim.notify('[forge]: structured CI data not available for this forge', vim.log.levels.INFO)
|
require('fzf-lua').fzf_exec(f:list_runs_cmd(branch), {
|
||||||
|
fzf_args = fzf_args,
|
||||||
|
prompt = f.labels.ci .. '> ',
|
||||||
|
fzf_opts = { ['--ansi'] = '' },
|
||||||
|
})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -438,27 +535,6 @@ end
|
||||||
function M.commits(f)
|
function M.commits(f)
|
||||||
local forge_mod = require('forge')
|
local forge_mod = require('forge')
|
||||||
local review = require('forge.review')
|
local review = require('forge.review')
|
||||||
|
|
||||||
if picker.backend() ~= 'fzf-lua' then
|
|
||||||
vim.notify('[forge]: commits picker requires fzf-lua', vim.log.levels.WARN)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
local fzf_args = (vim.env.FZF_DEFAULT_OPTS or '')
|
|
||||||
:gsub('%-%-bind=[^%s]+', '')
|
|
||||||
:gsub('%-%-color=[^%s]+', '')
|
|
||||||
|
|
||||||
local function to_fzf_key(key)
|
|
||||||
if key == '<cr>' then
|
|
||||||
return 'default'
|
|
||||||
end
|
|
||||||
return key:gsub('<c%-(%a)>', function(ch)
|
|
||||||
return 'ctrl-' .. ch:lower()
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
local cfg = require('forge').config()
|
|
||||||
local keys = type(cfg.keys) == 'table' and cfg.keys.commits or {}
|
|
||||||
local log_cmd =
|
local log_cmd =
|
||||||
'git log --color --pretty=format:"%C(yellow)%h%Creset %Cgreen(%><(12)%cr%><|(12))%Creset %s %C(blue)<%an>%Creset"'
|
'git log --color --pretty=format:"%C(yellow)%h%Creset %Cgreen(%><(12)%cr%><|(12))%Creset %s %C(blue)<%an>%Creset"'
|
||||||
|
|
||||||
|
|
@ -469,9 +545,10 @@ function M.commits(f)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local fzf_actions = {}
|
local defs = {
|
||||||
if keys.checkout then
|
{
|
||||||
fzf_actions[to_fzf_key(keys.checkout)] = function(selected)
|
name = 'checkout',
|
||||||
|
fn = function(selected)
|
||||||
with_sha(selected, function(sha)
|
with_sha(selected, function(sha)
|
||||||
forge_mod.log_now('checking out ' .. sha .. '...')
|
forge_mod.log_now('checking out ' .. sha .. '...')
|
||||||
vim.system({ 'git', 'checkout', sha }, { text = true }, function(result)
|
vim.system({ 'git', 'checkout', sha }, { text = true }, function(result)
|
||||||
|
|
@ -479,16 +556,20 @@ function M.commits(f)
|
||||||
if result.code == 0 then
|
if result.code == 0 then
|
||||||
vim.notify(('[forge]: checked out %s (detached)'):format(sha))
|
vim.notify(('[forge]: checked out %s (detached)'):format(sha))
|
||||||
else
|
else
|
||||||
vim.notify('[forge]: ' .. cmd_error(result, 'checkout failed'), vim.log.levels.ERROR)
|
vim.notify(
|
||||||
|
'[forge]: ' .. cmd_error(result, 'checkout failed'),
|
||||||
|
vim.log.levels.ERROR
|
||||||
|
)
|
||||||
end
|
end
|
||||||
vim.cmd.redraw()
|
vim.cmd.redraw()
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
end
|
end,
|
||||||
end
|
},
|
||||||
if keys.diff then
|
{
|
||||||
fzf_actions[to_fzf_key(keys.diff)] = function(selected)
|
name = 'diff',
|
||||||
|
fn = function(selected)
|
||||||
with_sha(selected, function(sha)
|
with_sha(selected, function(sha)
|
||||||
local range = sha .. '^..' .. sha
|
local range = sha .. '^..' .. sha
|
||||||
review.start(range)
|
review.start(range)
|
||||||
|
|
@ -498,25 +579,30 @@ function M.commits(f)
|
||||||
end
|
end
|
||||||
forge_mod.log_now('reviewing ' .. sha)
|
forge_mod.log_now('reviewing ' .. sha)
|
||||||
end)
|
end)
|
||||||
end
|
end,
|
||||||
end
|
},
|
||||||
if keys.browse then
|
{
|
||||||
fzf_actions[to_fzf_key(keys.browse)] = function(selected)
|
name = 'browse',
|
||||||
|
fn = function(selected)
|
||||||
with_sha(selected, function(sha)
|
with_sha(selected, function(sha)
|
||||||
if f then
|
if f then
|
||||||
f:browse_commit(sha)
|
f:browse_commit(sha)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end,
|
||||||
end
|
},
|
||||||
if keys.yank then
|
{
|
||||||
fzf_actions[to_fzf_key(keys.yank)] = function(selected)
|
name = 'yank',
|
||||||
|
fn = function(selected)
|
||||||
with_sha(selected, function(sha)
|
with_sha(selected, function(sha)
|
||||||
vim.fn.setreg('+', sha)
|
vim.fn.setreg('+', sha)
|
||||||
vim.notify('[forge]: copied ' .. sha)
|
vim.notify('[forge]: copied ' .. sha)
|
||||||
end)
|
end)
|
||||||
end
|
end,
|
||||||
end
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
local commit_actions = build_actions('commits', defs)
|
||||||
|
|
||||||
require('fzf-lua').fzf_exec(log_cmd, {
|
require('fzf-lua').fzf_exec(log_cmd, {
|
||||||
fzf_args = fzf_args,
|
fzf_args = fzf_args,
|
||||||
|
|
@ -526,7 +612,7 @@ function M.commits(f)
|
||||||
['--no-multi'] = '',
|
['--no-multi'] = '',
|
||||||
['--preview'] = 'git show --color {1}',
|
['--preview'] = 'git show --color {1}',
|
||||||
},
|
},
|
||||||
actions = fzf_actions,
|
actions = commit_actions,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -535,26 +621,10 @@ function M.branches(f)
|
||||||
local forge_mod = require('forge')
|
local forge_mod = require('forge')
|
||||||
local review = require('forge.review')
|
local review = require('forge.review')
|
||||||
|
|
||||||
if picker.backend() ~= 'fzf-lua' then
|
local defs = {
|
||||||
vim.notify('[forge]: branches picker requires fzf-lua', vim.log.levels.WARN)
|
{
|
||||||
return
|
name = 'diff',
|
||||||
end
|
fn = function(selected)
|
||||||
|
|
||||||
local function to_fzf_key(key)
|
|
||||||
if key == '<cr>' then
|
|
||||||
return 'default'
|
|
||||||
end
|
|
||||||
return key:gsub('<c%-(%a)>', function(ch)
|
|
||||||
return 'ctrl-' .. ch:lower()
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
local cfg = require('forge').config()
|
|
||||||
local keys = type(cfg.keys) == 'table' and cfg.keys.branches or {}
|
|
||||||
local fzf_actions = {}
|
|
||||||
|
|
||||||
if keys.diff then
|
|
||||||
fzf_actions[to_fzf_key(keys.diff)] = function(selected)
|
|
||||||
if not selected[1] then
|
if not selected[1] then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
@ -568,10 +638,11 @@ function M.branches(f)
|
||||||
commands.greview(br)
|
commands.greview(br)
|
||||||
end
|
end
|
||||||
forge_mod.log_now('reviewing ' .. br)
|
forge_mod.log_now('reviewing ' .. br)
|
||||||
end
|
end,
|
||||||
end
|
},
|
||||||
if keys.browse then
|
{
|
||||||
fzf_actions[to_fzf_key(keys.browse)] = function(selected)
|
name = 'browse',
|
||||||
|
fn = function(selected)
|
||||||
if not selected[1] then
|
if not selected[1] then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
@ -579,10 +650,12 @@ function M.branches(f)
|
||||||
if br and f then
|
if br and f then
|
||||||
f:browse_branch(br)
|
f:browse_branch(br)
|
||||||
end
|
end
|
||||||
end
|
end,
|
||||||
end
|
},
|
||||||
|
}
|
||||||
|
|
||||||
require('fzf-lua').git_branches({ actions = fzf_actions })
|
local branch_actions = build_actions('branches', defs)
|
||||||
|
require('fzf-lua').git_branches({ actions = branch_actions })
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param state 'all'|'open'|'closed'
|
---@param state 'all'|'open'|'closed'
|
||||||
|
|
@ -596,66 +669,64 @@ function M.pr(state, f)
|
||||||
local show_state = state ~= 'open'
|
local show_state = state ~= 'open'
|
||||||
|
|
||||||
local function open_pr_list(prs)
|
local function open_pr_list(prs)
|
||||||
local entries = {}
|
local lines = {}
|
||||||
for _, pr in ipairs(prs) do
|
for _, pr in ipairs(prs) do
|
||||||
local num = tostring(pr[pr_fields.number] or '')
|
table.insert(lines, forge_mod.format_pr(pr, pr_fields, show_state))
|
||||||
table.insert(entries, {
|
end
|
||||||
display = forge_mod.format_pr(pr, pr_fields, show_state),
|
local function with_pr_num(selected, fn)
|
||||||
value = num,
|
local num = selected[1] and selected[1]:match('[#!](%d+)')
|
||||||
ordinal = (pr[pr_fields.title] or '') .. ' #' .. num,
|
if num then
|
||||||
})
|
fn(num)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
picker.pick({
|
local list_actions = build_actions('pr', {
|
||||||
prompt = ('%s (%s)> '):format(f.labels.pr, state),
|
|
||||||
entries = entries,
|
|
||||||
actions = {
|
|
||||||
{
|
{
|
||||||
name = 'checkout',
|
name = 'checkout',
|
||||||
fn = function(entry)
|
fn = function(selected)
|
||||||
if entry then
|
with_pr_num(selected, function(num)
|
||||||
pr_action_fns(f, entry.value).checkout()
|
pr_actions(f, num)._by_name['checkout']()
|
||||||
end
|
end)
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name = 'diff',
|
name = 'diff',
|
||||||
fn = function(entry)
|
fn = function(selected)
|
||||||
if entry then
|
with_pr_num(selected, function(num)
|
||||||
pr_action_fns(f, entry.value).diff()
|
pr_actions(f, num)._by_name['diff']()
|
||||||
end
|
end)
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name = 'worktree',
|
name = 'worktree',
|
||||||
fn = function(entry)
|
fn = function(selected)
|
||||||
if entry then
|
with_pr_num(selected, function(num)
|
||||||
pr_action_fns(f, entry.value).worktree()
|
pr_actions(f, num)._by_name['worktree']()
|
||||||
end
|
end)
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name = 'ci',
|
name = 'ci',
|
||||||
fn = function(entry)
|
fn = function(selected)
|
||||||
if entry then
|
with_pr_num(selected, function(num)
|
||||||
pr_action_fns(f, entry.value).ci()
|
pr_actions(f, num)._by_name['ci']()
|
||||||
end
|
end)
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name = 'browse',
|
name = 'browse',
|
||||||
fn = function(entry)
|
fn = function(selected)
|
||||||
if entry then
|
with_pr_num(selected, function(num)
|
||||||
f:view_web(cli_kind, entry.value)
|
f:view_web(cli_kind, num)
|
||||||
end
|
end)
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name = 'manage',
|
name = 'manage',
|
||||||
fn = function(entry)
|
fn = function(selected)
|
||||||
if entry then
|
with_pr_num(selected, function(num)
|
||||||
pr_action_fns(f, entry.value).manage()
|
pr_actions(f, num)._by_name['manage']()
|
||||||
end
|
end)
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -677,8 +748,16 @@ function M.pr(state, f)
|
||||||
M.pr(state, f)
|
M.pr(state, f)
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
require('fzf-lua').fzf_exec(lines, {
|
||||||
|
fzf_args = fzf_args,
|
||||||
|
prompt = ('%s (%s)> '):format(f.labels.pr, state),
|
||||||
|
fzf_opts = {
|
||||||
|
['--ansi'] = '',
|
||||||
|
['--no-multi'] = '',
|
||||||
},
|
},
|
||||||
picker_name = 'pr',
|
actions = list_actions,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -716,36 +795,35 @@ function M.issue(state, f)
|
||||||
end)
|
end)
|
||||||
local state_field = issue_fields.state
|
local state_field = issue_fields.state
|
||||||
local state_map = {}
|
local state_map = {}
|
||||||
local entries = {}
|
local lines = {}
|
||||||
for _, issue in ipairs(issues) do
|
for _, issue in ipairs(issues) do
|
||||||
local n = tostring(issue[num_field] or '')
|
local n = tostring(issue[num_field] or '')
|
||||||
local s = (issue[state_field] or ''):lower()
|
local s = (issue[state_field] or ''):lower()
|
||||||
state_map[n] = s == 'open' or s == 'opened'
|
state_map[n] = s == 'open' or s == 'opened'
|
||||||
table.insert(entries, {
|
table.insert(lines, forge_mod.format_issue(issue, issue_fields, issue_show_state))
|
||||||
display = forge_mod.format_issue(issue, issue_fields, issue_show_state),
|
end
|
||||||
value = n,
|
local function with_issue_num(selected, fn)
|
||||||
ordinal = (issue[issue_fields.title] or '') .. ' #' .. n,
|
local num = selected[1] and selected[1]:match('[#!](%d+)')
|
||||||
})
|
if num then
|
||||||
|
fn(num)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
picker.pick({
|
local issue_actions = build_actions('issue', {
|
||||||
prompt = ('%s (%s)> '):format(f.labels.issue, state),
|
|
||||||
entries = entries,
|
|
||||||
actions = {
|
|
||||||
{
|
{
|
||||||
name = 'browse',
|
name = 'browse',
|
||||||
fn = function(entry)
|
fn = function(selected)
|
||||||
if entry then
|
with_issue_num(selected, function(num)
|
||||||
f:view_web(cli_kind, entry.value)
|
f:view_web(cli_kind, num)
|
||||||
end
|
end)
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name = 'close',
|
name = 'close',
|
||||||
fn = function(entry)
|
fn = function(selected)
|
||||||
if entry then
|
with_issue_num(selected, function(num)
|
||||||
issue_toggle_state(f, entry.value, state_map[entry.value] ~= false)
|
issue_toggle_state(f, num, state_map[num] ~= false)
|
||||||
end
|
end)
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -761,8 +839,16 @@ function M.issue(state, f)
|
||||||
M.issue(state, f)
|
M.issue(state, f)
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
require('fzf-lua').fzf_exec(lines, {
|
||||||
|
fzf_args = fzf_args,
|
||||||
|
prompt = ('%s (%s)> '):format(f.labels.issue, state),
|
||||||
|
fzf_opts = {
|
||||||
|
['--ansi'] = '',
|
||||||
|
['--no-multi'] = '',
|
||||||
},
|
},
|
||||||
picker_name = 'issue',
|
actions = issue_actions,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -805,7 +891,7 @@ end
|
||||||
---@param num string
|
---@param num string
|
||||||
---@return table<string, function>
|
---@return table<string, function>
|
||||||
function M.pr_actions(f, num)
|
function M.pr_actions(f, num)
|
||||||
return pr_action_fns(f, num)
|
return pr_actions(f, num)
|
||||||
end
|
end
|
||||||
|
|
||||||
function M.git()
|
function M.git()
|
||||||
|
|
@ -827,14 +913,11 @@ function M.git()
|
||||||
local branch = vim.trim(vim.fn.system('git branch --show-current'))
|
local branch = vim.trim(vim.fn.system('git branch --show-current'))
|
||||||
|
|
||||||
local items = {}
|
local items = {}
|
||||||
local action_map = {}
|
local actions = {}
|
||||||
|
|
||||||
local function add(label, action)
|
local function add(label, action)
|
||||||
table.insert(items, {
|
table.insert(items, label)
|
||||||
display = { { label } },
|
actions[label] = action
|
||||||
value = label,
|
|
||||||
})
|
|
||||||
action_map[label] = action
|
|
||||||
end
|
end
|
||||||
|
|
||||||
if f then
|
if f then
|
||||||
|
|
@ -885,29 +968,21 @@ function M.git()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
add('Worktrees', function()
|
add('Worktrees', function()
|
||||||
if picker.backend() == 'fzf-lua' then
|
|
||||||
require('fzf-lua').git_worktrees()
|
require('fzf-lua').git_worktrees()
|
||||||
else
|
|
||||||
vim.notify('[forge]: worktrees picker requires fzf-lua', vim.log.levels.WARN)
|
|
||||||
end
|
|
||||||
end)
|
end)
|
||||||
|
|
||||||
local prompt = f and (f.name:sub(1, 1):upper() .. f.name:sub(2)) .. '> ' or 'Git> '
|
local prompt = f and (f.name:sub(1, 1):upper() .. f.name:sub(2)) .. '> ' or 'Git> '
|
||||||
|
|
||||||
picker.pick({
|
require('fzf-lua').fzf_exec(items, {
|
||||||
|
fzf_args = fzf_args,
|
||||||
prompt = prompt,
|
prompt = prompt,
|
||||||
entries = items,
|
|
||||||
actions = {
|
actions = {
|
||||||
{
|
['default'] = function(selected)
|
||||||
name = 'default',
|
if selected[1] and actions[selected[1]] then
|
||||||
fn = function(entry)
|
actions[selected[1]]()
|
||||||
if entry and action_map[entry.value] then
|
|
||||||
action_map[entry.value]()
|
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
},
|
|
||||||
picker_name = '_menu',
|
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,6 @@ end
|
||||||
|
|
||||||
local forge = require('forge')
|
local forge = require('forge')
|
||||||
|
|
||||||
local function flatten(segments)
|
|
||||||
local parts = {}
|
|
||||||
for _, seg in ipairs(segments) do
|
|
||||||
parts[#parts + 1] = seg[1]
|
|
||||||
end
|
|
||||||
return table.concat(parts)
|
|
||||||
end
|
|
||||||
|
|
||||||
describe('config', function()
|
describe('config', function()
|
||||||
after_each(function()
|
after_each(function()
|
||||||
vim.g.forge = nil
|
vim.g.forge = nil
|
||||||
|
|
@ -61,7 +53,7 @@ describe('format_pr', function()
|
||||||
it('formats open PR with state icon', function()
|
it('formats open PR with state icon', function()
|
||||||
local entry =
|
local entry =
|
||||||
{ number = 42, title = 'fix bug', state = 'OPEN', login = 'alice', created_at = '' }
|
{ number = 42, title = 'fix bug', state = 'OPEN', login = 'alice', created_at = '' }
|
||||||
local result = flatten(forge.format_pr(entry, fields, true))
|
local result = forge.format_pr(entry, fields, true)
|
||||||
assert.truthy(result:find('+'))
|
assert.truthy(result:find('+'))
|
||||||
assert.truthy(result:find('#42'))
|
assert.truthy(result:find('#42'))
|
||||||
assert.truthy(result:find('fix bug'))
|
assert.truthy(result:find('fix bug'))
|
||||||
|
|
@ -70,20 +62,20 @@ describe('format_pr', function()
|
||||||
it('formats merged PR', function()
|
it('formats merged PR', function()
|
||||||
local entry =
|
local entry =
|
||||||
{ number = 7, title = 'add feature', state = 'MERGED', login = 'bob', created_at = '' }
|
{ number = 7, title = 'add feature', state = 'MERGED', login = 'bob', created_at = '' }
|
||||||
local result = flatten(forge.format_pr(entry, fields, true))
|
local result = forge.format_pr(entry, fields, true)
|
||||||
assert.truthy(result:find('m'))
|
assert.truthy(result:find('m'))
|
||||||
assert.truthy(result:find('#7'))
|
assert.truthy(result:find('#7'))
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('formats closed PR', function()
|
it('formats closed PR', function()
|
||||||
local entry = { number = 3, title = 'stale', state = 'CLOSED', login = 'eve', created_at = '' }
|
local entry = { number = 3, title = 'stale', state = 'CLOSED', login = 'eve', created_at = '' }
|
||||||
local result = flatten(forge.format_pr(entry, fields, true))
|
local result = forge.format_pr(entry, fields, true)
|
||||||
assert.truthy(result:find('x'))
|
assert.truthy(result:find('x'))
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('omits state prefix when show_state is false', function()
|
it('omits state prefix when show_state is false', function()
|
||||||
local entry = { number = 1, title = 'no state', state = 'OPEN', login = 'dev', created_at = '' }
|
local entry = { number = 1, title = 'no state', state = 'OPEN', login = 'dev', created_at = '' }
|
||||||
local result = flatten(forge.format_pr(entry, fields, false))
|
local result = forge.format_pr(entry, fields, false)
|
||||||
assert.truthy(result:find('#1'))
|
assert.truthy(result:find('#1'))
|
||||||
assert.falsy(result:match('^+'))
|
assert.falsy(result:match('^+'))
|
||||||
end)
|
end)
|
||||||
|
|
@ -91,14 +83,14 @@ describe('format_pr', function()
|
||||||
it('truncates long titles', function()
|
it('truncates long titles', function()
|
||||||
local long_title = string.rep('a', 100)
|
local long_title = string.rep('a', 100)
|
||||||
local entry = { number = 9, title = long_title, state = 'OPEN', login = 'x', created_at = '' }
|
local entry = { number = 9, title = long_title, state = 'OPEN', login = 'x', created_at = '' }
|
||||||
local result = flatten(forge.format_pr(entry, fields, false))
|
local result = forge.format_pr(entry, fields, false)
|
||||||
assert.falsy(result:find(long_title))
|
assert.falsy(result:find(long_title))
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('extracts author from table with login field', function()
|
it('extracts author from table with login field', function()
|
||||||
local entry =
|
local entry =
|
||||||
{ number = 5, title = 't', state = 'OPEN', login = { login = 'nested' }, created_at = '' }
|
{ number = 5, title = 't', state = 'OPEN', login = { login = 'nested' }, created_at = '' }
|
||||||
local result = flatten(forge.format_pr(entry, fields, false))
|
local result = forge.format_pr(entry, fields, false)
|
||||||
assert.truthy(result:find('nested'))
|
assert.truthy(result:find('nested'))
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
@ -115,59 +107,59 @@ describe('format_issue', function()
|
||||||
it('formats open issue', function()
|
it('formats open issue', function()
|
||||||
local entry =
|
local entry =
|
||||||
{ number = 10, title = 'bug report', state = 'open', author = 'alice', created_at = '' }
|
{ number = 10, title = 'bug report', state = 'open', author = 'alice', created_at = '' }
|
||||||
local result = flatten(forge.format_issue(entry, fields, true))
|
local result = forge.format_issue(entry, fields, true)
|
||||||
assert.truthy(result:find('+'))
|
assert.truthy(result:find('+'))
|
||||||
assert.truthy(result:find('#10'))
|
assert.truthy(result:find('#10'))
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('formats closed issue', function()
|
it('formats closed issue', function()
|
||||||
local entry = { number = 11, title = 'done', state = 'closed', author = 'bob', created_at = '' }
|
local entry = { number = 11, title = 'done', state = 'closed', author = 'bob', created_at = '' }
|
||||||
local result = flatten(forge.format_issue(entry, fields, true))
|
local result = forge.format_issue(entry, fields, true)
|
||||||
assert.truthy(result:find('x'))
|
assert.truthy(result:find('x'))
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('handles opened state (GitLab)', function()
|
it('handles opened state (GitLab)', function()
|
||||||
local entry =
|
local entry =
|
||||||
{ number = 12, title = 'mr issue', state = 'opened', author = 'c', created_at = '' }
|
{ number = 12, title = 'mr issue', state = 'opened', author = 'c', created_at = '' }
|
||||||
local result = flatten(forge.format_issue(entry, fields, true))
|
local result = forge.format_issue(entry, fields, true)
|
||||||
assert.truthy(result:find('+'))
|
assert.truthy(result:find('+'))
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
describe('format_check', function()
|
describe('format_check', function()
|
||||||
it('maps pass bucket', function()
|
it('maps pass bucket', function()
|
||||||
local result = flatten(forge.format_check({ name = 'lint', bucket = 'pass' }))
|
local result = forge.format_check({ name = 'lint', bucket = 'pass' })
|
||||||
assert.truthy(result:find('%*'))
|
assert.truthy(result:find('%*'))
|
||||||
assert.truthy(result:find('lint'))
|
assert.truthy(result:find('lint'))
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('maps fail bucket', function()
|
it('maps fail bucket', function()
|
||||||
local result = flatten(forge.format_check({ name = 'build', bucket = 'fail' }))
|
local result = forge.format_check({ name = 'build', bucket = 'fail' })
|
||||||
assert.truthy(result:find('x'))
|
assert.truthy(result:find('x'))
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('maps pending bucket', function()
|
it('maps pending bucket', function()
|
||||||
local result = flatten(forge.format_check({ name = 'test', bucket = 'pending' }))
|
local result = forge.format_check({ name = 'test', bucket = 'pending' })
|
||||||
assert.truthy(result:find('~'))
|
assert.truthy(result:find('~'))
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('maps skipping bucket', function()
|
it('maps skipping bucket', function()
|
||||||
local result = flatten(forge.format_check({ name = 'optional', bucket = 'skipping' }))
|
local result = forge.format_check({ name = 'optional', bucket = 'skipping' })
|
||||||
assert.truthy(result:find('%-'))
|
assert.truthy(result:find('%-'))
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('maps cancel bucket', function()
|
it('maps cancel bucket', function()
|
||||||
local result = flatten(forge.format_check({ name = 'cancelled', bucket = 'cancel' }))
|
local result = forge.format_check({ name = 'cancelled', bucket = 'cancel' })
|
||||||
assert.truthy(result:find('%-'))
|
assert.truthy(result:find('%-'))
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('maps unknown bucket', function()
|
it('maps unknown bucket', function()
|
||||||
local result = flatten(forge.format_check({ name = 'mystery', bucket = 'something_else' }))
|
local result = forge.format_check({ name = 'mystery', bucket = 'something_else' })
|
||||||
assert.truthy(result:find('%?'))
|
assert.truthy(result:find('%?'))
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('defaults to pending when bucket is nil', function()
|
it('defaults to pending when bucket is nil', function()
|
||||||
local result = flatten(forge.format_check({ name = 'none' }))
|
local result = forge.format_check({ name = 'none' })
|
||||||
assert.truthy(result:find('~'))
|
assert.truthy(result:find('~'))
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
@ -176,7 +168,7 @@ describe('format_run', function()
|
||||||
it('formats successful run with branch', function()
|
it('formats successful run with branch', function()
|
||||||
local run =
|
local run =
|
||||||
{ name = 'CI', branch = 'main', status = 'success', event = 'push', created_at = '' }
|
{ name = 'CI', branch = 'main', status = 'success', event = 'push', created_at = '' }
|
||||||
local result = flatten(forge.format_run(run))
|
local result = forge.format_run(run)
|
||||||
assert.truthy(result:find('%*'))
|
assert.truthy(result:find('%*'))
|
||||||
assert.truthy(result:find('CI'))
|
assert.truthy(result:find('CI'))
|
||||||
assert.truthy(result:find('main'))
|
assert.truthy(result:find('main'))
|
||||||
|
|
@ -191,7 +183,7 @@ describe('format_run', function()
|
||||||
event = 'workflow_dispatch',
|
event = 'workflow_dispatch',
|
||||||
created_at = '',
|
created_at = '',
|
||||||
}
|
}
|
||||||
local result = flatten(forge.format_run(run))
|
local result = forge.format_run(run)
|
||||||
assert.truthy(result:find('x'))
|
assert.truthy(result:find('x'))
|
||||||
assert.truthy(result:find('manual'))
|
assert.truthy(result:find('manual'))
|
||||||
end)
|
end)
|
||||||
|
|
@ -199,13 +191,13 @@ describe('format_run', function()
|
||||||
it('maps in_progress status', function()
|
it('maps in_progress status', function()
|
||||||
local run =
|
local run =
|
||||||
{ name = 'Test', branch = '', status = 'in_progress', event = 'push', created_at = '' }
|
{ name = 'Test', branch = '', status = 'in_progress', event = 'push', created_at = '' }
|
||||||
local result = flatten(forge.format_run(run))
|
local result = forge.format_run(run)
|
||||||
assert.truthy(result:find('~'))
|
assert.truthy(result:find('~'))
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('maps cancelled status', function()
|
it('maps cancelled status', function()
|
||||||
local run = { name = 'Old', branch = '', status = 'cancelled', event = 'push', created_at = '' }
|
local run = { name = 'Old', branch = '', status = 'cancelled', event = 'push', created_at = '' }
|
||||||
local result = flatten(forge.format_run(run))
|
local result = forge.format_run(run)
|
||||||
assert.truthy(result:find('%-'))
|
assert.truthy(result:find('%-'))
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
@ -250,39 +242,39 @@ describe('relative_time via format_pr', function()
|
||||||
it('shows minutes for recent timestamps', function()
|
it('shows minutes for recent timestamps', function()
|
||||||
local ts = os.date('%Y-%m-%dT%H:%M:%SZ', os.time() - 120)
|
local ts = os.date('%Y-%m-%dT%H:%M:%SZ', os.time() - 120)
|
||||||
local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = ts }
|
local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = ts }
|
||||||
local result = flatten(forge.format_pr(entry, fields, false))
|
local result = forge.format_pr(entry, fields, false)
|
||||||
assert.truthy(result:match('%d+m'))
|
assert.truthy(result:match('%d+m'))
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('shows hours', function()
|
it('shows hours', function()
|
||||||
local ts = os.date('%Y-%m-%dT%H:%M:%SZ', os.time() - 7200)
|
local ts = os.date('%Y-%m-%dT%H:%M:%SZ', os.time() - 7200)
|
||||||
local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = ts }
|
local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = ts }
|
||||||
local result = flatten(forge.format_pr(entry, fields, false))
|
local result = forge.format_pr(entry, fields, false)
|
||||||
assert.truthy(result:match('%d+h'))
|
assert.truthy(result:match('%d+h'))
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('shows days', function()
|
it('shows days', function()
|
||||||
local ts = os.date('%Y-%m-%dT%H:%M:%SZ', os.time() - 172800)
|
local ts = os.date('%Y-%m-%dT%H:%M:%SZ', os.time() - 172800)
|
||||||
local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = ts }
|
local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = ts }
|
||||||
local result = flatten(forge.format_pr(entry, fields, false))
|
local result = forge.format_pr(entry, fields, false)
|
||||||
assert.truthy(result:match('%d+d'))
|
assert.truthy(result:match('%d+d'))
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('returns empty for nil timestamp', function()
|
it('returns empty for nil timestamp', function()
|
||||||
local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = nil }
|
local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = nil }
|
||||||
local result = flatten(forge.format_pr(entry, fields, false))
|
local result = forge.format_pr(entry, fields, false)
|
||||||
assert.truthy(result)
|
assert.truthy(result)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('returns empty for empty string timestamp', function()
|
it('returns empty for empty string timestamp', function()
|
||||||
local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = '' }
|
local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = '' }
|
||||||
local result = flatten(forge.format_pr(entry, fields, false))
|
local result = forge.format_pr(entry, fields, false)
|
||||||
assert.truthy(result)
|
assert.truthy(result)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('returns empty for garbage timestamp', function()
|
it('returns empty for garbage timestamp', function()
|
||||||
local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = 'not-a-date' }
|
local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = 'not-a-date' }
|
||||||
local result = flatten(forge.format_pr(entry, fields, false))
|
local result = forge.format_pr(entry, fields, false)
|
||||||
assert.truthy(result)
|
assert.truthy(result)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue