Skip to content

Commit fea5404

Browse files
committed
fix(tools): Fix find_current_source_id when the current directory is not a git repo or has no remote url
1 parent 0e7f331 commit fea5404

File tree

3 files changed

+194
-40
lines changed

3 files changed

+194
-40
lines changed

packages/developer_mcp_server/src/developer_mcp_server/register_tools.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,10 @@ def register_developer_tools(mcp: FastMCP):
8484

8585
mcp.tool(
8686
find_current_source_id,
87-
description="Find the GitGuardian source_id for the current repository. "
88-
"This tool automatically detects the current git repository and searches for its source_id in GitGuardian. "
89-
"Useful when you need to reference the repository in other API calls.",
87+
description="Find the GitGuardian source_id for a repository. "
88+
"This tool attempts to detect the repository name from git remote URL, or falls back to using the directory name. "
89+
"By default it uses the current directory ('.'), but you can specify a custom repository_path parameter "
90+
"to analyze a different repository. Useful when you need to reference the repository in other API calls.",
9091
required_scopes=["sources:read"],
9192
)
9293

packages/gg_api_core/src/gg_api_core/tools/find_current_source_id.py

Lines changed: 65 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import logging
2+
import os
23
import subprocess
4+
from pathlib import Path
35
from typing import Any
46

57
from pydantic import BaseModel, Field
@@ -42,17 +44,24 @@ class FindCurrentSourceIdError(BaseModel):
4244
suggestion: str | None = Field(default=None, description="Suggestions for resolving the error")
4345

4446

45-
async def find_current_source_id() -> FindCurrentSourceIdResult | FindCurrentSourceIdError:
47+
async def find_current_source_id(repository_path: str = ".") -> FindCurrentSourceIdResult | FindCurrentSourceIdError:
4648
"""
47-
Find the GitGuardian source_id for the current repository.
49+
Find the GitGuardian source_id for a repository.
4850
4951
This tool:
50-
1. Gets the current repository information from git
51-
2. Extracts the repository name from the remote URL
52+
1. Attempts to get the repository name from git remote URL
53+
2. If git fails, falls back to using the directory name
5254
3. Searches GitGuardian for matching sources
5355
4. Returns the source_id if an exact match is found
5456
5. If no exact match, returns all search results for the model to choose from
5557
58+
Args:
59+
repository_path: Path to the repository directory. Defaults to "." (current directory).
60+
If you're working in a specific repository, provide the full path to ensure
61+
the correct repository is analyzed (e.g., "/home/user/my-project").
62+
Note: If the directory is not a git repository, the tool will use the
63+
directory name as the repository name.
64+
5665
Returns:
5766
FindCurrentSourceIdResult: Pydantic model containing:
5867
- repository_name: The detected repository name
@@ -70,38 +79,44 @@ async def find_current_source_id() -> FindCurrentSourceIdResult | FindCurrentSou
7079
- suggestion: Suggestions for resolving the error
7180
"""
7281
client = get_client()
73-
logger.debug("Finding source_id for current repository")
82+
logger.debug(f"Finding source_id for repository at path: {repository_path}")
83+
84+
repository_name = None
85+
remote_url = None
86+
detection_method = None
7487

7588
try:
76-
# Get current repository remote URL
89+
# Try Method 1: Get repository name from git remote URL
7790
try:
7891
result = subprocess.run(
7992
["git", "config", "--get", "remote.origin.url"],
8093
capture_output=True,
8194
text=True,
8295
check=True,
8396
timeout=5,
97+
cwd=repository_path,
8498
)
8599
remote_url = result.stdout.strip()
86-
logger.debug(f"Found remote URL: {remote_url}")
87-
except subprocess.CalledProcessError as e:
88-
return FindCurrentSourceIdError(
89-
error="Not a git repository or no remote 'origin' configured",
90-
details=str(e),
91-
)
92-
except subprocess.TimeoutExpired:
93-
return FindCurrentSourceIdError(error="Git command timed out")
94-
95-
# Parse repository name from remote URL
96-
repository_name = parse_repo_url(remote_url).split("/")[-1]
100+
repository_name = parse_repo_url(remote_url).split("/")[-1]
101+
detection_method = "git remote URL"
102+
logger.debug(f"Found remote URL: {remote_url}, parsed repository name: {repository_name}")
103+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
104+
logger.debug(f"Git remote detection failed: {e}, falling back to directory name")
105+
106+
# Fallback Method 2: Use the directory name as repository name
107+
abs_path = os.path.abspath(repository_path)
108+
repository_name = Path(abs_path).name
109+
detection_method = "directory name"
110+
logger.info(f"Using directory name as repository name: {repository_name}")
97111

