mirror of
https://github.com/zoriya/jj.nvim.git
synced 2026-08-15 23:53:18 +00:00
feat(cmd): add platform-agnostic PR/MR opening functionality
This commit is contained in:
+105
-8
@@ -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
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user