codedna 0.8.4__tar.gz → 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. {codedna-0.8.4 → codedna-0.9.0}/PKG-INFO +1 -1
  2. {codedna-0.8.4 → codedna-0.9.0}/codedna/__init__.py +1 -1
  3. {codedna-0.8.4 → codedna-0.9.0}/codedna/analyzer.py +161 -0
  4. {codedna-0.8.4 → codedna-0.9.0}/codedna/api.py +174 -0
  5. {codedna-0.8.4 → codedna-0.9.0}/codedna/cli.py +401 -2
  6. {codedna-0.8.4 → codedna-0.9.0}/codedna/db.py +126 -0
  7. codedna-0.9.0/codedna/webhooks.py +138 -0
  8. codedna-0.9.0/dashboard/Dockerfile +28 -0
  9. codedna-0.9.0/dashboard/app/feedback/page.tsx +209 -0
  10. codedna-0.9.0/dashboard/app/repos/page.tsx +179 -0
  11. codedna-0.9.0/dashboard/app/trends/page.tsx +202 -0
  12. {codedna-0.8.4 → codedna-0.9.0}/dashboard/components/NavClient.tsx +3 -0
  13. {codedna-0.8.4 → codedna-0.9.0}/dashboard/lib/i18n/en.ts +43 -0
  14. {codedna-0.8.4 → codedna-0.9.0}/dashboard/lib/i18n/tr.ts +40 -0
  15. {codedna-0.8.4 → codedna-0.9.0}/pyproject.toml +1 -1
  16. {codedna-0.8.4 → codedna-0.9.0}/.gitignore +0 -0
  17. {codedna-0.8.4 → codedna-0.9.0}/LICENSE +0 -0
  18. {codedna-0.8.4 → codedna-0.9.0}/README.md +0 -0
  19. {codedna-0.8.4 → codedna-0.9.0}/codedna/ai.py +0 -0
  20. {codedna-0.8.4 → codedna-0.9.0}/codedna/ai_box.py +0 -0
  21. {codedna-0.8.4 → codedna-0.9.0}/codedna/ai_fingerprint.py +0 -0
  22. {codedna-0.8.4 → codedna-0.9.0}/codedna/auth.py +0 -0
  23. {codedna-0.8.4 → codedna-0.9.0}/codedna/bus_factor.py +0 -0
  24. {codedna-0.8.4 → codedna-0.9.0}/codedna/demo.py +0 -0
  25. {codedna-0.8.4 → codedna-0.9.0}/codedna/git_hook.py +0 -0
  26. {codedna-0.8.4 → codedna-0.9.0}/codedna/integrations/__init__.py +0 -0
  27. {codedna-0.8.4 → codedna-0.9.0}/codedna/integrations/github_bot.py +0 -0
  28. {codedna-0.8.4 → codedna-0.9.0}/codedna/integrations/jira.py +0 -0
  29. {codedna-0.8.4 → codedna-0.9.0}/codedna/integrations/lemonsqueezy.py +0 -0
  30. {codedna-0.8.4 → codedna-0.9.0}/codedna/interview.py +0 -0
  31. {codedna-0.8.4 → codedna-0.9.0}/codedna/onboarding.py +0 -0
  32. {codedna-0.8.4 → codedna-0.9.0}/codedna/plan.py +0 -0
  33. {codedna-0.8.4 → codedna-0.9.0}/codedna/protection.py +0 -0
  34. {codedna-0.8.4 → codedna-0.9.0}/codedna/rate_limit.py +0 -0
  35. {codedna-0.8.4 → codedna-0.9.0}/codedna/scorer.py +0 -0
  36. {codedna-0.8.4 → codedna-0.9.0}/codedna/sprint_health.py +0 -0
  37. {codedna-0.8.4 → codedna-0.9.0}/codedna/survey.py +0 -0
  38. {codedna-0.8.4 → codedna-0.9.0}/codedna/tech_debt.py +0 -0
  39. {codedna-0.8.4 → codedna-0.9.0}/dashboard/.env.local.example +0 -0
  40. {codedna-0.8.4 → codedna-0.9.0}/dashboard/.gitignore +0 -0
  41. {codedna-0.8.4 → codedna-0.9.0}/dashboard/README.md +0 -0
  42. {codedna-0.8.4 → codedna-0.9.0}/dashboard/app/ai-compare/page.tsx +0 -0
  43. {codedna-0.8.4 → codedna-0.9.0}/dashboard/app/api/auth/login/route.ts +0 -0
  44. {codedna-0.8.4 → codedna-0.9.0}/dashboard/app/api/auth/logout/route.ts +0 -0
  45. {codedna-0.8.4 → codedna-0.9.0}/dashboard/app/api/auth/me/route.ts +0 -0
  46. {codedna-0.8.4 → codedna-0.9.0}/dashboard/app/api/auth/register/route.ts +0 -0
  47. {codedna-0.8.4 → codedna-0.9.0}/dashboard/app/api/billing/checkout/route.ts +0 -0
  48. {codedna-0.8.4 → codedna-0.9.0}/dashboard/app/billing/page.tsx +0 -0
  49. {codedna-0.8.4 → codedna-0.9.0}/dashboard/app/bus-factor/page.tsx +0 -0
  50. {codedna-0.8.4 → codedna-0.9.0}/dashboard/app/commits/page.tsx +0 -0
  51. {codedna-0.8.4 → codedna-0.9.0}/dashboard/app/debt/page.tsx +0 -0
  52. {codedna-0.8.4 → codedna-0.9.0}/dashboard/app/favicon.ico +0 -0
  53. {codedna-0.8.4 → codedna-0.9.0}/dashboard/app/files/page.tsx +0 -0
  54. {codedna-0.8.4 → codedna-0.9.0}/dashboard/app/fonts/GeistMonoVF.woff +0 -0
  55. {codedna-0.8.4 → codedna-0.9.0}/dashboard/app/fonts/GeistVF.woff +0 -0
  56. {codedna-0.8.4 → codedna-0.9.0}/dashboard/app/globals.css +0 -0
  57. {codedna-0.8.4 → codedna-0.9.0}/dashboard/app/interview/page.tsx +0 -0
  58. {codedna-0.8.4 → codedna-0.9.0}/dashboard/app/layout.tsx +0 -0
  59. {codedna-0.8.4 → codedna-0.9.0}/dashboard/app/login/page.tsx +0 -0
  60. {codedna-0.8.4 → codedna-0.9.0}/dashboard/app/onboarding/page.tsx +0 -0
  61. {codedna-0.8.4 → codedna-0.9.0}/dashboard/app/page.tsx +0 -0
  62. {codedna-0.8.4 → codedna-0.9.0}/dashboard/app/pricing/page.tsx +0 -0
  63. {codedna-0.8.4 → codedna-0.9.0}/dashboard/app/protected/page.tsx +0 -0
  64. {codedna-0.8.4 → codedna-0.9.0}/dashboard/app/register/page.tsx +0 -0
  65. {codedna-0.8.4 → codedna-0.9.0}/dashboard/app/settings/integrations/page.tsx +0 -0
  66. {codedna-0.8.4 → codedna-0.9.0}/dashboard/app/sprints/page.tsx +0 -0
  67. {codedna-0.8.4 → codedna-0.9.0}/dashboard/components/AuthProvider.tsx +0 -0
  68. {codedna-0.8.4 → codedna-0.9.0}/dashboard/components/ClientProviders.tsx +0 -0
  69. {codedna-0.8.4 → codedna-0.9.0}/dashboard/components/CommitTable.tsx +0 -0
  70. {codedna-0.8.4 → codedna-0.9.0}/dashboard/components/CostInfoTooltip.tsx +0 -0
  71. {codedna-0.8.4 → codedna-0.9.0}/dashboard/components/ErrorBanner.tsx +0 -0
  72. {codedna-0.8.4 → codedna-0.9.0}/dashboard/components/FeatureGate.tsx +0 -0
  73. {codedna-0.8.4 → codedna-0.9.0}/dashboard/components/FileHeatmap.tsx +0 -0
  74. {codedna-0.8.4 → codedna-0.9.0}/dashboard/components/LanguageSwitcher.tsx +0 -0
  75. {codedna-0.8.4 → codedna-0.9.0}/dashboard/components/OverviewTitles.tsx +0 -0
  76. {codedna-0.8.4 → codedna-0.9.0}/dashboard/components/QuickInsights.tsx +0 -0
  77. {codedna-0.8.4 → codedna-0.9.0}/dashboard/components/RiskBadge.tsx +0 -0
  78. {codedna-0.8.4 → codedna-0.9.0}/dashboard/components/SummaryCards.tsx +0 -0
  79. {codedna-0.8.4 → codedna-0.9.0}/dashboard/components/UnderstandingChart.tsx +0 -0
  80. {codedna-0.8.4 → codedna-0.9.0}/dashboard/components/UpgradeBanner.tsx +0 -0
  81. {codedna-0.8.4 → codedna-0.9.0}/dashboard/lib/api.ts +0 -0
  82. {codedna-0.8.4 → codedna-0.9.0}/dashboard/lib/auth.ts +0 -0
  83. {codedna-0.8.4 → codedna-0.9.0}/dashboard/lib/i18n/index.tsx +0 -0
  84. {codedna-0.8.4 → codedna-0.9.0}/dashboard/lib/plan.ts +0 -0
  85. {codedna-0.8.4 → codedna-0.9.0}/dashboard/next-env.d.ts +0 -0
  86. {codedna-0.8.4 → codedna-0.9.0}/dashboard/next.config.mjs +0 -0
  87. {codedna-0.8.4 → codedna-0.9.0}/dashboard/package-lock.json +0 -0
  88. {codedna-0.8.4 → codedna-0.9.0}/dashboard/package.json +0 -0
  89. {codedna-0.8.4 → codedna-0.9.0}/dashboard/postcss.config.mjs +0 -0
  90. {codedna-0.8.4 → codedna-0.9.0}/dashboard/tailwind.config.ts +0 -0
  91. {codedna-0.8.4 → codedna-0.9.0}/dashboard/tsconfig.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codedna