98112
if not repository_name:
99113
return FindCurrentSourceIdError(
100-
error=f"Could not parse repository URL: {remote_url}",
101-
details="The URL format is not recognized. Supported platforms: GitHub, GitLab (Cloud & Self-hosted), Bitbucket (Cloud & Data Center), Azure DevOps",
114+
error="Could not determine repository name",
115+
message="Failed to determine repository name from both git remote and directory name.",
116+
suggestion="Please ensure you're in a valid directory or provide a valid repository_path parameter.",
102117
)
103118

104-
logger.info(f"Detected repository name: {repository_name}")
119+
logger.info(f"Detected repository name: {repository_name} (method: {detection_method})")
105120

106121
# Search for the source in GitGuardian with robust non-exact matching
107122
result = await client.get_source_by_name(repository_name, return_all_on_no_match=True)
@@ -110,19 +125,29 @@ async def find_current_source_id() -> FindCurrentSourceIdResult | FindCurrentSou
110125
if isinstance(result, dict):
111126
source_id = result.get("id")
112127
logger.info(f"Found exact match with source_id: {source_id}")
128+
129+
message = f"Successfully found exact match for GitGuardian source: {repository_name}"
130+
if detection_method == "directory name":
131+
message += f" (repository name inferred from {detection_method})"
132+
113133
return FindCurrentSourceIdResult(
114134
repository_name=repository_name,
115135
source_id=source_id,
116136
source=result,
117-
message=f"Successfully found exact match for GitGuardian source: {repository_name}",
137+
message=message,
118138
)
119139

