Skip to content
Open
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
4 changes: 3 additions & 1 deletion code_review_graph/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ def list_graph_stats_tool(
@mcp.tool()
def get_docs_section_tool(
section_name: str,
repo_root: Optional[str] = None,
) -> dict:
"""Get a specific section from the LLM-optimized documentation reference.

Expand All @@ -303,8 +304,9 @@ def get_docs_section_tool(

Args:
section_name: The section to retrieve (e.g. "review-delta", "usage").
repo_root: Repository root path. Auto-detected if omitted.
"""
return get_docs_section(section_name=section_name, repo_root=_default_repo_root)
return get_docs_section(section_name=section_name, repo_root=repo_root)


@mcp.tool()
Expand Down
33 changes: 21 additions & 12 deletions code_review_graph/skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ def generate_skills(repo_root: Path, skills_dir: Path | None = None) -> Path:
return skills_dir


def generate_hooks_config() -> dict[str, Any]:
def generate_hooks_config(repo_root: Path) -> dict[str, Any]:
"""Generate Claude Code hooks configuration.

Returns a hooks config dict with PostToolUse, SessionStart, and
Expand All @@ -340,25 +340,34 @@ def generate_hooks_config() -> dict[str, Any]:
Returns:
Dict with hooks configuration suitable for .claude/settings.json.
"""
repo_arg = json.dumps(repo_root.resolve().as_posix())
return {
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|Bash",
"command": "code-review-graph update --skip-flows",
"timeout": 5000,
"hooks": [
{
"type": "command",
"command": (
f"code-review-graph update --skip-flows "
f"--repo {repo_arg}"
),
"timeout": 5000,
}
],
},
],
"SessionStart": [
{
"command": "code-review-graph status",
"timeout": 3000,
},
],
"PreCommit": [
{
"command": "code-review-graph detect-changes --brief",
"timeout": 10000,
"matcher": "",
"hooks": [
{
"type": "command",
"command": f"code-review-graph status --repo {repo_arg}",
"timeout": 3000,
}
],
},
],
}
Expand All @@ -385,7 +394,7 @@ def install_hooks(repo_root: Path) -> None:
except (json.JSONDecodeError, OSError) as exc:
logger.warning("Could not read existing %s: %s", settings_path, exc)

hooks_config = generate_hooks_config()
hooks_config = generate_hooks_config(repo_root)
existing.update(hooks_config)

settings_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
Expand Down
69 changes: 51 additions & 18 deletions tests/test_skills.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
"""Tests for skills and hooks auto-install."""

import json
from pathlib import Path
from unittest.mock import patch

