Coverage for repo_ctx/__main__.py: 0%

1"""Entry point for repo-ctx."""

2 import asyncio

3 import argparse

4 import sys

5 from datetime import datetime

6 from .mcp_server import serve

7 from .core import GitLabContext, RepositoryContext

8 from .config import Config

9

10

11 def main():

12 parser = argparse.ArgumentParser(

13 description="Repository Context - Repository documentation indexer and search tool",

14 epilog="""

15 Configuration 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

21 Examples:

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

199 async 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

263 async 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

330 async 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

397 async 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

458 def 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

486 if __name__ == "__main__":

487 main()