diff --git a/.github/code-review-graph.instruction.md b/.github/code-review-graph.instruction.md new file mode 100644 index 00000000..1d51118b --- /dev/null +++ b/.github/code-review-graph.instruction.md @@ -0,0 +1,43 @@ +--- +applyTo: '**' +description: Use code-review-graph MCP tools for token-efficient codebase exploration and code review instead of built-in file/search tools. +--- + + +## MCP Tools: code-review-graph + +**IMPORTANT: This project has a knowledge graph. ALWAYS use the +code-review-graph MCP tools BEFORE using #tool:read/readFile #tool:search/fileSearch #tool:search/textSearch to explore +the codebase.** The graph is faster, cheaper (fewer tokens), and gives +you structural context (callers, dependents, test coverage) that file +scanning cannot. + +### When to use graph tools FIRST + +- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep +- **Understanding impact**: `get_impact_radius` instead of manually tracing imports +- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files +- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for +- **Architecture questions**: `get_architecture_overview` + `list_communities` + +Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need. + +### Key Tools + +| Tool | Use when | +| ------ | ---------- | +| `detect_changes` | Reviewing code changes — gives risk-scored analysis | +| `get_review_context` | Need source snippets for review — token-efficient | +| `get_impact_radius` | Understanding blast radius of a change | +| `get_affected_flows` | Finding which execution paths are impacted | +| `query_graph` | Tracing callers, callees, imports, tests, dependencies | +| `semantic_search_nodes` | Finding functions/classes by name or keyword | +| `get_architecture_overview` | Understanding high-level codebase structure | +| `refactor_tool` | Planning renames, finding dead code | + +### Workflow + +1. The graph auto-updates on file changes (via hooks). +2. Use `detect_changes` for code review. +3. Use `get_affected_flows` to understand impact. +4. Use `query_graph` pattern="tests_for" to check coverage. diff --git a/README.md b/README.md index 07249031..69ec730f 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ code-review-graph build # parse your codebase One command sets up everything. `install` detects which AI coding tools you have, writes the correct MCP configuration for each one, and injects graph-aware instructions into your platform rules. It auto-detects whether you installed via `uvx` or `pip`/`pipx` and generates the right config. Restart your editor/tool after installing.

- One Install, Every Platform: auto-detects Codex, Claude Code, Cursor, Windsurf, Zed, Continue, OpenCode, Antigravity, Qwen, Qoder, and Kiro + One Install, Every Platform: auto-detects Codex, Claude Code, Cursor, Windsurf, Zed, Continue, OpenCode, Antigravity, Qwen, Qoder, Kiro, and GitHub Copilot

To target a specific platform: @@ -55,6 +55,8 @@ code-review-graph install --platform codex # configure only Codex code-review-graph install --platform cursor # configure only Cursor code-review-graph install --platform claude-code # configure only Claude Code code-review-graph install --platform kiro # configure only Kiro +code-review-graph install --platform copilot # configure only GitHub Copilot (VS Code) +code-review-graph install --platform copilot-cli # configure only GitHub Copilot CLI ``` Requires Python 3.10+. For the best experience, install [uv](https://docs.astral.sh/uv/) (the MCP config will use `uvx` if available, otherwise falls back to the `code-review-graph` command directly). @@ -511,5 +513,5 @@ MIT. See [LICENSE](LICENSE).
code-review-graph.com

pip install code-review-graph && code-review-graph install
-Works with Codex, Claude Code, Cursor, Windsurf, Zed, Continue, OpenCode, Antigravity, Qwen, Qoder, and Kiro +Works with Codex, Claude Code, Cursor, Windsurf, Zed, Continue, OpenCode, Antigravity, Qwen, Qoder, Kiro, GitHub Copilot, and GitHub Copilot CLI

