From bbba4051c862473637e98277f284d12b050588ca Mon Sep 17 00:00:00 2001 From: NicolasGB Date: Tue, 24 Feb 2026 16:38:58 +0100 Subject: [PATCH] feat(fetch_pr): Introdue the `fetch_pr` command that allows to pick open Pull requests from github and import them locally --- lua/jj/cmd/init.lua | 92 ++++++++++++++++++++++++++++++++++++++++++ lua/jj/core/runner.lua | 6 ++- lua/jj/utils.lua | 47 +++++++++++++++++++++ 3 files changed, 144 insertions(+), 1 deletion(-) diff --git a/lua/jj/cmd/init.lua b/lua/jj/cmd/init.lua index f49523d..59f448c 100644 --- a/lua/jj/cmd/init.lua +++ b/lua/jj/cmd/init.lua @@ -127,6 +127,9 @@ local split_module = require("jj.cmd.split") --- @class jj.cmd.open_pr_opts --- @field list_bookmarks? boolean Whether to select from all bookmarks instead of current revision +--- @class jj.cmd.fetch_pr_opts +--- @field limit? number Limit the number of PRs to select from + --- @type jj.cmd.opts M.config = { describe = { @@ -1008,6 +1011,91 @@ function M.tag_push() end end +--- Opens a picker to localy fetch a PR from a github repository +--- @param opts? jj.cmd.fetch_pr_opts Options for fetching PRs +function M.fetch_pr(opts) + if not utils.ensure_jj() then + return + end + + if not utils.has_executable("git") then + return + end + + if not utils.is_colocated() then + utils.notify("Current repository is not colocated. Cannot fetch PR.", vim.log.levels.ERROR) + return + end + + -- Create a new opts table with the default limit if not provided + opts = vim.tbl_deep_extend("force", { + limit = 100, + }, opts or {}) + + -- Get the prs from github + local prs = utils.list_github_prs(opts) + if not prs or #prs == 0 then + return + end + + local needs_refresh = terminal.is_log_buffer_open() + + vim.ui.select(prs, { + prompt = "Select PR to fetch: ", + format_item = function(pr) + return string.format("#%s %s %s", pr.number, pr.title, pr.author) + end, + }, function(choice) + if choice then + utils.notify("Pulling PR #" .. choice.number .. "...", vim.log.levels.INFO) + local pr = choice.number + local count = 1 + local max_retries = 30 + + -- the function that actually tries to fetch recursively + local function try_fetch() + if count > max_retries then + utils.notify( + string.format("Failed to fetch PR #%s. Tried %d times.", pr, max_retries), + vim.log.levels.ERROR + ) + return + end + + local ref = string.format("pull/%s/head:pr-%s-%d", pr, pr, count) + local cmd = string.format("git fetch origin %s", ref) + + runner.execute_command_async( + cmd, + function() + -- If we successfully pulled the PR, notify the user and refresh the log if it's open + runner.execute_command_async("jj git import", function() + utils.notify( + string.format("PR #%s fetched as pr-%s-%d.", pr, pr, count), + vim.log.levels.INFO + ) + if needs_refresh then + M.log({}) + end + end, "Failed to import git refs") + end, + "", + nil, + true, + function() + -- If we errored increment the counter by one and try and fetch it again + count = count + 1 + try_fetch() + end + ) + end + + -- Try and pull it once + try_fetch() + end + end) +end + --- @param args string|string[] jj command arguments function M.j(args) if not utils.ensure_jj() then @@ -1164,6 +1252,9 @@ function M.j(args) end end end, + fetch_pr = function() + M.fetch_pr() + end, } if handlers[subcommand] then @@ -1213,6 +1304,7 @@ function M.register_command() "annotate_line", "commit", "tag", + "fetch_pr", } local matches = {} for _, cmd in ipairs(subcommands) do diff --git a/lua/jj/core/runner.lua b/lua/jj/core/runner.lua index 45efbf2..972f5da 100644 --- a/lua/jj/core/runner.lua +++ b/lua/jj/core/runner.lua @@ -51,7 +51,8 @@ end --- @param error_prefix string|nil Optional error message prefix --- @param input string|nil Optional input to pass to stdin --- @param silent boolean|nil Optional to silent the notification -function M.execute_command_async(cmd, on_success, error_prefix, input, silent) +--- @param on_error function|nil Callback on error, receives ouptut as the parameter +function M.execute_command_async(cmd, on_success, error_prefix, input, silent, on_error) local output_lines = {} local job_id = vim.fn.jobstart({ "sh", "-c", cmd }, { @@ -85,6 +86,9 @@ function M.execute_command_async(cmd, on_success, error_prefix, input, silent) if not silent then vim.notify(error_message, vim.log.levels.ERROR, { title = "JJ" }) end + if on_error then + on_error(output) + end end end, }) diff --git a/lua/jj/utils.lua b/lua/jj/utils.lua index dde0110..545b29d 100644 --- a/lua/jj/utils.lua +++ b/lua/jj/utils.lua @@ -695,4 +695,51 @@ function M.reload_changed_file_buffers() vim.cmd.checktime() end +--- Using the gh cli lists all open prs +--- @param opts {limit: integer|nil} +--- @return {number: integer, title: string, author: string}[]|nil +function M.list_github_prs(opts) + if not M.has_executable("gh") then + M.notify("Missing `gh` executable to list prs", vim.log.levels.ERROR) + return + end + + -- Set the default limit + local limit = 100 + + if opts and opts.limit then + limit = opts.limit + end + + -- Start with a hardcoded limit of 100 + local cmd = + [[gh pr list -L %s --json number,title,author --jq '.[] | "#\(.number);;;\(.title);;;(@\(.author.login))"']] + cmd = string.format(cmd, limit) + + -- Run the command to get the pr's + local output, success = runner.execute_command_sync(cmd, nil, "Failed to get prs") + if not success or not output then + return + end + + local open_prs = {} + -- Split each line + local lines = vim.split(output, "\n", { trimempty = true }) + for _, line in ipairs(lines) do + local parts = vim.split(line, ";;;", { trimempty = true }) + if #parts == 3 then + local number = tonumber(parts[1]:match("#(%d+)")) + local title = parts[2] + local author = parts[3] + if number and title and author then + table.insert(open_prs, { number = number, title = title, author = author }) + end + else + M.notify("Unexpected PR list format: " .. line, vim.log.levels.WARN) + end + end + + return open_prs +end + return M