120140
# Handle multiple candidates (list result)
121141
elif isinstance(result, list) and len(result) > 0:
122142
logger.info(f"Found {len(result)} candidate sources for repository: {repository_name}")
143+
144+
message = f"No exact match found for '{repository_name}', but found {len(result)} potential matches."
145+
if detection_method == "directory name":
146+
message += f" (repository name inferred from {detection_method})"
147+
123148
return FindCurrentSourceIdResult(
124149
repository_name=repository_name,
125-
message=f"No exact match found for '{repository_name}', but found {len(result)} potential matches.",
150+
message=message,
126151
suggestion="Review the candidates below and determine which source best matches the current repository based on the name and URL.",
127152
candidates=[
128153
SourceCandidate(
@@ -148,17 +173,27 @@ async def find_current_source_id() -> FindCurrentSourceIdResult | FindCurrentSou
148173
if isinstance(fallback_result, dict):
149174
source_id = fallback_result.get("id")
150175
logger.info(f"Found match using repo name only, source_id: {source_id}")
176+
177+
message = f"Found match using repository name '{repo_only}' (without organization prefix)"
178+
if detection_method == "directory name":
179+
message += f" (repository name inferred from {detection_method})"
180+
151181
return FindCurrentSourceIdResult(
152182
repository_name=repository_name,
153183
source_id=source_id,
154184
source=fallback_result,
155-
message=f"Found match using repository name '{repo_only}' (without organization prefix)",
185+
message=message,
156186
)
157187
elif isinstance(fallback_result, list) and len(fallback_result) > 0:
158188
logger.info(f"Found {len(fallback_result)} candidates using repo name only")
189+
190+
message = f"No exact match for '{repository_name}', but found {len(fallback_result)} potential matches using repo name '{repo_only}'."
191+
if detection_method == "directory name":
192+
message += f" (repository name inferred from {detection_method})"
193+
159194
return FindCurrentSourceIdResult(
160195
repository_name=repository_name,
161-
message=f"No exact match for '{repository_name}', but found {len(fallback_result)} potential matches using repo name '{repo_only}'.",
196+
message=message,
162197
suggestion="Review the candidates below and determine which source best matches the current repository.",
163198
candidates=[
164199
SourceCandidate(
@@ -174,10 +209,15 @@ async def find_current_source_id() -> FindCurrentSourceIdResult | FindCurrentSou
174209

175210
# Absolutely no matches found
176211
logger.warning(f"No sources found for repository: {repository_name}")
212+
213+
message = "The repository may not be connected to GitGuardian, or you may not have access to it."
214+
if detection_method == "directory name":
215+
message += f" Note: repository name was inferred from {detection_method}, which may not match the actual GitGuardian source name."
216+
177217
return FindCurrentSourceIdError(
178218
repository_name=repository_name,
179219
error=f"Repository '{repository_name}' not found in GitGuardian",
180-
message="The repository may not be connected to GitGuardian, or you may not have access to it.",
220+
message=message,
181221
suggestion="Check that the repository is properly connected to GitGuardian and that your account has access to it.",
182222
)
183223

tests/tools/test_find_current_source_id.py

Lines changed: 125 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ async def test_find_current_source_id_exact_match(self, mock_gitguardian_client)
4141
text=True,
4242
check=True,
4343
timeout=5,
44+
cwd=".",
4445
)
4546

4647
# Verify client was called with parsed repository name (just repo name, not org/repo)
@@ -147,40 +148,78 @@ async def test_find_current_source_id_no_match_at_all(self, mock_gitguardian_cli
147148
assert "not found in GitGuardian" in result.error
148149

149150
@pytest.mark.asyncio
150-
async def test_find_current_source_id_not_a_git_repo(self, mock_gitguardian_client):
151+
async def test_find_current_source_id_not_a_git_repo_fallback_to_dir_name(self, mock_gitguardian_client):
151152
"""
152153
GIVEN: The current directory is not a git repository
153154
WHEN: Attempting to find the source_id
154-
THEN: An error is returned
155+
THEN: The tool falls back to using the directory name and searches GitGuardian
155156
"""
156157
# Mock git command to raise an error
157-
with patch("subprocess.run") as mock_run:
158+
with (
159+
patch("subprocess.run") as mock_run,
160+
patch("os.path.abspath") as mock_abspath,
161+
patch("pathlib.Path") as mock_path,
162+
):
158163
mock_run.side_effect = subprocess.CalledProcessError(128, "git", stderr="not a git repository")
164+
mock_abspath.return_value = "/some/path/my-repo-name"
165+
166+
# Mock Path to return the directory name
167+
mock_path_instance = MagicMock()
168+
mock_path_instance.name = "my-repo-name"
169+
mock_path.return_value = mock_path_instance
170+
171+
# Mock GitGuardian client to return a match
172+
mock_response = {
173+
"id": "source_fallback",
174+
"full_name": "org/my-repo-name",
175+
"url": "https://github.com/org/my-repo-name",
176+
}
177+
mock_gitguardian_client.get_source_by_name = AsyncMock(return_value=mock_response)
159178

160179
# Call the function
161180
result = await find_current_source_id()
162181

163-
# Verify error response
164-
assert hasattr(result, "error")
165-
assert "Not a git repository" in result.error
182+
# Verify it used directory name and found a match
183+
assert result.repository_name == "my-repo-name"
184+
assert result.source_id == "source_fallback"
185+
assert "directory name" in result.message
166186

167187
@pytest.mark.asyncio
168-
async def test_find_current_source_id_git_timeout(self, mock_gitguardian_client):
188+
async def test_find_current_source_id_git_timeout_fallback(self, mock_gitguardian_client):
169189
"""
170190
GIVEN: The git command times out
171191
WHEN: Attempting to find the source_id
172-
THEN: An error is returned
192+
THEN: The tool falls back to using the directory name
173193
"""
174194
# Mock git command to timeout
175-
with patch("subprocess.run") as mock_run:
195+
with (
196+
patch("subprocess.run") as mock_run,
197+
patch("os.path.abspath") as mock_abspath,
198+
patch("pathlib.Path") as mock_path,
199+
):
176200
mock_run.side_effect = subprocess.TimeoutExpired("git", 5)
201+
mock_abspath.return_value = "/some/path/timeout-repo"
202+
203+
# Mock Path to return the directory name
204+
mock_path_instance = MagicMock()
205+
mock_path_instance.name = "timeout-repo"
206+
mock_path.return_value = mock_path_instance
207+
208+
# Mock GitGuardian client to return a match
209+
mock_response = {
210+
"id": "source_timeout",
211+
"full_name": "org/timeout-repo",
212+
"url": "https://github.com/org/timeout-repo",
213+
}
214+
mock_gitguardian_client.get_source_by_name = AsyncMock(return_value=mock_response)
177215

178216
# Call the function
179217
result = await find_current_source_id()
180218

181-
# Verify error response
182-
assert hasattr(result, "error")
183-
assert "timed out" in result.error
219+
# Verify it used directory name fallback
220+
assert result.repository_name == "timeout-repo"
221+
assert result.source_id == "source_timeout"
222+
assert "directory name" in result.message
184223

185224
@pytest.mark.asyncio
186225
async def test_find_current_source_id_invalid_url(self, mock_gitguardian_client):
@@ -284,3 +323,77 @@ async def test_find_current_source_id_client_error(self, mock_gitguardian_client
284323
# Verify error response
285324
assert hasattr(result, "error")
286325
assert "Failed to find source_id" in result.error
326+
327+
@pytest.mark.asyncio
328+
async def test_find_current_source_id_custom_path(self, mock_gitguardian_client):
329+
"""
330+
GIVEN: A custom repository path is provided
331+
WHEN: Finding the source_id
332+
THEN: The git command runs in the specified directory
333+
"""
334+
custom_path = "/path/to/custom/repo"
335+
336+
# Mock git command
337+
with patch("subprocess.run") as mock_run:
338+
mock_run.return_value = MagicMock(
339+
stdout="https://github.com/GitGuardian/custom-repo.git\n",
340+
returncode=0,
341+
)
342+
343+
# Mock the client response
344+
mock_response = {
345+
"id": "source_custom",
346+
"full_name": "GitGuardian/custom-repo",
347+
"url": "https://github.com/GitGuardian/custom-repo",
348+
}
349+
mock_gitguardian_client.get_source_by_name = AsyncMock(return_value=mock_response)
350+
351+
# Call the function with custom path
352+
result = await find_current_source_id(repository_path=custom_path)
353+
354+
# Verify git command was called with custom path
355+
mock_run.assert_called_once_with(
356+
["git", "config", "--get", "remote.origin.url"],
357+
capture_output=True,
358+
text=True,
359+
check=True,
360+
timeout=5,
361+
cwd=custom_path,
362+
)
363+
364+
# Verify response
365+
assert result.repository_name == "custom-repo"
366+
assert result.source_id == "source_custom"
367+
368+
@pytest.mark.asyncio
369+
async def test_find_current_source_id_fallback_no_match(self, mock_gitguardian_client):
370+
"""
371+
GIVEN: The directory is not a git repo and the directory name doesn't match any source
372+
WHEN: Attempting to find the source_id
373+
THEN: An error is returned with helpful information about the fallback
374+
"""
375+
# Mock git command to raise an error
376+
with (
377+
patch("subprocess.run") as mock_run,
378+
patch("os.path.abspath") as mock_abspath,
379+
patch("pathlib.Path") as mock_path,
380+
):
381+
mock_run.side_effect = subprocess.CalledProcessError(128, "git", stderr="not a git repository")
382+
mock_abspath.return_value = "/some/path/unknown-repo"
383+
384+
# Mock Path to return the directory name
385+
mock_path_instance = MagicMock()
386+
mock_path_instance.name = "unknown-repo"
387+
mock_path.return_value = mock_path_instance
388+
389+
# Mock GitGuardian client to return no matches
390+
mock_gitguardian_client.get_source_by_name = AsyncMock(return_value=[])
391+
392+
# Call the function
393+
result = await find_current_source_id()
394+
395+
# Verify error response with fallback info
396+
assert result.repository_name == "unknown-repo"
397+
assert hasattr(result, "error")
398+
assert "not found in GitGuardian" in result.error
399+
assert "directory name" in result.message

0 commit comments

Comments
 (0)