feat(cmd): add platform-agnostic PR/MR opening functionality

This commit is contained in:
NicolasGB
2025-11-27 20:58:34 +01:00
committed by Nicolas GB
parent e22bf5ec4e
commit 8350606dab
4 changed files with 389 additions and 18 deletions
+50 -4
View File
@@ -29,10 +29,11 @@ This plugin aims to be something like vim-fugitive but for driving the jj-vcs CL
- `bookmark create/delete` - Create and delete bookmarks
- `undo` - Undo the last operation
- `redo` - Redo the last undone operation
- Diff commands
- `open_pr` - Open a PR/MR on your remote (GitHub, GitLab, Gitea, Forgejo, etc.)
- Diff commands
- `:Jdiff [revision]` - Vertical split diff against a jj revision
- `:Jhdiff [revision]` - Horizontal split diff
- Picker for for [Snacks.nvim](https://github.com/folke/snacks.nvim)
- Picker for for [Snacks.nvim](https://github.com/folke/snacks.nvim)
- `jj status` Displays the current changes diffs
- `jj file_history` Displays a buffer's history changes and allows to edit it's change (including immutable changes)
@@ -82,6 +83,19 @@ You can fetch and push directly from the log buffer:
- `<S-p>` - Push all changes to remote
- `p` - Push bookmark of revision under cursor to remote
### Open a PR/MR from the log buffer
- `o` - Open a PR/MR for the revision under cursor
- `<S-o>` - Select a bookmark from all available bookmarks and open a PR/MR
The plugin automatically:
- Extracts the bookmark from the revision
- Detects your git platform (GitHub, GitLab, Gitea, Forgejo, etc.)
- Constructs the appropriate PR/MR URL
- Handles both HTTPS and SSH remote URLs
- Prompts you to select a remote if you have multiple
### Open a changed file
Just press enter to open the a file from the `status` output in your current window.
@@ -93,6 +107,14 @@ Press `<S-x>` on a file from the `status` output and that's it, it's restored.
![Restore-status](https://github.com/NicolasGB/jj.nvim/raw/main/assets/x-status.gif)
### Open a PR/MR on your remote
Press `p` on a change in the log buffer to open a PR/MR on your remote (GitHub, GitLab, Gitea, Forgejo, etc.).
The plugin automatically detects your git platform and constructs the appropriate PR URL. If you have multiple remotes, you'll be prompted to select which one to use. Works with both HTTPS and SSH URLs.
**This is a jj.nvim exclusive feature** - the ability to seamlessly bridge from your Neovim jj workflow directly to your remote platform's PR/MR interface.
## Installation
Using [lazy.nvim](https://github.com/folke/lazy.nvim):
@@ -118,6 +140,8 @@ The plugin provides a `:J` command that accepts jj subcommands:
:J push " Push all changes
:J push main " Push only main bookmark
:J fetch " Fetch from remote
:J open_pr " Open PR for current change's bookmark
:J open_pr --list " Select bookmark from all and open PR
:J # This will use your defined default command
:J <your-alias>
```
@@ -197,8 +221,10 @@ The plugin also provides `:Jdiff`, `:Jvdiff`, and `:Jhdiff` commands for diffing
redo = "<S-r>", -- Redo last undone operation
abandon = "a", -- Abandon revision under cursor
fetch = "f", -- Fetch from remote
push = "p", -- Push bookmark of revision under cursor
push_all = "<S-p>", -- Push all changes to remote
push = "p", -- Push bookmark of revision under cursor
push_all = "<S-p>", -- Push all changes to remote
open_pr = "o", -- Open PR/MR for revision under cursor
open_pr_list = "<S-o>", -- Open PR/MR by selecting from all bookmarks
},
-- Status buffer keymaps (set to nil to disable)
status = {
@@ -333,6 +359,21 @@ cmd.push({ bookmark = "main" }) -- Push only main bookmark
cmd.push({ bookmark = "feature" }) -- Push only feature bookmark
```
### Open PR/MR Command Options
The `open_pr` function accepts an options table:
```lua
local cmd = require("jj.cmd")
cmd.open_pr({
list_bookmarks = false -- Whether to select from all bookmarks (default: false, uses current revision)
})
-- Examples:
cmd.open_pr() -- Open PR for current change's bookmark
cmd.open_pr({ list_bookmarks = true }) -- Select bookmark from all and open PR
```
### Diff Split Views
Use the `diff` module for opening splits:
@@ -409,6 +450,11 @@ diff.open_hsplit({ rev = "@-2" }) -- Horizontal split against @-2
vim.keymap.set("n", "<leader>ja", cmd.abandon, { desc = "JJ abandon" })
vim.keymap.set("n", "<leader>jf", cmd.fetch, { desc = "JJ fetch" })
vim.keymap.set("n", "<leader>jp", cmd.push, { desc = "JJ push" })
vim.keymap.set("n", "<leader>jpr", cmd.open_pr, { desc = "JJ open PR from bookmark in current revision or parent" })
vim.keymap.set("n", "<leader>jpl", function()
cmd.open_pr { list_bookmarks = true }
end, { desc = "JJ open PR listing available bookmarks" })
-- Diff commands
local diff = require("jj.diff")
+105 -8
View File
@@ -39,6 +39,8 @@ local status_module = require("jj.cmd.status")
--- @field fetch? string|string[]
--- @field push_all? string|string[]
--- @field push? string|string[]
--- @field open_pr? string|string[]
--- @field open_pr_list? string|string[]
--- @class jj.cmd.status.keymaps
--- @field open_file? string|string[] Keymaps for the status command buffer, setting a keymap to nil will disable it
@@ -69,6 +71,9 @@ local status_module = require("jj.cmd.status")
--- @class jj.cmd.push_opts
--- @field bookmark? string Specific bookmark to push (default: all)
--- @class jj.cmd.open_pr_opts
--- @field list_bookmarks? boolean Whether to select from all bookmarks instead of current revision
--- @type jj.cmd.opts
M.config = {
describe = {
@@ -97,6 +102,8 @@ M.config = {
fetch = "f",
push_all = "<S-P>",
push = "p",
open_pr = "o",
open_pr_list = "<S-o>",
},
status = {
open_file = "<CR>",
@@ -441,16 +448,45 @@ function M.fetch()
return
end
-- Save the lop one state to refresh
local log_open = terminal.state.buf_cmd == "log"
local cmd = "jj git fetch"
utils.notify("Fetching ...", vim.log.levels.INFO, 1000)
runner.execute_command_async(cmd, function()
utils.notify("Successfully fetched from remote", vim.log.levels.INFO)
if log_open then
M.log({})
end
end, "Error fetching from remote")
-- Get the list of remotes
local remotes = utils.get_remotes()
if not remotes or #remotes == 0 then
utils.notify("No git remotes found to fetch from", vim.log.levels.ERROR)
return
end
if #remotes > 1 then
-- Prompt to select a remote
vim.ui.select(remotes, {
prompt = "Select remote to fetch from: ",
format_item = function(item)
return string.format("%s (%s)", item.name, item.url)
end,
}, function(choice)
if choice then
local cmd = string.format("jj git fetch --remote %s", choice.name)
runner.execute_command_async(cmd, function()
utils.notify(string.format("Fetching from %s...", choice), vim.log.levels.INFO)
if log_open then
M.log({})
end
end, "Error fetching from remote")
end
end)
else
-- Only one remote, fetch from it directly
local cmd = "jj git fetch"
utils.notify("Fetching from remote...", vim.log.levels.INFO)
runner.execute_command_async(cmd, function()
utils.notify("Successfully fetched from remote", vim.log.levels.INFO)
if log_open then
M.log({})
end
end, "Error fetching from remote")
end
end
-- Jujutsu push
@@ -462,6 +498,7 @@ function M.push(opts)
opts = opts or {}
-- Save the lop one state to refresh
local log_open = terminal.state.buf_cmd == "log"
local cmd = "jj git push"
@@ -480,6 +517,58 @@ function M.push(opts)
end, "Error pushing to remote")
end
--- Open a PR on the remote from the current change's bookmark
--- @param opts? jj.cmd.open_pr_opts Options for opening PR
function M.open_pr(opts)
if not utils.ensure_jj() then
return
end
opts = opts or {}
if opts.list_bookmarks then
-- Get all bookmarks
local bookmarks = utils.get_all_bookmarks()
if #bookmarks == 0 then
utils.notify("No bookmarks found", vim.log.levels.ERROR)
return
end
-- Prompt to select a bookmark
vim.ui.select(bookmarks, {
prompt = "Select bookmark to open PR for: ",
}, function(choice)
if choice then
utils.open_pr_for_bookmark(choice)
end
end)
-- Return early
return
end
-- Get the bookmark from the current change (@)
local bookmark, success =
runner.execute_command("jj log -r @ --no-graph -T 'bookmarks'", "Failed to get current bookmark", nil, true)
if not success or not bookmark or bookmark:match("^%s*$") then
-- If no bookmark on @, try @-
bookmark, success =
runner.execute_command("jj log -r @- --no-graph -T 'bookmarks'", "Failed to get parent bookmark", nil, true)
if not success or not bookmark or bookmark:match("^%s*$") then
utils.notify("No bookmark found on @ or @- commits. Cannot open PR.", vim.log.levels.ERROR)
return
end
end
-- Trim and remove asterisks from bookmark
bookmark = bookmark:match("^%*?(.-)%*?$"):gsub("%s+", "")
-- Open the PR using the utility function
utils.open_pr_for_bookmark(bookmark)
end
--- @param args string|string[] jj command arguments
function M.j(args)
if not utils.ensure_jj() then
@@ -569,6 +658,13 @@ function M.j(args)
fetch = function()
M.fetch()
end,
open_pr = function()
if remaining_args_str:match("--list") then
M.open_pr({ list_bookmarks = true })
else
M.open_pr()
end
end,
}
if handlers[subcommand] then
@@ -612,6 +708,7 @@ function M.register_command()
"st",
"status",
"undo",
"open_pr",
}
local matches = {}
for _, cmd in ipairs(subcommands) do
+95 -6
View File
@@ -198,12 +198,37 @@ end
--- Handle fetching from `jj log` buffer.
function M.handle_log_fetch()
local cmd = "jj git fetch"
utils.notify("Fetching from remote...", vim.log.levels.INFO)
runner.execute_command_async(cmd, function()
utils.notify("Successfully fetched from remote", vim.log.levels.INFO)
M.log({})
end, "Error fetching from remote")
local remotes = utils.get_remotes()
if not remotes or #remotes == 0 then
utils.notify("No git remotes found to fetch from", vim.log.levels.ERROR)
return
end
if #remotes > 1 then
-- Prompt to select a remote
vim.ui.select(remotes, {
prompt = "Select remote to fetch from: ",
format_item = function(item)
return string.format("%s (%s)", item.name, item.url)
end,
}, function(choice)
if choice then
local cmd = string.format("jj git fetch --remote %s", choice.name)
runner.execute_command_async(cmd, function()
utils.notify(string.format("Fetching from %s...", choice), vim.log.levels.INFO)
M.log({})
end, "Error fetching from remote")
end
end)
else
-- Only one remote, fetch from it directly
local cmd = "jj git fetch"
utils.notify("Fetching from remote...", vim.log.levels.INFO)
runner.execute_command_async(cmd, function()
utils.notify("Successfully fetched from remote", vim.log.levels.INFO)
M.log({})
end, "Error fetching from remote")
end
end
--- Handle pushing from `jj log` buffer.
@@ -251,6 +276,61 @@ function M.handle_log_push_bookmark()
end, string.format("Error pushing bookmark for `%s`", revset))
end
--- Handle opening a PR/MR from `jj log` buffer for the revision under cursor
--- @param list_bookmarks? boolean If true, prompt to select from all bookmarks instead of using current revision
function M.handle_log_open_pr(list_bookmarks)
if list_bookmarks then
-- Get all bookmarks
local bookmarks = utils.get_all_bookmarks()
if #bookmarks == 0 then
utils.notify("No bookmarks found", vim.log.levels.ERROR)
return
end
-- Prompt to select a bookmark
vim.ui.select(bookmarks, {
prompt = "Select bookmark to open PR for: ",
}, function(choice)
if choice then
utils.open_pr_for_bookmark(choice)
end
end)
-- Return early
return
end
-- Default behavior: parse revision and open PR
local line = vim.api.nvim_get_current_line()
local revset = parser.get_rev_from_log_line(line)
if not revset or revset == "" then
return
end
-- Get the bookmark for this revision
local bookmark, success = runner.execute_command(
string.format("jj log -r %s -T 'bookmarks' --no-graph", revset),
string.format("Error retrieving bookmark for `%s`", revset),
nil,
false
)
if not success or not bookmark then
return
end
-- Trim and clean the bookmark (remove asterisks and whitespace)
bookmark = bookmark:match("^%*?(.-)%*?$"):gsub("%s+", "")
if bookmark == "" then
utils.notify("[OPEN PR] No bookmark found for revision", vim.log.levels.ERROR)
return
end
-- Open the PR using the utility function
utils.open_pr_for_bookmark(bookmark)
end
--- Resolve log keymaps from config, filtering out nil values
--- @return jj.core.buffer.keymap[]
function M.log_keymaps()
@@ -325,6 +405,15 @@ function M.log_keymaps()
desc = "Push bookmark of revision under cursor to remote",
handler = M.handle_log_push_bookmark,
},
open_pr = {
desc = "Open PR/MR for revision under cursor",
handler = M.handle_log_open_pr,
},
open_pr_list = {
desc = "Open PR/MR by selecting from all bookmarks",
handler = M.handle_log_open_pr,
args = { true },
},
}
return cmd.merge_keymaps(cmd.resolve_keymaps_from_specs(keymaps, specs), cmd.terminal_keymaps())
+139
View File
@@ -115,4 +115,143 @@ function M.notify(message, level, timeout)
vim.notify(message, level, { title = "JJ", timeout = timeout })
end
--- URL encode a string for use in URLs
--- @param str string The string to encode
--- @return string The URL-encoded string
function M.url_encode(str)
return (str:gsub("([^%w%-_.~])", function(c)
return string.format("%%%02X", string.byte(c))
end))
end
--- Get all bookmarks in the repository
--- @return string[] List of bookmarks, or empty list if none found
function M.get_all_bookmarks()
-- Use a custom template to output just the bookmark names, one per line
-- This is more reliable than parsing the default output format
local bookmarks_output, success = runner.execute_command(
[[jj bookmark list -T 'if(!name.contains("@"), name ++ "\n")']],
"Failed to get bookmarks",
nil,
true
)
if not success or not bookmarks_output then
return {}
end
-- Parse bookmarks from template output
local bookmarks = {}
local seen = {}
for line in bookmarks_output:gmatch("[^\n]+") do
local bookmark = vim.trim(line)
if bookmark ~= "" and not seen[bookmark] then
table.insert(bookmarks, bookmark)
seen[bookmark] = true
end
end
return bookmarks
end
--- Get git remotes for the current jj repository
--- @return {name: string, url: string}[]|nil A list of remotes with name and URL
function M.get_remotes()
local remote_list, remote_success =
runner.execute_command("jj git remote list", "Failed to get git remote", nil, true)
if not remote_success or not remote_list then
return
end
-- Parse remotes into a table
local remotes = {}
for line in remote_list:gmatch("[^\n]+") do
local name, url = line:match("^(%S+)%s+(.+)$")
if name and url then
table.insert(remotes, { name = name, url = url })
end
end
return remotes
end
--- Open a PR/MR on the remote for a given bookmark
--- @param bookmark string The bookmark to create a PR for
function M.open_pr_for_bookmark(bookmark)
-- Get all git remotes
local remotes = M.get_remotes()
if #remotes == 0 then
M.notify("No git remotes found", vim.log.levels.ERROR)
return
end
-- Helper function to open PR for a given remote URL
local function open_pr_with_url(raw_url)
-- Remove .git suffix if present
raw_url = raw_url:gsub("%.git$", "")
-- Convert SSH URL to HTTPS and detect platform
local repo_url, host
if raw_url:match("^git@") then
-- Extract host and path from git@host:path
host = raw_url:match("^git@([^:]+):")
local repo_path = raw_url:match("^git@[^:]+:(.+)$")
repo_url = "https://" .. host .. "/" .. repo_path
else
-- Extract host from https://host/path
host = raw_url:match("https?://([^/]+)")
repo_url = raw_url
end
-- Construct the appropriate PR/MR URL based on the platform
local encoded_bookmark = M.url_encode(bookmark)
local pr_url
if host:match("gitlab") then
-- GitLab merge request URL
pr_url = repo_url .. "/-/merge_requests/new?merge_request[source_branch]=" .. encoded_bookmark
elseif host:match("gitea") or host:match("forgejo") then
-- Gitea/Forgejo compare URL
pr_url = repo_url .. "/compare/" .. encoded_bookmark
else
-- Default to GitHub-style compare URL (works for GitHub, Gitea, etc.)
pr_url = repo_url .. "/compare/" .. encoded_bookmark .. "?expand=1"
end
-- Open the URL using xdg-open or the system's default browser
local open_cmd
if vim.fn.has("mac") == 1 then
open_cmd = "open"
elseif vim.fn.has("win32") == 1 then
open_cmd = "start"
else
open_cmd = "xdg-open"
end
vim.fn.jobstart({ open_cmd, pr_url }, { detach = true })
M.notify(string.format("Opening PR for bookmark `%s`", bookmark), vim.log.levels.INFO)
end
-- If only one remote, use it directly
if #remotes == 1 then
open_pr_with_url(remotes[1].url)
return
end
-- Multiple remotes: prompt user to select
vim.ui.select(remotes, {
prompt = "Select remote to open PR on: ",
format_item = function(item)
return item.name .. " (" .. item.url .. ")"
end,
}, function(choice)
if choice then
open_pr_with_url(choice.url)
end
end)
end
return M