From c4da2cda2ae97232b294bab500f09a1f60f9ac8b Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 27 Mar 2026 16:46:28 -0400 Subject: [PATCH] feat: initial commit --- .editorconfig | 9 + .github/DISCUSSION_TEMPLATE/q-a.yaml | 17 + .github/ISSUE_TEMPLATE/bug_report.yaml | 60 + .github/ISSUE_TEMPLATE/config.yaml | 5 + .github/ISSUE_TEMPLATE/feature_request.yaml | 30 + .github/workflows/luarocks.yaml | 21 + .github/workflows/quality.yaml | 87 ++ .gitignore | 13 + .luarc.json | 15 + .pre-commit-config.yaml | 17 + .prettierignore | 1 + .prettierrc | 9 + LICENSE | 21 + forge.nvim-scm-1.rockspec | 21 + lua/forge/codeberg.lua | 316 +++++ lua/forge/github.lua | 387 +++++++ lua/forge/gitlab.lua | 421 +++++++ lua/forge/health.lua | 47 + lua/forge/init.lua | 1064 +++++++++++++++++ plugin/forge.lua | 1144 +++++++++++++++++++ selene.toml | 4 + stylua.toml | 8 + vim.yaml | 26 + 23 files changed, 3743 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/DISCUSSION_TEMPLATE/q-a.yaml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yaml create mode 100644 .github/ISSUE_TEMPLATE/config.yaml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yaml create mode 100644 .github/workflows/luarocks.yaml create mode 100644 .github/workflows/quality.yaml create mode 100644 .gitignore create mode 100644 .luarc.json create mode 100644 .pre-commit-config.yaml create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 LICENSE create mode 100644 forge.nvim-scm-1.rockspec create mode 100644 lua/forge/codeberg.lua create mode 100644 lua/forge/github.lua create mode 100644 lua/forge/gitlab.lua create mode 100644 lua/forge/health.lua create mode 100644 lua/forge/init.lua create mode 100644 plugin/forge.lua create mode 100644 selene.toml create mode 100644 stylua.toml create mode 100644 vim.yaml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b9de190 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +insert_final_newline = true +charset = utf-8 + +[*.lua] +indent_style = space +indent_size = 2 diff --git a/.github/DISCUSSION_TEMPLATE/q-a.yaml b/.github/DISCUSSION_TEMPLATE/q-a.yaml new file mode 100644 index 0000000..c10d29f --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/q-a.yaml @@ -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) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 0000000..9c2430b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/config.yaml b/.github/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..6da41a4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yaml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml new file mode 100644 index 0000000..279c7e5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -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 diff --git a/.github/workflows/luarocks.yaml b/.github/workflows/luarocks.yaml new file mode 100644 index 0000000..9b6664e --- /dev/null +++ b/.github/workflows/luarocks.yaml @@ -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 }} diff --git a/.github/workflows/quality.yaml b/.github/workflows/quality.yaml new file mode 100644 index 0000000..303519f --- /dev/null +++ b/.github/workflows/quality.yaml @@ -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 . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..93ac2c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +doc/tags +*.log + +.*cache* +CLAUDE.md +.claude/ + +node_modules/ + +result +result-* +.direnv/ +.envrc diff --git a/.luarc.json b/.luarc.json new file mode 100644 index 0000000..bfbf500 --- /dev/null +++ b/.luarc.json @@ -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" +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..5d1f13f --- /dev/null +++ b/.pre-commit-config.yaml @@ -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)$ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +node_modules/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..0663621 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "proseWrap": "always", + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "trailingComma": "none", + "semi": false, + "singleQuote": true +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3aba69a --- /dev/null +++ b/LICENSE @@ -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. diff --git a/forge.nvim-scm-1.rockspec b/forge.nvim-scm-1.rockspec new file mode 100644 index 0000000..09f9368 --- /dev/null +++ b/forge.nvim-scm-1.rockspec @@ -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', +} diff --git a/lua/forge/codeberg.lua b/lua/forge/codeberg.lua new file mode 100644 index 0000000..f47f7b5 --- /dev/null +++ b/lua/forge/codeberg.lua @@ -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 diff --git a/lua/forge/github.lua b/lua/forge/github.lua new file mode 100644 index 0000000..97721f8 --- /dev/null +++ b/lua/forge/github.lua @@ -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 diff --git a/lua/forge/gitlab.lua b/lua/forge/gitlab.lua new file mode 100644 index 0000000..58a445e --- /dev/null +++ b/lua/forge/gitlab.lua @@ -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 diff --git a/lua/forge/health.lua b/lua/forge/health.lua new file mode 100644 index 0000000..18ede08 --- /dev/null +++ b/lua/forge/health.lua @@ -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 diff --git a/lua/forge/init.lua b/lua/forge/init.lua new file mode 100644 index 0000000..4b24823 --- /dev/null +++ b/lua/forge/init.lua @@ -0,0 +1,1064 @@ +local M = {} + +local hl_defaults = { + ForgeComposeComment = 'Comment', + ForgeComposeBranch = 'Normal', + ForgeComposeForge = 'Type', + ForgeComposeDraft = 'DiagnosticWarn', + ForgeComposeFile = 'Normal', + ForgeComposeAdded = 'Added', + ForgeComposeRemoved = 'Removed', +} + +for group, link in pairs(hl_defaults) do + vim.api.nvim_set_hl(0, group, { default = true, link = link }) +end + +---@type table> +local compose_marks = {} +local compose_ns = vim.api.nvim_create_namespace('forge_pr_compose') + +vim.api.nvim_set_decoration_provider(compose_ns, { + on_win = function(_, _, bufnr) + return compose_marks[bufnr] ~= nil + end, + on_line = function(_, _, bufnr, row) + local by_line = compose_marks[bufnr] + if not by_line then + return + end + local row_marks = by_line[row] + if not row_marks then + return + end + for _, m in ipairs(row_marks) do + vim.api.nvim_buf_set_extmark(bufnr, compose_ns, row, m.col, { + end_col = m.end_col, + hl_group = m.hl, + ephemeral = true, + priority = 4097, + }) + end + end, +}) + +---@param msg string +---@param level integer? +function M.log(msg, level) + vim.schedule(function() + vim.notify('[forge.nvim]: ' .. msg, level or vim.log.levels.INFO) + vim.cmd.redraw() + end) +end + +---@param msg string +---@param level integer? +function M.log_now(msg, level) + vim.notify('[forge.nvim]: ' .. msg, level or vim.log.levels.INFO) + vim.cmd.redraw() +end + +---@class forge.PRState +---@field state string +---@field mergeable string +---@field review_decision string +---@field is_draft boolean + +---@class forge.Check +---@field name string +---@field status string +---@field elapsed string +---@field run_id string + +---@class forge.CIRun +---@field id string +---@field name string +---@field branch string +---@field status string +---@field event string +---@field url string +---@field created_at string + +---@class forge.RepoInfo +---@field permission string +---@field merge_methods string[] + +---@class forge.Forge +---@field name string +---@field cli string +---@field kinds { issue: string, pr: string } +---@field labels { issue: string, pr: string, pr_one: string, pr_full: string, ci: string } +---@field list_cmd fun(self: forge.Forge, kind: string, state: string): string +---@field list_pr_json_cmd fun(self: forge.Forge, state: string): string[] +---@field list_issue_json_cmd fun(self: forge.Forge, state: string): string[] +---@field pr_json_fields fun(self: forge.Forge): { number: string, title: string, branch: string, state: string, author: string, created_at: string } +---@field issue_json_fields fun(self: forge.Forge): { number: string, title: string, state: string, author: string, created_at: string } +---@field view_web fun(self: forge.Forge, kind: string, num: string) +---@field browse fun(self: forge.Forge, loc: string, branch: string) +---@field browse_root fun(self: forge.Forge) +---@field browse_branch fun(self: forge.Forge, branch: string) +---@field browse_commit fun(self: forge.Forge, sha: string) +---@field checkout_cmd fun(self: forge.Forge, num: string): string[] +---@field yank_branch fun(self: forge.Forge, loc: string) +---@field yank_commit fun(self: forge.Forge, loc: string) +---@field fetch_pr fun(self: forge.Forge, num: string): string[] +---@field pr_base_cmd fun(self: forge.Forge, num: string): string[] +---@field pr_for_branch_cmd fun(self: forge.Forge, branch: string): string[] +---@field checks_cmd fun(self: forge.Forge, num: string): string +---@field check_log_cmd fun(self: forge.Forge, run_id: string, failed_only: boolean): string[] +---@field check_tail_cmd fun(self: forge.Forge, run_id: string): string[] +---@field list_runs_json_cmd fun(self: forge.Forge, branch: string?): string[] +---@field list_runs_cmd fun(self: forge.Forge, branch: string?): string +---@field normalize_run fun(self: forge.Forge, entry: table): forge.CIRun +---@field run_log_cmd fun(self: forge.Forge, id: string, failed_only: boolean): string[] +---@field run_tail_cmd fun(self: forge.Forge, id: string): string[] +---@field merge_cmd fun(self: forge.Forge, num: string, method: string): string[] +---@field approve_cmd fun(self: forge.Forge, num: string): string[] +---@field repo_info fun(self: forge.Forge): forge.RepoInfo +---@field pr_state fun(self: forge.Forge, num: string): forge.PRState +---@field close_cmd fun(self: forge.Forge, num: string): string[] +---@field reopen_cmd fun(self: forge.Forge, num: string): string[] +---@field close_issue_cmd fun(self: forge.Forge, num: string): string[] +---@field reopen_issue_cmd fun(self: forge.Forge, num: string): string[] +---@field draft_toggle_cmd fun(self: forge.Forge, num: string, is_draft: boolean): string[]? +---@field create_pr_cmd fun(self: forge.Forge, title: string, body: string, base: string, draft: boolean, reviewers: string[]?): string[] +---@field create_pr_web_cmd fun(self: forge.Forge): string[]? +---@field default_branch_cmd fun(self: forge.Forge): string[] +---@field template_paths fun(self: forge.Forge): string[] + +---@type table +local forge_cache = {} + +---@type table +local repo_info_cache = {} + +---@type table +local root_cache = {} + +---@type table +local list_cache = {} + +---@return string? +local function git_root() + local cwd = vim.fn.getcwd() + if root_cache[cwd] then + return root_cache[cwd] + end + local root = vim.trim(vim.fn.system('git rev-parse --show-toplevel')) + if vim.v.shell_error ~= 0 then + return nil + end + root_cache[cwd] = root + return root +end + +---@param remote string +---@return string? forge_name +local function detect_from_remote(remote) + if remote:find('github') and vim.fn.executable('gh') == 1 then + return 'github' + end + if remote:find('gitlab') and vim.fn.executable('glab') == 1 then + return 'gitlab' + end + if + ( + remote:find('codeberg') + or remote:find('gitea') + or remote:find('forgejo') + ) and vim.fn.executable('tea') == 1 + then + return 'codeberg' + end + return nil +end + +---@return forge.Forge? +function M.detect() + local root = git_root() + if not root then + return nil + end + if forge_cache[root] then + M.log('forge cache hit (' .. forge_cache[root].name .. ')') + return forge_cache[root] + end + local remote = vim.trim(vim.fn.system('git remote get-url origin')) + if vim.v.shell_error ~= 0 then + return nil + end + local name = detect_from_remote(remote) + if not name then + return nil + end + local f = require('forge.' .. name) + forge_cache[root] = f + M.log('detected ' .. name .. ' via origin remote') + return f +end + +---@param f forge.Forge +---@return forge.RepoInfo +function M.repo_info(f) + local root = git_root() + if root and repo_info_cache[root] then + M.log('repo_info cache hit') + return repo_info_cache[root] + end + M.log('fetching repo info...') + local info = f:repo_info() + if root then + repo_info_cache[root] = info + end + M.log('repo_info fetched (permission: ' .. info.permission .. ')') + return info +end + +---@param kind string +---@param state string +---@return string +function M.list_key(kind, state) + local root = git_root() or '' + return root .. ':' .. kind .. ':' .. state +end + +---@param key string +---@return table[]? +function M.get_list(key) + if list_cache[key] then + M.log('list cache hit (' .. key .. ')') + end + return list_cache[key] +end + +---@param key string +---@param data table[] +function M.set_list(key, data) + list_cache[key] = data + M.log('list cache set (' .. key .. ', ' .. #data .. ' items)') +end + +---@param key string? +function M.clear_list(key) + if key then + list_cache[key] = nil + M.log('list cache cleared (' .. key .. ')') + else + list_cache = {} + M.log('list cache cleared (all)') + end +end + +function M.clear_cache() + forge_cache = {} + repo_info_cache = {} + root_cache = {} + list_cache = {} + M.log('all caches cleared') +end + +---@return string +function M.file_loc() + local root = git_root() + if not root then + return vim.fn.expand('%:t') + end + local file = vim.api.nvim_buf_get_name(0):sub(#root + 2) + local mode = vim.fn.mode() + if mode:match('[vV]') or mode == '\22' then + local s = vim.fn.line('v') + local e = vim.fn.line('.') + if s > e then + s, e = e, s + end + if s == e then + return ('%s:%d'):format(file, s) + end + return ('%s:%d-%d'):format(file, s, e) + end + return ('%s:%d'):format(file, vim.fn.line('.')) +end + +---@return string +function M.remote_web_url() + local root = git_root() + if not root then + return '' + end + local remote = vim.trim(vim.fn.system('git remote get-url origin')) + remote = remote:gsub('%.git$', '') + remote = remote:gsub('^ssh://git@', 'https://') + remote = remote:gsub('^git@([^:]+):', 'https://%1/') + return remote +end + +---@param s string +---@param width integer +---@return string +local function pad_or_truncate(s, width) + local len = #s + if len > width then + return s:sub(1, width - 1) .. '…' + end + return s .. string.rep(' ', width - len) +end + +---@param iso string? +---@return integer? +local function parse_iso(iso) + if not iso or iso == '' then + return nil + end + local y, mo, d, h, mi, s = iso:match('(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)') + if not y then + return nil + end + local ok, ts = pcall(os.time, { + year = tonumber(y), + month = tonumber(mo), + day = tonumber(d), + hour = tonumber(h), + min = tonumber(mi), + sec = tonumber(s), + }) + if ok and ts then + return ts + end + return nil +end + +---@param iso string? +---@return string +local function relative_time(iso) + local ts = parse_iso(iso) + if not ts then + return '' + end + local diff = os.time() - ts + if diff < 0 then + diff = 0 + end + if diff < 3600 then + return ('%dm'):format(math.max(1, math.floor(diff / 60))) + end + if diff < 86400 then + return ('%dh'):format(math.floor(diff / 3600)) + end + if diff < 2592000 then + return ('%dd'):format(math.floor(diff / 86400)) + end + if diff < 31536000 then + return ('%dmo'):format(math.floor(diff / 2592000)) + end + return ('%dy'):format(math.floor(diff / 31536000)) +end + +---@param iso string? +---@return string +local function compact_date(iso) + local ts = parse_iso(iso) + if not ts then + return '' + end + local current_year = os.date('%Y') + local entry_year = os.date('%Y', ts) + if entry_year == current_year then + return os.date('%d/%m %H:%M', ts) + end + return os.date('%d/%m/%y %H:%M', ts) +end + +local event_map = { + merge_request_event = 'mr', + external_pull_request_event = 'ext', + pull_request = 'pr', + workflow_dispatch = 'manual', + schedule = 'cron', + pipeline = 'child', + push = 'push', + web = 'web', + api = 'api', + trigger = 'trigger', +} + +---@param event string +---@return string +local function abbreviate_event(event) + return event_map[event] or event +end + +---@param entry table +---@param field string +---@return string +local function extract_author(entry, field) + local v = entry[field] + if type(v) == 'table' then + return v.login or v.username or v.name or '' + end + return tostring(v or '') +end + +---@param entry table +---@param fields table +---@param show_state boolean +---@return string +function M.format_pr(entry, fields, show_state) + local num = tostring(entry[fields.number] or '') + local title = entry[fields.title] or '' + local author = extract_author(entry, fields.author) + local age = relative_time(entry[fields.created_at]) + local prefix = '' + if show_state then + local state = (entry[fields.state] or ''):lower() + local icon, color + if state == 'open' or state == 'opened' then + icon, color = '+', '\27[34m' + elseif state == 'merged' then + icon, color = 'm', '\27[35m' + else + icon, color = 'x', '\27[31m' + end + prefix = color .. icon .. '\27[0m ' + end + return ('%s\27[34m#%-5s\27[0m %s \27[2m%-15s %3s\27[0m'):format( + prefix, + num, + pad_or_truncate(title, 45), + pad_or_truncate(author, 15), + age + ) +end + +---@param entry table +---@param fields table +---@param show_state boolean +---@return string +function M.format_issue(entry, fields, show_state) + local num = tostring(entry[fields.number] or '') + local title = entry[fields.title] or '' + local author = extract_author(entry, fields.author) + local age = relative_time(entry[fields.created_at]) + local prefix = '' + if show_state then + local state = (entry[fields.state] or ''):lower() + local icon, color + if state == 'open' or state == 'opened' then + icon, color = '+', '\27[34m' + else + icon, color = '*', '\27[2m' + end + prefix = color .. icon .. '\27[0m ' + end + return ('%s\27[34m#%-5s\27[0m %s \27[2m%-15s %3s\27[0m'):format( + prefix, + num, + pad_or_truncate(title, 45), + pad_or_truncate(author, 15), + age + ) +end + +---@param check table +---@return string +function M.format_check(check) + local bucket = (check.bucket or 'pending'):lower() + local name = check.name or '' + local icon, color + if bucket == 'pass' then + icon, color = '*', '\27[32m' + elseif bucket == 'fail' then + icon, color = 'x', '\27[31m' + elseif bucket == 'pending' then + icon, color = '~', '\27[33m' + elseif bucket == 'skipping' or bucket == 'cancel' then + icon, color = '-', '\27[2m' + else + icon, color = '?', '\27[2m' + end + local elapsed = '' + if check.startedAt and check.completedAt and check.completedAt ~= '' then + local ok_s, ts = + pcall(vim.fn.strptime, '%Y-%m-%dT%H:%M:%SZ', check.startedAt) + local ok_e, te = + pcall(vim.fn.strptime, '%Y-%m-%dT%H:%M:%SZ', check.completedAt) + if ok_s and ok_e and ts > 0 and te > 0 then + local secs = te - ts + if secs >= 60 then + elapsed = ('%dm%ds'):format(math.floor(secs / 60), secs % 60) + else + elapsed = ('%ds'):format(secs) + end + end + end + return ('%s%s\27[0m %s \27[2m%s\27[0m'):format( + color, + icon, + pad_or_truncate(name, 35), + elapsed + ) +end + +---@param run forge.CIRun +---@return string +function M.format_run(run) + local icon, color + local s = run.status:lower() + if s == 'success' then + icon, color = '*', '\27[32m' + elseif s == 'failure' or s == 'failed' then + icon, color = 'x', '\27[31m' + elseif + s == 'in_progress' + or s == 'running' + or s == 'pending' + or s == 'queued' + then + icon, color = '~', '\27[33m' + elseif s == 'cancelled' or s == 'canceled' or s == 'skipped' then + icon, color = '-', '\27[2m' + else + icon, color = '?', '\27[2m' + end + local event = abbreviate_event(run.event) + local date = compact_date(run.created_at) + if run.branch ~= '' then + return ('%s%s\27[0m %s \27[36m%s\27[0m \27[2m%-6s %s\27[0m'):format( + color, + icon, + pad_or_truncate(run.name, 20), + pad_or_truncate(run.branch, 25), + event, + date + ) + end + return ('%s%s\27[0m %s \27[2m%-6s %s\27[0m'):format( + color, + icon, + pad_or_truncate(run.name, 35), + event, + date + ) +end + +---@param checks table[] +---@param filter string? +---@return table[] +function M.filter_checks(checks, filter) + if not filter or filter == 'all' then + table.sort(checks, function(a, b) + local order = + { fail = 1, pending = 2, pass = 3, skipping = 4, cancel = 5 } + local oa = order[(a.bucket or ''):lower()] or 9 + local ob = order[(b.bucket or ''):lower()] or 9 + return oa < ob + end) + return checks + end + local filtered = {} + for _, c in ipairs(checks) do + if (c.bucket or ''):lower() == filter then + table.insert(filtered, c) + end + end + return filtered +end + +function M.config() + return vim.tbl_deep_extend('force', { + ci = { lines = 10000 }, + }, vim.g.forge or {}) +end + +---@type { base: string?, mode: 'unified'|'split' } +M.review = { base = nil, mode = 'unified' } + +---@param args string[] +function M.yank_url(args) + vim.system(args, { text = true }, function(result) + if result.code == 0 then + local url = vim.trim(result.stdout or '') + if url ~= '' then + vim.schedule(function() + vim.fn.setreg('+', url) + end) + end + end + end) +end + +---@param branch string +---@param base string +---@return string title, string body +local function fill_from_commits(branch, base) + local result = vim.system( + { 'git', 'log', 'origin/' .. base .. '..HEAD', '--format=%s%n%b%x00' }, + { text = true } + ):wait() + local raw = vim.trim(result.stdout or '') + if raw == '' then + local clean = branch:gsub('^%w+/', ''):gsub('[/-]', ' ') + return clean, '' + end + + local commits = {} + for chunk in raw:gmatch('([^%z]+)') do + local lines = vim.split(vim.trim(chunk), '\n', { plain = true }) + local subject = lines[1] or '' + local body = vim.trim(table.concat(lines, '\n', 2)) + table.insert(commits, { subject = subject, body = body }) + end + + if #commits == 0 then + local clean = branch:gsub('^%w+/', ''):gsub('[/-]', ' ') + return clean, '' + end + + if #commits == 1 then + return commits[1].subject, commits[1].body + end + + local clean = branch:gsub('^%w+/', ''):gsub('[/-]', ' ') + local lines = {} + for _, c in ipairs(commits) do + table.insert(lines, '- ' .. c.subject) + end + return clean, table.concat(lines, '\n') +end + +---@param f forge.Forge +---@param repo_root string +---@return string? +local function discover_template(f, repo_root) + local paths = f:template_paths() + for _, p in ipairs(paths) do + local full = repo_root .. '/' .. p + local stat = vim.uv.fs_stat(full) + if stat and stat.type == 'file' then + local fd = vim.uv.fs_open(full, 'r', 438) + if fd then + local content = vim.uv.fs_read(fd, stat.size, 0) + vim.uv.fs_close(fd) + if content then + return vim.trim(content) + end + end + elseif stat and stat.type == 'directory' then + local handle = vim.uv.fs_scandir(full) + if handle then + local templates = {} + while true do + local name, typ = vim.uv.fs_scandir_next(handle) + if not name then + break + end + if (typ == 'file' or not typ) and name:match('%.md$') then + table.insert(templates, name) + end + end + if #templates == 1 then + local tpath = full .. '/' .. templates[1] + local tstat = vim.uv.fs_stat(tpath) + if tstat then + local fd = vim.uv.fs_open(tpath, 'r', 438) + if fd then + local content = vim.uv.fs_read(fd, tstat.size, 0) + vim.uv.fs_close(fd) + if content then + return vim.trim(content) + end + end + end + elseif #templates > 1 then + table.sort(templates) + local chosen + vim.ui.select(templates, { + prompt = 'PR template: ', + }, function(choice) + chosen = choice + end) + if chosen then + local tpath = full .. '/' .. chosen + local tstat = vim.uv.fs_stat(tpath) + if tstat then + local fd = vim.uv.fs_open(tpath, 'r', 438) + if fd then + local content = + vim.uv.fs_read(fd, tstat.size, 0) + vim.uv.fs_close(fd) + if content then + return vim.trim(content) + end + end + end + end + end + end + end + end + return nil +end + +---@param f forge.Forge +---@param branch string +---@param title string +---@param body string +---@param pr_base string +---@param pr_draft boolean +---@param pr_reviewers string[]? +---@param buf integer? +local function push_and_create(f, branch, title, body, pr_base, pr_draft, pr_reviewers, buf) + M.log_now('pushing and creating ' .. f.labels.pr_one .. '...') + vim.system( + { 'git', 'push', '-u', 'origin', branch }, + { text = true }, + function(push_result) + if push_result.code ~= 0 then + local msg = vim.trim(push_result.stderr or '') + if msg == '' then + msg = 'push failed' + end + M.log(msg, vim.log.levels.ERROR) + return + end + vim.system( + f:create_pr_cmd(title, body, pr_base, pr_draft, pr_reviewers), + { text = true }, + function(create_result) + vim.schedule(function() + if create_result.code == 0 then + local url = vim.trim(create_result.stdout or '') + if url ~= '' then + vim.fn.setreg('+', url) + end + M.log_now(('created %s → %s'):format(f.labels.pr_one, url)) + M.clear_list() + if buf and vim.api.nvim_buf_is_valid(buf) then + vim.bo[buf].modified = false + vim.api.nvim_buf_delete(buf, { force = true }) + end + else + local msg = vim.trim(create_result.stderr or '') + if msg == '' then + msg = vim.trim(create_result.stdout or '') + end + if msg == '' then + msg = 'creation failed' + end + M.log_now(msg, vim.log.levels.ERROR) + end + vim.cmd.redraw() + end) + end + ) + end + ) +end + +---@param f forge.Forge +---@param branch string +---@param base string +---@param draft boolean +local function open_compose_buffer(f, branch, base, draft) + local root = git_root() or '' + local title, commit_body = fill_from_commits(branch, base) + local template = discover_template(f, root) + local body = template or commit_body + + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(buf, 'forge://pr/new') + vim.bo[buf].buftype = 'acwrite' + vim.bo[buf].filetype = 'markdown' + vim.bo[buf].swapfile = false + + local lines = { title, '' } + if body ~= '' then + for _, line in ipairs(vim.split(body, '\n', { plain = true })) do + table.insert(lines, line) + end + else + table.insert(lines, '') + end + + table.insert(lines, '') + local comment_start = #lines + 1 + + local pr_kind = f.labels.pr_full:gsub('s$', '') + local diff_stat = vim.fn.system( + 'git diff --stat origin/' .. base .. '..HEAD' + ):gsub('%s+$', '') + + ---@type {line: integer, col: integer, end_col: integer, hl: string}[] + local marks = {} + + local function add_line(fmt, ...) + local text = fmt:format(...) + table.insert(lines, text) + return #lines + end + + ---@param ln integer + ---@param start integer + ---@param len integer + ---@param hl string + local function mark(ln, start, len, hl) + table.insert(marks, { line = ln, col = start, end_col = start + len, hl = hl }) + end + + add_line('') + + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.bo[buf].modified = false + + vim.api.nvim_set_current_buf(buf) + + if stat_start and stat_end then + for i = stat_start, stat_end do + local line = lines[i] + local pipe = line:find('|') + if pipe then + local fname_start = line:find('%S') + if fname_start then + mark(i, fname_start - 1, pipe - fname_start - 1, 'ForgeComposeFile') + end + for pos, run in line:gmatch('()([+-]+)') do + if pos > pipe then + local hl = run:sub(1, 1) == '+' and 'ForgeComposeAdded' or 'ForgeComposeRemoved' + mark(i, pos - 1, #run, hl) + end + end + end + end + end + + local by_line = {} + for _, m in ipairs(marks) do + local row = m.line - 1 + if not by_line[row] then + by_line[row] = {} + end + table.insert(by_line[row], { col = m.col, end_col = m.end_col, hl = m.hl }) + end + compose_marks[buf] = by_line + + local comment_ns = vim.api.nvim_create_namespace('forge_pr_comment') + for i = comment_start, #lines do + vim.api.nvim_buf_set_extmark(buf, comment_ns, i - 1, 0, { + line_hl_group = 'ForgeComposeComment', + }) + end + + vim.api.nvim_create_autocmd('BufWipeout', { + buffer = buf, + callback = function() + compose_marks[buf] = nil + end, + }) + + ---@return boolean, string[], string + local function parse_comment() + local buf_lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + local in_comment = false + local pr_draft = false + local pr_reviewers = {} + for _, l in ipairs(buf_lines) do + if l:match('^