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

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 

9 

10 

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) 

20 

21Examples: 

22 # Start MCP server 

23 repo-ctx 

24 

25 # Index a repository 

26 repo-ctx --index mygroup/myproject 

27 

28 # Search for repositories 

29 repo-ctx search "python" 

30 

31 # List all indexed repositories 

32 repo-ctx list 

33 

34 # Get documentation 

35 repo-ctx docs mygroup/myproject --topic api 

36 """ 

37 ) 

38 

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 ) 

44 

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 ) 

66 

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 ) 

74 

75 # Legacy action (for backwards compatibility) 

76 parser.add_argument( 

77 "--index", 

78 help="Index a repository (format: group/project or owner/repo)" 

79 ) 

80 

81 # Subcommands 

82 subparsers = parser.add_subparsers(dest="command", help="Available commands") 

83 

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 ) 

103 

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 ) 

115 

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 ) 

135 

136 args = parser.parse_args() 

137 

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 

151 

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 )) 

197 

198 

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 

211 

212 # Auto-detect provider type to determine if it's a local path 

213 detected_provider = ProviderDetector.detect(repo) 

214 

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]) 

228 

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 

245 

246 context = RepositoryContext(config) 

247 await context.init() 

248 

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...") 

254 

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}") 

261 

262 

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 

287 

288 context = GitLabContext(config) 

289 await context.init() 

290 

291 print(f"Searching for '{query}'...\n") 

292 

293 try: 

294 results = await context.fuzzy_search_libraries(query, limit=limit) 

295 

296 if not results: 

297 print("No results found.") 

298 return 

299 

300 # Filter by repo if specified 

301 if repo: 

302 results = [r for r in results if repo.lower() in r.library_id.lower()] 

303 

304 if not results: 

305 print(f"No results found matching repository '{repo}'.") 

306 return 

307 

308 print(f"Found {len(results)} result(s):\n") 

309 

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, "") 

317 

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() 

325 

326 except Exception as e: 

327 print(f"Search error: {e}") 

328 

329 

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 

352 

353 context = GitLabContext(config) 

354 await context.init() 

355 

356 try: 

357 libraries = await context.storage.get_all_libraries() 

358 

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 

364 

365 print(f"Indexed Repositories ({len(libraries)}):\n") 

366 

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}") 

377 

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() 

392 

393 except Exception as e: 

394 print(f"List error: {e}") 

395 

396 

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 

421 

422 context = GitLabContext(config) 

423 await context.init() 

424 

425 # Ensure repository starts with / 

426 if not repository.startswith("/"): 

427 repository = f"/{repository}" 

428 

429 try: 

430 print(f"Retrieving documentation for {repository}...") 

431 if topic: 

432 print(f"Filtering by topic: {topic}") 

433 print() 

434 

435 docs = await context.get_documentation(repository, topic=topic, page=page) 

436 

437 metadata = docs["metadata"] 

438 content = docs["content"][0]["text"] 

439 

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) 

447 

448 if metadata['documents_count'] >= 10: 

449 print() 

450 print(f"More documents available. Use --page {page + 1} to see next page.") 

451 

452 except ValueError as e: 

453 print(f"Error: {e}") 

454 except Exception as e: 

455 print(f"Documentation retrieval error: {e}") 

456 

457 

458def format_time_ago(dt: datetime) -> str: 

459 """Format datetime as human-readable time ago.""" 

460 now = datetime.now() 

461 diff = now - dt 

462 

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" 

484 

485 

486if __name__ == "__main__": 

487 main()