feat: initial commit

This commit is contained in:
Barrett Ruth 2026-03-27 16:46:28 -04:00
commit c4da2cda2a
No known key found for this signature in database
GPG key ID: A6C96C9349D2FC81
23 changed files with 3743 additions and 0 deletions

9
.editorconfig Normal file
View file

@ -0,0 +1,9 @@
root = true
[*]
insert_final_newline = true
charset = utf-8
[*.lua]
indent_style = space
indent_size = 2

17
.github/DISCUSSION_TEMPLATE/q-a.yaml vendored Normal file
View file

@ -0,0 +1,17 @@
title: 'Q&A'
labels: []
body:
- type: markdown
attributes:
value: |
Use this space for questions, ideas, and general discussion about forge.nvim.
For bug reports, please [open an issue](https://github.com/barrettruth/forge.nvim/issues/new/choose) instead.
- type: textarea
attributes:
label: Question or topic
validations:
required: true
- type: textarea
attributes:
label: Context
description: Any relevant details (Neovim version, config, screenshots)

60
.github/ISSUE_TEMPLATE/bug_report.yaml vendored Normal file
View file

@ -0,0 +1,60 @@
name: Bug Report
description: Report a bug
title: 'bug: '
labels: [bug]
body:
- type: checkboxes
attributes:
label: Prerequisites
options:
- label:
I have searched [existing
issues](https://github.com/barrettruth/forge.nvim/issues)
required: true
- label: I have updated to the latest version
required: true
- type: textarea
attributes:
label: 'Neovim version'
description: 'Output of `nvim --version`'
render: text
validations:
required: true
- type: input
attributes:
label: 'Operating system'
placeholder: 'e.g. Arch Linux, macOS 15, Ubuntu 24.04'
validations:
required: true
- type: textarea
attributes:
label: Description
description: What happened? What did you expect?
validations:
required: true
- type: textarea
attributes:
label: Steps to reproduce
description: Minimal steps to trigger the bug
value: |
1.
2.
3.
validations:
required: true
- type: textarea
attributes:
label: 'Health check'
description: 'Output of `:checkhealth forge`'
render: text
- type: textarea
attributes:
label: 'Forge CLI version'
description: 'Output of `gh --version`, `glab --version`, or `tea --version`'
render: text

5
.github/ISSUE_TEMPLATE/config.yaml vendored Normal file
View file

@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Questions
url: https://github.com/barrettruth/forge.nvim/discussions
about: Ask questions and discuss ideas

View file

@ -0,0 +1,30 @@
name: Feature Request
description: Suggest a feature
title: 'feat: '
labels: [enhancement]
body:
- type: checkboxes
attributes:
label: Prerequisites
options:
- label:
I have searched [existing
issues](https://github.com/barrettruth/forge.nvim/issues)
required: true
- type: textarea
attributes:
label: Problem
description: What problem does this solve?
validations:
required: true
- type: textarea
attributes:
label: Proposed solution
validations:
required: true
- type: textarea
attributes:
label: Alternatives considered

21
.github/workflows/luarocks.yaml vendored Normal file
View file

@ -0,0 +1,21 @@
name: luarocks
on:
push:
tags:
- 'v*'
jobs:
quality:
uses: ./.github/workflows/quality.yaml
publish:
needs: quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: nvim-neorocks/luarocks-tag-release@v7
env:
LUAROCKS_API_KEY: ${{ secrets.LUAROCKS_API_KEY }}

87
.github/workflows/quality.yaml vendored Normal file
View file

@ -0,0 +1,87 @@
name: quality
on:
workflow_call:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
changes:
runs-on: ubuntu-latest
outputs:
lua: ${{ steps.changes.outputs.lua }}
markdown: ${{ steps.changes.outputs.markdown }}
vimdoc: ${{ steps.changes.outputs.vimdoc }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: changes
with:
filters: |
lua:
- 'lua/**'
- 'plugin/**'
- '*.lua'
- '.luarc.json'
- '*.toml'
- 'vim.yaml'
markdown:
- '*.md'
vimdoc:
- 'doc/**'
lua-format:
name: Lua Format Check
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.lua == 'true' }}
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v31
- run: nix develop .#ci --command stylua --check .
lua-lint:
name: Lua Lint Check
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.lua == 'true' }}
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v31
- run: nix develop .#ci --command selene --display-style quiet .
lua-typecheck:
name: Lua Type Check
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.lua == 'true' }}
steps:
- uses: actions/checkout@v4
- name: Run Lua LS Type Check
uses: mrcjkb/lua-typecheck-action@v0
with:
checklevel: Warning
directories: lua
configpath: .luarc.json
vimdoc-check:
name: Vimdoc Check
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.vimdoc == 'true' }}
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v31
- run: nix develop .#ci --command vimdoc-language-server check doc/
markdown-format:
name: Markdown Format Check
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.markdown == 'true' }}
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v31
- run: nix develop .#ci --command prettier --check .

