codedna 0.2.0__py3-none-any.whl

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.
codedna/api.py ADDED
@@ -0,0 +1,1505 @@
1
+ """CodeDNA FastAPI REST servisi."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from contextlib import asynccontextmanager
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ from fastapi import FastAPI, HTTPException, Query, Request
13
+ from fastapi.middleware.cors import CORSMiddleware
14
+ from fastapi.responses import HTMLResponse
15
+ from pydantic import BaseModel
16
+
17
+ from codedna import __version__
18
+ from codedna.db import (
19
+ get_commit_history,
20
+ get_db_path,
21
+ get_file_scores_for_commit,
22
+ init_db,
23
+ update_understanding_score,
24
+ )
25
+ from codedna.scorer import scan_repository
26
+ from codedna.git_hook import find_git_root
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Ortam değişkenlerinden yapılandırma
30
+ # ---------------------------------------------------------------------------
31
+
32
+ def _repo_yolu() -> Path:
33
+ """Ortam değişkeninden veya otomatik bularak repo yolunu döndür."""
34
+ env = os.environ.get("CODEDNA_REPO_PATH")
35
+ if env:
36
+ return Path(env).resolve()
37
+ return find_git_root() or Path.cwd()
38
+
39
+
40
+ def _db_yolu() -> Path:
41
+ """Ortam değişkeninden veya repo köküne göre DB yolunu döndür."""
42
+ env = os.environ.get("CODEDNA_DB_PATH")
43
+ if env:
44
+ return Path(env).resolve()
45
+ return get_db_path(_repo_yolu())
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # FastAPI uygulaması — lifespan pattern (startup deprecated değil)
50
+ # ---------------------------------------------------------------------------
51
+ @asynccontextmanager
52
+ async def lifespan(app: FastAPI):
53
+ """Uygulama yaşam döngüsü — başlangıçta DB'yi hazırla."""
54
+ init_db(_db_yolu())
55
+ yield
56
+
57
+
58
+ app = FastAPI(
59
+ title="CodeDNA API",
60
+ description="AI kod şeffaflık aracı — REST API",
61
+ version=__version__,
62
+ docs_url="/docs",
63
+ redoc_url="/redoc",
64
+ lifespan=lifespan,
65
+ )
66
+
67
+ # CORS — dashboard veya harici araçlar için tam açık
68
+ app.add_middleware(
69
+ CORSMiddleware,
70
+ allow_origins=["*"],
71
+ allow_methods=["*"],
72
+ allow_headers=["*"],
73
+ )
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Pydantic modelleri
78
+ # ---------------------------------------------------------------------------
79
+ class SurveyGirdi(BaseModel):
80
+ """Anlama anketi giriş verisi."""
81
+ skor_1: float # Bu değişikliği 3 ay sonra açıklayabilir misin? [1-5]
82
+ skor_2: float # Bir hata çıksa debug edebilir misin? [1-5]
83
+ skor_3: float # Başkası sorsa, nasıl çalıştığını anlatabilir misin? [1-5]
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Endpoint'ler
88
+ # ---------------------------------------------------------------------------
89
+
90
+ @app.get("/health", tags=["Sistem"])
91
+ async def saglik_kontrolu() -> dict:
92
+ """Servisin ayakta olduğunu doğrula."""
93
+ return {
94
+ "durum": "çalışıyor",
95
+ "versiyon": __version__,
96
+ "zaman": datetime.utcnow().isoformat(),
97
+ }
98
+
99
+
100
+ @app.get("/repo/summary", tags=["Repo"])
101
+ async def repo_ozeti() -> dict:
102
+ """Repo geneli özet: ortalama AI skoru, toplam commit, risk seviyesi."""
103
+ db = _db_yolu()
104
+ init_db(db)
105
+
106
+ commitler = get_commit_history(limit=1000, db_path=db)
107
+
108
+ if not commitler:
109
+ return {
110
+ "toplam_commit": 0,
111
+ "ortalama_ai_skoru": None,
112
+ "risk_seviyesi": "BİLİNMİYOR",
113
+ "anlama_skoru_olan_commit": 0,
114
+ "ortalama_anlama_skoru": None,
115
+ }
116
+
117
+ toplam = len(commitler)
118
+ anlama_skorlari = [
119
+ float(c["understanding_score"])
120
+ for c in commitler
121
+ if c["understanding_score"] is not None
122
+ ]
123
+
124
+ # Dosya skorlarından ortalama AI olasılığını hesapla
125
+ tum_ai_skorlari: list[float] = []
126
+ for commit in commitler[:50]: # Son 50 commit yeterli
127
+ dosyalar = get_file_scores_for_commit(commit["commit_hash"], db_path=db)
128
+ for d in dosyalar:
129
+ if d["ai_probability"] is not None:
130
+ tum_ai_skorlari.append(float(d["ai_probability"]))
131
+
132
+ ort_ai = sum(tum_ai_skorlari) / len(tum_ai_skorlari) if tum_ai_skorlari else None
133
+ ort_anlama = sum(anlama_skorlari) / len(anlama_skorlari) if anlama_skorlari else None
134
+
135
+ # Risk seviyesi
136
+ if ort_ai is None:
137
+ risk = "BİLİNMİYOR"
138
+ elif ort_ai >= 0.7:
139
+ risk = "YÜKSEK"
140
+ elif ort_ai >= 0.4:
141
+ risk = "ORTA"
142
+ else:
143
+ risk = "DÜŞÜK"
144
+
145
+ return {
146
+ "toplam_commit": toplam,
147
+ "ortalama_ai_skoru": round(ort_ai, 3) if ort_ai is not None else None,
148
+ "ortalama_ai_yuzdesi": round(ort_ai * 100, 1) if ort_ai is not None else None,
149
+ "risk_seviyesi": risk,
150
+ "anlama_skoru_olan_commit": len(anlama_skorlari),
151
+ "ortalama_anlama_skoru": round(ort_anlama, 2) if ort_anlama is not None else None,
152
+ }
153
+
154
+
155
+ @app.get("/repo/files", tags=["Repo"])
156
+ async def repo_dosyalari(
157
+ min_risk: float = Query(0.0, ge=0.0, le=1.0, description="Minimum AI olasılığı filtresi"),
158
+ max_dosya: int = Query(200, ge=1, le=1000, description="Maksimum dosya sayısı"),
159
+ ) -> dict:
160
+ """Tüm desteklenen dosyaları tara ve AI skorlarını döndür."""
161
+ kok = _repo_yolu()
162
+
163
+ sonuclar = scan_repository(kok, max_files=max_dosya)
164
+
165
+ # Filtrele ve sırala
166
+ if min_risk > 0:
167
+ sonuclar = [s for s in sonuclar if s.ai_probability >= min_risk]
168
+ sonuclar.sort(key=lambda s: s.ai_probability, reverse=True)
169
+
170
+ dosyalar = []
171
+ for s in sonuclar:
172
+ # Göreli yol hesapla
173
+ try:
174
+ goreceli = str(Path(s.file_path).relative_to(kok))
175
+ except ValueError:
176
+ goreceli = s.file_path
177
+
178
+ dosyalar.append({
179
+ "dosya_yolu": goreceli,
180
+ "ai_olasıligi": round(s.ai_probability, 3),
181
+ "ai_yuzdesi": round(s.ai_probability * 100, 1),
182
+ "karmasiklik_skoru": round(s.complexity_score, 1),
183
+ "karmasiklik_etiketi": s.complexity_label,
184
+ "yorum_orani": round(s.comment_ratio, 3),
185
+ "ortalama_fonksiyon_uzunlugu": round(s.avg_function_length, 1),
186
+ "tek_commit_orani": round(s.single_commit_ratio, 3),
187
+ "toplam_satir": s.total_lines,
188
+ "fonksiyon_sayisi": s.function_count,
189
+ })
190
+
191
+ toplam_ai = sum(d["ai_olasıligi"] for d in dosyalar)
192
+ ort_ai = toplam_ai / len(dosyalar) if dosyalar else 0
193
+
194
+ return {
195
+ "toplam_dosya": len(dosyalar),
196
+ "ortalama_ai_skoru": round(ort_ai, 3),
197
+ "dosyalar": dosyalar,
198
+ }
199
+
200
+
201
+ @app.get("/commits", tags=["Commit"])
202
+ async def commit_listesi(
203
+ limit: int = Query(20, ge=1, le=100, description="Döndürülecek commit sayısı"),
204
+ ) -> dict:
205
+ """Geçmiş commit listesini döndür."""
206
+ db = _db_yolu()
207
+ init_db(db)
208
+ commitler = get_commit_history(limit=limit, db_path=db)
209
+
210
+ liste = []
211
+ for c in commitler:
212
+ liste.append({
213
+ "commit_hash": c["commit_hash"],
214
+ "hash_kisa": c["commit_hash"][:8] if c["commit_hash"] else "",
215
+ "yazar": c["author"],
216
+ "zaman_dam": c["timestamp"],
217
+ "tarih": (
218
+ datetime.fromtimestamp(c["timestamp"]).strftime("%Y-%m-%d %H:%M")
219
+ if c["timestamp"] else None
220
+ ),
221
+ "degisen_dosya_sayisi": c["files_changed"],
222
+ "anlama_skoru": (
223
+ round(float(c["understanding_score"]), 2)
224
+ if c["understanding_score"] is not None else None
225
+ ),
226
+ "olusturulma": c["created_at"],
227
+ })
228
+
229
+ return {"toplam": len(liste), "commitler": liste}
230
+
231
+
232
+ @app.get("/commits/{commit_hash}", tags=["Commit"])
233
+ async def commit_detayi(commit_hash: str) -> dict:
234
+ """Tek commit detayı ve ilgili dosya skorlarını döndür."""
235
+ db = _db_yolu()
236
+ init_db(db)
237
+
238
+ # Tam hash veya kısa hash ile ara
239
+ commitler = get_commit_history(limit=1000, db_path=db)
240
+ bulunan = None
241
+ for c in commitler:
242
+ if c["commit_hash"] and (
243
+ c["commit_hash"] == commit_hash
244
+ or c["commit_hash"].startswith(commit_hash)
245
+ ):
246
+ bulunan = c
247
+ break
248
+
249
+ if not bulunan:
250
+ raise HTTPException(
251
+ status_code=404,
252
+ detail=f"'{commit_hash}' hash'li commit bulunamadı.",
253
+ )
254
+
255
+ dosyalar = get_file_scores_for_commit(bulunan["commit_hash"], db_path=db)
256
+ dosya_listesi = [
257
+ {
258
+ "dosya_yolu": d["file_path"],
259
+ "ai_olasıligi": round(float(d["ai_probability"]), 3) if d["ai_probability"] is not None else None,
260
+ "karmasiklik_skoru": round(float(d["complexity_score"]), 1) if d["complexity_score"] is not None else None,
261
+ "yorum_orani": round(float(d["comment_ratio"]), 3) if d["comment_ratio"] is not None else None,
262
+ "anlama_skoru": round(float(d["understanding_score"]), 2) if d["understanding_score"] is not None else None,
263
+ }
264
+ for d in dosyalar
265
+ ]
266
+
267
+ return {
268
+ "commit_hash": bulunan["commit_hash"],
269
+ "yazar": bulunan["author"],
270
+ "tarih": (
271
+ datetime.fromtimestamp(bulunan["timestamp"]).strftime("%Y-%m-%d %H:%M")
272
+ if bulunan["timestamp"] else None
273
+ ),
274
+ "degisen_dosya_sayisi": bulunan["files_changed"],
275
+ "anlama_skoru": (
276
+ round(float(bulunan["understanding_score"]), 2)
277
+ if bulunan["understanding_score"] is not None else None
278
+ ),
279
+ "dosyalar": dosya_listesi,
280
+ }
281
+
282
+
283
+ @app.post("/survey/{commit_hash}", tags=["Anket"])
284
+ async def anket_kaydet(commit_hash: str, girdi: SurveyGirdi) -> dict:
285
+ """Anlama anketi sonucunu kaydet."""
286
+ # Skor doğrulama
287
+ for alan, deger in [("skor_1", girdi.skor_1), ("skor_2", girdi.skor_2), ("skor_3", girdi.skor_3)]:
288
+ if not (1.0 <= deger <= 5.0):
289
+ raise HTTPException(
290
+ status_code=422,
291
+ detail=f"'{alan}' değeri 1 ile 5 arasında olmalıdır, gelen: {deger}",
292
+ )
293
+
294
+ ortalama = (girdi.skor_1 + girdi.skor_2 + girdi.skor_3) / 3.0
295
+ db = _db_yolu()
296
+ init_db(db)
297
+
298
+ # Commit var mı kontrol et
299
+ commitler = get_commit_history(limit=1000, db_path=db)
300
+ bulunan_hash = None
301
+ for c in commitler:
302
+ if c["commit_hash"] and (
303
+ c["commit_hash"] == commit_hash
304
+ or c["commit_hash"].startswith(commit_hash)
305
+ ):
306
+ bulunan_hash = c["commit_hash"]
307
+ break
308
+
309
+ if not bulunan_hash:
310
+ raise HTTPException(
311
+ status_code=404,
312
+ detail=f"'{commit_hash}' hash'li commit bulunamadı.",
313
+ )
314
+
315
+ update_understanding_score(bulunan_hash, ortalama, db_path=db)
316
+
317
+ return {
318
+ "commit_hash": bulunan_hash,
319
+ "anlama_skoru": round(ortalama, 2),
320
+ "mesaj": "Anlama skoru başarıyla kaydedildi.",
321
+ }
322
+
323
+
324
+ @app.get("/report", tags=["Rapor"])
325
+ async def rapor(fmt: str = Query("json", description="Çıktı formatı: 'json' veya 'html'")) -> object:
326
+ """Repo özet raporunu JSON veya HTML olarak döndür."""
327
+ db = _db_yolu()
328
+ init_db(db)
329
+ kok = _repo_yolu()
330
+
331
+ # Veri topla
332
+ commitler = get_commit_history(limit=50, db_path=db)
333
+ dosyalar_sonuc = scan_repository(kok, max_files=100)
334
+ dosyalar_sonuc.sort(key=lambda s: s.ai_probability, reverse=True)
335
+
336
+ tum_ai = [s.ai_probability for s in dosyalar_sonuc]
337
+ ort_ai = sum(tum_ai) / len(tum_ai) if tum_ai else 0.0
338
+
339
+ anlama_skorlari = [
340
+ float(c["understanding_score"])
341
+ for c in commitler
342
+ if c["understanding_score"] is not None
343
+ ]
344
+ ort_anlama = sum(anlama_skorlari) / len(anlama_skorlari) if anlama_skorlari else None
345
+
346
+ if ort_ai >= 0.7:
347
+ risk = "YÜKSEK"
348
+ elif ort_ai >= 0.4:
349
+ risk = "ORTA"
350
+ else:
351
+ risk = "DÜŞÜK"
352
+
353
+ if fmt == "html":
354
+ html = _rapor_html_olustur(
355
+ repo_adi=kok.name,
356
+ toplam_dosya=len(dosyalar_sonuc),
357
+ ort_ai=ort_ai,
358
+ risk=risk,
359
+ ort_anlama=ort_anlama,
360
+ toplam_commit=len(commitler),
361
+ dosyalar=dosyalar_sonuc,
362
+ commitler=commitler,
363
+ kok=kok,
364
+ )
365
+ return HTMLResponse(content=html)
366
+
367
+ # JSON formatı
368
+ return {
369
+ "repo": kok.name,
370
+ "tarih": datetime.utcnow().isoformat(),
371
+ "ozet": {
372
+ "toplam_dosya": len(dosyalar_sonuc),
373
+ "ortalama_ai_skoru": round(ort_ai, 3),
374
+ "risk_seviyesi": risk,
375
+ "toplam_commit": len(commitler),
376
+ "ortalama_anlama_skoru": round(ort_anlama, 2) if ort_anlama else None,
377
+ },
378
+ "dosyalar": [
379
+ {
380
+ "yol": str(Path(s.file_path).relative_to(kok)) if Path(s.file_path).is_relative_to(kok) else s.file_path,
381
+ "ai_yuzdesi": round(s.ai_probability * 100, 1),
382
+ "karmasiklik": s.complexity_label,
383
+ "satir": s.total_lines,
384
+ }
385
+ for s in dosyalar_sonuc[:20]
386
+ ],
387
+ }
388
+
389
+
390
+ # ---------------------------------------------------------------------------
391
+ # HTML rapor üretici (Jinja2 yok — f-string)
392
+ # ---------------------------------------------------------------------------
393
+ def _rapor_html_olustur(
394
+ repo_adi: str,
395
+ toplam_dosya: int,
396
+ ort_ai: float,
397
+ risk: str,
398
+ ort_anlama: Optional[float],
399
+ toplam_commit: int,
400
+ dosyalar: list,
401
+ commitler: list,
402
+ kok: Path,
403
+ ) -> str:
404
+ """Inline CSS ile sade HTML rapor üret."""
405
+ tarih_str = datetime.now().strftime("%d %B %Y, %H:%M")
406
+ risk_renk = {"YÜKSEK": "#e74c3c", "ORTA": "#f39c12", "DÜŞÜK": "#27ae60"}.get(risk, "#95a5a6")
407
+
408
+ anlama_str = f"{ort_anlama:.1f}/5" if ort_anlama is not None else "Veri yok"
409
+
410
+ # Dosya satırları
411
+ dosya_satirlari = ""
412
+ for s in dosyalar:
413
+ try:
414
+ yol = str(Path(s.file_path).relative_to(kok))
415
+ except ValueError:
416
+ yol = s.file_path
417
+
418
+ yuzde = s.ai_probability * 100
419
+ if yuzde >= 70:
420
+ renk = "#e74c3c"
421
+ emoji = "🔴"
422
+ elif yuzde >= 40:
423
+ renk = "#f39c12"
424
+ emoji = "🟡"
425
+ else:
426
+ renk = "#27ae60"
427
+ emoji = "🟢"
428
+
429
+ dosya_satirlari += f"""
430
+ <tr>
431
+ <td style="font-family:monospace;font-size:13px">{yol}</td>
432
+ <td style="color:{renk};font-weight:bold;text-align:center">{emoji} %{yuzde:.0f}</td>
433
+ <td style="text-align:center">{s.complexity_label}</td>
434
+ <td style="text-align:right">{s.total_lines}</td>
435
+ <td style="text-align:right">{s.function_count}</td>
436
+ </tr>"""
437
+
438
+ # Commit satırları
439
+ commit_satirlari = ""
440
+ for c in commitler[:20]:
441
+ tarih = (
442
+ datetime.fromtimestamp(c["timestamp"]).strftime("%Y-%m-%d %H:%M")
443
+ if c["timestamp"] else "?"
444
+ )
445
+ anlama = (
446
+ f"{float(c['understanding_score']):.1f}/5"
447
+ if c["understanding_score"] is not None else "—"
448
+ )
449
+ commit_satirlari += f"""
450
+ <tr>
451
+ <td style="font-family:monospace">{(c['commit_hash'] or '')[:8]}</td>
452
+ <td>{c['author'] or '?'}</td>
453
+ <td>{tarih}</td>
454
+ <td style="text-align:right">{c['files_changed'] or 0}</td>
455
+ <td style="text-align:center">{anlama}</td>
456
+ </tr>"""
457
+
458
+ return f"""<!DOCTYPE html>
459
+ <html lang="tr">
460
+ <head>
461
+ <meta charset="UTF-8">
462
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
463
+ <title>🧬 CodeDNA Raporu — {repo_adi}</title>
464
+ <style>
465
+ * {{ box-sizing: border-box; margin: 0; padding: 0; }}
466
+ body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
467
+ background: #0f1117; color: #e2e8f0; padding: 32px; }}
468
+ h1 {{ font-size: 28px; margin-bottom: 4px; }}
469
+ h2 {{ font-size: 18px; margin: 32px 0 12px; color: #94a3b8; text-transform: uppercase;
470
+ letter-spacing: 1px; font-size: 13px; }}
471
+ .subtitle {{ color: #64748b; font-size: 14px; margin-bottom: 32px; }}
472
+ .cards {{ display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 32px; }}
473
+ .card {{ background: #1e2433; border-radius: 12px; padding: 20px 28px;
474
+ min-width: 160px; flex: 1; border: 1px solid #2d3748; }}
475
+ .card-label {{ font-size: 12px; color: #64748b; text-transform: uppercase;
476
+ letter-spacing: 1px; margin-bottom: 8px; }}
477
+ .card-value {{ font-size: 28px; font-weight: 700; }}
478
+ table {{ width: 100%; border-collapse: collapse; background: #1e2433;
479
+ border-radius: 12px; overflow: hidden; border: 1px solid #2d3748; }}
480
+ th {{ background: #252d3d; padding: 12px 16px; text-align: left;
481
+ font-size: 12px; text-transform: uppercase; letter-spacing: 1px;
482
+ color: #94a3b8; }}
483
+ td {{ padding: 11px 16px; border-top: 1px solid #2d3748; font-size: 14px; }}
484
+ tr:hover td {{ background: #252d3d; }}
485
+ .badge {{ display: inline-block; padding: 3px 10px; border-radius: 20px;
486
+ font-size: 12px; font-weight: 600; color: white;
487
+ background: {risk_renk}; }}
488
+ footer {{ margin-top: 40px; color: #4a5568; font-size: 12px; text-align: center; }}
489
+ </style>
490
+ </head>
491
+ <body>
492
+ <h1>🧬 CodeDNA Raporu</h1>
493
+ <p class="subtitle">📁 {repo_adi} &nbsp;·&nbsp; 📅 {tarih_str}</p>
494
+
495
+ <div class="cards">
496
+ <div class="card">
497
+ <div class="card-label">Toplam Dosya</div>
498
+ <div class="card-value">{toplam_dosya}</div>
499
+ </div>
500
+ <div class="card">
501
+ <div class="card-label">Ort. AI Skoru</div>
502
+ <div class="card-value">%{ort_ai*100:.0f}</div>
503
+ </div>
504
+ <div class="card">
505
+ <div class="card-label">Risk Seviyesi</div>
506
+ <div class="card-value"><span class="badge">{risk}</span></div>
507
+ </div>
508
+ <div class="card">
509
+ <div class="card-label">Toplam Commit</div>
510
+ <div class="card-value">{toplam_commit}</div>
511
+ </div>
512
+ <div class="card">
513
+ <div class="card-label">Ort. Anlama</div>
514
+ <div class="card-value">{anlama_str}</div>
515
+ </div>
516
+ </div>
517
+
518
+ <h2>Dosya Analizi</h2>
519
+ <table>
520
+ <thead>
521
+ <tr>
522
+ <th>Dosya</th><th>AI Olasılığı</th><th>Karmaşıklık</th>
523
+ <th style="text-align:right">Satır</th><th style="text-align:right">Fonksiyon</th>
524
+ </tr>
525
+ </thead>
526
+ <tbody>{dosya_satirlari}</tbody>
527
+ </table>
528
+
529
+ <h2>Commit Geçmişi</h2>
530
+ <table>
531
+ <thead>
532
+ <tr>
533
+ <th>Hash</th><th>Yazar</th><th>Tarih</th>
534
+ <th style="text-align:right">Dosya</th><th style="text-align:center">Anlama</th>
535
+ </tr>
536
+ </thead>
537
+ <tbody>{commit_satirlari}</tbody>
538
+ </table>
539
+
540
+ <footer>🧬 CodeDNA v{__version__} &nbsp;·&nbsp; codedna raporu otomatik oluşturuldu</footer>
541
+ </body>
542
+ </html>"""
543
+
544
+
545
+ # ---------------------------------------------------------------------------
546
+ # Yardımcı: plan 403 yanıtı
547
+ # ---------------------------------------------------------------------------
548
+ def _plan_403(ozellik: str, tr_mesaj: str, en_mesaj: str) -> HTTPException:
549
+ """Plan kısıtlaması için standart 403 hatası üret."""
550
+ return HTTPException(
551
+ status_code=403,
552
+ detail={
553
+ "hata": tr_mesaj,
554
+ "error": en_mesaj,
555
+ "ozellik": ozellik,
556
+ "gerekli_plan": "team",
557
+ },
558
+ )
559
+
560
+
561
+ # ---------------------------------------------------------------------------
562
+ # Bus Factor endpoint'leri
563
+ # ---------------------------------------------------------------------------
564
+
565
+ @app.get("/bus-factor", tags=["Bus Factor"])
566
+ async def bus_factor_listesi(
567
+ max_dosya: int = Query(200, ge=1, le=500, description="Maksimum dosya sayısı"),
568
+ ) -> dict:
569
+ """Tüm dosyalar için bus factor listesi. Team+ planı gerektirir."""
570
+ from codedna.plan import is_feature_available
571
+ from codedna.bus_factor import calculate_bus_factor
572
+
573
+ if not is_feature_available("bus_factor"):
574
+ raise _plan_403(
575
+ "bus_factor",
576
+ "Bu özellik Team planında mevcut.",
577
+ "This feature is available on Team plan.",
578
+ )
579
+
580
+ kok = _repo_yolu()
581
+ db = _db_yolu()
582
+ init_db(db)
583
+
584
+ sonuclar = calculate_bus_factor(kok, db, max_dosya=max_dosya)
585
+
586
+ return {
587
+ "toplam_dosya": len(sonuclar),
588
+ "kritik_sayisi": sum(1 for s in sonuclar if s.risk == "KRİTİK"),
589
+ "riskli_sayisi": sum(1 for s in sonuclar if s.risk == "RİSKLİ"),
590
+ "dosyalar": [
591
+ {
592
+ "dosya_yolu": s.dosya_yolu,
593
+ "bus_factor": s.bus_factor,
594
+ "birincil_sahip": s.birincil_sahip,
595
+ "sahiplik_yuzdesi": s.sahiplik_yuzdesi,
596
+ "risk": s.risk,
597
+ "anlayan_yazarlar": s.anlayan_yazarlar,
598
+ "toplam_satir": s.toplam_satir,
599
+ }
600
+ for s in sonuclar
601
+ ],
602
+ }
603
+
604
+
605
+ @app.get("/bus-factor/critical", tags=["Bus Factor"])
606
+ async def bus_factor_kritik() -> dict:
607
+ """Sadece bus_factor=1 olan kritik dosyaları döndür. Team+ planı gerektirir."""
608
+ from codedna.plan import is_feature_available
609
+ from codedna.bus_factor import get_at_risk_files
610
+
611
+ if not is_feature_available("bus_factor"):
612
+ raise _plan_403(
613
+ "bus_factor",
614
+ "Bu özellik Team planında mevcut.",
615
+ "This feature is available on Team plan.",
616
+ )
617
+
618
+ kok = _repo_yolu()
619
+ db = _db_yolu()
620
+ init_db(db)
621
+
622
+ sonuclar = get_at_risk_files(kok, db)
623
+
624
+ return {
625
+ "kritik_sayisi": len(sonuclar),
626
+ "dosyalar": [
627
+ {
628
+ "dosya_yolu": s.dosya_yolu,
629
+ "bus_factor": s.bus_factor,
630
+ "birincil_sahip": s.birincil_sahip,
631
+ "sahiplik_yuzdesi": s.sahiplik_yuzdesi,
632
+ "risk": s.risk,
633
+ "toplam_satir": s.toplam_satir,
634
+ }
635
+ for s in sonuclar
636
+ ],
637
+ }
638
+
639
+
640
+ # ---------------------------------------------------------------------------
641
+ # Teknik Borç endpoint'leri
642
+ # ---------------------------------------------------------------------------
643
+
644
+ @app.get("/debt/summary", tags=["Teknik Borç"])
645
+ async def borclu_ozet(
646
+ rate: float = Query(75.0, ge=1.0, le=1000.0, description="Saatlik maliyet ($/saat)"),
647
+ ) -> dict:
648
+ """
649
+ Repo geneli teknik borç özeti.
650
+ Free planda dolar tutarları maskelenir.
651
+ """
652
+ from codedna.plan import get_current_plan, Plan
653
+ from codedna.tech_debt import calculate_repo_debt
654
+
655
+ kok = _repo_yolu()
656
+ db = _db_yolu()
657
+ init_db(db)
658
+
659
+ ozet = calculate_repo_debt(kok, db, hourly_rate=rate)
660
+ mevcut_plan = get_current_plan()
661
+ dolar_gizli = mevcut_plan == Plan.FREE
662
+
663
+ en_pahali = []
664
+ for d in ozet.en_pahali_5:
665
+ try:
666
+ goreceli = str(Path(d.dosya_yolu).relative_to(kok))
667
+ except ValueError:
668
+ goreceli = d.dosya_yolu
669
+
670
+ en_pahali.append({
671
+ "dosya_yolu": goreceli,
672
+ "debt_saatleri": d.debt_saatleri,
673
+ "aylik_maliyet_usd": None if dolar_gizli else d.aylik_maliyet_usd,
674
+ "risk_seviyesi": d.risk_seviyesi,
675
+ })
676
+
677
+ return {
678
+ "toplam_debt_saatleri": ozet.toplam_debt_saatleri,
679
+ "toplam_aylik_maliyet_usd": None if dolar_gizli else ozet.toplam_aylik_maliyet_usd,
680
+ "dolar_gizli": dolar_gizli,
681
+ "saatlik_ucret": rate,
682
+ "toplam_dosya": ozet.toplam_dosya,
683
+ "en_pahali_5": en_pahali,
684
+ }
685
+
686
+
687
+ @app.get("/debt/files", tags=["Teknik Borç"])
688
+ async def borclu_dosyalar(
689
+ rate: float = Query(75.0, ge=1.0, le=1000.0, description="Saatlik maliyet ($/saat)"),
690
+ limit: int = Query(20, ge=1, le=100, description="Döndürülecek dosya sayısı"),
691
+ ) -> dict:
692
+ """
693
+ Dosya bazlı teknik borç listesi, en maliyetliden sıralı.
694
+ Free planda dolar tutarları maskelenir.
695
+ """
696
+ from codedna.plan import get_current_plan, Plan
697
+ from codedna.tech_debt import calculate_repo_debt
698
+
699
+ kok = _repo_yolu()
700
+ db = _db_yolu()
701
+ init_db(db)
702
+
703
+ ozet = calculate_repo_debt(kok, db, hourly_rate=rate)
704
+ mevcut_plan = get_current_plan()
705
+ dolar_gizli = mevcut_plan == Plan.FREE
706
+
707
+ # Tüm dosyaları en pahali'dan sıralı al
708
+ from codedna.tech_debt import calculate_file_debt
709
+ from codedna.scorer import scan_repository
710
+
711
+ taranan = scan_repository(kok, max_files=200)
712
+ taranan.sort(key=lambda s: s.ai_probability, reverse=True)
713
+
714
+ dosya_listesi = []
715
+ for s in taranan[:limit]:
716
+ borc = calculate_file_debt(s.file_path, db, hourly_rate=rate)
717
+ if borc is None:
718
+ continue
719
+ try:
720
+ goreceli = str(Path(s.file_path).relative_to(kok))
721
+ except ValueError:
722
+ goreceli = s.file_path
723
+
724
+ dosya_listesi.append({
725
+ "dosya_yolu": goreceli,
726
+ "debt_saatleri": borc.debt_saatleri,
727
+ "aylik_maliyet_usd": None if dolar_gizli else borc.aylik_maliyet_usd,
728
+ "risk_seviyesi": borc.risk_seviyesi,
729
+ "ai_olasiligi": borc.ai_olasiligi,
730
+ "karmasiklik": borc.karmasiklik,
731
+ "toplam_satir": borc.toplam_satir,
732
+ })
733
+
734
+ dosya_listesi.sort(key=lambda d: d["debt_saatleri"], reverse=True)
735
+
736
+ return {
737
+ "toplam_dosya": len(dosya_listesi),
738
+ "dolar_gizli": dolar_gizli,
739
+ "saatlik_ucret": rate,
740
+ "dosyalar": dosya_listesi,
741
+ }
742
+
743
+
744
+ # ---------------------------------------------------------------------------
745
+ # Sprint endpoint'leri
746
+ # ---------------------------------------------------------------------------
747
+
748
+ class SprintGirdi(BaseModel):
749
+ """Yeni sprint oluşturma giriş verisi."""
750
+ sprint_adi: str
751
+ baslangic: str # ISO date: "2026-06-01"
752
+ bitis: str # ISO date: "2026-06-14"
753
+
754
+
755
+ @app.post("/sprints", tags=["Sprint"])
756
+ async def sprint_olustur(girdi: SprintGirdi) -> dict:
757
+ """
758
+ Yeni sprint kaydı oluştur ve sağlık skoru hesapla.
759
+ Team+ planı gerektirir.
760
+ """
761
+ from codedna.plan import is_feature_available
762
+ from codedna.sprint_health import calculate_sprint_health, save_sprint_result
763
+ from datetime import datetime as dt
764
+
765
+ if not is_feature_available("sprint_health"):
766
+ raise _plan_403(
767
+ "sprint_health",
768
+ "Bu özellik Team planında mevcut.",
769
+ "This feature is available on Team plan.",
770
+ )
771
+
772
+ try:
773
+ baslangic = dt.fromisoformat(girdi.baslangic)
774
+ bitis = dt.fromisoformat(girdi.bitis)
775
+ except ValueError as e:
776
+ raise HTTPException(status_code=422, detail=f"Geçersiz tarih formatı: {e}")
777
+
778
+ if bitis <= baslangic:
779
+ raise HTTPException(status_code=422, detail="Bitiş tarihi başlangıçtan sonra olmalı.")
780
+
781
+ kok = _repo_yolu()
782
+ db = _db_yolu()
783
+ init_db(db)
784
+
785
+ sonuc = calculate_sprint_health(kok, db, baslangic, bitis, girdi.sprint_adi)
786
+ sprint_id = save_sprint_result(sonuc, db)
787
+
788
+ return {
789
+ "sprint_id": sprint_id,
790
+ "sprint_adi": sonuc.sprint_adi,
791
+ "health_score": sonuc.health_score,
792
+ "durum": sonuc.durum,
793
+ "avg_understanding": sonuc.avg_understanding,
794
+ "ai_orani": sonuc.ai_orani,
795
+ "debt_delta_saati": sonuc.debt_delta_saati,
796
+ "toplam_commit": sonuc.toplam_commit,
797
+ "ai_insan_orani": sonuc.ai_insan_orani_str,
798
+ }
799
+
800
+
801
+ @app.get("/sprints/current/health", tags=["Sprint"])
802
+ async def aktif_sprint_sagligi() -> dict:
803
+ """
804
+ En son sprint'in sağlık skorunu döndür.
805
+ Team+ planı gerektirir.
806
+ """
807
+ from codedna.plan import is_feature_available
808
+ from codedna.db import get_latest_sprint
809
+
810
+ if not is_feature_available("sprint_health"):
811
+ raise _plan_403(
812
+ "sprint_health",
813
+ "Bu özellik Team planında mevcut.",
814
+ "This feature is available on Team plan.",
815
+ )
816
+
817
+ db = _db_yolu()
818
+ init_db(db)
819
+
820
+ sprint = get_latest_sprint(db_path=db)
821
+ if not sprint:
822
+ raise HTTPException(status_code=404, detail="Henüz kayıtlı sprint yok.")
823
+
824
+ return {
825
+ "sprint_id": sprint["id"],
826
+ "sprint_adi": sprint["sprint_name"],
827
+ "health_score": sprint["health_score"],
828
+ "durum": _sprint_durumu(sprint["health_score"]),
829
+ "avg_understanding": sprint["avg_understanding"],
830
+ "debt_delta_saati": sprint["debt_delta_hours"],
831
+ "ai_satir": sprint["total_lines_ai"],
832
+ "insan_satir": sprint["total_lines_human"],
833
+ "baslangic": _ts_to_str(sprint["start_date"]),
834
+ "bitis": _ts_to_str(sprint["end_date"]),
835
+ }
836
+
837
+
838
+ @app.get("/sprints/history", tags=["Sprint"])
839
+ async def sprint_gecmisi(
840
+ limit: int = Query(10, ge=1, le=50, description="Döndürülecek sprint sayısı"),
841
+ ) -> dict:
842
+ """Geçmiş sprint listesini döndür. Team+ planı gerektirir."""
843
+ from codedna.plan import is_feature_available
844
+ from codedna.db import get_sprint_history as db_sprint_history
845
+
846
+ if not is_feature_available("sprint_health"):
847
+ raise _plan_403(
848
+ "sprint_health",
849
+ "Bu özellik Team planında mevcut.",
850
+ "This feature is available on Team plan.",
851
+ )
852
+
853
+ db = _db_yolu()
854
+ init_db(db)
855
+ sprintler = db_sprint_history(limit=limit, db_path=db)
856
+
857
+ return {
858
+ "toplam": len(sprintler),
859
+ "sprintler": [
860
+ {
861
+ "id": s["id"],
862
+ "sprint_adi": s["sprint_name"],
863
+ "baslangic": _ts_to_str(s["start_date"]),
864
+ "bitis": _ts_to_str(s["end_date"]),
865
+ "health_score": s["health_score"],
866
+ "durum": _sprint_durumu(s["health_score"]),
867
+ "avg_understanding": s["avg_understanding"],
868
+ "debt_delta_saati": s["debt_delta_hours"],
869
+ "ai_satir": s["total_lines_ai"],
870
+ "insan_satir": s["total_lines_human"],
871
+ }
872
+ for s in sprintler
873
+ ],
874
+ }
875
+
876
+
877
+ # ---------------------------------------------------------------------------
878
+ # Jira webhook endpoint'i
879
+ # ---------------------------------------------------------------------------
880
+
881
+ @app.post("/integrations/jira/webhook", tags=["Entegrasyonlar"])
882
+ async def jira_webhook(request: Request) -> dict:
883
+ """
884
+ Jira'dan gelen sprint event'lerini al ve işle.
885
+ HMAC-SHA256 imza doğrulaması ZORUNLUDUR (Team+ planı gerektirir).
886
+ """
887
+ from codedna.integrations.jira import (
888
+ get_or_create_secret,
889
+ verify_signature,
890
+ handle_jira_webhook,
891
+ )
892
+ from codedna.plan import is_feature_available
893
+
894
+ # Plan kontrolü
895
+ if not is_feature_available("sprint_health"):
896
+ raise HTTPException(
897
+ status_code=403,
898
+ detail="Jira entegrasyonu Team planında mevcut. / Jira integration requires Team plan.",
899
+ )
900
+
901
+ body = await request.body()
902
+ secret = get_or_create_secret()
903
+ imza = request.headers.get("X-Hub-Signature-256", "")
904
+
905
+ # İmza ZORUNLU — boşsa veya geçersizse reddet
906
+ if not imza or not verify_signature(body, imza, secret):
907
+ raise HTTPException(
908
+ status_code=401,
909
+ detail="Geçersiz veya eksik webhook imzası.",
910
+ )
911
+
912
+ try:
913
+ payload = json.loads(body)
914
+ except Exception:
915
+ raise HTTPException(status_code=400, detail="Geçersiz JSON payload.")
916
+
917
+ db = _db_yolu()
918
+ init_db(db)
919
+ sonuc = handle_jira_webhook(payload, db)
920
+ return sonuc
921
+
922
+
923
+ @app.get("/integrations/jira/config", tags=["Entegrasyonlar"])
924
+ async def jira_konfig() -> dict:
925
+ """Jira webhook yapılandırmasını döndür. Team+ planı gerektirir."""
926
+ from codedna.plan import is_feature_available
927
+ from codedna.integrations.jira import get_or_create_secret
928
+
929
+ if not is_feature_available("sprint_health"):
930
+ raise _plan_403(
931
+ "sprint_health",
932
+ "Bu özellik Team planında mevcut.",
933
+ "This feature is available on Team plan.",
934
+ )
935
+
936
+ secret = get_or_create_secret()
937
+ return {
938
+ "webhook_url": f"{_repo_yolu().name}/api/integrations/jira/webhook",
939
+ "secret_mevcut": bool(secret),
940
+ "secret_uzunluk": len(secret),
941
+ "desteklenen_eventler": ["sprint_started", "sprint_closed"],
942
+ }
943
+
944
+
945
+ @app.post("/integrations/jira/rotate-secret", tags=["Entegrasyonlar"])
946
+ async def jira_secret_yenile() -> dict:
947
+ """Webhook secret'ı yenile. Team+ planı gerektirir."""
948
+ from codedna.plan import is_feature_available
949
+ from codedna.integrations.jira import rotate_secret
950
+
951
+ if not is_feature_available("sprint_health"):
952
+ raise _plan_403(
953
+ "sprint_health",
954
+ "Bu özellik Team planında mevcut.",
955
+ "This feature is available on Team plan.",
956
+ )
957
+
958
+ yeni = rotate_secret()
959
+ return {
960
+ "mesaj": "Webhook secret yenilendi.",
961
+ "secret": yeni,
962
+ }
963
+
964
+
965
+ # ---------------------------------------------------------------------------
966
+ # Yardımcı fonksiyonlar (sprint için)
967
+ # ---------------------------------------------------------------------------
968
+
969
+ def _sprint_durumu(skor: Optional[float]) -> str:
970
+ """Skor değerine göre durum etiketi döndür."""
971
+ if skor is None:
972
+ return "BİLİNMİYOR"
973
+ if skor >= 80:
974
+ return "SAĞLIKLI"
975
+ elif skor >= 50:
976
+ return "DİKKAT"
977
+ return "RİSKLİ"
978
+
979
+
980
+ def _ts_to_str(ts: Optional[int]) -> Optional[str]:
981
+ """Unix timestamp'i okunabilir tarih string'ine çevir."""
982
+ if not ts:
983
+ return None
984
+ return datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
985
+
986
+
987
+ # ---------------------------------------------------------------------------
988
+ # AI Araç Karşılaştırma endpoint'i
989
+ # ---------------------------------------------------------------------------
990
+
991
+ @app.get("/ai-compare", tags=["AI Karşılaştırma"])
992
+ async def ai_arac_karsilastir() -> dict:
993
+ """
994
+ Repo genelinde AI araç bazlı karşılaştırma.
995
+ Enterprise planı gerektirir.
996
+ """
997
+ from codedna.plan import is_feature_available
998
+ from codedna.ai_fingerprint import compare_tools_in_repo
999
+
1000
+ if not is_feature_available("ai_comparison"):
1001
+ raise _plan_403(
1002
+ "ai_comparison",
1003
+ "Bu özellik Enterprise planında mevcut.",
1004
+ "This feature is available on Enterprise plan.",
1005
+ )
1006
+
1007
+ kok = _repo_yolu()
1008
+ db = _db_yolu()
1009
+ init_db(db)
1010
+
1011
+ sonuclar = compare_tools_in_repo(kok, db)
1012
+
1013
+ return {
1014
+ "uyari": (
1015
+ "Bu tespit örüntü tabanlı bir tahmindir — kesin değildir. "
1016
+ "This detection is pattern-based estimation — not definitive."
1017
+ ),
1018
+ "araclar": sonuclar,
1019
+ "toplam_dosya": sum(
1020
+ v.get("dosya_sayisi", 0) for v in sonuclar.values()
1021
+ ),
1022
+ }
1023
+
1024
+
1025
+ # ---------------------------------------------------------------------------
1026
+ # Onboarding endpoint'leri
1027
+ # ---------------------------------------------------------------------------
1028
+
1029
+ @app.get("/onboarding/team", tags=["Onboarding"])
1030
+ async def onboarding_takim_ozeti() -> dict:
1031
+ """Takımdaki tüm yazarların ramp-up özeti. Team+ planı gerektirir."""
1032
+ from codedna.plan import is_feature_available
1033
+ from codedna.onboarding import team_onboarding_summary
1034
+
1035
+ if not is_feature_available("sprint_health"):
1036
+ raise _plan_403(
1037
+ "sprint_health",
1038
+ "Bu özellik Team planında mevcut.",
1039
+ "This feature is available on Team plan.",
1040
+ )
1041
+
1042
+ db = _db_yolu()
1043
+ init_db(db)
1044
+
1045
+ ozet = team_onboarding_summary(db)
1046
+ return {
1047
+ "toplam_yazar": len(ozet),
1048
+ "yazarlar": ozet,
1049
+ }
1050
+
1051
+
1052
+ @app.get("/onboarding/{author}", tags=["Onboarding"])
1053
+ async def onboarding_yazar_egrisi(author: str) -> dict:
1054
+ """Tek yazar için onboarding zaman çizelgesi. Team+ planı gerektirir."""
1055
+ from codedna.plan import is_feature_available
1056
+ from codedna.onboarding import get_author_curve
1057
+
1058
+ if not is_feature_available("sprint_health"):
1059
+ raise _plan_403(
1060
+ "sprint_health",
1061
+ "Bu özellik Team planında mevcut.",
1062
+ "This feature is available on Team plan.",
1063
+ )
1064
+
1065
+ db = _db_yolu()
1066
+ init_db(db)
1067
+
1068
+ egri = get_author_curve(author, db)
1069
+
1070
+ if egri.toplam_commit == 0:
1071
+ raise HTTPException(
1072
+ status_code=404,
1073
+ detail=f"'{author}' yazarına ait commit bulunamadı.",
1074
+ )
1075
+
1076
+ return {
1077
+ "yazar": egri.yazar,
1078
+ "toplam_commit": egri.toplam_commit,
1079
+ "anlama_skoru_olan": egri.anlama_skoru_olan,
1080
+ "ramp_up_hafta": egri.ramp_up_hafta,
1081
+ "son_ort_anlama": egri.son_ort_anlama,
1082
+ "yeterli_veri": egri.anlama_skoru_olan >= 5,
1083
+ "noktalar": [
1084
+ {
1085
+ "commit_no": n.commit_no,
1086
+ "hafta_no": n.hafta_no,
1087
+ "tarih": n.tarih.strftime("%Y-%m-%d"),
1088
+ "understanding_score": n.understanding_score,
1089
+ }
1090
+ for n in egri.noktalar
1091
+ ],
1092
+ }
1093
+
1094
+
1095
+ # ---------------------------------------------------------------------------
1096
+ # Korumalı Modül endpoint'leri
1097
+ # ---------------------------------------------------------------------------
1098
+
1099
+ class ProtectedModulGirdi(BaseModel):
1100
+ """Korumalı modül ekleme giriş verisi."""
1101
+ dosya_yolu: str
1102
+ esik: float = 3.5
1103
+ etiket: str = ""
1104
+ ekleyen: str = "api"
1105
+
1106
+
1107
+ @app.post("/protected-modules", tags=["Korumalı Modüller"])
1108
+ async def korunali_modul_ekle(girdi: ProtectedModulGirdi) -> dict:
1109
+ """Yeni korumalı modül ekle. Team+ planı gerektirir."""
1110
+ from codedna.plan import is_feature_available
1111
+ from codedna.protection import protect_module
1112
+
1113
+ if not is_feature_available("bus_factor"):
1114
+ raise _plan_403("bus_factor", "Bu özellik Team planında mevcut.", "This feature is available on Team plan.")
1115
+
1116
+ if not (1.0 <= girdi.esik <= 5.0):
1117
+ raise HTTPException(status_code=422, detail="Eşik 1.0–5.0 arasında olmalı.")
1118
+
1119
+ db = _db_yolu()
1120
+ init_db(db)
1121
+ kayit_id = protect_module(
1122
+ girdi.dosya_yolu, girdi.esik,
1123
+ girdi.etiket or girdi.dosya_yolu, girdi.ekleyen, db,
1124
+ )
1125
+ return {"id": kayit_id, "dosya_yolu": girdi.dosya_yolu, "mesaj": "Korumalı modül eklendi."}
1126
+
1127
+
1128
+ @app.get("/protected-modules", tags=["Korumalı Modüller"])
1129
+ async def korunali_modul_listesi() -> dict:
1130
+ """Tüm korumalı modülleri ve durumlarını döndür. Team+ planı gerektirir."""
1131
+ from codedna.plan import is_feature_available
1132
+ from codedna.protection import check_protected_modules
1133
+
1134
+ if not is_feature_available("bus_factor"):
1135
+ raise _plan_403("bus_factor", "Bu özellik Team planında mevcut.", "This feature is available on Team plan.")
1136
+
1137
+ db = _db_yolu()
1138
+ init_db(db)
1139
+ moduller = check_protected_modules(db)
1140
+
1141
+ return {
1142
+ "toplam": len(moduller),
1143
+ "ihlal_sayisi": sum(1 for m in moduller if m.durum == "İHLAL"),
1144
+ "moduller": [
1145
+ {
1146
+ "dosya_yolu": m.dosya_yolu,
1147
+ "etiket": m.etiket,
1148
+ "esik": m.esik,
1149
+ "mevcut_skor": m.mevcut_skor,
1150
+ "durum": m.durum,
1151
+ }
1152
+ for m in moduller
1153
+ ],
1154
+ }
1155
+
1156
+
1157
+ @app.delete("/protected-modules/{file_path:path}", tags=["Korumalı Modüller"])
1158
+ async def korunali_modul_kaldir(file_path: str) -> dict:
1159
+ """Korumalı modülü kaldır. Team+ planı gerektirir."""
1160
+ from codedna.plan import is_feature_available
1161
+ from codedna.protection import unprotect_module
1162
+
1163
+ if not is_feature_available("bus_factor"):
1164
+ raise _plan_403("bus_factor", "Bu özellik Team planında mevcut.", "This feature is available on Team plan.")
1165
+
1166
+ db = _db_yolu()
1167
+ if unprotect_module(file_path, db):
1168
+ return {"mesaj": "Koruma kaldırıldı.", "dosya_yolu": file_path}
1169
+ raise HTTPException(status_code=404, detail="Korumalı modül bulunamadı.")
1170
+
1171
+
1172
+ @app.get("/protected-modules/violations", tags=["Korumalı Modüller"])
1173
+ async def korunali_modul_ihlalleri() -> dict:
1174
+ """Sadece ihlaldeki korumalı modülleri döndür. Team+ planı gerektirir."""
1175
+ from codedna.plan import is_feature_available
1176
+ from codedna.protection import get_violations
1177
+
1178
+ if not is_feature_available("bus_factor"):
1179
+ raise _plan_403("bus_factor", "Bu özellik Team planında mevcut.", "This feature is available on Team plan.")
1180
+
1181
+ db = _db_yolu()
1182
+ init_db(db)
1183
+ ihlaller = get_violations(db)
1184
+
1185
+ return {
1186
+ "ihlal_sayisi": len(ihlaller),
1187
+ "ihlaller": [
1188
+ {"dosya_yolu": m.dosya_yolu, "etiket": m.etiket,
1189
+ "esik": m.esik, "mevcut_skor": m.mevcut_skor}
1190
+ for m in ihlaller
1191
+ ],
1192
+ }
1193
+
1194
+
1195
+ # ---------------------------------------------------------------------------
1196
+ # Mülakat endpoint'leri
1197
+ # ---------------------------------------------------------------------------
1198
+
1199
+ class MulakatBaslatGirdi(BaseModel):
1200
+ """Mülakat başlatma giriş verisi."""
1201
+ candidate_name: str
1202
+ difficulty: str = "medium"
1203
+
1204
+
1205
+ @app.post("/interview/start", tags=["Mülakat"])
1206
+ async def mulakat_baslat(girdi: MulakatBaslatGirdi) -> dict:
1207
+ """Yeni mülakat oturumu başlat. Enterprise planı gerektirir."""
1208
+ from codedna.plan import is_feature_available
1209
+ from codedna.interview import select_candidate_file, generate_questions, start_session
1210
+
1211
+ if not is_feature_available("interview_tool"):
1212
+ raise _plan_403("interview_tool", "Bu özellik Enterprise planında mevcut.", "This feature is available on Enterprise plan.")
1213
+
1214
+ if girdi.difficulty not in ("easy", "medium", "hard"):
1215
+ raise HTTPException(status_code=422, detail="Zorluk: easy | medium | hard")
1216
+
1217
+ kok = _repo_yolu()
1218
+ db = _db_yolu()
1219
+ init_db(db)
1220
+
1221
+ dosya = select_candidate_file(kok, db, girdi.difficulty)
1222
+ if not dosya:
1223
+ raise HTTPException(status_code=404, detail=f"'{girdi.difficulty}' zorluğunda uygun dosya bulunamadı.")
1224
+
1225
+ sorular = generate_questions(dosya.anonimlestirilmis_kod)
1226
+ session_id = start_session(girdi.candidate_name, dosya.dosya_yolu, sorular, db)
1227
+
1228
+ return {
1229
+ "session_id": session_id,
1230
+ "aday": girdi.candidate_name,
1231
+ "zorluk": girdi.difficulty,
1232
+ "karmasiklik": dosya.karmasiklik_skoru,
1233
+ "satir_sayisi": dosya.satir_sayisi,
1234
+ "anonimlestirilmis_kod": dosya.anonimlestirilmis_kod,
1235
+ "sorular": sorular,
1236
+ "uyari": "Bu araç insan değerlendirmesinin yerine geçmez. This tool should not be used as the sole hiring decision factor.",
1237
+ }
1238
+
1239
+
1240
+ class PuanGirdi(BaseModel):
1241
+ """Mülakat puanı giriş verisi."""
1242
+ score: float
1243
+ evaluator_notes: str = ""
1244
+
1245
+
1246
+ @app.post("/interview/{session_id}/score", tags=["Mülakat"])
1247
+ async def mulakat_puan_kaydet(session_id: int, girdi: PuanGirdi) -> dict:
1248
+ """İnsan değerlendirici puanını kaydet. Enterprise planı gerektirir."""
1249
+ from codedna.plan import is_feature_available
1250
+ from codedna.interview import submit_score
1251
+
1252
+ if not is_feature_available("interview_tool"):
1253
+ raise _plan_403("interview_tool", "Bu özellik Enterprise planında mevcut.", "This feature is available on Enterprise plan.")
1254
+
1255
+ db = _db_yolu()
1256
+ try:
1257
+ return submit_score(session_id, girdi.score, girdi.evaluator_notes, db)
1258
+ except ValueError as e:
1259
+ raise HTTPException(status_code=404, detail=str(e))
1260
+
1261
+
1262
+ @app.get("/interview/sessions", tags=["Mülakat"])
1263
+ async def mulakat_oturumlari(
1264
+ limit: int = Query(20, ge=1, le=100),
1265
+ ) -> dict:
1266
+ """Geçmiş mülakat oturumlarını döndür. Enterprise planı gerektirir."""
1267
+ from codedna.plan import is_feature_available
1268
+ from codedna.interview import get_sessions
1269
+
1270
+ if not is_feature_available("interview_tool"):
1271
+ raise _plan_403("interview_tool", "Bu özellik Enterprise planında mevcut.", "This feature is available on Enterprise plan.")
1272
+
1273
+ db = _db_yolu()
1274
+ init_db(db)
1275
+ return {"oturumlar": get_sessions(db, limit=limit)}
1276
+
1277
+
1278
+ # ---------------------------------------------------------------------------
1279
+ # Auth endpoint'leri
1280
+ # ---------------------------------------------------------------------------
1281
+
1282
+ def _auth_db_yolu() -> Path:
1283
+ """Auth veritabanı yolunu döndür."""
1284
+ import os
1285
+ env = os.environ.get("CODEDNA_AUTH_DB_PATH")
1286
+ return Path(env).resolve() if env else Path.home() / ".codedna" / "auth.db"
1287
+
1288
+
1289
+ def _token_al(request: Request) -> Optional[str]:
1290
+ """Authorization: Bearer <token> header'ından token'ı ayıkla."""
1291
+ auth = request.headers.get("Authorization", "")
1292
+ if auth.startswith("Bearer "):
1293
+ return auth[7:].strip()
1294
+ return None
1295
+
1296
+
1297
+ class AuthKayitGirdi(BaseModel):
1298
+ """Kayıt giriş verisi."""
1299
+ email: str
1300
+ password: str
1301
+
1302
+
1303
+ class AuthGirisGirdi(BaseModel):
1304
+ """Giriş giriş verisi."""
1305
+ email: str
1306
+ password: str
1307
+
1308
+
1309
+ @app.post("/auth/register", tags=["Auth"])
1310
+ async def auth_kayit(girdi: AuthKayitGirdi) -> dict:
1311
+ """Yeni kullanıcı kaydı."""
1312
+ from codedna.auth import register_user, init_auth_db
1313
+
1314
+ db = _auth_db_yolu()
1315
+ init_auth_db(db)
1316
+
1317
+ try:
1318
+ sonuc = register_user(girdi.email, girdi.password, db)
1319
+ except ValueError as e:
1320
+ raise HTTPException(status_code=422, detail=str(e))
1321
+
1322
+ return {
1323
+ "user_id": sonuc["user_id"],
1324
+ "token": sonuc["token"],
1325
+ "plan": sonuc["plan"],
1326
+ "mesaj": "Kayıt başarılı.",
1327
+ }
1328
+
1329
+
1330
+ @app.post("/auth/login", tags=["Auth"])
1331
+ async def auth_giris(girdi: AuthGirisGirdi, request: Request) -> dict:
1332
+ """
1333
+ Giriş yap ve JWT token döndür.
1334
+ Rate limiting: IP bazlı VE e-posta bazlı — ikisi de 5 dk'da 5 deneme / 60s kilit.
1335
+ Botnet/proxy rotasyonuna karşı e-posta anahtarı eklendi.
1336
+ """
1337
+ from codedna.auth import login_user, init_auth_db
1338
+ from codedna.rate_limit import login_limiter
1339
+
1340
+ db = _auth_db_yolu()
1341
+ init_auth_db(db)
1342
+
1343
+ ip = request.client.host if request.client else "unknown"
1344
+ # "email:" öneki, e-posta anahtarının IP ile çakışmasını önler
1345
+ email_anahtari = f"email:{girdi.email.strip().lower()}"
1346
+
1347
+ # IP bazlı VE e-posta bazlı kontrol — ikisi de geçmeli
1348
+ for anahtar in (ip, email_anahtari):
1349
+ izin_var, kalan = login_limiter.kontrol_et(anahtar)
1350
+ if not izin_var:
1351
+ raise HTTPException(
1352
+ status_code=429,
1353
+ detail=f"Çok fazla başarısız deneme. {kalan} saniye bekleyin.",
1354
+ )
1355
+
1356
+ try:
1357
+ sonuc = login_user(girdi.email, girdi.password, db)
1358
+ # Başarılı girişte her iki anahtarı da sıfırla
1359
+ login_limiter.basarili_kaydet(ip)
1360
+ login_limiter.basarili_kaydet(email_anahtari)
1361
+ except ValueError:
1362
+ # Her iki anahtarı da say
1363
+ login_limiter.basarisiz_kaydet(ip)
1364
+ login_limiter.basarisiz_kaydet(email_anahtari)
1365
+ raise HTTPException(status_code=401, detail="E-posta veya şifre hatalı.")
1366
+
1367
+ return {
1368
+ "user_id": sonuc["user_id"],
1369
+ "token": sonuc["token"],
1370
+ "plan": sonuc["plan"],
1371
+ "subscription_status": sonuc["subscription_status"],
1372
+ }
1373
+
1374
+
1375
+ @app.post("/auth/logout", tags=["Auth"])
1376
+ async def auth_cikis(request: Request) -> dict:
1377
+ """Oturumu sonlandır."""
1378
+ from codedna.auth import logout_user
1379
+
1380
+ token = _token_al(request)
1381
+ if not token:
1382
+ raise HTTPException(status_code=401, detail="Token gerekli.")
1383
+
1384
+ db = _auth_db_yolu()
1385
+ logout_user(token, db)
1386
+ return {"mesaj": "Çıkış başarılı."}
1387
+
1388
+
1389
+ @app.get("/auth/me", tags=["Auth"])
1390
+ async def auth_ben(request: Request) -> dict:
1391
+ """Mevcut kullanıcı bilgisini döndür."""
1392
+ from codedna.auth import verify_token, get_user_by_id
1393
+
1394
+ token = _token_al(request)
1395
+ if not token:
1396
+ raise HTTPException(status_code=401, detail="Token gerekli.")
1397
+
1398
+ db = _auth_db_yolu()
1399
+ kullanici = verify_token(token, db)
1400
+ if not kullanici:
1401
+ raise HTTPException(status_code=401, detail="Geçersiz veya süresi dolmuş token.")
1402
+
1403
+ detay = get_user_by_id(kullanici["user_id"], db)
1404
+ if not detay:
1405
+ raise HTTPException(status_code=404, detail="Kullanıcı bulunamadı.")
1406
+
1407
+ return {
1408
+ "user_id": detay["id"],
1409
+ "email": detay["email"],
1410
+ "plan": detay["plan"],
1411
+ "subscription_status": detay["subscription_status"],
1412
+ }
1413
+
1414
+
1415
+ # ---------------------------------------------------------------------------
1416
+ # Billing endpoint'leri
1417
+ # ---------------------------------------------------------------------------
1418
+
1419
+ class CheckoutGirdi(BaseModel):
1420
+ """Checkout isteği giriş verisi."""
1421
+ plan: str # "pro" | "team" | "enterprise"
1422
+
1423
+
1424
+ @app.post("/billing/checkout", tags=["Billing"])
1425
+ async def billing_checkout(girdi: CheckoutGirdi, request: Request) -> dict:
1426
+ """
1427
+ Lemon Squeezy checkout URL'i oluştur.
1428
+ Authorization header gerektirir.
1429
+ """
1430
+ from codedna.auth import verify_token
1431
+ from codedna.integrations.lemonsqueezy import create_checkout_url
1432
+
1433
+ token = _token_al(request)
1434
+ if not token:
1435
+ raise HTTPException(status_code=401, detail="Token gerekli.")
1436
+
1437
+ db = _auth_db_yolu()
1438
+ kullanici = verify_token(token, db)
1439
+ if not kullanici:
1440
+ raise HTTPException(status_code=401, detail="Geçersiz token.")
1441
+
1442
+ if girdi.plan not in ("pro", "team", "enterprise"):
1443
+ raise HTTPException(status_code=422, detail="Geçersiz plan: pro | team | enterprise")
1444
+
1445
+ try:
1446
+ checkout_url = create_checkout_url(girdi.plan, kullanici["email"], kullanici["user_id"])
1447
+ except ValueError as e:
1448
+ raise HTTPException(status_code=503, detail=str(e))
1449
+ except RuntimeError as e:
1450
+ raise HTTPException(status_code=502, detail=str(e))
1451
+
1452
+ return {"checkout_url": checkout_url, "plan": girdi.plan}
1453
+
1454
+
1455
+ @app.post("/billing/webhook", tags=["Billing"])
1456
+ async def billing_webhook(request: Request) -> dict:
1457
+ """
1458
+ Lemon Squeezy'den gelen abonelik event'lerini al ve işle.
1459
+ İmza ZORUNLU — imzasız veya geçersiz imzalı istek her zaman 401.
1460
+ """
1461
+ from codedna.integrations.lemonsqueezy import (
1462
+ verify_webhook_signature,
1463
+ handle_subscription_webhook,
1464
+ )
1465
+
1466
+ body = await request.body()
1467
+ imza = request.headers.get("X-Signature", "")
1468
+
1469
+ # İmza ZORUNLU — boşsa veya geçersizse reddet (Faz 6/7 deseniyle tutarlı)
1470
+ if not imza or not verify_webhook_signature(body, imza):
1471
+ raise HTTPException(status_code=401, detail="Geçersiz veya eksik webhook imzası.")
1472
+
1473
+ try:
1474
+ payload = json.loads(body)
1475
+ except Exception:
1476
+ raise HTTPException(status_code=400, detail="Geçersiz JSON payload.")
1477
+
1478
+ db = _auth_db_yolu()
1479
+ sonuc = handle_subscription_webhook(payload, db)
1480
+ return sonuc
1481
+
1482
+
1483
+ @app.get("/billing/subscription", tags=["Billing"])
1484
+ async def billing_abonelik(request: Request) -> dict:
1485
+ """Mevcut kullanıcının abonelik durumunu döndür."""
1486
+ from codedna.auth import verify_token, get_user_by_id
1487
+
1488
+ token = _token_al(request)
1489
+ if not token:
1490
+ raise HTTPException(status_code=401, detail="Token gerekli.")
1491
+
1492
+ db = _auth_db_yolu()
1493
+ kullanici = verify_token(token, db)
1494
+ if not kullanici:
1495
+ raise HTTPException(status_code=401, detail="Geçersiz token.")
1496
+
1497
+ detay = get_user_by_id(kullanici["user_id"], db)
1498
+ if not detay:
1499
+ raise HTTPException(status_code=404, detail="Kullanıcı bulunamadı.")
1500
+
1501
+ return {
1502
+ "plan": detay["plan"],
1503
+ "subscription_status": detay["subscription_status"],
1504
+ "lemonsqueezy_customer_id": detay["lemonsqueezy_customer_id"],
1505
+ }