from code_review_graph.skills import (
_CLAUDE_MD_SECTION_MARKER,
PLATFORMS,
_build_server_entry,
generate_hooks_config,
generate_skills,
inject_claude_md,
Expand Down Expand Up @@ -78,38 +80,41 @@ def test_idempotent(self, tmp_path):

class TestGenerateHooksConfig:
def test_returns_dict_with_hooks(self):
config = generate_hooks_config()
config = generate_hooks_config(Path("/repo"))
assert "hooks" in config

def test_has_post_tool_use(self):
config = generate_hooks_config()
config = generate_hooks_config(Path("/repo"))
assert "PostToolUse" in config["hooks"]
hooks = config["hooks"]["PostToolUse"]
assert len(hooks) >= 1
assert hooks[0]["matcher"] == "Edit|Write|Bash"
assert "update" in hooks[0]["command"]
assert hooks[0]["timeout"] == 5000
assert hooks[0]["hooks"][0]["type"] == "command"
assert hooks[0]["hooks"][0]["command"].endswith('--repo "E:/repo"')
assert hooks[0]["hooks"][0]["timeout"] == 5000

def test_has_session_start(self):
config = generate_hooks_config()
config = generate_hooks_config(Path("/repo"))
assert "SessionStart" in config["hooks"]
hooks = config["hooks"]["SessionStart"]
assert len(hooks) >= 1
assert "status" in hooks[0]["command"]
assert hooks[0]["timeout"] == 3000
assert hooks[0]["matcher"] == ""
assert hooks[0]["hooks"][0]["command"].endswith('--repo "E:/repo"')
assert hooks[0]["hooks"][0]["timeout"] == 3000

def test_has_pre_commit(self):
config = generate_hooks_config()
assert "PreCommit" in config["hooks"]
hooks = config["hooks"]["PreCommit"]
assert len(hooks) >= 1
assert "detect-changes" in hooks[0]["command"]
assert hooks[0]["timeout"] == 10000
def test_quotes_repo_paths_with_spaces(self):
config = generate_hooks_config(Path("/repo with spaces"))
command = config["hooks"]["PostToolUse"][0]["hooks"][0]["command"]
assert '--repo "E:/repo with spaces"' in command

def test_does_not_emit_invalid_pre_commit_hook(self):
config = generate_hooks_config(Path("/repo"))
assert "PreCommit" not in config["hooks"]

def test_has_all_three_hook_types(self):
config = generate_hooks_config()
def test_has_only_valid_hook_types(self):
config = generate_hooks_config(Path("/repo"))
hook_types = set(config["hooks"].keys())
assert hook_types == {"PostToolUse", "SessionStart", "PreCommit"}
assert hook_types == {"PostToolUse", "SessionStart"}


class TestInstallHooks:
Expand All @@ -132,7 +137,7 @@ def test_merges_with_existing(self, tmp_path):
assert data["customSetting"] is True
assert "PostToolUse" in data["hooks"]
assert "SessionStart" in data["hooks"]
assert "PreCommit" in data["hooks"]
assert "PreCommit" not in data["hooks"]

def test_creates_claude_directory(self, tmp_path):
install_hooks(tmp_path)
Expand Down Expand Up @@ -183,6 +188,14 @@ def test_idempotent_with_existing_content(self, tmp_path):


class TestInstallPlatformConfigs:
def test_build_server_entry_includes_repo_target(self):
entry = _build_server_entry(
PLATFORMS["claude"],
key="claude",
repo_target="grimoirescribe",
)
assert entry["args"][-2:] == ["--repo", "grimoirescribe"]

def test_install_cursor_config(self, tmp_path):
with patch.dict(PLATFORMS, {
"cursor": {**PLATFORMS["cursor"], "detect": lambda: True},
Expand Down Expand Up @@ -307,3 +320,23 @@ def test_continue_array_no_duplicate(self, tmp_path):
install_platform_configs(tmp_path, target="continue")
data = json.loads(config_path.read_text())
assert len(data["mcpServers"]) == 1

def test_install_claude_user_scope_uses_cli(self, tmp_path):
with patch("code_review_graph.skills.shutil.which") as mock_which:
mock_which.side_effect = lambda name: name
with patch("code_review_graph.skills.subprocess.run") as mock_run:
configured = install_platform_configs(
tmp_path,
target="claude",
scope="user",
repo_target="grimoirescribe",
)

assert configured == ["Claude Code (user)"]
assert mock_run.call_count == 2
add_cmd = mock_run.call_args_list[1].args[0]
assert add_cmd[:6] == [
"claude", "mcp", "add-json", "--scope", "user", "code-review-graph",
]
assert "--repo" in add_cmd[-1]
assert "grimoirescribe" in add_cmd[-1]
13 changes: 13 additions & 0 deletions tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,19 @@ def test_search_edges_by_target_name(self):
class TestGetDocsSection:
"""Tests for the get_docs_section tool."""

def test_explicit_repo_root_uses_that_docs_file(self, tmp_path):
docs_dir = tmp_path / "docs"
docs_dir.mkdir()
(docs_dir / "LLM-OPTIMIZED-REFERENCE.md").write_text(
'<section name="usage">hello</section>\n',
encoding="utf-8",
)

result = get_docs_section("usage", repo_root=str(tmp_path))

assert result["status"] == "ok"
assert result["content"] == "hello"

def test_section_not_found(self):
result = get_docs_section("nonexistent-section")
assert result["status"] == "not_found"
Expand Down