13
.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
doc/tags
*.log
.*cache*
CLAUDE.md
.claude/
node_modules/
result
result-*
.direnv/
.envrc

15
.luarc.json Normal file
View file

@ -0,0 +1,15 @@
{
"runtime.version": "LuaJIT",
"runtime.path": ["lua/?.lua", "lua/?/init.lua"],
"diagnostics.globals": ["vim", "jit"],
"workspace.library": [
"$VIMRUNTIME/lua",
"${3rd}/luv/library",
"${3rd}/busted/library",
"${3rd}/luassert/library"
],
"workspace.checkThirdParty": false,
"diagnostics.libraryFiles": "Disable",
"workspace.ignoreDir": [".direnv"],
"completion.callSnippet": "Replace"
}

17
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,17 @@
minimum_pre_commit_version: '3.5.0'
repos:
- repo: https://github.com/JohnnyMorganz/StyLua
rev: v2.3.1
hooks:
- id: stylua-github
name: stylua (Lua formatter)
files: \.lua$
pass_filenames: true
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v4.0.0-alpha.8
hooks:
- id: prettier
name: prettier
files: \.(md|toml|yaml|yml|sh)$

1
.prettierignore Normal file
View file

@ -0,0 +1 @@
node_modules/

9
.prettierrc Normal file
View file

@ -0,0 +1,9 @@
{
"proseWrap": "always",
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"trailingComma": "none",
"semi": false,
"singleQuote": true
}

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Raphael
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

21
forge.nvim-scm-1.rockspec Normal file
View file

@ -0,0 +1,21 @@
rockspec_format = '3.0'
package = 'forge.nvim'
version = 'scm-1'
source = {
url = 'git+https://github.com/barrettruth/forge.nvim.git',
}
description = {
summary = 'Forge-agnostic git workflow for Neovim',
homepage = 'https://github.com/barrettruth/forge.nvim',
license = 'MIT',
}
dependencies = {
'lua >= 5.1',
}
build = {
type = 'builtin',
}

316
lua/forge/codeberg.lua Normal file
View file