diff --git a/code_review_graph/cli.py b/code_review_graph/cli.py index 18e9d527..86411aeb 100644 --- a/code_review_graph/cli.py +++ b/code_review_graph/cli.py @@ -52,7 +52,8 @@ # Shared platform choices for install and init commands _PLATFORM_CHOICES = [ "codex", "claude", "claude-code", "cursor", "windsurf", "zed", - "continue", "opencode", "antigravity", "qwen", "kiro", "qoder", "all", + "continue", "opencode", "antigravity", "qwen", "kiro", "qoder", + "copilot", "copilot-cli", "all", ] diff --git a/code_review_graph/skills.py b/code_review_graph/skills.py index 66265534..efddc136 100644 --- a/code_review_graph/skills.py +++ b/code_review_graph/skills.py @@ -120,6 +120,22 @@ def _zed_settings_path() -> Path: "format": "object", "needs_type": True, }, + "copilot": { + "name": "GitHub Copilot", + "config_path": lambda root: root / ".vscode" / "mcp.json", + "key": "servers", + "detect": lambda: (Path.home() / ".vscode").exists(), + "format": "object", + "needs_type": True, + }, + "copilot-cli": { + "name": "GitHub Copilot CLI", + "config_path": lambda root: Path.home() / ".copilot" / "mcp-config.json", + "key": "servers", + "detect": lambda: (Path.home() / ".copilot").exists(), + "format": "object", + "needs_type": True, + }, } @@ -681,6 +697,59 @@ def install_hooks(repo_root: Path, platform: str = "claude") -> None: 4. Use `query_graph` pattern=\"tests_for\" to check coverage. """ +# Copilot-specific instruction file content: uses VS Code tool references and +# includes YAML front matter so Copilot Chat applies it across the workspace. +_COPILOT_SECTION = f"""--- +applyTo: '**' +description: Use code-review-graph MCP tools for token-efficient codebase exploration and code review instead of built-in file/search tools. +--- + +{_CLAUDE_MD_SECTION_MARKER} +## MCP Tools: code-review-graph + +**IMPORTANT: This project has a knowledge graph. ALWAYS use the +code-review-graph MCP tools BEFORE using #tool:read/readFile #tool:search/fileSearch #tool:search/textSearch to explore +the codebase.** The graph is faster, cheaper (fewer tokens), and gives +you structural context (callers, dependents, test coverage) that file +scanning cannot. + +### When to use graph tools FIRST + +- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of #tool:search/fileSearch +- **Understanding impact**: `get_impact_radius` instead of manually tracing imports +- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files +- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for +- **Architecture questions**: `get_architecture_overview` + `list_communities` + +Fall back to #tool:read/readFile, #tool:search/fileSearch, or #tool:search/textSearch **only** when the graph doesn't cover what you need. + +### Key Tools + +| Tool | Use when | +| ------ | ---------- | +| `detect_changes` | Reviewing code changes — gives risk-scored analysis | +| `get_review_context` | Need source snippets for review — token-efficient | +| `get_impact_radius` | Understanding blast radius of a change | +| `get_affected_flows` | Finding which execution paths are impacted | +| `query_graph` | Tracing callers, callees, imports, tests, dependencies | +| `semantic_search_nodes` | Finding functions/classes by name or keyword | +| `get_architecture_overview` | Understanding high-level codebase structure | +| `refactor_tool` | Planning renames, finding dead code | + +### Workflow + +1. The graph auto-updates on file changes (via hooks). +2. Use `detect_changes` for code review. +3. Use `get_affected_flows` to understand impact. +4. Use `query_graph` pattern=\"tests_for\" to check coverage. +""" + +# Maps instruction file path → (marker, section) for files that need content +# different from the default _CLAUDE_MD_SECTION. +_PLATFORM_INSTRUCTION_CUSTOM_SECTIONS: dict[str, tuple[str, str]] = { + ".github/code-review-graph.instruction.md": (_CLAUDE_MD_SECTION_MARKER, _COPILOT_SECTION), +} + def _inject_instructions(file_path: Path, marker: str, section: str) -> bool: """Append an instruction section to a file if not already present. @@ -725,6 +794,7 @@ def inject_claude_md(repo_root: Path) -> None: ".windsurfrules": ("windsurf",), "QODER.md": ("qoder",), ".kiro/steering/code-review-graph.md": ("kiro",), + ".github/code-review-graph.instruction.md": ("copilot", "copilot-cli"), } @@ -746,7 +816,10 @@ def inject_platform_instructions(repo_root: Path, target: str = "all") -> list[s if target != "all" and target not in owners: continue path = repo_root / filename - if _inject_instructions(path, _CLAUDE_MD_SECTION_MARKER, _CLAUDE_MD_SECTION): + marker, section = _PLATFORM_INSTRUCTION_CUSTOM_SECTIONS.get( + filename, (_CLAUDE_MD_SECTION_MARKER, _CLAUDE_MD_SECTION) + ) + if _inject_instructions(path, marker, section): updated.append(filename) return updated diff --git a/tests/test_skills.py b/tests/test_skills.py index 873ed2f8..b0dddae7 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -329,11 +329,11 @@ def test_idempotent_with_existing_content(self, tmp_path): class TestInjectPlatformInstructionsFiltering: def test_all_writes_every_file(self, tmp_path): updated = inject_platform_instructions(tmp_path, target="all") - assert set(updated) == {"AGENTS.md", "GEMINI.md", ".cursorrules", ".windsurfrules", "QODER.md", ".kiro/steering/code-review-graph.md"} + assert set(updated) == {"AGENTS.md", "GEMINI.md", ".cursorrules", ".windsurfrules", "QODER.md", ".kiro/steering/code-review-graph.md", ".github/code-review-graph.instruction.md"} def test_default_is_all(self, tmp_path): updated = inject_platform_instructions(tmp_path) - assert set(updated) == {"AGENTS.md", "GEMINI.md", ".cursorrules", ".windsurfrules", "QODER.md", ".kiro/steering/code-review-graph.md"} + assert set(updated) == {"AGENTS.md", "GEMINI.md", ".cursorrules", ".windsurfrules", "QODER.md", ".kiro/steering/code-review-graph.md", ".github/code-review-graph.instruction.md"} def test_claude_writes_nothing(self, tmp_path): updated = inject_platform_instructions(tmp_path, target="claude") @@ -343,6 +343,7 @@ def test_claude_writes_nothing(self, tmp_path): assert not (tmp_path / ".cursorrules").exists() assert not (tmp_path / ".windsurfrules").exists() assert not (tmp_path / "QODER.md").exists() + assert not (tmp_path / ".github" / "code-review-graph.instruction.md").exists() def test_cursor_writes_only_cursor_files(self, tmp_path): updated = inject_platform_instructions(tmp_path, target="cursor") @@ -925,6 +926,236 @@ def test_kiro_dry_run(self, tmp_path): assert not config_path.exists() +class TestCopilotPlatform: + """Tests for GitHub Copilot platform support.""" + + def test_copilot_platform_entry_exists(self): + """PLATFORMS dict has a 'copilot' key with correct metadata.""" + assert "copilot" in PLATFORMS + copilot = PLATFORMS["copilot"] + assert copilot["name"] == "GitHub Copilot" + assert copilot["key"] == "servers" + assert copilot["format"] == "object" + assert copilot["needs_type"] is True + + def test_install_copilot_config(self, tmp_path): + """install_platform_configs creates .vscode/mcp.json with 'servers' key.""" + configured = install_platform_configs(tmp_path, target="copilot") + assert "GitHub Copilot" in configured + config_path = tmp_path / ".vscode" / "mcp.json" + assert config_path.exists() + data = json.loads(config_path.read_text()) + assert "code-review-graph" in data["servers"] + entry = data["servers"]["code-review-graph"] + assert entry["type"] == "stdio" + assert "serve" in entry["args"] + + def test_install_copilot_preserves_existing_servers(self, tmp_path): + """Existing server entries are preserved when adding code-review-graph.""" + config_path = tmp_path / ".vscode" / "mcp.json" + config_path.parent.mkdir(parents=True) + config_path.write_text( + json.dumps({"servers": {"other-server": {"command": "other"}}}), + encoding="utf-8", + ) + install_platform_configs(tmp_path, target="copilot") + data = json.loads(config_path.read_text()) + assert "other-server" in data["servers"] + assert "code-review-graph" in data["servers"] + + def test_install_copilot_no_duplicate(self, tmp_path): + """Second install skips when code-review-graph already exists.""" + install_platform_configs(tmp_path, target="copilot") + config_path = tmp_path / ".vscode" / "mcp.json" + first_content = config_path.read_text() + install_platform_configs(tmp_path, target="copilot") + second_content = config_path.read_text() + assert first_content == second_content + data = json.loads(second_content) + assert list(data["servers"].keys()).count("code-review-graph") == 1 + + def test_copilot_instructions_file_written(self, tmp_path): + """inject_platform_instructions creates .github/code-review-graph.instruction.md.""" + updated = inject_platform_instructions(tmp_path, target="copilot") + assert ".github/code-review-graph.instruction.md" in updated + instructions = tmp_path / ".github" / "code-review-graph.instruction.md" + assert instructions.exists() + content = instructions.read_text() + assert _CLAUDE_MD_SECTION_MARKER in content + + def test_copilot_instructions_idempotent(self, tmp_path): + """Running inject twice produces identical content.""" + inject_platform_instructions(tmp_path, target="copilot") + first = (tmp_path / ".github" / "code-review-graph.instruction.md").read_text() + inject_platform_instructions(tmp_path, target="copilot") + second = (tmp_path / ".github" / "code-review-graph.instruction.md").read_text() + assert first == second + + def test_copilot_dry_run(self, tmp_path): + """dry_run=True does not create any files.""" + configured = install_platform_configs(tmp_path, target="copilot", dry_run=True) + assert "GitHub Copilot" in configured + config_path = tmp_path / ".vscode" / "mcp.json" + assert not config_path.exists() + + def test_copilot_writes_only_copilot_instructions(self, tmp_path): + """inject_platform_instructions with target='copilot' writes only copilot file.""" + updated = inject_platform_instructions(tmp_path, target="copilot") + assert updated == [".github/code-review-graph.instruction.md"] + assert not (tmp_path / "AGENTS.md").exists() + assert not (tmp_path / "GEMINI.md").exists() + assert not (tmp_path / ".cursorrules").exists() + assert not (tmp_path / ".windsurfrules").exists() + assert not (tmp_path / "QODER.md").exists() + + def test_copilot_included_in_all_when_detected(self, tmp_path): + """install_platform_configs with target='all' includes Copilot when ~/.vscode exists.""" + fake_home = tmp_path / "fakehome" + (fake_home / ".vscode").mkdir(parents=True) + with patch("code_review_graph.skills.Path.home", return_value=fake_home): + configured = install_platform_configs(tmp_path, target="all") + assert "GitHub Copilot" in configured + config_path = tmp_path / ".vscode" / "mcp.json" + assert config_path.exists() + + +class TestCopilotCLIPlatform: + """Tests for GitHub Copilot CLI platform support.""" + + def test_copilot_cli_platform_entry_exists(self): + """PLATFORMS dict has a 'copilot-cli' key with correct metadata.""" + assert "copilot-cli" in PLATFORMS + copilot_cli = PLATFORMS["copilot-cli"] + assert copilot_cli["name"] == "GitHub Copilot CLI" + assert copilot_cli["key"] == "servers" + assert copilot_cli["format"] == "object" + assert copilot_cli["needs_type"] is True + + def test_install_copilot_cli_config(self, tmp_path): + """install_platform_configs creates ~/.copilot/mcp-config.json with 'servers' key.""" + fake_home = tmp_path / "fakehome" + (fake_home / ".copilot").mkdir(parents=True) + config_path = fake_home / ".copilot" / "mcp-config.json" + with patch.dict( + PLATFORMS, + { + "copilot-cli": { + **PLATFORMS["copilot-cli"], + "config_path": lambda root: config_path, + "detect": lambda: True, + }, + }, + ): + configured = install_platform_configs(tmp_path, target="copilot-cli") + assert "GitHub Copilot CLI" in configured + assert config_path.exists() + data = json.loads(config_path.read_text()) + assert "code-review-graph" in data["servers"] + entry = data["servers"]["code-review-graph"] + assert entry["type"] == "stdio" + assert "serve" in entry["args"] + + def test_install_copilot_cli_preserves_existing_servers(self, tmp_path): + """Existing server entries are preserved when adding code-review-graph.""" + fake_home = tmp_path / "fakehome" + config_path = fake_home / ".copilot" / "mcp-config.json" + config_path.parent.mkdir(parents=True) + config_path.write_text( + json.dumps({"servers": {"other-server": {"command": "other"}}}), + encoding="utf-8", + ) + with patch.dict( + PLATFORMS, + { + "copilot-cli": { + **PLATFORMS["copilot-cli"], + "config_path": lambda root: config_path, + "detect": lambda: True, + }, + }, + ): + install_platform_configs(tmp_path, target="copilot-cli") + data = json.loads(config_path.read_text()) + assert "other-server" in data["servers"] + assert "code-review-graph" in data["servers"] + + def test_install_copilot_cli_no_duplicate(self, tmp_path): + """Second install skips when code-review-graph already exists.""" + fake_home = tmp_path / "fakehome" + config_path = fake_home / ".copilot" / "mcp-config.json" + with patch.dict( + PLATFORMS, + { + "copilot-cli": { + **PLATFORMS["copilot-cli"], + "config_path": lambda root: config_path, + "detect": lambda: True, + }, + }, + ): + install_platform_configs(tmp_path, target="copilot-cli") + first_content = config_path.read_text() + install_platform_configs(tmp_path, target="copilot-cli") + second_content = config_path.read_text() + assert first_content == second_content + data = json.loads(second_content) + assert list(data["servers"].keys()).count("code-review-graph") == 1 + + def test_copilot_cli_injects_copilot_instructions(self, tmp_path): + """inject_platform_instructions with target='copilot-cli' writes .github/code-review-graph.instruction.md.""" + updated = inject_platform_instructions(tmp_path, target="copilot-cli") + assert ".github/code-review-graph.instruction.md" in updated + instructions = tmp_path / ".github" / "code-review-graph.instruction.md" + assert instructions.exists() + content = instructions.read_text() + assert _CLAUDE_MD_SECTION_MARKER in content + + def test_copilot_cli_dry_run(self, tmp_path): + """dry_run=True does not create any files.""" + fake_home = tmp_path / "fakehome" + config_path = fake_home / ".copilot" / "mcp-config.json" + with patch.dict( + PLATFORMS, + { + "copilot-cli": { + **PLATFORMS["copilot-cli"], + "config_path": lambda root: config_path, + "detect": lambda: True, + }, + }, + ): + configured = install_platform_configs(tmp_path, target="copilot-cli", dry_run=True) + assert "GitHub Copilot CLI" in configured + assert not config_path.exists() + + def test_copilot_cli_included_in_all_when_detected(self, tmp_path): + """install_platform_configs with target='all' includes Copilot CLI when ~/.copilot exists.""" + fake_home = tmp_path / "fakehome" + (fake_home / ".copilot").mkdir(parents=True) + config_path = fake_home / ".copilot" / "mcp-config.json" + with patch.dict( + PLATFORMS, + { + "copilot-cli": { + **PLATFORMS["copilot-cli"], + "config_path": lambda root: config_path, + "detect": lambda: True, + }, + "copilot": {**PLATFORMS["copilot"], "detect": lambda: False}, + }, + ): + configured = install_platform_configs(tmp_path, target="all") + assert "GitHub Copilot CLI" in configured + assert config_path.exists() + + def test_copilot_cli_user_level_config_not_in_project(self, tmp_path): + """Copilot CLI config is user-level (not inside the repo root).""" + config_path = PLATFORMS["copilot-cli"]["config_path"](tmp_path) + assert not str(config_path).startswith(str(tmp_path)), ( + "copilot-cli config should be user-level (~/.copilot/), not project-level" + ) + + class TestDetectServeCommand: """Tests for _detect_serve_command() and its helpers.""" diff --git a/uv.lock b/uv.lock index 62a32add..c40535b3 100644 --- a/uv.lock +++ b/uv.lock @@ -411,6 +411,7 @@ requires-dist = [ { name = "pyyaml", marker = "extra == 'eval'", specifier = ">=6.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3.0,<1" }, { name = "sentence-transformers", marker = "extra == 'embeddings'", specifier = ">=3.0.0,<4" }, + { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.0,<3" }, { name = "tomli", marker = "python_full_version < '3.11' and extra == 'dev'", specifier = ">=2.0" }, { name = "tree-sitter", specifier = ">=0.23.0,<1" }, { name = "tree-sitter-language-pack", specifier = ">=0.3.0,<1" },