3
- Version: 0.8.4
3
+ Version: 0.9.0
4
4
  Summary: AI Code Transparency Tool - detect AI-written code and measure developer understanding
5
5
  Project-URL: Homepage, https://codedna.dev
6
6
  Project-URL: Repository, https://github.com/natureco-official/codedna
@@ -1,4 +1,4 @@
1
1
  """CodeDNA — AI Code Transparency Tool."""
2
2
 
3
- __version__ = "0.8.4"
3
+ __version__ = "0.9.0"
4
4
  __app_name__ = "codedna"
@@ -7,6 +7,41 @@ from dataclasses import dataclass, field
7
7
  from pathlib import Path
8
8
  from typing import Optional
9
9
 
10
+
11
+ CONVENTIONAL_COMMIT_PATTERNS = {
12
+ "feat": "Feature",
13
+ "fix": "Bug Fix",
14
+ "docs": "Documentation",
15
+ "refactor": "Refactor",
16
+ "style": "Style",
17
+ "test": "Test",
18
+ "chore": "Chore",
19
+ "ci": "CI",
20
+ "build": "Build",
21
+ "perf": "Performance",
22
+ "revert": "Revert",
23
+ "wip": "WIP",
24
+ "merge": "Merge",
25
+ "release": "Release",
26
+ }
27
+
28
+ COMMIT_QUALITY_MAP = {
29
+ "Feature": 4.5,
30
+ "Bug Fix": 4.0,
31
+ "Refactor": 3.5,
32
+ "Documentation": 3.5,
33
+ "Style": 2.0,
34
+ "Test": 4.0,
35
+ "Chore": 2.5,
36
+ "CI": 3.0,
37
+ "Build": 3.0,
38
+ "Performance": 4.5,
39
+ "Revert": 1.0,
40
+ "WIP": 1.5,
41
+ "Merge": 2.0,
42
+ "Release": 3.5,
43
+ }
44
+
10
45
  import tree_sitter_python as tspython