@ -0,0 +1,316 @@
local forge = require('forge')
---@type forge.Forge
local M = {
name = 'codeberg',
cli = 'tea',
kinds = { issue = 'issues', pr = 'pulls' },
labels = {
issue = 'Issues',
pr = 'PRs',
pr_one = 'PR',
pr_full = 'Pull Requests',
ci = 'CI/CD',
},
}
---@param kind string
---@param state string
---@return string
function M:list_cmd(kind, state)
return ('tea %s list --state %s'):format(kind, state)
end
---@param state string
---@return string[]
function M:list_pr_json_cmd(state)
return {
'tea',
'pulls',
'list',
'--state',
state,
'--output',
'json',
'--fields',
'index,title,head,state,poster,created_at',
}
end
---@param state string
---@return string[]
function M:list_issue_json_cmd(state)
return {
'tea',
'issues',
'list',
'--state',
state,
'--output',
'json',
'--fields',
'index,title,state,poster,created_at',
}
end
function M:pr_json_fields()
return {
number = 'index',
title = 'title',
branch = 'head',
state = 'state',
author = 'poster',
created_at = 'created_at',
}
end
function M:issue_json_fields()
return {
number = 'index',
title = 'title',
state = 'state',
author = 'poster',
created_at = 'created_at',
}
end
---@param kind string
---@param num string
function M:view_web(kind, num)
local base = forge.remote_web_url()
vim.ui.open(('%s/%s/%s'):format(base, kind, num))
end
---@param loc string
---@param branch string
function M:browse(loc, branch)
local base = forge.remote_web_url()
local file, lines = loc:match('^(.+):(.+)$')
vim.ui.open(('%s/src/branch/%s/%s#L%s'):format(base, branch, file, lines))
end
function M:browse_root()
vim.ui.open(forge.remote_web_url())
end
function M:browse_branch(branch)
local base = forge.remote_web_url()
vim.ui.open(base .. '/src/branch/' .. branch)
end
function M:browse_commit(sha)
local base = forge.remote_web_url()
vim.ui.open(base .. '/commit/' .. sha)
end
function M:checkout_cmd(num)
return { 'tea', 'pr', 'checkout', num }
end
---@param loc string
function M:yank_branch(loc)
local branch = vim.trim(vim.fn.system('git branch --show-current'))
local base = forge.remote_web_url()
local file, lines = loc:match('^(.+):(.+)$')
vim.fn.setreg(
'+',
('%s/src/branch/%s/%s#L%s'):format(base, branch, file, lines)
)
end
---@param loc string
function M:yank_commit(loc)
local commit = vim.trim(vim.fn.system('git rev-parse HEAD'))
local base = forge.remote_web_url()
local file, lines = loc:match('^(.+):(.+)$')
vim.fn.setreg(
'+',
('%s/src/commit/%s/%s#L%s'):format(base, commit, file, lines)
)
end
---@param num string
---@return string[]
function M:fetch_pr(num)
return { 'git', 'fetch', 'origin', ('pull/%s/head:pr-%s'):format(num, num) }
end
---@param num string
---@return string[]
function M:pr_base_cmd(num)
return { 'tea', 'pr', num, '--fields', 'base', '--output', 'simple' }
end
---@param branch string
---@return string[]
function M:pr_for_branch_cmd(_branch)
return {
'tea',
'pr',
'list',
'--fields',
'index,head',
'--output',
'simple',
'--state',
'open',
}
end
---@param num string
---@return string
function M:checks_cmd(num)
local _ = num
return 'tea actions runs list'
end
---@param run_id string
---@param failed_only boolean
---@return string[]
function M:check_log_cmd(run_id, failed_only)
local _ = failed_only
local lines = forge.config().ci.lines
return {
'sh',
'-c',
('tea actions runs logs %s | tail -n %d'):format(run_id, lines),
}
end
---@param run_id string
---@return string[]
function M:check_tail_cmd(run_id)
return { 'tea', 'actions', 'runs', 'logs', run_id, '--follow' }
end
function M:list_runs_cmd(_branch)
return 'tea actions runs list'
end
function M:run_log_cmd(id, failed_only)
local _ = failed_only
local lines = forge.config().ci.lines
return {
'sh',
'-c',
('tea actions runs logs %s | tail -n %d'):format(id, lines),
}
end
function M:run_tail_cmd(id)
return { 'tea', 'actions', 'runs', 'logs', id, '--follow' }
end
---@param num string
---@param method string
---@return string[]
function M:merge_cmd(num, method)
return { 'tea', 'pr', 'merge', num, '--style', method }
end
---@param num string
---@return string[]
function M:approve_cmd(num)
return { 'tea', 'pr', 'approve', num }
end
---@param num string
---@return string[]
function M:close_cmd(num)
return { 'tea', 'pulls', 'close', num }
end
---@param num string
---@return string[]
function M:reopen_cmd(num)
return { 'tea', 'pulls', 'reopen', num }
end
---@param num string
---@return string[]
function M:close_issue_cmd(num)
return { 'tea', 'issues', 'close', num }
end
---@param num string
---@return string[]
function M:reopen_issue_cmd(num)
return { 'tea', 'issues', 'reopen', num }
end
---@param title string
---@param body string
---@param base string
---@param _draft boolean
---@param _reviewers string[]?
---@return string[]
function M:create_pr_cmd(title, body, base, _draft, _reviewers)
return { 'tea', 'pr', 'create', '--title', title, '--description', body, '--base', base }
end
---@return string[]?
function M:create_pr_web_cmd()
local branch = vim.trim(vim.fn.system('git branch --show-current'))
local base_url = forge.remote_web_url()
local default = vim.trim(vim.fn.system(
"git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||'"
))
if default == '' then
default = 'main'
end
vim.ui.open(('%s/compare/%s...%s'):format(base_url, default, branch))
return nil
end
---@return string[]
function M:default_branch_cmd()
return {
'sh', '-c',
"git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||'",
}
end
---@return string[]
function M:template_paths()
return {
'.gitea/pull_request_template.md',
'.github/pull_request_template.md',
'.github/PULL_REQUEST_TEMPLATE.md',
}
end
---@param _num string
---@param _is_draft boolean
---@return string[]?
function M:draft_toggle_cmd(_num, _is_draft)
return nil
end
---@return forge.RepoInfo
function M:repo_info()
return {
permission = 'ADMIN',
merge_methods = { 'squash', 'rebase', 'merge' },
}
end
---@param num string
---@return forge.PRState
function M:pr_state(num)
local result = vim.system(
{ 'tea', 'pr', num, '--fields', 'state,mergeable', '--output', 'json' },
{ text = true }
):wait()
local ok, data = pcall(vim.json.decode, result.stdout or '{}')
if not ok or type(data) ~= 'table' then
data = {}
end
return {
state = (data.state or 'unknown'):upper(),
mergeable = data.mergeable and 'MERGEABLE' or 'UNKNOWN',
review_decision = '',
is_draft = false,
}
end
return M

