A unified, offline-first, non-invasive Merge/Pull Request code review plugin for Neovim. Review changes locally across GitHub and GitLab using a local SQLite cache.
This plugin is still experimental and under active development.
- Do not use this as your primary review tool yet.
- The API, commands, configuration, and storage format may change or break without notice between versions.
- There are a bunch of performance issues and likely bugs along the way that may trigger unexpected conditions.
- Bugs and data-loss edge cases are possible. Use it at your own risk.
If you do try it, prefer testing against a throwaway repository or a non-critical branch before relying on it for real reviews.
graph TD
U([User]) -->|LocalReviewComment| SC["Comment scratchpad"]
SC -->|instant save| DB[("SQLite cache")]
DB -->|threads, comments| ST["Content state per comment<br/>DRAFT / SYNCED / MODIFIED / DELETED / CONFLICT"]
DB -->|push_ops| OB["Submit outbox<br/>QUEUED / RUNNING / DONE / FAILED"]
DB -->|issues, issue_notes| IC["Issue tracker cache"]
DB -->|repo_users| UX["Offline reviewer index"]
U -->|LocalReviewSubmit| CF["Confirm float<br/>verdict comment / approve / request"]
CF -->|async answer| OB
OB -->|enqueue, dedupe by c_id + op| WRK["uv.new_work worker<br/>isolated Lua state, msgpack"]
WRK -->|io.popen fallback| AD["Adapter<br/>github / gitlab / custom"]
AD -->|gh, glab CLI + capability flags| HUB[["GitHub / GitLab"]]
WRK -->|per-op DONE + state in one txn| DB
WRK -->|fetch, reconcile, adopt uncertain| DB
U -->|LocalReviewPull| WRK
U -->|LocalReviewIssuePull| PG["Page driver<br/>sequential, non-blocking"]
PG --> AD
PG -->|upsert per page| IC
IC -->|FTS5 trigram| SR["Local issue search"]
U -->|LocalReviewConflicts| CX["Conflict float<br/>keep local or accept remote"]
DB -->|open conflicts block submit| CX
CX -->|clears the gate| DB
WRK -->|queued notifications| RN["vim.notify replay on main thread"]
DB -->|mark_dirty| SB["Sync bus, debounced ~80ms"]
DC["In-memory diff cache"] --> SB
DC -. changed_lines on worker .-> WRK
SB --> DEC["Gutter signs + virtual text"]
SB --> SUM["Summary panel"]
SB --> TV["Thread view float"]
GC["Startup GC<br/>drafts preserved"] --> DB
sequenceDiagram
participant U as User
participant S as LocalReviewSubmit
participant F as Confirm float
participant O as Outbox push_ops
participant W as uv.new_work worker
participant A as Adapter
participant H as Hub API
participant D as SQLite
U->>S: submit the review
S->>F: pending changes + verdict choices
Note over F: returns immediately —<br/>the answer arrives on a keypress
U->>F: a / c / q
F-->>S: verdict (async continuation)
S->>O: enqueue one op per mutation (dedupe by c_id + op)
S->>W: queue job (cwd, detail, verdict, thread_id)
W->>D: op RUNNING, the crash marker
W->>A: create threads / reply / edit / delete
A->>H: gh api · glab api
H-->>A: result
alt call succeeded
A-->>W: ok, with a remote id when the hub returns one
W->>D: txn — op DONE and comment state advanced together
Note over W,D: a later failure can never<br/>re-send what already landed
else call failed
A-->>W: error
W->>D: op FAILED, remaining ops stay QUEUED
Note over W,D: nothing is stranded — the next<br/>submit retries exactly those
end
W->>A: fetch remote threads
A->>H: discussions / review threads
W->>D: reconcile and adopt RUNNING-recovered ops by body match
W-->>U: pushed N, with M still queued when a call failed
W->>D: notifications replayed, diff cache invalidated
Note over U,D: the sync bus refreshes decor and panels
sequenceDiagram
participant U as User
participant P as Page driver
participant W as uv.new_work worker
participant A as Adapter
participant H as Hub API
participant D as SQLite
U->>P: LocalReviewIssuePull open
loop one page at a time until a short page
P->>W: queue fetch page N (per_page, state, filters)
W->>A: list_issues(N)
A->>H: gh api · glab api
H-->>A: page of issues
A-->>W: mapped rows, PRs filtered out
W-->>P: rows via MessagePack
P->>D: upsert page in one txn (FTS triggers update)
P-->>U: progress — page N, K cached so far
end
P->>D: meta issues_synced_at (staleness marker)
Note over U,D: LocalReviewIssues then searches the cache locally,<br/>so issue refs in an MR description resolve with zero network
- Offline-First Workflow: Save draft comments locally instantly; publish them all in a single batch once you finish the review.
- Idempotent Submit (outbox): every pending mutation is one
push_opsrow. Each row is committed the moment its call succeeds, so a half-failed submit never re-posts what already landed, and nothing can be stranded in a state you cannot push. Re-run:LocalReviewSubmitto retry exactly what is left. - Asynchronous Engine: Background pulling and pushing run on
uv.new_workworker threads, so Neovim never freezes on a network request. Issue lists are paged sequentially with progress, also off the UI thread. - Unified Adapter Structure: Handles both GitLab Merge Requests and GitHub Pull Requests with a single unified set of commands and UI. Platform differences that cannot be abstracted (batch vs per-note creation, verdict delivery) are declared as adapter capability flags rather than guessed.
- Issue Tracker Context: GitHub/GitLab issues are cached locally with FTS5 search, so you can see what an MR is actually about (
:LocalReviewIssues,:LocalReviewIssue 42) without leaving the editor — and an MR description's#123references resolve from cache with zero network. - Conflict Awareness: If the hub edits a comment you are editing, submit is blocked and
:LocalReviewConflictsshows your version against the remote's so you can keep or accept it. - Auto-Garbage Collection: Keeps the SQLite cache clean by purging old closed/merged reviews after 30 days while safeguarding your local drafts.
- Dynamic Context Resolution: Handles forks, nested subgroups (GitLab), and multiple concurrent repositories seamlessly in a single database.
- Neovim
0.10+ sqlite3installed on your system- sqlite.lua dependency
- Platform CLIs configured and authenticated:
ghCLI (for GitHub)glabCLI (for GitLab)
Using lazy.nvim:
{
"zerosign/lreview.nvim",
dependencies = {
"kkharji/sqlite.lua",
},
rocks = { "lsqlite3" },
opts = {
defaults = {
db_path = vim.fn.stdpath("data") .. "/lreview/lreview.db",
ui = {
decor = "both", -- "both" | "sign" | "virtual_text" | "none"
layout = "float", -- "float" | "split" | "vsplit" | "buffer"
},
},
["github\\.com"] = {
adapter = "github",
provider = "gh",
host = "github.com",
},
["gitlab\\.com|gitlab\\..*"] = {
adapter = "gitlab",
provider = "glab",
host = "gitlab.com",
},
},
config = function(_, opts)
require("lreview").setup(opts)
end,
keys = {
{ "<leader>oq", "<cmd>LocalReviewQuery<cr>", desc = "List PRs/MRs" },
{ "<leader>ot", "<cmd>LocalReviewToggle<cr>", desc = "Toggle Review" },
{ "<leader>op", "<cmd>LocalReviewPull<cr>", desc = "Pull/Sync Review Comments" },
{ "<leader>os", "<cmd>LocalReviewSubmit<cr>", desc = "Submit PR Review" },
{ "<leader>od", "<cmd>LocalReviewSummary<cr>", desc = "View Review Summary Panel" },
{ "<leader>oA", "<cmd>LocalReviewApprove<cr>", desc = "Approve MR/PR" },
{ "<leader>oC", "<cmd>LocalReviewClose<cr>", desc = "Close MR/PR" },
{ "<leader>on", "<cmd>LocalReviewCreate<cr>", desc = "Create MR/PR" },
{ "<leader>oc", ":LocalReviewComment<cr>", mode = "x", desc = "Add inline review comment" },
},
}Run :LocalReviewToggle (or press <leader>ot) in a repository. The plugin automatically and lazily resolves the git branch target MR/PR, queries the API, opens the SQLite database, and syncs all remote discussions to SQLite under the hood.
- View Comments: Hover your cursor over a line marked with comments. The comments panel will open automatically showing the discussion thread.
- Add a Comment: Make a visual selection of code lines and run
:LocalReviewComment(or press<leader>oc). Write your comment and save with:wor<leader>s(automatically closes and caches it as a draft).
Press K or hover to focus the thread panel. Use these buffer-local keys:
r: Reply to the thread (opens a scratchpad).e: Edit your local draft comment.d: Delete your local draft comment.s: Toggle Resolved/Reopened thread state.q/<esc>: Close the thread view.
Once you finish your review, run :LocalReviewSubmit (or <leader>os) to batch push all local drafts to the remote platform.
| Command | Description |
|---|---|
:LocalReviewQuery [scope] |
Query and list active MRs for this repository (mine or all). |
:LocalReviewStart |
Resolve the MR/PR for the current branch and start a review session (non-blocking). |
:LocalReviewDetail [number] |
Open/edit metadata and description for a specific MR/PR. |
:LocalReviewToggle |
Toggle buffer review signs and virtual text annotations. |
:LocalReviewComment |
Add an inline draft comment for the visual selection (instant, local). |
:LocalReviewSubmit |
Push all pending drafts, edits, and deletions via the outbox. |
:LocalReviewPull |
Sync the latest remote discussions into the local cache. |
:LocalReviewOpen / :LocalReviewHover |
Show the thread at the cursor in a float. |
:LocalReviewNext / :LocalReviewPrev |
Jump to the next/previous comment thread in the buffer. |
:LocalReviewSummary |
Open the interactive review summary panel (g toggles Threads/Files). |
:LocalReviewConflicts |
List blocked conflicts with your version vs. the remote; k keeps yours, a accepts theirs. |
:LocalReviewApprove / :LocalReviewClose |
Approve or close the active MR/PR. |
:LocalReviewCreate |
Create a new MR/PR using template and branch pickers. |
:LocalReviewRequestReview |
Request a review from a cached repo user (offline-first picker). |
:LocalReviewBranch <name> |
Materialize a branch locally (checkout or worktree per topology config). |
:LocalReviewPullUser |
Cache the repository's user list for reviewer autocomplete. |
:LocalReviewPullRequest |
Cache the open MR/PR list for link autocomplete. |
:LocalReviewIssuePull [state] |
Pull the issue list page by page (sequential, progress reported, UI never blocked). |
:LocalReviewIssues [state] |
Pick a cached issue (open/closed/all) and view it; refreshes when stale. |
:LocalReviewIssue <number> |
View one issue by number, pulling it from the hub only if uncached. |
Pass these options inside the setup() block to customize behaviors:
require("lreview").setup({
defaults = {
db_path = vim.fn.stdpath("data") .. "/lreview/lreview.db",
db = {
busy_timeout_ms = 5000,
keep_days = 30, -- Garbage collect reviews older than 30 days
},
ui = {
decor = "both", -- Annotation style: "sign" | "virtual_text" | "both" | "none"
layout = "float", -- Float style: "float" | "split" | "vsplit" | "buffer"
float = {
border = "rounded",
width = 0.5, -- Floating window width (percentage of editor)
height = 0.6, -- Floating window height (percentage of editor)
},
split = {
position = "botright",
size = 15,
}
},
open = { method = "checkout" },
-- Issue tracker cache: the context an MR description usually points at.
issues = {
per_page = 50, -- hub page size for the sequential issue pull
max_pages = 20, -- hard stop, so a huge tracker cannot run away
stale_after_min = 30, -- :LocalReviewIssues refreshes the cache past this age
},
}
})The plugin highlights gutter line numbers and signs to represent review statuses and code changes. These are linked to standard Vim groups by default so they match your theme automatically, but you can override them in your configuration:
LReviewSignDraft(Default:WarningMsglink) - Sign column indicator for draft comment threads.LReviewSignSynced(Default:Commentlink) - Sign column indicator for active remote threads.LReviewNumDraft(Default:DiffChangelink) - Gutter line number background highlight for drafts.LReviewNumSynced(Default:DiffAddlink) - Gutter line number background highlight for active threads.LReviewNumResolved(Default:Commentlink) - Gutter line number background highlight for resolved threads.LReviewDiffAdd(Default:DiffAddlink) - Gutter line number background highlight for git diff changes.
lreview.nvim is fully extensible. You can add support for other hosting platforms (e.g. Gitea, Sourcehut, or custom internal systems) by defining a custom adapter module and registering it through the opts setup without changing any plugin code:
-
Create your custom adapter file (e.g.
lua/my_adapters/gitea.lua). These are the signatures the engine calls today — the argument order is part of the contract (a batch submit once passed a comment body where a remote id belonged, which 404'd and crashed):local M = {} M.name = "gitea" -- canonical provider key used in stored ids M.provider = "gitea-cli" -- CLI binary the ops shell out to -- Required: discovery + fetching function M.list_pull_requests(cfg, ctx, opts) end -- opts.scope = "mine"|"all" function M.get_mr_detail(cfg, ctx) end -- ctx.ref = number|branch function M.get_mr_by_branch(cfg, ctx, branch) end function M.fetch_threads(cfg, ctx, number, mo_id) end -- -> lreview.Thread[] -- Required: the mutation surface used by submit (storage/outbox.lua drives these) function M.submit_inline_review(cfg, ctx, number, comments, body, opts) end -- -> ok, err function M.submit_reply(cfg, ctx, number, thread_id, body) end -- -> remote_comment_id, err function M.update_comment(cfg, ctx, number, thread_id, comment_id, body) end -- -> ok, err function M.delete_comment(cfg, ctx, number, thread_id, comment_id) end -- -> ok, err function M.resolve_thread(cfg, ctx, number, thread_id, resolved) end -- -> ok, err -- Optional, each gated by a capability flag (see the table below) function M.submit_verdict(cfg, ctx, number, verdict, body) end -- review_verdict function M.approve_mr(cfg, ctx, number) end -- approve_mr function M.close_mr(cfg, ctx, number) end -- close_mr function M.update_mr(cfg, ctx, number, title, body) end -- update_mr function M.create_mr(cfg, ctx, opts) end -- draft_mr function M.list_templates(cfg, ctx) end -- list_templates function M.list_users(cfg, ctx) end -- assign_reviewers function M.assign_reviewers(cfg, ctx, number, reviewers) end -- assign_reviewers function M.list_issues(cfg, ctx, opts) end -- issues function M.get_issue(cfg, ctx, number) end -- issues function M.fetch_issue_notes(cfg, ctx, number, opts) end -- issues M.capabilities = { issues = true } -- anything unset falls back to the base defaults return M
Any method you do not implement is simply skipped — but declare the capability only if you implement it.
review_verdictwith nosubmit_verdictis reported as a failed op rather than silently dropping your approve. -
Declare how your hub's submit behaves. These three flags decide the shape of the network calls, and getting them wrong is what causes duplicate or dropped posts:
Capability Meaning GitHub GitLab batch_submitcan create several inline comments in one call truefalseatomic_inline_batchthat call is all-or-nothing, so nothing partially lands truefalseverdict_in_review_batchthe review event can ride along with that call truefalseAnything unset inherits
adapter/base.luadefaults (atomic_inline_batchfalse), which makes the engine loop per-op and commit each success individually — always the safe assumption for a hub you have not profiled. -
Register it in your configuration:
opts = { ["gitea\\.mycompany\\.com"] = { adapter = "my_adapters.gitea", -- Dynamically required on remote host match provider = "gitea-cli", host = "gitea.mycompany.com", } }
- SQLite Engine Compatibility (Flavors): The storage layer dynamically binds to the system
libsqlite3.solibrary using LuaJIT FFI (Foreign Function Interface) for native C execution speed and zero-overhead Neovim startup. If LuaJIT FFI is unavailable, it automatically falls back to loading the standard LuaRockslsqlite3module. - WAL Mode & Concurrency: SQLite is initialized with Write-Ahead Logging (
PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;) to support concurrent read and write operations. This prevents SQLite lockups and lets background pull workers update comments without blocking the main Neovim thread. - Submit Outbox (idempotency): pending mutations live as
push_opsrows (QUEUED → RUNNING → DONE | FAILED) instead of a flag on the comment. A comment's own state only ever describes its content, so an interrupted submit cannot hide drafts; each success is committed with its remote id the moment the call returns, so a retry never re-posts what already landed. An op interrupted mid-call is re-queued asuncertainand adopted by body-match on the next sync. - Index-Driven Queries: Schema v4 keeps flat columns for everything a query filters or sorts on (branch resolution, issue lists, outbox scans) and uses partial indexes for predicate-only lookups (
WHERE remote_id IS NULL,WHERE status = 'RUNNING'). Display-only data stays in MessagePack payload blobs so it costs no write amplification. - Differential Context: changed-line sets are cached in memory per
(repo, path)against the merge base (git diff -U0 target...), computed on a worker thread and refreshed through the debounced sync bus. - Log Safety: command lines are masked before they reach any log or error message — token--shaped arguments are redacted and long payloads truncated, so a hub credential can never end up in a timing file or a notification.
- Safety Gate Keepers: Automatic garbage collection runs asynchronously on startup. Old closed/merged reviews, stale outbox ledger rows, and long-closed issues are deleted to keep the database size minimal. Local drafts and threads with draft comments are strictly preserved and skipped by the garbage collector.
Tests, coverage, and benchmarks run through the sandbox Neovim (scripts/sandbox-nvim.fish) so they never touch your real ~/.config/nvim. All recipes are defined in the justfile:
just test # unit tests (offline, mock-based)
just test-one test_git # run a single test by name
just test-integration # integration tests (hit live GitHub/GitLab APIs)
just test-all # unit + integration
just coverage # unit tests under luacov -> luacov.report.out
just coverage-summary # compact coverage summary
just bench # SQL query latency benchmark (1000 iterations)Coverage is scoped to lua/lreview/ via .luacov. The integration tests require authenticated gh/glab CLIs and the seeded sample repos in tmp/.
License: MIT