11
46
  import tree_sitter_javascript as tsjavascript
12
47
  from tree_sitter import Language, Parser, Node
@@ -46,6 +81,9 @@ class FileAnalysisResult:
46
81
  function_count: int = 0
47
82
  unsupported: bool = False # kept for internal use
48
83
  error: Optional[str] = None # kept for internal use
84
+ explanation: list[str] = field(default_factory=list)
85
+ commit_type: Optional[str] = None
86
+ commit_quality: float = 2.5
49
87
 
50
88
  @property
51
89
  def complexity_label(self) -> str:
@@ -210,9 +248,132 @@ def analyze_file(
210
248
  # Calculate AI probability
211
249
  result.ai_probability = _calculate_ai_probability(result)
212
250
 
251
+ # Generate explanation
252
+ result.explanation = explain_ai_score(result)
253
+
254
+ return result
255
+
256
+
257
+ def analyze_commit_message(message: str) -> dict:
258
+ """
259
+ Analyze a commit message and return:
260
+ - type: conventional commit type (feat, fix, etc.)
261
+ - category: human-readable category
262
+ - quality_score: estimated quality (0-5)
263
+ - has_ticket: whether it references a ticket (JIRA/GitHub issue)
264
+ - is_conventional: whether it follows conventional commit format
265
+ - scope: the scope if present (e.g. "auth" in "feat(auth): ...")
266
+ """
267
+ result = {
268
+ "type": None,
269
+ "category": None,
270
+ "quality_score": 2.5,
271
+ "has_ticket": False,
272
+ "is_conventional": False,
273
+ "scope": None,
274
+ }
275
+
276
+ if not message:
277
+ return result
278
+
279
+ first_line = message.strip().split("\n")[0]
280
+
281
+ # Ticket reference detection
282
+ ticket_patterns = [
283
+ r"[A-Z]+-\d+", # JIRA: PROJ-123
284
+ r"#\d+", # GitHub: #42
285
+ r"gh-\d+", # GitHub CLI: gh-42
286
+ ]
287
+ for pat in ticket_patterns:
288
+ if re.search(pat, first_line, re.IGNORECASE):
289
+ result["has_ticket"] = True
290
+ break
291
+
292
+ # Conventional commit: type(scope): description
293
+ conv_match = re.match(
294
+ r"^(fix|feat|docs|refactor|style|test|chore|ci|build|perf|revert)"
295
+ r"(\(([^)]+)\))?\s*:\s*(.+)",
296
+ first_line,
297
+ re.IGNORECASE,
298
+ )
299
+ if conv_match:
300
+ result["is_conventional"] = True
301
+ result["type"] = conv_match.group(1).lower()
302
+ result["scope"] = conv_match.group(3)
303
+ raw_type = conv_match.group(1).lower()
304
+ category = CONVENTIONAL_COMMIT_PATTERNS.get(raw_type, "Unknown")
305
+ result["category"] = category
306
+ result["quality_score"] = COMMIT_QUALITY_MAP.get(category, 2.5)
307
+
308
+ # Bonus for having a scope
309
+ if result["scope"]:
310
+ result["quality_score"] = min(result["quality_score"] + 0.5, 5.0)
311
+
312
+ # Bonus for having a ticket reference
313
+ if result["has_ticket"]:
314
+ result["quality_score"] = min(result["quality_score"] + 0.5, 5.0)
315
+ else:
316
+ # Non-conventional message — estimate quality
317
+ words = len(first_line.split())
318
+ if words < 3:
319
+ result["quality_score"] = 1.0
320
+ result["category"] = "Vague"
321
+ elif first_line.startswith("Merge"):
322
+ result["category"] = "Merge"
323
+ result["quality_score"] = 2.0
324
+ elif "wip" in first_line.lower() or "work in progress" in first_line.lower():
325
+ result["category"] = "WIP"
326
+ result["quality_score"] = 1.5
327
+ elif words > 10:
328
+ result["quality_score"] = 3.5
329
+ result["category"] = "Descriptive"
330
+ else:
331
+ result["category"] = "Standard"
332
+ result["quality_score"] = 2.5
333
+
213
334
  return result
214
335
 
215
336
 
337
+ def explain_ai_score(result: FileAnalysisResult) -> list[str]:
338
+ """
339
+ Generate human-readable explanations for the AI probability score.
340
+ Returns a list of reasons why the file scored as it did.
341
+ """
342
+ reasons: list[str] = []
343
+
344
+ if result.comment_ratio > 0.3:
345
+ pct = int(result.comment_ratio * 100)
346
+ reasons.append(
347
+ f"High comment ratio ({pct}%) — AI-generated code tends to over-comment (+0.20)"
348
+ )
349
+
350
+ if result.avg_function_length > 50:
351
+ reasons.append(
352
+ f"Long avg. function length ({result.avg_function_length:.0f} lines) — "
353
+ f"AI tends to produce larger blocks (+0.15)"
354
+ )
355
+
356
+ if result.single_commit_ratio > 0.7:
357
+ pct = int(result.single_commit_ratio * 100)
358
+ reasons.append(
359
+ f"High single-commit ratio ({pct}%) — bulk paste indicator (+0.30)"
360
+ )
361
+
362
+ if result.complexity_score > 10 and result.single_commit_ratio > 0.5:
363
+ reasons.append(
364
+ f"High complexity ({result.complexity_score:.0f}) combined with bulk change — "
365
+ f"complex AI-generated code pattern (+0.25)"
366
+ )
367
+
368
+ if result.function_count == 0:
369
+ reasons.append("No functions detected — may be a config/data file")
370
+
371
+ if not reasons:
372
+ reasons.append("All metrics within normal range — likely human-written code")
373
+
374
+ return reasons
375
+
376
+
216
377
  def _calculate_ai_probability(result: FileAnalysisResult) -> float:
217
378
  """
218
379
  Calculate a rule-based AI probability score (0.0 – 1.0).
@@ -16,9 +16,11 @@ from pydantic import BaseModel
16
16
 
17
17
  from codedna import __version__
18
18
  from codedna.db import (
19
+ export_all_data as _export_all_data,
19
20
  get_commit_history,
20
21
  get_db_path,
21
22
  get_file_scores_for_commit,
23
+ import_data as _import_data,
22
24
  init_db,
23
25
  update_understanding_score,
24
26
  )
@@ -576,6 +578,178 @@ def _build_html_report(
576
578
  </html>"""
577
579
 
578
580
 
581
+ # ---------------------------------------------------------------------------
582
+ # Export/Import endpoints
583
+ # ---------------------------------------------------------------------------
584
+
585
+ @app.get("/export", tags=["Export"])
586
+ async def export_data_endpoint() -> dict:
587
+ """Export all database data as JSON."""
588
+ db = _db_path()
589
+ init_db(db)
590
+ return _export_all_data(db_path=db)
591
+
592
+
593
+ @app.post("/import", tags=["Export"])
594
+ async def import_data_endpoint(data: dict) -> dict:
595
+ """Import previously exported data."""
596
+ db = _db_path()
597
+ init_db(db)
598
+ counts = _import_data(data, db_path=db)
599
+ return {"status": "imported", **counts}
600
+
601
+
602
+ # ---------------------------------------------------------------------------
603
+ # Trend data endpoints
604
+ # ---------------------------------------------------------------------------
605
+
606
+ @app.get("/trends", tags=["Trends"])
607
+ async def trends(
608
+ limit: int = Query(30, ge=1, le=365, description="Number of days to return"),
609
+ ) -> dict:
610
+ """Return day-by-day trend data for AI score and understanding over time."""
611
+ db = _db_path()
612
+ init_db(db)
613
+ try:
614
+ from codedna.db import get_trend_data
615
+ rows = get_trend_data(db_path=db, limit=limit)
616
+ except Exception:
617
+ rows = []
618
+ return {"total_days": len(rows), "trends": rows}
619
+
620
+
621
+ @app.get("/trends/commits", tags=["Trends"])
622
+ async def commit_trends(
623
+ limit: int = Query(50, ge=1, le=500, description="Number of commits"),
624
+ ) -> dict:
625
+ """Return commit-level trend data (AI score, understanding per commit)."""
626
+ db = _db_path()
627
+ init_db(db)
628
+ commits = get_commit_history(limit=limit, db_path=db)
629
+ items = []
630
+ for c in commits:
631
+ files = get_file_scores_for_commit(c["commit_hash"], db_path=db)
632
+ ai_scores = [float(f["ai_probability"]) for f in files if f["ai_probability"] is not None]
633
+ avg_ai = sum(ai_scores) / len(ai_scores) if ai_scores else None
634
+ items.append({
635
+ "commit_hash": c["commit_hash"][:8] if c["commit_hash"] else "",
636
+ "author": c["author"],
637
+ "date": datetime.fromtimestamp(c["timestamp"]).strftime("%Y-%m-%d") if c["timestamp"] else None,
638
+ "avg_ai_score": round(avg_ai, 3) if avg_ai else None,
639
+ "understanding_score": round(float(c["understanding_score"]), 2) if c["understanding_score"] is not None else None,
640
+ })
641
+ return {"total": len(items), "trends": list(reversed(items))}
642
+
643
+
644
+ # ---------------------------------------------------------------------------
645
+ # Feedback endpoints
646
+ # ---------------------------------------------------------------------------
647
+
648
+ FEEDBACK_PATH = Path.home() / ".codedna" / "feedback.json"
649
+
650
+
651
+ @app.post("/feedback", tags=["Feedback"])
652
+ async def save_feedback(data: dict) -> dict:
653
+ """Save user feedback on AI analysis accuracy."""
654
+ db = _db_path()
655
+ init_db(db)
656
+ feedback_file = FEEDBACK_PATH
657
+
658
+ feedback_file.parent.mkdir(parents=True, exist_ok=True)
659
+ existing = []
660
+ if feedback_file.exists():
661
+ try:
662
+ existing = json.loads(feedback_file.read_text())
663
+ except Exception:
664
+ existing = []
665
+
666
+ entry = {
667
+ "id": len(existing) + 1,
668
+ "timestamp": datetime.utcnow().isoformat(),
669
+ "file_path": data.get("file_path", ""),
670
+ "commit_hash": data.get("commit_hash", ""),
671
+ "ai_probability": data.get("ai_probability"),
672
+ "user_rating": data.get("rating"), # "correct", "incorrect", "unsure"
673
+ "user_note": data.get("note", ""),
674
+ }
675
+ existing.append(entry)
676
+ feedback_file.write_text(json.dumps(existing, indent=2))
677
+ return {"status": "saved", "id": entry["id"]}
678
+
679
+
680
+ @app.get("/feedback", tags=["Feedback"])
681
+ async def list_feedback(limit: int = Query(50, ge=1, le=500)) -> dict:
682
+ """Return user feedback entries."""
683
+ feedback_file = FEEDBACK_PATH
684
+ if not feedback_file.exists():
685
+ return {"total": 0, "feedback": []}
686
+ try:
687
+ items = json.loads(feedback_file.read_text())
688
+ except Exception:
689
+ items = []
690
+ return {"total": len(items), "feedback": items[-limit:]}
691
+
692
+
693
+ # ---------------------------------------------------------------------------
694
+ # Multi-repo endpoints
695
+ # ---------------------------------------------------------------------------
696
+
697
+ MULTI_REPO_PATH = Path.home() / ".codedna" / "repos.json"
698
+
699
+
700
+ @app.get("/repos", tags=["Multi-Repo"])
701
+ async def list_repos() -> dict:
702
+ """Return configured multi-repo list."""
703
+ repos_file = MULTI_REPO_PATH
704
+ if not repos_file.exists():
705
+ return {"total": 0, "repos": []}
706
+ try:
707
+ items = json.loads(repos_file.read_text())
708
+ except Exception:
709
+ items = []
710
+ return {"total": len(items), "repos": items}
711
+
712
+
713
+ @app.post("/repos", tags=["Multi-Repo"])
714
+ async def add_repo(data: dict) -> dict:
715
+ """Add a repository to the multi-repo config."""
716
+ repos_file = MULTI_REPO_PATH
717
+ repos_file.parent.mkdir(parents=True, exist_ok=True)
718
+ existing = []
719
+ if repos_file.exists():
720
+ try:
721
+ existing = json.loads(repos_file.read_text())
722
+ except Exception:
723
+ existing = []
724
+
725
+ new_repo = {
726
+ "id": len(existing) + 1,
727
+ "name": data.get("name", ""),
728
+ "path": data.get("path", ""),
729
+ "added_at": datetime.utcnow().isoformat(),
730
+ }
731
+ existing.append(new_repo)
732
+ repos_file.write_text(json.dumps(existing, indent=2))
733
+ return {"status": "added", "repo": new_repo}
734
+
735
+
736
+ @app.delete("/repos/{repo_id}", tags=["Multi-Repo"])
737
+ async def remove_repo(repo_id: int) -> dict:
738
+ """Remove a repository from the multi-repo config."""
739
+ repos_file = MULTI_REPO_PATH
740
+ if not repos_file.exists():
741
+ raise HTTPException(status_code=404, detail="No repos configured.")
742
+ try:
743
+ items = json.loads(repos_file.read_text())
744
+ except Exception:
745
+ items = []
746
+ filtered = [r for r in items if r.get("id") != repo_id]
747
+ if len(filtered) == len(items):
748
+ raise HTTPException(status_code=404, detail=f"Repo #{repo_id} not found.")
749
+ repos_file.write_text(json.dumps(filtered, indent=2))
750
+ return {"status": "removed", "repo_id": repo_id}
751
+
752
+
579
753
  # ---------------------------------------------------------------------------
580
754
  # Helper: plan 403 response
581
755
  # ---------------------------------------------------------------------------