387
lua/forge/github.lua Normal file
View file

@ -0,0 +1,387 @@
local forge = require('forge')
---@type forge.Forge
local M = {
name = 'github',
cli = 'gh',
kinds = { issue = 'issue', pr = 'pr' },
labels = {
issue = 'Issues',
pr = 'PRs',
pr_one = 'PR',
pr_full = 'Pull Requests',
ci = 'CI/CD',
},
}
local function nwo()
local url = forge.remote_web_url()
return url:match('github%.com/(.+)$') or ''
end
---@param kind string
---@param state string
---@return string
function M:list_cmd(kind, state)
return ('gh %s list --limit 100 --state %s'):format(kind, state)
end
---@param state string
---@return string[]
function M:list_pr_json_cmd(state)
return {
'gh',
'pr',
'list',
'--limit',
'100',
'--state',
state,
'--json',
'number,title,headRefName,state,author,createdAt',
}
end
---@param state string
---@return string[]
function M:list_issue_json_cmd(state)
return {
'gh',
'issue',
'list',
'--limit',
'100',
'--state',
state,
'--json',
'number,title,state,author,createdAt',
}
end
function M:pr_json_fields()
return {
number = 'number',
title = 'title',
branch = 'headRefName',
state = 'state',
author = 'author',
created_at = 'createdAt',
}
end
function M:issue_json_fields()
return {
number = 'number',
title = 'title',
state = 'state',
author = 'author',
created_at = 'createdAt',
}
end
---@param kind string
---@param num string
function M:view_web(kind, num)
vim.system({ 'gh', kind, 'view', num, '--web' })
end
---@param loc string
---@param branch string
function M:browse(loc, branch)
vim.system({ 'gh', 'browse', loc, '--branch', branch })
end
function M:browse_root()
vim.system({ 'gh', 'browse' })
end
function M:browse_branch(branch)
vim.system({ 'gh', 'browse', '--branch', branch })
end
function M:browse_commit(sha)
vim.system({ 'gh', 'browse', sha })
end
function M:checkout_cmd(num)
return { 'gh', 'pr', 'checkout', num }
end
---@param loc string
function M:yank_branch(loc)
forge.yank_url({ 'gh', 'browse', loc, '-n' })
end
---@param loc string
function M:yank_commit(loc)
forge.yank_url({ 'gh', 'browse', loc, '--commit=last', '-n' })
end
---@param num string
---@return string[]
function M:fetch_pr(num)
return { 'git', 'fetch', 'origin', ('pull/%s/head:pr-%s'):format(num, num) }
end
---@param num string
---@return string[]
function M:pr_base_cmd(num)
return {
'gh',
'pr',
'view',
num,
'--json',
'baseRefName',
'--jq',
'.baseRefName',
}
end
---@param branch string
---@return string[]
function M:pr_for_branch_cmd(branch)
return {
'gh',
'pr',
'list',
'--head',
branch,
'--json',
'number',
'--jq',
'.[0].number',
}
end
---@param num string
---@return string
function M:checks_cmd(num)
return ('gh pr checks %s'):format(num)
end
---@param num string
---@return string[]
function M:checks_json_cmd(num)
return {
'gh',
'pr',
'checks',
num,
'--json',
'name,bucket,link,state,startedAt,completedAt',
}
end
---@param run_id string
---@param failed_only boolean
---@return string[]
function M:check_log_cmd(run_id, failed_only)
local lines = forge.config().ci.lines
local flag = failed_only and '--log-failed' or '--log'
return {
'sh',
'-c',
('gh run view %s -R %s %s | tail -n %d'):format(
run_id,
nwo(),
flag,
lines
),
}
end
---@param run_id string
---@return string[]
function M:check_tail_cmd(run_id)
return { 'gh', 'run', 'watch', run_id, '-R', nwo() }
end
function M:list_runs_json_cmd(branch)
local cmd = {
'gh',
'run',
'list',
'--json',
'databaseId,name,headBranch,status,conclusion,event,url,createdAt',
'--limit',
'30',
}
if branch then
table.insert(cmd, '--branch')
table.insert(cmd, branch)
end
return cmd
end
function M:normalize_run(entry)
local status = entry.status or ''
if status == 'completed' then
status = entry.conclusion or 'unknown'
end
return {
id = tostring(entry.databaseId or ''),
name = entry.name or '',
branch = entry.headBranch or '',
status = status,
event = entry.event or '',
url = entry.url or '',
created_at = entry.createdAt or '',
}
end
function M:run_log_cmd(id, failed_only)
local lines = forge.config().ci.lines
local flag = failed_only and '--log-failed' or '--log'
return {
'sh',
'-c',
('gh run view %s -R %s %s | tail -n %d'):format(id, nwo(), flag, lines),
}
end
function M:run_tail_cmd(id)
return { 'gh', 'run', 'watch', id, '-R', nwo() }
end
---@param num string
---@param method string
---@return string[]
function M:merge_cmd(num, method)
return { 'gh', 'pr', 'merge', num, '--' .. method }
end
---@param num string
---@return string[]
function M:approve_cmd(num)
return { 'gh', 'pr', 'review', num, '--approve' }
end
---@param num string
---@return string[]
function M:close_cmd(num)
return { 'gh', 'pr', 'close', num }
end
---@param num string
---@return string[]
function M:reopen_cmd(num)
return { 'gh', 'pr', 'reopen', num }
end
---@param num string
---@return string[]
function M:close_issue_cmd(num)
return { 'gh', 'issue', 'close', num }
end
---@param num string
---@return string[]
function M:reopen_issue_cmd(num)
return { 'gh', 'issue', 'reopen', num }
end
---@param title string
---@param body string
---@param base string
---@param draft boolean
---@param reviewers string[]?
---@return string[]
function M:create_pr_cmd(title, body, base, draft, reviewers)
local cmd = { 'gh', 'pr', 'create', '--title', title, '--body', body, '--base', base }
if draft then
table.insert(cmd, '--draft')
end
for _, r in ipairs(reviewers or {}) do
table.insert(cmd, '--reviewer')
table.insert(cmd, r)
end
return cmd
end
---@return string[]
function M:create_pr_web_cmd()
return { 'gh', 'pr', 'create', '--web' }
end
---@return string[]
function M:default_branch_cmd()
return { 'gh', 'repo', 'view', '--json', 'defaultBranchRef', '--jq', '.defaultBranchRef.name' }
end
---@return string[]
function M:template_paths()
return {
'.github/pull_request_template.md',
'.github/PULL_REQUEST_TEMPLATE.md',
'.github/PULL_REQUEST_TEMPLATE/',
}
end
---@param num string
---@param is_draft boolean
---@return string[]?
function M:draft_toggle_cmd(num, is_draft)
if is_draft then
return { 'gh', 'pr', 'ready', num }
end
return { 'gh', 'pr', 'ready', num, '--undo' }
end
---@return forge.RepoInfo
function M:repo_info()
local result = vim.system({
'gh',
'repo',
'view',
nwo(),
'--json',
'viewerPermission,squashMergeAllowed,rebaseMergeAllowed,mergeCommitAllowed',
}, { text = true }):wait()
local ok, data = pcall(vim.json.decode, result.stdout or '{}')
if not ok or type(data) ~= 'table' then
data = {}
end
local methods = {}
if data.squashMergeAllowed then
table.insert(methods, 'squash')
end
if data.rebaseMergeAllowed then
table.insert(methods, 'rebase')
end
if data.mergeCommitAllowed then
table.insert(methods, 'merge')
end
return {
permission = (data.viewerPermission or 'READ'):upper(),
merge_methods = methods,
}
end
---@param num string
---@return forge.PRState
function M:pr_state(num)
local result = vim.system({
'gh',
'pr',
'view',
num,
'--json',
'state,mergeable,reviewDecision,isDraft',
}, { text = true }):wait()
local ok, data = pcall(vim.json.decode, result.stdout or '{}')
if not ok or type(data) ~= 'table' then
data = {}
end
return {
state = data.state or 'UNKNOWN',
mergeable = data.mergeable or 'UNKNOWN',
review_decision = data.reviewDecision or '',
is_draft = data.isDraft == true,
}
end
return M

