From 8350606dabb241cf8fc6d7c8e1a1acd60ab8579e Mon Sep 17 00:00:00 2001 From: NicolasGB Date: Thu, 27 Nov 2025 17:29:37 +0100 Subject: [PATCH] feat(cmd): add platform-agnostic PR/MR opening functionality --- README.md | 54 +++++++++++++++-- lua/jj/cmd/init.lua | 113 ++++++++++++++++++++++++++++++++--- lua/jj/cmd/log.lua | 101 ++++++++++++++++++++++++++++++-- lua/jj/utils.lua | 139 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 389 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index ae71229..d2b5b87 100644 --- a/README.md +++ b/README.md @@ -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: - `` - 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 +- `` - 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 `` 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 ``` @@ -197,8 +221,10 @@ The plugin also provides `:Jdiff`, `:Jvdiff`, and `:Jhdiff` commands for diffing redo = "", -- 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 = "", -- Push all changes to remote + push = "p", -- Push bookmark of revision under cursor + push_all = "", -- Push all changes to remote + open_pr = "o", -- Open PR/MR for revision under cursor + open_pr_list = "", -- 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", "ja", cmd.abandon, { desc = "JJ abandon" }) vim.keymap.set("n", "jf", cmd.fetch, { desc = "JJ fetch" }) vim.keymap.set("n", "jp", cmd.push, { desc = "JJ push" }) + vim.keymap.set("n", "jpr", cmd.open_pr, { desc = "JJ open PR from bookmark in current revision or parent" }) + vim.keymap.set("n", "jpl", function() + cmd.open_pr { list_bookmarks = true } + end, { desc = "JJ open PR listing available bookmarks" }) + -- Diff commands local diff = require("jj.diff") diff --git a/lua/jj/cmd/init.lua b/lua/jj/cmd/init.lua index 165e2ee..945d242 100644 --- a/lua/jj/cmd/init.lua +++ b/lua/jj/cmd/init.lua @@ -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 = "", push = "p", + open_pr = "o", + open_pr_list = "", }, status = { open_file = "", @@ -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 diff --git a/lua/jj/cmd/log.lua b/lua/jj/cmd/log.lua index 1cad8f1..b78fdec 100644 --- a/lua/jj/cmd/log.lua +++ b/lua/jj/cmd/log.lua @@ -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()) diff --git a/lua/jj/utils.lua b/lua/jj/utils.lua index 51c3eac..55ace42 100644 --- a/lua/jj/utils.lua +++ b/lua/jj/utils.lua @@ -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 +