Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ The first Neovim integration for [CodeRabbit](https://coderabbit.link/sam-natale
- **Review viewer** — read the full review in a floating window or buffer, with findings grouped by file, severity levels, and syntax-highlighted code suggestions
- **Review types** — review all changes, only committed changes, or only uncommitted changes, with optional base branch/commit comparison
- **Review history** — browse and revisit past reviews, persisted to disk across sessions
- **Quickfix integration** — send findings to the quickfix list for fast `:cnext`/`:cprev` navigation
- **Statusline integration** — drop `require("coderabbit").status()` into your statusline for a live spinner while reviews run

## Getting Started
Expand Down Expand Up @@ -49,6 +50,7 @@ Run `:checkhealth coderabbit` to verify everything is wired up.
| `:CodeRabbitClear` | Clear diagnostics |
| `:CodeRabbitShow [id]` | View results (float or buffer). Defaults to the latest review |
| `:CodeRabbitRestore [id]` | Reapply diagnostics from a saved review. Defaults to the most recent |
| `:CodeRabbitQuickfix [id]` | Populate quickfix list with findings |
| `:CodeRabbitHistory` | Browse past reviews |

For your statusline:
Expand Down Expand Up @@ -92,6 +94,9 @@ require("coderabbit").setup({
border = "rounded",
},
},
quickfix = {
auto = false, -- populate on review complete
},
on_review_complete = nil,
})
```
Expand Down
14 changes: 14 additions & 0 deletions doc/coderabbit.txt
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ All options are optional. Defaults: >lua
border = "rounded",
},
},
quickfix = {
auto = false,
},
on_review_complete = nil,
})
<
Expand All @@ -87,6 +90,9 @@ show.float.width Fraction of editor width (0-1). Default: `0.6`.
show.float.height Fraction of editor height (0-1). Default: `0.7`.
show.float.border Border style for the floating window. Default: `"rounded"`.

quickfix.auto Populate the quickfix list automatically when a review
completes. Default: `false`.

on_review_complete Callback receiving the findings table when a review
finishes.

Expand Down Expand Up @@ -115,6 +121,11 @@ COMMANDS *coderabbit-commands*
:CodeRabbitHistory *:CodeRabbitHistory*
Browse saved reviews via |vim.ui.select|.

:CodeRabbitQuickfix [id] *:CodeRabbitQuickfix*
Populate the quickfix list with findings. Pass an `id` from
`:CodeRabbitHistory` to load a saved review. Without an `id`,
uses the current review findings. Navigate with |:cnext| and |:cprev|.

==============================================================================
LUA API *coderabbit-api*

Expand All @@ -139,6 +150,9 @@ require("coderabbit").restore({id}) *coderabbit.restore()*
require("coderabbit").history() *coderabbit.history()*
Open the review history picker.

require("coderabbit").quickfix({id}) *coderabbit.quickfix()*
Populate the quickfix list with findings. `nil` = current, number = saved.

require("coderabbit").status() *coderabbit.status()*
Returns `"⠋ CodeRabbit (12s)"` while reviewing, `nil` when idle.
Designed for statusline use.
Expand Down
3 changes: 3 additions & 0 deletions lua/coderabbit/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ M.defaults = {
border = "rounded",
},
},
quickfix = {
auto = false,
},
on_review_complete = nil,
}

Expand Down
4 changes: 4 additions & 0 deletions lua/coderabbit/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ function M.history()
require("coderabbit.history").open()
end

function M.quickfix(id)
require("coderabbit.quickfix").populate(id)
end

function M.status()
return require("coderabbit.review").status()
end
Expand Down
76 changes: 76 additions & 0 deletions lua/coderabbit/quickfix.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
local M = {}

local severity_types = {
[vim.diagnostic.severity.ERROR] = "E",
[vim.diagnostic.severity.WARN] = "W",
}

--- Map vim.diagnostic.severity to quickfix type character.
--- @param severity number
--- @return string "E", "W", or "I"
function M.severity_to_type(severity)
return severity_types[severity] or "I"
end

--- Convert findings to quickfix items (pure function, no side effects).
--- @param findings table[] Array of { diagnostic, filepath }
--- @return table[] Array of { filename, lnum, col, text, type } for setqflist()
function M.findings_to_qf_items(findings)
local items = {}
for _, f in ipairs(findings) do
local d = f.diagnostic
local raw = d.user_data and d.user_data.severity_raw
local prefix = raw and ("[" .. raw .. "] ") or ""
local first_line = d.message:match("^([^\n]*)") or d.message
table.insert(items, {
filename = f.filepath,
lnum = d.lnum + 1,
col = d.col + 1,
text = prefix .. first_line,
type = M.severity_to_type(d.severity),
})
end
return items
end

--- Populate the quickfix list from findings and open the window.
--- @param findings table[] Array of { diagnostic, filepath }
--- @param opts table|nil { title = string }
function M.set(findings, opts)
opts = opts or {}
local items = M.findings_to_qf_items(findings)
vim.fn.setqflist({}, "r", {
title = opts.title or "CodeRabbit Review",
items = items,
})
vim.cmd("copen")
end

--- Populate quickfix from current review or a saved review by ID.
--- @param id number|nil Review ID (nil = current in-memory findings)
function M.populate(id)
local findings, title

local review = require("coderabbit.review")

if id then
local entry = review.get_review(id)
if not entry then
vim.notify("CodeRabbit: Review #" .. id .. " not found", vim.log.levels.WARN)
return
end
findings = type(entry.findings) == "table" and entry.findings or {}
title = "CodeRabbit Review #" .. id
else
findings = review.get_results()
if #findings == 0 and not review.get_context() then
vim.notify("CodeRabbit: No review results. Run :CodeRabbitReview first", vim.log.levels.WARN)
return
end
title = "CodeRabbit Review"
end

M.set(findings, { title = title })
end

return M
6 changes: 6 additions & 0 deletions lua/coderabbit/review.lua
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,12 @@ function M.run(opts)

storage.save(state.findings, M.get_context())

if type(cfg.quickfix) == "table" and cfg.quickfix.auto and #state.findings > 0 then
require("coderabbit.quickfix").set(state.findings, {
title = "CodeRabbit Review",
})
end

if cfg.on_review_complete then
cfg.on_review_complete(state.findings)
end
Expand Down
19 changes: 19 additions & 0 deletions plugin/coderabbit.lua
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,22 @@ vim.api.nvim_create_user_command("CodeRabbitHistory", function()
end, {
desc = "Browse CodeRabbit review history",
})

vim.api.nvim_create_user_command("CodeRabbitQuickfix", function(args)
ensure_setup()
local id = nil
if args.fargs[1] then
id = tonumber(args.fargs[1])
if not id then
vim.notify("CodeRabbitQuickfix: invalid review ID: " .. args.fargs[1], vim.log.levels.ERROR)
return
end
end
require("coderabbit").quickfix(id)
end, {
nargs = "?",
complete = function()
return require("coderabbit.storage").ids()
end,
desc = "Populate quickfix list with CodeRabbit findings (optional: review ID)",
})
186 changes: 186 additions & 0 deletions tests/coderabbit/quickfix_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
local quickfix = require("coderabbit.quickfix")
local h = require("tests.helpers")
local test, eq = h.test, h.eq
local E, W, I = h.E, h.W, h.I

-- ──────────────────────────────────────────────────────────
-- Tests: severity_to_type
-- ──────────────────────────────────────────────────────────

test("severity_to_type: ERROR -> E", function()
eq(quickfix.severity_to_type(E), "E")
end)

test("severity_to_type: WARN -> W", function()
eq(quickfix.severity_to_type(W), "W")
end)

test("severity_to_type: INFO -> I", function()
eq(quickfix.severity_to_type(I), "I")
end)

test("severity_to_type: HINT -> I (fallback)", function()
eq(quickfix.severity_to_type(vim.diagnostic.severity.HINT), "I")
end)

-- ──────────────────────────────────────────────────────────
-- Tests: findings_to_qf_items
-- ──────────────────────────────────────────────────────────

test("findings_to_qf_items: empty findings returns empty", function()
local items = quickfix.findings_to_qf_items({})
eq(#items, 0)
end)

test("findings_to_qf_items: single finding produces correct entry", function()
local findings = { h.finding("/tmp/repo/foo.lua", 41, E, "null check", {}) }
local items = quickfix.findings_to_qf_items(findings)
eq(#items, 1)
eq(items[1].filename, "/tmp/repo/foo.lua")
eq(items[1].lnum, 42) -- 0-indexed -> 1-indexed
eq(items[1].col, 1) -- col 0 -> 1
eq(items[1].type, "E")
end)

test("findings_to_qf_items: lnum 0 becomes 1", function()
local findings = { h.finding("/tmp/repo/a.lua", 0, I, "file-level issue") }
local items = quickfix.findings_to_qf_items(findings)
eq(items[1].lnum, 1)
end)

test("findings_to_qf_items: severity maps correctly", function()
local findings = {
h.finding("/tmp/repo/a.lua", 0, E, "error"),
h.finding("/tmp/repo/b.lua", 0, W, "warn"),
h.finding("/tmp/repo/c.lua", 0, I, "info"),
}
local items = quickfix.findings_to_qf_items(findings)
eq(items[1].type, "E")
eq(items[2].type, "W")
eq(items[3].type, "I")
end)

test("findings_to_qf_items: text includes severity_raw prefix", function()
local findings = { h.finding("/tmp/repo/a.lua", 10, W, "missing import") }
local items = quickfix.findings_to_qf_items(findings)
-- helpers.finding sets severity_raw = "minor" by default
eq(items[1].text, "[minor] missing import")
end)

test("findings_to_qf_items: multi-line message uses first line only", function()
local findings = { h.finding("/tmp/repo/a.lua", 5, E, "first line\nsecond line\nthird") }
local items = quickfix.findings_to_qf_items(findings)
eq(items[1].text, "[minor] first line")
end)

test("findings_to_qf_items: missing severity_raw omits prefix", function()
local findings = {
{
filepath = "/tmp/repo/a.lua",
diagnostic = {
lnum = 0,
col = 0,
severity = E,
message = "bare finding",
source = "coderabbit",
},
},
}
local items = quickfix.findings_to_qf_items(findings)
eq(items[1].text, "bare finding")
end)

test("findings_to_qf_items: multiple findings produce correct count", function()
local findings = {
h.finding("/tmp/repo/a.lua", 1, E, "one"),
h.finding("/tmp/repo/b.lua", 2, W, "two"),
h.finding("/tmp/repo/c.lua", 3, I, "three"),
}
local items = quickfix.findings_to_qf_items(findings)
eq(#items, 3)
end)

-- ──────────────────────────────────────────────────────────
-- Tests: populate
-- ──────────────────────────────────────────────────────────

local storage = require("coderabbit.storage")
local populate_test_dir = vim.fn.tempname() .. "/coderabbit_populate_test"
storage._set_base_dir(populate_test_dir)

-- Save a review so storage.load(1) returns it.
local saved_findings = {
h.finding("/tmp/repo/a.lua", 10, E, "error here"),
h.finding("/tmp/repo/b.lua", 20, W, "warning here"),
}
storage.save(saved_findings, h.context())

test("populate: valid id populates quickfix from stored review", function()
quickfix.populate(1)
vim.cmd("cclose")
local qf = vim.fn.getqflist({ title = 1, items = 1 })
eq(qf.title, "CodeRabbit Review #1")
eq(#qf.items, 2)
end)

test("populate: invalid id does not error and leaves quickfix unchanged", function()
-- Set a known state first
quickfix.set({ h.finding("/tmp/repo/x.lua", 0, I, "baseline") }, { title = "Baseline" })
vim.cmd("cclose")
-- Call with non-existent id
quickfix.populate(999)
local qf = vim.fn.getqflist({ title = 1, items = 1 })
-- Should remain unchanged (populate returns early with a warning)
eq(qf.title, "Baseline")
eq(#qf.items, 1)
end)

test("populate: nil id with no review context warns and leaves quickfix unchanged", function()
-- Set a known state first
quickfix.set({ h.finding("/tmp/repo/x.lua", 0, I, "baseline") }, { title = "Baseline" })
vim.cmd("cclose")
-- Clear review state so get_results() returns {} and get_context() returns nil
require("coderabbit.review").clear()
quickfix.populate(nil)
local qf = vim.fn.getqflist({ title = 1, items = 1 })
-- Should remain unchanged (populate returns early with a warning)
eq(qf.title, "Baseline")
eq(#qf.items, 1)
end)

-- ──────────────────────────────────────────────────────────
-- Tests: set
-- ──────────────────────────────────────────────────────────

test("set: populates quickfix list with items", function()
local findings = {
h.finding("/tmp/repo/a.lua", 10, E, "error here"),
h.finding("/tmp/repo/b.lua", 20, W, "warning here"),
}
quickfix.set(findings, { title = "Test Review" })
vim.cmd("cclose")
local qf = vim.fn.getqflist({ title = 1, items = 1 })
eq(qf.title, "Test Review")
eq(#qf.items, 2)
end)

test("set: empty findings clears quickfix list", function()
quickfix.set({ h.finding("/tmp/repo/a.lua", 0, E, "x") })
quickfix.set({})
vim.cmd("cclose")
local qf = vim.fn.getqflist({ items = 1 })
eq(#qf.items, 0)
end)

test("set: replaces existing quickfix content", function()
quickfix.set({ h.finding("/tmp/repo/a.lua", 0, E, "first") })
quickfix.set({ h.finding("/tmp/repo/b.lua", 1, W, "second") })
vim.cmd("cclose")
local qf = vim.fn.getqflist({ items = 1 })
eq(#qf.items, 1)
end)

h.summary()

-- Clean up temp dir after all tests complete
vim.fn.delete(populate_test_dir, "rf")
Loading