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/auth.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Kullanıcı kimlik doğrulama — kayıt, giriş, JWT oturum yönetimi."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import bcrypt
|
|
13
|
+
import jwt
|
|
14
|
+
|
|
15
|
+
from codedna.db import get_connection
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Yapılandırma
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
# JWT secret MUTLAKA ortam değişkeninden okunmalı — koda gömülmemeli
|
|
22
|
+
JWT_SECRET: Optional[str] = os.environ.get("CODEDNA_JWT_SECRET")
|
|
23
|
+
JWT_ALGORITHM = "HS256"
|
|
24
|
+
JWT_EXPIRY_HOURS = 24 * 7 # 7 gün
|
|
25
|
+
|
|
26
|
+
# Auth veritabanı yolu — repo db'sinden ayrı
|
|
27
|
+
_AUTH_DB_VARSAYILAN = Path.home() / ".codedna" / "auth.db"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _auth_db() -> Path:
|
|
31
|
+
"""Auth veritabanı yolunu döndür (ortam değişkeninden veya varsayılan)."""
|
|
32
|
+
env = os.environ.get("CODEDNA_AUTH_DB_PATH")
|
|
33
|
+
return Path(env).resolve() if env else _AUTH_DB_VARSAYILAN
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _jwt_secret_kontrol() -> str:
|
|
37
|
+
"""
|
|
38
|
+
JWT secret'ın var olduğunu doğrula.
|
|
39
|
+
|
|
40
|
+
Production'da secret olmadan uygulama başlamamalı.
|
|
41
|
+
Development/test için otomatik rastgele secret üretir (persistent değil).
|
|
42
|
+
"""
|
|
43
|
+
secret = JWT_SECRET or os.environ.get("CODEDNA_JWT_SECRET")
|
|
44
|
+
if not secret:
|
|
45
|
+
# Development modunda otomatik dev secret üret (process başına)
|
|
46
|
+
# Production'da CODEDNA_JWT_SECRET environment variable tanımlanmalı
|
|
47
|
+
secret = os.environ.get("CODEDNA_JWT_DEV_SECRET")
|
|
48
|
+
if not secret:
|
|
49
|
+
import secrets
|
|
50
|
+
secret = "dev-" + secrets.token_urlsafe(48)
|
|
51
|
+
os.environ["CODEDNA_JWT_DEV_SECRET"] = secret
|
|
52
|
+
import warnings
|
|
53
|
+
warnings.warn(
|
|
54
|
+
"CODEDNA_JWT_SECRET tanımlı değil — development secret kullanılıyor. "
|
|
55
|
+
"Production için CODEDNA_JWT_SECRET environment variable tanımlayın.",
|
|
56
|
+
RuntimeWarning,
|
|
57
|
+
stacklevel=2
|
|
58
|
+
)
|
|
59
|
+
return secret
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Auth DB şeması
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
def init_auth_db(db_path: Optional[Path] = None) -> None:
|
|
67
|
+
"""Kullanıcı ve oturum tablolarını oluştur (yoksa)."""
|
|
68
|
+
yol = db_path or _auth_db()
|
|
69
|
+
yol.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
with get_connection(yol) as conn:
|
|
71
|
+
conn.executescript("""
|
|
72
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
73
|
+
id INTEGER PRIMARY KEY,
|
|
74
|
+
email TEXT UNIQUE NOT NULL,
|
|
75
|
+
password_hash TEXT NOT NULL,
|
|
76
|
+
plan TEXT DEFAULT 'free',
|
|
77
|
+
lemonsqueezy_customer_id TEXT,
|
|
78
|
+
lemonsqueezy_subscription_id TEXT,
|
|
79
|
+
subscription_status TEXT DEFAULT 'none',
|
|
80
|
+
created_at INTEGER,
|
|
81
|
+
updated_at INTEGER
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
85
|
+
id INTEGER PRIMARY KEY,
|
|
86
|
+
user_id INTEGER NOT NULL,
|
|
87
|
+
token_hash TEXT UNIQUE NOT NULL,
|
|
88
|
+
created_at INTEGER,
|
|
89
|
+
expires_at INTEGER,
|
|
90
|
+
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
91
|
+
);
|
|
92
|
+
""")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Şifre işlemleri
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def hash_password(password: str) -> str:
|
|
100
|
+
"""bcrypt ile şifreyi hash'le."""
|
|
101
|
+
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def verify_password(password: str, password_hash: str) -> bool:
|
|
105
|
+
"""Şifreyi hash ile güvenli biçimde karşılaştır."""
|
|
106
|
+
try:
|
|
107
|
+
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
|
|
108
|
+
except Exception:
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# Token yardımcıları
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def _token_hash(token: str) -> str:
|
|
117
|
+
"""Token'ı SHA-256 ile hash'le — DB'de token'ın kendisi SAKLANMAZ."""
|
|
118
|
+
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _jwt_olustur(user_id: int, email: str, plan: str) -> str:
|
|
122
|
+
"""JWT token üret."""
|
|
123
|
+
secret = _jwt_secret_kontrol()
|
|
124
|
+
su_an = int(time.time())
|
|
125
|
+
payload = {
|
|
126
|
+
"sub": str(user_id),
|
|
127
|
+
"email": email,
|
|
128
|
+
"plan": plan,
|
|
129
|
+
"iat": su_an,
|
|
130
|
+
"exp": su_an + JWT_EXPIRY_HOURS * 3600,
|
|
131
|
+
}
|
|
132
|
+
return jwt.encode(payload, secret, algorithm=JWT_ALGORITHM)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _jwt_coz(token: str) -> Optional[dict]:
|
|
136
|
+
"""JWT token'ı çöz, geçersizse None döndür."""
|
|
137
|
+
secret = _jwt_secret_kontrol()
|
|
138
|
+
try:
|
|
139
|
+
return jwt.decode(token, secret, algorithms=[JWT_ALGORITHM])
|
|
140
|
+
except jwt.ExpiredSignatureError:
|
|
141
|
+
return None
|
|
142
|
+
except jwt.InvalidTokenError:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
# Kayıt / Giriş / Çıkış
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
_EMAIL_REGEX = re.compile(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def register_user(
|
|
154
|
+
email: str,
|
|
155
|
+
password: str,
|
|
156
|
+
db_path: Optional[Path] = None,
|
|
157
|
+
) -> dict:
|
|
158
|
+
"""
|
|
159
|
+
Yeni kullanıcı kaydı yap.
|
|
160
|
+
|
|
161
|
+
Kurallar:
|
|
162
|
+
- E-posta formatı geçerli olmalı
|
|
163
|
+
- Şifre en az 8 karakter
|
|
164
|
+
- E-posta zaten kayıtlıysa hata
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
email: Kullanıcı e-postası
|
|
168
|
+
password: Düz metin şifre (kayıt sonrası saklanmaz)
|
|
169
|
+
db_path: Auth DB yolu
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
{"user_id", "token", "plan"} sözlüğü
|
|
173
|
+
"""
|
|
174
|
+
yol = db_path or _auth_db()
|
|
175
|
+
init_auth_db(yol)
|
|
176
|
+
|
|
177
|
+
# Girdi doğrulama
|
|
178
|
+
email = email.strip().lower()
|
|
179
|
+
if not _EMAIL_REGEX.match(email):
|
|
180
|
+
raise ValueError("Geçersiz e-posta formatı.")
|
|
181
|
+
if len(password) < 8:
|
|
182
|
+
raise ValueError("Şifre en az 8 karakter olmalı.")
|
|
183
|
+
|
|
184
|
+
sifre_hash = hash_password(password)
|
|
185
|
+
su_an = int(time.time())
|
|
186
|
+
|
|
187
|
+
# Lisans dosyasından plan al (demo/single-tenant modu)
|
|
188
|
+
# Production'da plan satın alma üzerinden atanır
|
|
189
|
+
try:
|
|
190
|
+
from codedna.plan import get_current_plan
|
|
191
|
+
initial_plan = get_current_plan().value
|
|
192
|
+
except Exception:
|
|
193
|
+
initial_plan = "free"
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
with get_connection(yol) as conn:
|
|
197
|
+
cur = conn.execute(
|
|
198
|
+
"""
|
|
199
|
+
INSERT INTO users (email, password_hash, plan, subscription_status, created_at, updated_at)
|
|
200
|
+
VALUES (?, ?, ?, 'demo', ?, ?)
|
|
201
|
+
""",
|
|
202
|
+
(email, sifre_hash, initial_plan, su_an, su_an),
|
|
203
|
+
)
|
|
204
|
+
user_id = cur.lastrowid or 0
|
|
205
|
+
except Exception as e:
|
|
206
|
+
if "UNIQUE" in str(e).upper():
|
|
207
|
+
raise ValueError("Bu e-posta adresi zaten kayıtlı.")
|
|
208
|
+
raise
|
|
209
|
+
|
|
210
|
+
token = _jwt_olustur(user_id, email, initial_plan)
|
|
211
|
+
_oturum_kaydet(user_id, token, yol)
|
|
212
|
+
|
|
213
|
+
return {"user_id": user_id, "token": token, "plan": initial_plan}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def login_user(
|
|
217
|
+
email: str,
|
|
218
|
+
password: str,
|
|
219
|
+
db_path: Optional[Path] = None,
|
|
220
|
+
) -> dict:
|
|
221
|
+
"""
|
|
222
|
+
Giriş yap ve JWT token üret.
|
|
223
|
+
|
|
224
|
+
Güvenlik notu: Başarısız girişlerde spesifik hata döndürme
|
|
225
|
+
(kullanıcı enumeration saldırısını önlemek için genel mesaj kullan).
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
email: Kullanıcı e-postası
|
|
229
|
+
password: Düz metin şifre
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
{"user_id", "token", "plan", "subscription_status"} sözlüğü
|
|
233
|
+
"""
|
|
234
|
+
yol = db_path or _auth_db()
|
|
235
|
+
init_auth_db(yol)
|
|
236
|
+
|
|
237
|
+
email = email.strip().lower()
|
|
238
|
+
|
|
239
|
+
with get_connection(yol) as conn:
|
|
240
|
+
kullanici = conn.execute(
|
|
241
|
+
"SELECT id, password_hash, plan, subscription_status FROM users WHERE email = ?",
|
|
242
|
+
(email,),
|
|
243
|
+
).fetchone()
|
|
244
|
+
|
|
245
|
+
# Kullanıcı yoksa veya şifre yanlışsa AYNI hata — enumeration önlemi
|
|
246
|
+
if not kullanici or not verify_password(password, kullanici["password_hash"]):
|
|
247
|
+
raise ValueError("E-posta veya şifre hatalı.")
|
|
248
|
+
|
|
249
|
+
token = _jwt_olustur(kullanici["id"], email, kullanici["plan"])
|
|
250
|
+
_oturum_kaydet(kullanici["id"], token, yol)
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
"user_id": kullanici["id"],
|
|
254
|
+
"token": token,
|
|
255
|
+
"plan": kullanici["plan"],
|
|
256
|
+
"subscription_status": kullanici["subscription_status"],
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def verify_token(
|
|
261
|
+
token: str,
|
|
262
|
+
db_path: Optional[Path] = None,
|
|
263
|
+
) -> Optional[dict]:
|
|
264
|
+
"""
|
|
265
|
+
JWT token'ı doğrula ve kullanıcı bilgisini döndür.
|
|
266
|
+
|
|
267
|
+
Hem JWT imzasını hem de oturum tablosundaki kaydı kontrol eder.
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
Geçerliyse {"user_id", "email", "plan"}, değilse None
|
|
271
|
+
"""
|
|
272
|
+
yol = db_path or _auth_db()
|
|
273
|
+
payload = _jwt_coz(token)
|
|
274
|
+
if not payload:
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
token_h = _token_hash(token)
|
|
278
|
+
su_an = int(time.time())
|
|
279
|
+
|
|
280
|
+
with get_connection(yol) as conn:
|
|
281
|
+
oturum = conn.execute(
|
|
282
|
+
"SELECT id FROM sessions WHERE token_hash = ? AND expires_at > ?",
|
|
283
|
+
(token_h, su_an),
|
|
284
|
+
).fetchone()
|
|
285
|
+
|
|
286
|
+
if not oturum:
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
"user_id": int(payload["sub"]),
|
|
291
|
+
"email": payload["email"],
|
|
292
|
+
"plan": payload["plan"],
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def logout_user(
|
|
297
|
+
token: str,
|
|
298
|
+
db_path: Optional[Path] = None,
|
|
299
|
+
) -> bool:
|
|
300
|
+
"""
|
|
301
|
+
Oturumu sonlandır — sessions tablosundan sil.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
Başarıyla silinirse True
|
|
305
|
+
"""
|
|
306
|
+
yol = db_path or _auth_db()
|
|
307
|
+
token_h = _token_hash(token)
|
|
308
|
+
with get_connection(yol) as conn:
|
|
309
|
+
cur = conn.execute("DELETE FROM sessions WHERE token_hash = ?", (token_h,))
|
|
310
|
+
return cur.rowcount > 0
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def get_user_by_id(user_id: int, db_path: Optional[Path] = None) -> Optional[dict]:
|
|
314
|
+
"""Kullanıcı bilgisini ID ile getir (şifre hash hariç)."""
|
|
315
|
+
yol = db_path or _auth_db()
|
|
316
|
+
with get_connection(yol) as conn:
|
|
317
|
+
row = conn.execute(
|
|
318
|
+
"SELECT id, email, plan, subscription_status, lemonsqueezy_customer_id FROM users WHERE id = ?",
|
|
319
|
+
(user_id,),
|
|
320
|
+
).fetchone()
|
|
321
|
+
if not row:
|
|
322
|
+
return None
|
|
323
|
+
return {
|
|
324
|
+
"id": row["id"],
|
|
325
|
+
"email": row["email"],
|
|
326
|
+
"plan": row["plan"],
|
|
327
|
+
"subscription_status": row["subscription_status"],
|
|
328
|
+
"lemonsqueezy_customer_id": row["lemonsqueezy_customer_id"],
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def update_user_plan(
|
|
333
|
+
user_id: int,
|
|
334
|
+
plan: str,
|
|
335
|
+
subscription_status: str,
|
|
336
|
+
customer_id: Optional[str] = None,
|
|
337
|
+
subscription_id: Optional[str] = None,
|
|
338
|
+
db_path: Optional[Path] = None,
|
|
339
|
+
) -> None:
|
|
340
|
+
"""Kullanıcının plan ve abonelik durumunu güncelle."""
|
|
341
|
+
yol = db_path or _auth_db()
|
|
342
|
+
su_an = int(time.time())
|
|
343
|
+
with get_connection(yol) as conn:
|
|
344
|
+
conn.execute(
|
|
345
|
+
"""
|
|
346
|
+
UPDATE users
|
|
347
|
+
SET plan = ?, subscription_status = ?,
|
|
348
|
+
lemonsqueezy_customer_id = COALESCE(?, lemonsqueezy_customer_id),
|
|
349
|
+
lemonsqueezy_subscription_id = COALESCE(?, lemonsqueezy_subscription_id),
|
|
350
|
+
updated_at = ?
|
|
351
|
+
WHERE id = ?
|
|
352
|
+
""",
|
|
353
|
+
(plan, subscription_status, customer_id, subscription_id, su_an, user_id),
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# ---------------------------------------------------------------------------
|
|
358
|
+
# Yardımcı — oturum kaydetme
|
|
359
|
+
# ---------------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
def _oturum_kaydet(user_id: int, token: str, db_path: Path) -> None:
|
|
362
|
+
"""Yeni oturumu DB'ye kaydet (token'ın hash'i saklanır)."""
|
|
363
|
+
su_an = int(time.time())
|
|
364
|
+
bitis = su_an + JWT_EXPIRY_HOURS * 3600
|
|
365
|
+
token_h = _token_hash(token)
|
|
366
|
+
with get_connection(db_path) as conn:
|
|
367
|
+
# Eski süresi dolmuş oturumları temizle
|
|
368
|
+
conn.execute("DELETE FROM sessions WHERE expires_at < ?", (su_an,))
|
|
369
|
+
conn.execute(
|
|
370
|
+
"INSERT OR IGNORE INTO sessions (user_id, token_hash, created_at, expires_at) VALUES (?, ?, ?, ?)",
|
|
371
|
+
(user_id, token_h, su_an, bitis),
|
|
372
|
+
)
|
codedna/bus_factor.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Bus factor hesaplama — bir dosyayı kaç kişi gerçekten anlıyor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from codedna.db import get_file_ownership, upsert_file_ownership
|
|
12
|
+
|
|
13
|
+
# Büyük repo uyarı eşiği
|
|
14
|
+
_BUYUK_REPO_ESIGI = 500
|
|
15
|
+
|
|
16
|
+
# Yeterli anlayış eşiği (bu skorun altındaki yazarlar "anlıyor" sayılmaz)
|
|
17
|
+
_ANLAMA_ESIGI = 3.5
|
|
18
|
+
|
|
19
|
+
# Birincil sahip sayılmak için gereken minimum satır yüzdesi
|
|
20
|
+
_SAHIPLIK_ESIGI = 0.10 # %10 → o dosyada "ilgili" sayılır
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class DosyaSahiplik:
|
|
25
|
+
"""Tek bir dosyanın yazar bazlı sahiplik özeti."""
|
|
26
|
+
|
|
27
|
+
dosya_yolu: str
|
|
28
|
+
toplam_satir: int
|
|
29
|
+
yazarlar: dict[str, int] = field(default_factory=dict) # yazar → satır sayısı
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def birincil_sahip(self) -> Optional[str]:
|
|
33
|
+
"""En fazla satıra sahip yazarı döndür."""
|
|
34
|
+
if not self.yazarlar:
|
|
35
|
+
return None
|
|
36
|
+
return max(self.yazarlar, key=lambda y: self.yazarlar[y])
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def birincil_sahiplik_yuzdesi(self) -> float:
|
|
40
|
+
"""Birincil sahibin sahiplik yüzdesi (0.0–1.0)."""
|
|
41
|
+
if not self.yazarlar or self.toplam_satir == 0:
|
|
42
|
+
return 0.0
|
|
43
|
+
birincil = self.birincil_sahip
|
|
44
|
+
return self.yazarlar.get(birincil or "", 0) / self.toplam_satir
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class BusFaktorSonucu:
|
|
49
|
+
"""Tek dosya için bus factor sonucu."""
|
|
50
|
+
|
|
51
|
+
dosya_yolu: str
|
|
52
|
+
bus_factor: int # kaç kişi bu dosyayı anlıyor
|
|
53
|
+
birincil_sahip: Optional[str]
|
|
54
|
+
sahiplik_yuzdesi: float # birincil sahibin yüzdesi
|
|
55
|
+
risk: str # KRİTİK / RİSKLİ / GÜVENLİ
|
|
56
|
+
anlayan_yazarlar: list[str] # anlama skoru >= eşik olan yazarlar
|
|
57
|
+
toplam_satir: int
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _git_blame_calistir(dosya_yolu: Path, repo_koku: Path) -> dict[str, int]:
|
|
61
|
+
"""
|
|
62
|
+
git blame --line-porcelain ile satır bazlı yazar tespiti yap.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
{yazar_adi: satir_sayisi} sözlüğü
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
goreceli = dosya_yolu.relative_to(repo_koku)
|
|
69
|
+
except ValueError:
|
|
70
|
+
goreceli = dosya_yolu
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
sonuc = subprocess.run(
|
|
74
|
+
["git", "blame", "--line-porcelain", str(goreceli)],
|
|
75
|
+
cwd=str(repo_koku),
|
|
76
|
+
capture_output=True,
|
|
77
|
+
text=True,
|
|
78
|
+
timeout=30,
|
|
79
|
+
encoding="utf-8",
|
|
80
|
+
errors="replace",
|
|
81
|
+
)
|
|
82
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
83
|
+
return {}
|
|
84
|
+
|
|
85
|
+
if sonuc.returncode != 0:
|
|
86
|
+
return {}
|
|
87
|
+
|
|
88
|
+
# Çıktıdan "author " satırlarını topla
|
|
89
|
+
yazar_satirlari: dict[str, int] = defaultdict(int)
|
|
90
|
+
for satir in sonuc.stdout.splitlines():
|
|
91
|
+
if satir.startswith("author "):
|
|
92
|
+
yazar = satir[7:].strip()
|
|
93
|
+
# Özel "Not Committed Yet" durumunu atla
|
|
94
|
+
if yazar and yazar != "Not Committed Yet":
|
|
95
|
+
yazar_satirlari[yazar] += 1
|
|
96
|
+
|
|
97
|
+
return dict(yazar_satirlari)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _dosya_listesi_al(repo_koku: Path, max_dosya: int = _BUYUK_REPO_ESIGI) -> list[Path]:
|
|
101
|
+
"""
|
|
102
|
+
Git tarafından izlenen desteklenen kaynak dosyaları listele.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
repo_koku: Git repo kök dizini
|
|
106
|
+
max_dosya: İşlenecek maksimum dosya sayısı (performans için)
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Mutlak Path listesi
|
|
110
|
+
"""
|
|
111
|
+
desteklenen = {".py", ".js", ".jsx", ".ts", ".tsx"}
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
sonuc = subprocess.run(
|
|
115
|
+
["git", "ls-files"],
|
|
116
|
+
cwd=str(repo_koku),
|
|
117
|
+
capture_output=True,
|
|
118
|
+
text=True,
|
|
119
|
+
timeout=10,
|
|
120
|
+
encoding="utf-8",
|
|
121
|
+
errors="replace",
|
|
122
|
+
)
|
|
123
|
+
dosyalar = sonuc.stdout.strip().splitlines()
|
|
124
|
+
except Exception:
|
|
125
|
+
dosyalar = []
|
|
126
|
+
|
|
127
|
+
filtreli = [
|
|
128
|
+
repo_koku / d for d in dosyalar
|
|
129
|
+
if Path(d).suffix.lower() in desteklenen and (repo_koku / d).exists()
|
|
130
|
+
]
|
|
131
|
+
return filtreli[:max_dosya]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def calculate_bus_factor(
|
|
135
|
+
repo_path: Path,
|
|
136
|
+
db_path: Path,
|
|
137
|
+
max_dosya: int = _BUYUK_REPO_ESIGI,
|
|
138
|
+
) -> list[BusFaktorSonucu]:
|
|
139
|
+
"""
|
|
140
|
+
Repo genelinde her dosya için bus factor hesapla ve DB'ye kaydet.
|
|
141
|
+
|
|
142
|
+
Bus factor = o dosyanın %50'sinden fazlasına sahip OLAN
|
|
143
|
+
VE understanding_score >= 3.5 olan yazar sayısı.
|
|
144
|
+
Anlama skoru yoksa sahiplik oranı >= 10% yeterli sayılır.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
repo_path: Git repo kök dizini
|
|
148
|
+
max_dosya: İşlenecek maksimum dosya sayısı
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
BusFaktorSonucu listesi, bus_factor'a göre artan sırada
|
|
152
|
+
"""
|
|
153
|
+
kok = Path(repo_path).resolve()
|
|
154
|
+
dosyalar = _dosya_listesi_al(kok, max_dosya)
|
|
155
|
+
|
|
156
|
+
# Mevcut anlama skorlarını DB'den tek sorguda çek
|
|
157
|
+
from codedna.db import get_connection
|
|
158
|
+
anlama_map: dict[tuple[str, str], float] = {} # (dosya_yolu, yazar) → skor
|
|
159
|
+
try:
|
|
160
|
+
with get_connection(db_path) as conn:
|
|
161
|
+
rows = conn.execute(
|
|
162
|
+
"""
|
|
163
|
+
SELECT fo.file_path, fo.author, fo.avg_understanding
|
|
164
|
+
FROM file_ownership fo
|
|
165
|
+
WHERE fo.avg_understanding IS NOT NULL
|
|
166
|
+
"""
|
|
167
|
+
).fetchall()
|
|
168
|
+
for r in rows:
|
|
169
|
+
anlama_map[(r["file_path"], r["author"])] = float(r["avg_understanding"])
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
sonuclar: list[BusFaktorSonucu] = []
|
|
174
|
+
|
|
175
|
+
for dosya in dosyalar:
|
|
176
|
+
yazar_satirlari = _git_blame_calistir(dosya, kok)
|
|
177
|
+
if not yazar_satirlari:
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
toplam = sum(yazar_satirlari.values())
|
|
181
|
+
if toplam == 0:
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
# DB'ye kaydet
|
|
185
|
+
for yazar, satir in yazar_satirlari.items():
|
|
186
|
+
anlama = anlama_map.get((str(dosya), yazar))
|
|
187
|
+
upsert_file_ownership(
|
|
188
|
+
file_path=str(dosya),
|
|
189
|
+
author=yazar,
|
|
190
|
+
lines_owned=satir,
|
|
191
|
+
last_touched=0, # gelecekte git log ile doldurulacak
|
|
192
|
+
avg_understanding=anlama,
|
|
193
|
+
db_path=db_path,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# "Anlayan" yazarları belirle
|
|
197
|
+
anlayan: list[str] = []
|
|
198
|
+
for yazar, satir in yazar_satirlari.items():
|
|
199
|
+
yuzde = satir / toplam
|
|
200
|
+
# Anlama skoru varsa kontrol et, yoksa %10+ sahiplik yeterli
|
|
201
|
+
anlama_skoru = anlama_map.get((str(dosya), yazar))
|
|
202
|
+
if anlama_skoru is not None:
|
|
203
|
+
if anlama_skoru >= _ANLAMA_ESIGI and yuzde >= _SAHIPLIK_ESIGI:
|
|
204
|
+
anlayan.append(yazar)
|
|
205
|
+
elif yuzde >= _SAHIPLIK_ESIGI:
|
|
206
|
+
# Anket verisi yoksa sahipliği baz al
|
|
207
|
+
anlayan.append(yazar)
|
|
208
|
+
|
|
209
|
+
bus_factor = max(len(anlayan), 1)
|
|
210
|
+
if bus_factor == 1:
|
|
211
|
+
risk = "KRİTİK"
|
|
212
|
+
elif bus_factor == 2:
|
|
213
|
+
risk = "RİSKLİ"
|
|
214
|
+
else:
|
|
215
|
+
risk = "GÜVENLİ"
|
|
216
|
+
|
|
217
|
+
# Birincil sahip
|
|
218
|
+
birincil = max(yazar_satirlari, key=lambda y: yazar_satirlari[y])
|
|
219
|
+
birincil_yuzde = yazar_satirlari[birincil] / toplam
|
|
220
|
+
|
|
221
|
+
# Göreli dosya yolu
|
|
222
|
+
try:
|
|
223
|
+
goreceli_yol = str(dosya.relative_to(kok))
|
|
224
|
+
except ValueError:
|
|
225
|
+
goreceli_yol = str(dosya)
|
|
226
|
+
|
|
227
|
+
sonuclar.append(
|
|
228
|
+
BusFaktorSonucu(
|
|
229
|
+
dosya_yolu=goreceli_yol,
|
|
230
|
+
bus_factor=bus_factor,
|
|
231
|
+
birincil_sahip=birincil,
|
|
232
|
+
sahiplik_yuzdesi=round(birincil_yuzde * 100, 1),
|
|
233
|
+
risk=risk,
|
|
234
|
+
anlayan_yazarlar=anlayan,
|
|
235
|
+
toplam_satir=toplam,
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Bus factor'a göre artan sıralama (KRİTİK önce)
|
|
240
|
+
sonuclar.sort(key=lambda s: s.bus_factor)
|
|
241
|
+
return sonuclar
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def get_at_risk_files(
|
|
245
|
+
repo_path: Path,
|
|
246
|
+
db_path: Path,
|
|
247
|
+
) -> list[BusFaktorSonucu]:
|
|
248
|
+
"""
|
|
249
|
+
bus_factor == 1 olan dosyaları döndür (KRİTİK liste).
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
repo_path: Git repo kök dizini
|
|
253
|
+
db_path: SQLite veritabanı yolu
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
Kritik dosyaların BusFaktorSonucu listesi
|
|
257
|
+
"""
|
|
258
|
+
tum = calculate_bus_factor(repo_path, db_path)
|
|
259
|
+
return [s for s in tum if s.bus_factor == 1]
|