Skip to content
Draft
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
Add opt-in CodeRabbit review reminder hook
  • Loading branch information
nehal-a2z committed Sep 29, 2026
commit a872c0b56c2eaf4a5d342d9d56f5a95f2d12b957
29 changes: 29 additions & 0 deletions plugins/coderabbit/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# CodeRabbit review reminder

The plugin includes an optional `Stop` hook for Codex versions with plugin-hook
support. With Python 3 and Git on `PATH`, start a session with:

```sh
CODERABBIT_REVIEW_REMINDER=1 codex
```

Review and trust the bundled hook through `/hooks` (or the app's hook review UI).
For the desktop app, the environment variable must be present in the app's
environment before it starts. Unset it or set it to `0` to disable the reminder.

When tracked staged or unstaged changes exist, the hook gives the agent one
reminder to use the CodeRabbit code-review skill if this task still needs an
authorized review. It does not run the CLI, upload code, or grant permission for
review, authentication, spending, or fixes. The agent may finish immediately
when review is already done, unrelated, unavailable, or not authorized.

This is a stateless reminder, not a review gate: it does not know who made the
changes or whether they were reviewed. It can remind again on a later user
turn. It skips hook continuations, plan mode, clean/non-Git directories, raw
untracked files, submodule-only changes, and changes already committed.

The hook uses `CLAUDE_PLUGIN_ROOT`, which Codex supplies for compatibility, so
the command and script can also be used by the Claude Code plugin.

Validation: `python3 plugins/coderabbit/hooks/test_review_reminder.py` from the
repository root.
15 changes: 15 additions & 0 deletions plugins/coderabbit/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/review_reminder.py\"",
"timeout": 5
}
]
}
]
}
}
46 changes: 46 additions & 0 deletions plugins/coderabbit/hooks/review_reminder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Opt-in, local-only reminder; review execution stays with the agent's skill."""

import json
import os
import subprocess
import sys


def main():
if os.environ.get("CODERABBIT_REVIEW_REMINDER") != "1":
return
try:
event = json.load(sys.stdin)
if not isinstance(event, dict) or event.get("stop_hook_active") is not False:
return
if event.get("hook_event_name") != "Stop" or event.get("permission_mode") == "plan":
return
cwd = event.get("cwd")
if not isinstance(cwd, str) or not os.path.isabs(cwd):
return
status = subprocess.run(
["git", "status", "--porcelain=v1", "--untracked-files=no", "--ignore-submodules=all"],
cwd=cwd, capture_output=True, timeout=3,
env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"},
)
if status.returncode != 0 or not status.stdout:
return
except (ValueError, OSError, subprocess.TimeoutExpired):
return

print(json.dumps({
"decision": "block",
"reason": (
"CodeRabbit review reminder: tracked uncommitted changes exist. "
"If this task changed code that still needs review, use the CodeRabbit "
"code-review skill before finishing, preserving the user's scope and permissions. "
"If review is not authorized, already covers these changes, is unavailable, "
"or the changes are unrelated to this task, finish without starting a review. "
"This reminder does not authorize uploads, login, spending, or fixes. "
"Report skipped or incomplete review honestly; do not retry just for this reminder."
),
}))


if __name__ == "__main__":
main()
61 changes: 61 additions & 0 deletions plugins/coderabbit/hooks/test_review_reminder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Exercise the configured hook command against disposable Git repositories."""

import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest


PLUGIN = Path(__file__).resolve().parents[1]
COMMAND = json.loads((PLUGIN / "hooks/hooks.json").read_text())["hooks"]["Stop"][0]["hooks"][0]["command"]


class ReviewReminderTest(unittest.TestCase):
def test_reminder_boundaries(self):
with tempfile.TemporaryDirectory(prefix="review reminder ") as directory:
repo = Path(directory)
env = {**os.environ, "CODERABBIT_REVIEW_REMINDER": "1", "CLAUDE_PLUGIN_ROOT": str(PLUGIN)}
event = {"hook_event_name": "Stop", "stop_hook_active": False, "cwd": directory}

def run(payload=event, enabled="1"):
result = subprocess.run(
COMMAND, shell=True, input=json.dumps(payload), text=True,
capture_output=True, env={**env, "CODERABBIT_REVIEW_REMINDER": enabled},
timeout=5,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stderr, "")
return json.loads(result.stdout) if result.stdout else None

def git(*args):
subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True)

self.assertIsNone(run()) # Outside Git.
git("init")
(repo / "new.py").write_text("print('hello')\n")
self.assertIsNone(run()) # Raw untracked files are excluded.
git("add", "new.py")
self.assertEqual(run()["decision"], "block") # Unborn branch, staged file.
self.assertIsNone(run(enabled=""))
self.assertIsNone(run(enabled="0"))
self.assertIsNone(run({**event, "stop_hook_active": True}))
self.assertIsNone(run({**event, "permission_mode": "plan"}))
self.assertIsNone(run({**event, "hook_event_name": "SessionStart"}))
self.assertIsNone(run({**event, "cwd": "relative/path"}))
self.assertIsNone(run([]))
self.assertIsNone(run({}))
git("-c", "user.name=Hook Test", "-c", "user.email=hook@example.invalid",
"-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", "commit", "-m", "fixture")
self.assertIsNone(run()) # Clean, committed changes do not trigger.
(repo / "new.py").write_text("print('changed')\n")
self.assertEqual(run()["decision"], "block") # Tracked unstaged edit.
git("add", "new.py")
self.assertEqual(run()["decision"], "block") # Staged edit.
(repo / "new.py").unlink()
self.assertEqual(run()["decision"], "block") # Tracked deletion.


if __name__ == "__main__":
unittest.main()