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/__init__.py +4 -0
- codedna/ai_fingerprint.py +223 -0
- codedna/analyzer.py +245 -0
- codedna/api.py +1505 -0
- codedna/auth.py +372 -0
- codedna/bus_factor.py +259 -0
- codedna/cli.py +1965 -0
- codedna/db.py +336 -0
- codedna/git_hook.py +212 -0
- codedna/integrations/__init__.py +1 -0
- codedna/integrations/github_bot.py +259 -0
- codedna/integrations/jira.py +166 -0
- codedna/integrations/lemonsqueezy.py +236 -0
- codedna/interview.py +298 -0
- codedna/onboarding.py +195 -0
- codedna/plan.py +184 -0
- codedna/protection.py +211 -0
- codedna/rate_limit.py +83 -0
- codedna/scorer.py +221 -0
- codedna/sprint_health.py +187 -0
- codedna/survey.py +104 -0
- codedna/tech_debt.py +232 -0
- codedna-0.2.0.dist-info/METADATA +93 -0
- codedna-0.2.0.dist-info/RECORD +26 -0
- codedna-0.2.0.dist-info/WHEEL +4 -0
- codedna-0.2.0.dist-info/entry_points.txt +2 -0
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} · 📅 {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__} · 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
|
+
}
|