Coverage for repo_ctx / __main__.py: 0%
195 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-25 17:42 +0100
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-25 17:42 +0100
1"""Entry point for repo-ctx."""
2import asyncio
3import argparse
4import sys
5from datetime import datetime
6from .mcp_server import serve
7from .core import GitLabContext, RepositoryContext
8from .config import Config
11def main():
12 parser = argparse.ArgumentParser(
13 description="Repository Context - Repository documentation indexer and search tool",
14 epilog="""
15Configuration priority (highest to lowest):
16 1. Command-line arguments (--gitlab-url, --gitlab-token, etc.)
17 2. Specified config file (--config)
18 3. Environment variables (GITLAB_URL, GITLAB_TOKEN)
19 4. Standard config locations (~/.config/repo-ctx/config.yaml, ~/.repo-ctx/config.yaml, ./config.yaml)
21Examples:
22 # Start MCP server
23 repo-ctx
25 # Index a repository
26 repo-ctx --index mygroup/myproject
28 # Search for repositories
29 repo-ctx search "python"
31 # List all indexed repositories
32 repo-ctx list
34 # Get documentation
35 repo-ctx docs mygroup/myproject --topic api
36 """
37 )
39 # Config file
40 parser.add_argument(
41 "--config",
42 help="Path to config.yaml file (optional if using environment variables or CLI args)"
43 )
45 # Direct configuration arguments
46 parser.add_argument(
47 "--gitlab-url",
48 help="GitLab instance URL (e.g., https://gitlab.com)"
49 )
50 parser.add_argument(
51 "--gitlab-token",
52 help="GitLab personal access token"
53 )
54 parser.add_argument(
55 "--github-url",
56 help="GitHub API URL (default: https://api.github.com)"
57 )
58 parser.add_argument(
59 "--github-token",
60 help="GitHub personal access token (optional for public repos)"
61 )
62 parser.add_argument(
63 "--storage-path",
64 help="Path to SQLite database file (default: ~/.repo-ctx/context.db)"
65 )
67 # Provider selection
68 parser.add_argument(
69 "--provider",
70 choices=["gitlab", "github", "local", "auto"],
71 default="auto",
72 help="Provider to use (default: auto-detect from path format)"
73 )
75 # Legacy action (for backwards compatibility)
76 parser.add_argument(
77 "--index",
78 help="Index a repository (format: group/project or owner/repo)"
79 )
81 # Subcommands
82 subparsers = parser.add_subparsers(dest="command", help="Available commands")
84 # Search command
85 search_parser = subparsers.add_parser(
86 "search",
87 help="Search for repositories"
88 )
89 search_parser.add_argument(
90 "query",
91 help="Search query"
92 )
93 search_parser.add_argument(
94 "--limit",
95 type=int,
96 default=10,
97 help="Maximum number of results (default: 10)"
98 )
99 search_parser.add_argument(
100 "--repo",
101 help="Search only in specific repository (format: group/project)"
102 )
104 # List command
105 list_parser = subparsers.add_parser(
106 "list",
107 help="List all indexed repositories"
108 )
109 list_parser.add_argument(
110 "--format",
111 choices=["simple", "detailed"],
112 default="detailed",
113 help="Output format (default: detailed)"
114 )
116 # Docs command
117 docs_parser = subparsers.add_parser(
118 "docs",
119 help="Get documentation for a repository"
120 )
121 docs_parser.add_argument(
122 "repository",
123 help="Repository path (format: group/project or group/project/version)"
124 )
125 docs_parser.add_argument(
126 "--topic",
127 help="Filter by topic"
128 )
129 docs_parser.add_argument(
130 "--page",
131 type=int,
132 default=1,
133 help="Page number (default: 1)"
134 )
136 args = parser.parse_args()
138 # Handle legacy --index argument
139 if args.index:
140 asyncio.run(index_repository(
141 config_path=args.config,
142 gitlab_url=args.gitlab_url,
143 gitlab_token=args.gitlab_token,
144 github_url=args.github_url,
145 github_token=args.github_token,
146 storage_path=args.storage_path,
147 repo=args.index,
148 provider=args.provider if args.provider != "auto" else None
149 ))
150 return
152 # Handle subcommands
153 if args.command == "search":
154 asyncio.run(search_command(
155 config_path=args.config,
156 gitlab_url=args.gitlab_url,
157 gitlab_token=args.gitlab_token,
158 github_url=args.github_url,
159 github_token=args.github_token,
160 storage_path=args.storage_path,
161 query=args.query,
162 limit=args.limit,
163 repo=args.repo
164 ))
165 elif args.command == "list":
166 asyncio.run(list_command(
167 config_path=args.config,
168 gitlab_url=args.gitlab_url,
169 gitlab_token=args.gitlab_token,
170 github_url=args.github_url,
171 github_token=args.github_token,
172 storage_path=args.storage_path,
173 format_type=args.format
174 ))
175 elif args.command == "docs":
176 asyncio.run(docs_command(
177 config_path=args.config,
178 gitlab_url=args.gitlab_url,
179 gitlab_token=args.gitlab_token,
180 github_url=args.github_url,
181 github_token=args.github_token,
182 storage_path=args.storage_path,
183 repository=args.repository,
184 topic=args.topic,
185 page=args.page
186 ))
187 else:
188 # Server mode (default)
189 asyncio.run(serve(
190 config_path=args.config,
191 gitlab_url=args.gitlab_url,
192 gitlab_token=args.gitlab_token,
193 github_url=args.github_url,
194 github_token=args.github_token,
195 storage_path=args.storage_path
196 ))
199async def index_repository(
200 config_path: str = None,
201 gitlab_url: str = None,
202 gitlab_token: str = None,
203 github_url: str = None,
204 github_token: str = None,
205 storage_path: str = None,
206 repo: str = None,
207 provider: str = None
208):
209 """Index a repository."""
210 from .providers.detector import ProviderDetector
212 # Auto-detect provider type to determine if it's a local path
213 detected_provider = ProviderDetector.detect(repo)
215 # For local paths, don't split - use the full path
216 if detected_provider == "local" or provider == "local" or repo.startswith(("/", "./", "~/")):
217 group = repo
218 project = ""
219 else:
220 # For remote repos, split into group/project
221 parts = repo.split("/")
222 if len(parts) < 2:
223 print("Error: Repository must be in format group/project or owner/repo")
224 return
225 # Handle nested groups: everything except last part is the group
226 project = parts[-1]
227 group = "/".join(parts[:-1])
229 try:
230 config = Config.load(
231 config_path=config_path,
232 gitlab_url=gitlab_url,
233 gitlab_token=gitlab_token,
234 github_url=github_url,
235 github_token=github_token,
236 storage_path=storage_path
237 )
238 except ValueError as e:
239 # For local provider, configuration error is OK (no URL/token needed)
240 if provider == "local" or detected_provider == "local":
241 config = Config(storage_path=storage_path or Config._default_storage_path())
242 else:
243 print(f"Configuration error: {e}")
244 return
246 context = RepositoryContext(config)
247 await context.init()
249 # Show which provider will be used
250 if provider:
251 print(f"Using provider: {provider}")
252 else:
253 print(f"Auto-detecting provider from path format...")
255 print(f"Indexing {repo}...")
256 try:
257 await context.index_repository(group, project, provider_type=provider)
258 print(f"✓ Successfully indexed {repo}")
259 except Exception as e:
260 print(f"✗ Error indexing repository: {e}")
263async def search_command(
264 config_path: str = None,
265 gitlab_url: str = None,
266 gitlab_token: str = None,
267 github_url: str = None,
268 github_token: str = None,
269 storage_path: str = None,
270 query: str = None,
271 limit: int = 10,
272 repo: str = None
273):
274 """Search for repositories."""
275 try:
276 config = Config.load(
277 config_path=config_path,
278 gitlab_url=gitlab_url,
279 gitlab_token=gitlab_token,
280 github_url=github_url,
281 github_token=github_token,
282 storage_path=storage_path
283 )
284 except ValueError as e:
285 print(f"Configuration error: {e}")
286 return
288 context = GitLabContext(config)
289 await context.init()
291 print(f"Searching for '{query}'...\n")
293 try:
294 results = await context.fuzzy_search_libraries(query, limit=limit)
296 if not results:
297 print("No results found.")
298 return
300 # Filter by repo if specified
301 if repo:
302 results = [r for r in results if repo.lower() in r.library_id.lower()]
304 if not results:
305 print(f"No results found matching repository '{repo}'.")
306 return
308 print(f"Found {len(results)} result(s):\n")
310 for i, result in enumerate(results, 1):
311 match_icon = {
312 "exact": "🎯",
313 "starts_with": "▶️",
314 "contains": "📝",
315 "fuzzy": "🔍"
316 }.get(result.match_type, "")
318 print(f"{i}. {match_icon} {result.name}")
319 print(f" Library: /{result.group}/{result.name}")
320 print(f" Score: {result.score:.2f} ({result.match_type} match in {result.matched_field})")
321 if result.description:
322 desc = result.description[:80] + "..." if len(result.description) > 80 else result.description
323 print(f" {desc}")
324 print()
326 except Exception as e:
327 print(f"Search error: {e}")
330async def list_command(
331 config_path: str = None,
332 gitlab_url: str = None,
333 gitlab_token: str = None,
334 github_url: str = None,
335 github_token: str = None,
336 storage_path: str = None,
337 format_type: str = "detailed"
338):
339 """List all indexed repositories."""
340 try:
341 config = Config.load(
342 config_path=config_path,
343 gitlab_url=gitlab_url,
344 gitlab_token=gitlab_token,
345 github_url=github_url,
346 github_token=github_token,
347 storage_path=storage_path
348 )
349 except ValueError as e:
350 print(f"Configuration error: {e}")
351 return
353 context = GitLabContext(config)
354 await context.init()
356 try:
357 libraries = await context.storage.get_all_libraries()
359 if not libraries:
360 print("No indexed repositories found.")
361 print("\nTo index a repository, run:")
362 print(" repo-ctx --index group/project")
363 return
365 print(f"Indexed Repositories ({len(libraries)}):\n")
367 if format_type == "simple":
368 for lib in libraries:
369 print(f" - {lib.group_name}/{lib.project_name}")
370 else: # detailed
371 for i, lib in enumerate(libraries, 1):
372 print(f"{i}. {lib.group_name}/{lib.project_name}")
373 if lib.description:
374 desc = lib.description[:100] + "..." if len(lib.description) > 100 else lib.description
375 print(f" Description: {desc}")
376 print(f" Default version: {lib.default_version}")
378 # Format last indexed time
379 if lib.last_indexed:
380 if isinstance(lib.last_indexed, str):
381 # Parse from database timestamp string
382 try:
383 dt = datetime.fromisoformat(lib.last_indexed.replace(' ', 'T'))
384 time_ago = format_time_ago(dt)
385 print(f" Last indexed: {dt.strftime('%Y-%m-%d %H:%M:%S')} ({time_ago})")
386 except:
387 print(f" Last indexed: {lib.last_indexed}")
388 else:
389 time_ago = format_time_ago(lib.last_indexed)
390 print(f" Last indexed: {lib.last_indexed.strftime('%Y-%m-%d %H:%M:%S')} ({time_ago})")
391 print()
393 except Exception as e:
394 print(f"List error: {e}")
397async def docs_command(
398 config_path: str = None,
399 gitlab_url: str = None,
400 gitlab_token: str = None,
401 github_url: str = None,
402 github_token: str = None,
403 storage_path: str = None,
404 repository: str = None,
405 topic: str = None,
406 page: int = 1
407):
408 """Get documentation for a repository."""
409 try:
410 config = Config.load(
411 config_path=config_path,
412 gitlab_url=gitlab_url,
413 gitlab_token=gitlab_token,
414 github_url=github_url,
415 github_token=github_token,
416 storage_path=storage_path
417 )
418 except ValueError as e:
419 print(f"Configuration error: {e}")
420 return
422 context = GitLabContext(config)
423 await context.init()
425 # Ensure repository starts with /
426 if not repository.startswith("/"):
427 repository = f"/{repository}"
429 try:
430 print(f"Retrieving documentation for {repository}...")
431 if topic:
432 print(f"Filtering by topic: {topic}")
433 print()
435 docs = await context.get_documentation(repository, topic=topic, page=page)
437 metadata = docs["metadata"]
438 content = docs["content"][0]["text"]
440 print(f"Library: {metadata['library']}")
441 print(f"Version: {metadata['version']}")
442 print(f"Page: {metadata['page']}")
443 print(f"Documents: {metadata['documents_count']}")
444 print("=" * 80)
445 print()
446 print(content)
448 if metadata['documents_count'] >= 10:
449 print()
450 print(f"More documents available. Use --page {page + 1} to see next page.")
452 except ValueError as e:
453 print(f"Error: {e}")
454 except Exception as e:
455 print(f"Documentation retrieval error: {e}")
458def format_time_ago(dt: datetime) -> str:
459 """Format datetime as human-readable time ago."""
460 now = datetime.now()
461 diff = now - dt
463 seconds = diff.total_seconds()
464 if seconds < 60:
465 return "just now"
466 elif seconds < 3600:
467 minutes = int(seconds / 60)
468 return f"{minutes} minute{'s' if minutes != 1 else ''} ago"
469 elif seconds < 86400:
470 hours = int(seconds / 3600)
471 return f"{hours} hour{'s' if hours != 1 else ''} ago"
472 elif seconds < 604800:
473 days = int(seconds / 86400)
474 return f"{days} day{'s' if days != 1 else ''} ago"
475 elif seconds < 2592000:
476 weeks = int(seconds / 604800)
477 return f"{weeks} week{'s' if weeks != 1 else ''} ago"
478 elif seconds < 31536000:
479 months = int(seconds / 2592000)
480 return f"{months} month{'s' if months != 1 else ''} ago"
481 else:
482 years = int(seconds / 31536000)
483 return f"{years} year{'s' if years != 1 else ''} ago"
486if __name__ == "__main__":
487 main()