421
lua/forge/gitlab.lua Normal file
View file

@ -0,0 +1,421 @@
local forge = require('forge')
---@type forge.Forge
local M = {
name = 'gitlab',
cli = 'glab',
kinds = { issue = 'issue', pr = 'mr' },
labels = {
issue = 'Issues',
pr = 'MRs',
pr_one = 'MR',
pr_full = 'Merge Requests',
ci = 'CI/CD',
},
}
---@param kind string
---@param state string
---@return string
function M:list_cmd(kind, state)
local cmd = ('glab %s list --per-page 100'):format(kind)
if state == 'closed' then
cmd = cmd .. ' --closed'
elseif state == 'all' then
cmd = cmd .. ' --all'
end
return cmd
end
---@param state string
---@return string[]
function M:list_pr_json_cmd(state)
local cmd = {
'glab',
'mr',
'list',
'--per-page',
'100',
'--output',
'json',
}
if state == 'closed' then
table.insert(cmd, '--closed')
elseif state == 'all' then
table.insert(cmd, '--all')
end
return cmd
end
---@param state string
---@return string[]
function M:list_issue_json_cmd(state)
local cmd = {
'glab',
'issue',
'list',
'--per-page',
'100',
'--output',
'json',
}
if state == 'closed' then
table.insert(cmd, '--closed')
elseif state == 'all' then
table.insert(cmd, '--all')
end
return cmd
end
function M:pr_json_fields()
return {
number = 'iid',
title = 'title',
branch = 'source_branch',
state = 'state',
author = 'author',
created_at = 'created_at',
}
end
function M:issue_json_fields()
return {
number = 'iid',
title = 'title',
state = 'state',
author = 'author',
created_at = 'created_at',
}
end
---@param kind string
---@param num string
function M:view_web(kind, num)
vim.system({ 'glab', kind, 'view', num, '--web' })
end
---@param loc string
---@param branch string
function M:browse(loc, branch)
local base = forge.remote_web_url()
local file, lines = loc:match('^(.+):(.+)$')
vim.ui.open(('%s/-/blob/%s/%s#L%s'):format(base, branch, file, lines))
end
function M:browse_root()
vim.system({ 'glab', 'repo', 'view', '--web' })
end
function M:browse_branch(branch)
local base = forge.remote_web_url()
vim.ui.open(base .. '/-/tree/' .. branch)
end
function M:browse_commit(sha)
local base = forge.remote_web_url()
vim.ui.open(base .. '/-/commit/' .. sha)
end
function M:checkout_cmd(num)
return { 'glab', 'mr', 'checkout', num }
end
---@param loc string
function M:yank_branch(loc)
local branch = vim.trim(vim.fn.system('git branch --show-current'))
local base = forge.remote_web_url()
local file, lines = loc:match('^(.+):(.+)$')
vim.fn.setreg(
'+',
('%s/-/blob/%s/%s#L%s'):format(base, branch, file, lines)
)
end
---@param loc string
function M:yank_commit(loc)
local commit = vim.trim(vim.fn.system('git rev-parse HEAD'))
local base = forge.remote_web_url()
local file, lines = loc:match('^(.+):(.+)$')
vim.fn.setreg(
'+',
('%s/-/blob/%s/%s#L%s'):format(base, commit, file, lines)
)
end
---@param num string
---@return string[]
function M:fetch_pr(num)
return {
'git',
'fetch',
'origin',
('merge-requests/%s/head:mr-%s'):format(num, num),
}
end
---@param num string
---@return string[]
function M:pr_base_cmd(num)
return {
'sh',
'-c',
('glab mr view %s -F json | jq -r .target_branch'):format(num),
}
end
---@param branch string
---@return string[]
function M:pr_for_branch_cmd(branch)
return {
'sh',
'-c',
("glab mr list --source-branch '%s' -F json | jq -r '.[0].iid // empty'"):format(
branch
),
}
end
---@param num string
---@return string
function M:checks_cmd(num)
local _ = num
return 'glab ci list'
end
---@param run_id string
---@param failed_only boolean
---@return string[]
function M:check_log_cmd(run_id, failed_only)
local _ = failed_only
local lines = forge.config().ci.lines
return {
'sh',
'-c',
('glab ci trace %s | tail -n %d'):format(run_id, lines),
}
end
---@param run_id string
---@return string[]
function M:check_tail_cmd(run_id)
return { 'glab', 'ci', 'trace', run_id }
end
function M:list_runs_json_cmd(branch)
local cmd = {
'glab',
'ci',
'list',
'--output',
'json',
'--per-page',
'30',
}
if branch then
table.insert(cmd, '--ref')
table.insert(cmd, branch)
end
return cmd
end
function M:normalize_run(entry)
local ref = entry.ref or ''
local mr_num = ref:match('^refs/merge%-requests/(%d+)/head$')
return {
id = tostring(entry.id or ''),
name = mr_num and ('!%s'):format(mr_num) or ref,
branch = '',
status = entry.status or '',
event = entry.source or '',
url = entry.web_url or '',
created_at = entry.created_at or '',
}
end
function M:run_log_cmd(id, failed_only)
local lines = forge.config().ci.lines
local jq_filter = failed_only
and '[.[] | select(.status=="failed")][0].id // .[0].id'
or '.[0].id'
return {
'sh',
'-c',
('JOB=$(glab api \'projects/:id/pipelines/%s/jobs?per_page=100\' | jq -r \'%s\') && [ "$JOB" != "null" ] && glab ci trace "$JOB" | tail -n %d'):format(
id,
jq_filter,
lines
),
}
end
function M:run_tail_cmd(id)
local jq_filter =
'[.[] | select(.status=="running" or .status=="pending")][0].id // .[0].id'
return {
'sh',
'-c',
('JOB=$(glab api \'projects/:id/pipelines/%s/jobs?per_page=100\' | jq -r \'%s\') && [ "$JOB" != "null" ] && glab ci trace "$JOB"'):format(
id,
jq_filter
),
}
end
---@param num string
---@param method string
---@return string[]
function M:merge_cmd(num, method)
local cmd = { 'glab', 'mr', 'merge', num }
if method == 'squash' then
table.insert(cmd, '--squash')
elseif method == 'rebase' then
table.insert(cmd, '--rebase')
end
return cmd
end
---@param num string
---@return string[]
function M:approve_cmd(num)
return { 'glab', 'mr', 'approve', num }
end
---@param num string
---@return string[]
function M:close_cmd(num)
return { 'glab', 'mr', 'close', num }
end
---@param num string
---@return string[]
function M:reopen_cmd(num)
return { 'glab', 'mr', 'reopen', num }
end
---@param num string
---@return string[]
function M:close_issue_cmd(num)
return { 'glab', 'issue', 'close', num }
end
---@param num string
---@return string[]
function M:reopen_issue_cmd(num)
return { 'glab', 'issue', 'reopen', num }
end
---@param title string
---@param body string
---@param base string
---@param draft boolean
---@param reviewers string[]?
---@return string[]
function M:create_pr_cmd(title, body, base, draft, reviewers)
local cmd = {
'glab', 'mr', 'create',
'--title', title,
'--description', body,
'--target-branch', base,
'--yes',
}
if draft then
table.insert(cmd, '--draft')
end
for _, r in ipairs(reviewers or {}) do
table.insert(cmd, '--reviewer')
table.insert(cmd, r)
end
return cmd
end
---@return string[]
function M:create_pr_web_cmd()
return { 'glab', 'mr', 'create', '--web' }
end
---@return string[]
function M:default_branch_cmd()
return {
'sh', '-c',
"glab repo view -F json | jq -r '.default_branch'",
}
end
---@return string[]
function M:template_paths()
return { '.gitlab/merge_request_templates/' }
end
---@param num string
---@param is_draft boolean
---@return string[]?
function M:draft_toggle_cmd(num, is_draft)
if is_draft then
return { 'glab', 'mr', 'update', num, '--ready' }
end
return { 'glab', 'mr', 'update', num, '--draft' }
end
---@return forge.RepoInfo
function M:repo_info()
local result = vim.system(
{ 'glab', 'api', 'projects/:id' },
{ text = true }
)
:wait()
local ok, data = pcall(vim.json.decode, result.stdout or '{}')
if not ok or type(data) ~= 'table' then
data = {}
end
local perms = type(data.permissions) == 'table' and data.permissions or {}
local pa = type(perms.project_access) == 'table' and perms.project_access
or {}
local ga = type(perms.group_access) == 'table' and perms.group_access or {}
local access = pa.access_level or 0
local group_access = ga.access_level or 0
local level = math.max(access, group_access)
local permission = 'READ'
if level >= 40 then
permission = 'ADMIN'
elseif level >= 30 then
permission = 'WRITE'
end
local methods = {}
local merge_method = data.merge_method or 'merge'
if merge_method == 'ff' or merge_method == 'rebase_merge' then
table.insert(methods, 'rebase')
else
table.insert(methods, 'merge')
end
if data.squash_option ~= 'never' then
table.insert(methods, 'squash')
end
return {
permission = permission,
merge_methods = methods,
}
end
---@param num string
---@return forge.PRState
function M:pr_state(num)
local result = vim.system(
{ 'glab', 'mr', 'view', num, '--output', 'json' },
{ text = true }
):wait()
local ok, data = pcall(vim.json.decode, result.stdout or '{}')
if not ok or type(data) ~= 'table' then
data = {}
end
return {
state = (data.state or 'unknown'):upper(),
mergeable = data.merge_status or 'unknown',
review_decision = '',
is_draft = data.draft == true,
}
end
return M

