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
40 changes: 40 additions & 0 deletions nerve/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2080,6 +2080,44 @@ def from_dict(cls, d: dict) -> McpEndpointConfig:
)


@dataclass
class CodeReviewConfig:
"""Local code-review panel — browse on-disk git worktrees and exchange
line-anchored review comments with the agent, before anything is
committed or pushed.

Off by default; set ``enabled: true`` and list the repository roots you
want reviewable under ``code_review`` in config.local.yaml, e.g.::

code_review:
enabled: true
repos:
- ~/nerve
- ~/project

Only files inside a configured repo root (or one of its git worktrees)
are served. Authenticated with the existing web-UI JWT — same token
mechanism as the rest of the API.
"""

enabled: bool = False
repos: list[str] = field(default_factory=list)
max_file_bytes: int = 2_000_000 # skip diffing/serving files larger than this

@classmethod
@_coerced
def from_dict(cls, d: dict) -> "CodeReviewConfig":
# Pass raw values through; @_coerced normalizes them to the declared
# field types (str->bool for `enabled`, a bare scalar->one-element list
# for `repos`, str->int for `max_file_bytes`) the same way every other
# config section is coerced. Casting here would defeat that.
return cls(
enabled=d.get("enabled", False),
repos=d.get("repos") or [],
max_file_bytes=d.get("max_file_bytes", 2_000_000),
)


@dataclass
class ExternalAgentTargetConfig:
"""One configured external agent (Codex, Claude Code, ...).
Expand Down Expand Up @@ -2618,6 +2656,7 @@ class NerveConfig:
mcp_endpoint: McpEndpointConfig = field(default_factory=McpEndpointConfig)
mcp_servers: list[McpServerConfig] = field(default_factory=list)
external_agents: ExternalAgentsConfig = field(default_factory=ExternalAgentsConfig)
code_review: CodeReviewConfig = field(default_factory=CodeReviewConfig)

# API keys (from config.local.yaml)
anthropic_api_key: str = ""
Expand Down Expand Up @@ -2846,6 +2885,7 @@ def _build_from_dict(cls, d: dict) -> NerveConfig:
mcp_endpoint=McpEndpointConfig.from_dict(d.get("mcp_endpoint", {})),
mcp_servers=_parse_mcp_servers(d),
external_agents=ExternalAgentsConfig.from_dict(d.get("external_agents", {})),
code_review=CodeReviewConfig.from_dict(d.get("code_review", {})),
anthropic_api_key=d.get("anthropic_api_key", ""),
openai_api_key=d.get("openai_api_key", ""),
brave_search_api_key=d.get("brave_search_api_key", ""),
Expand Down
2 changes: 2 additions & 0 deletions nerve/db/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from nerve.db.notifications import NotificationStore
from nerve.db.plans import PlanStore
from nerve.db.review_loops import ReviewLoopStore
from nerve.db.reviews import ReviewStore
from nerve.db.sessions import SessionStore
from nerve.db.skills import SkillStore
from nerve.db.sources import SourceStore
Expand Down Expand Up @@ -94,6 +95,7 @@ class Database(
TaskStore,
TaskStatusStore,
PlanStore,
ReviewStore,
NotificationStore,
SourceStore,
CronStore,
Expand Down
70 changes: 70 additions & 0 deletions nerve/db/migrations/v045_code_reviews.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""V45: Local code-review panel — reviews, line-anchored threads, comments.

Backs the ``code_review`` feature: a browser panel for reviewing on-disk git
worktrees before anything is committed/pushed, with comment threads anchored
to a file + line range that route into a Nerve session and back.

Purely additive — three new tables, no changes to existing schema — so this
migration is safe to apply to an existing database and trivially reversible by
dropping the tables.
"""

from __future__ import annotations

import logging

import aiosqlite

logger = logging.getLogger(__name__)


async def up(db: aiosqlite.Connection) -> None:
await db.executescript(
"""
CREATE TABLE IF NOT EXISTS code_reviews (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
repo_root TEXT NOT NULL,
worktree TEXT NOT NULL,
branch TEXT,
base_ref TEXT NOT NULL DEFAULT 'HEAD',
target_session_id TEXT,
created_by TEXT NOT NULL DEFAULT 'human', -- 'human' | 'agent'
status TEXT NOT NULL DEFAULT 'open', -- 'open' | 'resolved'
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_code_reviews_status
ON code_reviews(status);
CREATE INDEX IF NOT EXISTS idx_code_reviews_session
ON code_reviews(target_session_id);

CREATE TABLE IF NOT EXISTS code_review_threads (
id TEXT PRIMARY KEY,
review_id TEXT NOT NULL REFERENCES code_reviews(id)
ON DELETE CASCADE ON UPDATE CASCADE,
file_path TEXT NOT NULL, -- repo-relative
side TEXT NOT NULL DEFAULT 'new', -- 'new' | 'old'
line_start INTEGER,
line_end INTEGER,
anchor_snippet TEXT, -- line text at creation
status TEXT NOT NULL DEFAULT 'open', -- 'open' | 'answered' | 'resolved'
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_code_review_threads_review
ON code_review_threads(review_id);

CREATE TABLE IF NOT EXISTS code_review_comments (
id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL REFERENCES code_review_threads(id)
ON DELETE CASCADE ON UPDATE CASCADE,
author TEXT NOT NULL, -- 'human' | 'agent'
body TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_code_review_comments_thread
ON code_review_comments(thread_id);
"""
)
logger.info("v040: created code_reviews, code_review_threads, code_review_comments")
29 changes: 29 additions & 0 deletions nerve/db/migrations/v046_code_review_pending.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""V46: draft/pending flag on code-review comments.

Human review comments are staged as *pending* (drafts) until the reviewer hits
"Submit review", at which point the whole batch is delivered to the target
session as a single turn — instead of one turn per comment. This adds the
column that tracks that state. Additive + idempotent.
"""

from __future__ import annotations

import logging

import aiosqlite

logger = logging.getLogger(__name__)


async def _columns(db: aiosqlite.Connection, table: str) -> set[str]:
async with db.execute(f"PRAGMA table_info({table})") as cursor:
return {str(row[1]) for row in await cursor.fetchall()}


async def up(db: aiosqlite.Connection) -> None:
cols = await _columns(db, "code_review_comments")
if "pending" not in cols:
await db.execute(
"ALTER TABLE code_review_comments ADD COLUMN pending INTEGER NOT NULL DEFAULT 0"
)
logger.info("v046: added code_review_comments.pending")
Loading
Loading