diff --git a/code_review_graph/main.py b/code_review_graph/main.py index 37c395b..4a5025e 100644 --- a/code_review_graph/main.py +++ b/code_review_graph/main.py @@ -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. @@ -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() diff --git a/code_review_graph/skills.py b/code_review_graph/skills.py index 18c555c..ccfc8d6 100644 --- a/code_review_graph/skills.py +++ b/code_review_graph/skills.py @@ -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 @@ -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, + } + ], }, ], } @@ -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") diff --git a/tests/test_skills.py b/tests/test_skills.py index bad8b8b..b329ec8 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -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, @@ -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: @@ -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) @@ -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}, @@ -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] diff --git a/tests/test_tools.py b/tests/test_tools.py index 7eede63..0b297b3 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -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( + '
hello
\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"