47
lua/forge/health.lua Normal file
View file

@ -0,0 +1,47 @@
local M = {}
function M.check()
vim.health.start('forge.nvim')
if vim.fn.executable('git') == 1 then
vim.health.ok('git found')
else
vim.health.error('git not found')
end
local clis = {
{ 'gh', 'GitHub' },
{ 'glab', 'GitLab' },
{ 'tea', 'Codeberg/Gitea/Forgejo' },
}
for _, cli in ipairs(clis) do
if vim.fn.executable(cli[1]) == 1 then
vim.health.ok(cli[1] .. ' found (' .. cli[2] .. ')')
else
vim.health.info(cli[1] .. ' not found (' .. cli[2] .. ' support disabled)')
end
end
local has_fzf = pcall(require, 'fzf-lua')
if has_fzf then
vim.health.ok('fzf-lua found')
else
vim.health.error('fzf-lua not found (required)')
end
local has_diffs = pcall(require, 'diffs')
if has_diffs then
vim.health.ok('diffs.nvim found (review mode available)')
else
vim.health.info('diffs.nvim not found (review mode disabled)')
end
local has_fugitive = vim.fn.exists(':Git') == 2
if has_fugitive then
vim.health.ok('vim-fugitive found (fugitive keymaps available)')
else
vim.health.info('vim-fugitive not found (fugitive keymaps disabled)')
end
end
return M

1064
lua/forge/init.lua Normal file

File diff suppressed because it is too large Load diff

1144
plugin/forge.lua Normal file

File diff suppressed because it is too large Load diff

4
selene.toml Normal file
View file

@ -0,0 +1,4 @@
std = 'vim'
[lints]
bad_string_escape = 'allow'

8
stylua.toml Normal file
View file

@ -0,0 +1,8 @@
column_width = 100
line_endings = "Unix"
indent_type = "Spaces"
indent_width = 2
quote_style = "AutoPreferSingle"
call_parentheses = "Always"
[sort_requires]
enabled = true

26
vim.yaml Normal file
View file

@ -0,0 +1,26 @@
---
base: lua51
name: vim
lua_versions:
- luajit
globals:
vim:
any: true
jit:
any: true
assert:
any: true
describe:
any: true
it:
any: true
before_each:
any: true
after_each:
any: true
spy:
any: true
stub:
any: true
bit:
any: true