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/interview.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Aday değerlendirme — kodbase anlama testi.
|
|
3
|
+
|
|
4
|
+
ÖNEMLİ UYARI:
|
|
5
|
+
Bu araç insan değerlendirmesinin YERİNE GEÇMEZ. Tamamlayıcı bir sinyaldir.
|
|
6
|
+
Tek başına işe alım kararı için kullanılmamalıdır.
|
|
7
|
+
Otomatik puanlama bu fazda eklenmiyor — insan değerlendiricisi skorу manuel girer.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from codedna.db import get_connection
|
|
20
|
+
|
|
21
|
+
# Zorluk → karmaşıklık skoru eşikleri
|
|
22
|
+
_ZORLU_ESIKLER = {
|
|
23
|
+
"easy": (0.0, 5.0), # düşük karmaşıklık
|
|
24
|
+
"medium": (5.0, 15.0), # orta karmaşıklık
|
|
25
|
+
"hard": (15.0, 999.), # yüksek karmaşıklık
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# Anonimleştirme: yaygın tanımlayıcı kalıpları jenerik isimlerle değiştir
|
|
29
|
+
_ANONIMLESTIME_KALIPLARI: list[tuple[str, str]] = [
|
|
30
|
+
(r'\b(payment|charge|invoice|billing)\b', 'transaction'),
|
|
31
|
+
(r'\b(user|account|customer|member)\b', 'entity'),
|
|
32
|
+
(r'\b(password|secret|token|key|credential)\b', 'credential'),
|
|
33
|
+
(r'\b(database|db|repository|repo|store)\b', 'storage'),
|
|
34
|
+
(r'\b(email|phone|address|contact)\b', 'contact_info'),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class MulakatDosyasi:
|
|
40
|
+
"""Mülakat için seçilmiş dosya bilgisi."""
|
|
41
|
+
|
|
42
|
+
dosya_yolu: str
|
|
43
|
+
anonimlestirilmis_kod: str
|
|
44
|
+
karmasiklik_skoru: float
|
|
45
|
+
zorluk: str
|
|
46
|
+
satir_sayisi: int
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _kod_anonimize_et(kod: str) -> str:
|
|
50
|
+
"""
|
|
51
|
+
Kaynak kodu anonimleştir — iş mantığını açık eden tanımlayıcıları
|
|
52
|
+
jenerik isimlerle değiştir. Kod yapısını ve mantığını korur.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
kod: Ham kaynak kodu
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Anonimleştirilmiş kod
|
|
59
|
+
"""
|
|
60
|
+
sonuc = kod
|
|
61
|
+
for kalip, yeni in _ANONIMLESTIME_KALIPLARI:
|
|
62
|
+
sonuc = re.sub(kalip, yeni, sonuc, flags=re.IGNORECASE)
|
|
63
|
+
return sonuc
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def select_candidate_file(
|
|
67
|
+
repo_path: Path,
|
|
68
|
+
db_path: Path,
|
|
69
|
+
difficulty: str = "medium",
|
|
70
|
+
) -> Optional[MulakatDosyasi]:
|
|
71
|
+
"""
|
|
72
|
+
Repo'dan mülakat için uygun bir dosya seç.
|
|
73
|
+
|
|
74
|
+
Seçim kriterleri:
|
|
75
|
+
- İstenen zorluk aralığındaki karmaşıklık skoru
|
|
76
|
+
- Korumalı modüller HİÇBİR ZAMAN seçilmez
|
|
77
|
+
- En az 20, en fazla 200 satır
|
|
78
|
+
- Desteklenen uzantı (.py, .js, .ts)
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
repo_path: Git repo kök dizini
|
|
82
|
+
db_path: SQLite veritabanı yolu
|
|
83
|
+
difficulty: "easy" | "medium" | "hard"
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
MulakatDosyasi nesnesi veya uygun dosya yoksa None
|
|
87
|
+
"""
|
|
88
|
+
from codedna.scorer import scan_repository
|
|
89
|
+
from codedna.protection import check_protected_modules
|
|
90
|
+
|
|
91
|
+
alt_esik, ust_esik = _ZORLU_ESIKLER.get(difficulty, _ZORLU_ESIKLER["medium"])
|
|
92
|
+
|
|
93
|
+
# Korumalı modülleri al — bunlar asla seçilmez
|
|
94
|
+
korunanlari = {
|
|
95
|
+
m.dosya_yolu
|
|
96
|
+
for m in check_protected_modules(db_path)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
taranan = scan_repository(repo_path, max_files=200)
|
|
100
|
+
|
|
101
|
+
uygun = [
|
|
102
|
+
s for s in taranan
|
|
103
|
+
if alt_esik <= s.complexity_score < ust_esik
|
|
104
|
+
and s.file_path not in korunanlari
|
|
105
|
+
and 20 <= s.total_lines <= 200
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
if not uygun:
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
# En ortanca karmaşıklığa sahip dosyayı seç (çok kolay/zor olmasın)
|
|
112
|
+
uygun.sort(key=lambda s: abs(s.complexity_score - (alt_esik + ust_esik) / 2))
|
|
113
|
+
secilen = uygun[0]
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
kod = Path(secilen.file_path).read_text(encoding="utf-8", errors="replace")
|
|
117
|
+
except Exception:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
anonimlestirilmis = _kod_anonimize_et(kod)
|
|
121
|
+
|
|
122
|
+
return MulakatDosyasi(
|
|
123
|
+
dosya_yolu=secilen.file_path,
|
|
124
|
+
anonimlestirilmis_kod=anonimlestirilmis,
|
|
125
|
+
karmasiklik_skoru=round(secilen.complexity_score, 1),
|
|
126
|
+
zorluk=difficulty,
|
|
127
|
+
satir_sayisi=secilen.total_lines,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def generate_questions(code: str) -> list[str]:
|
|
132
|
+
"""
|
|
133
|
+
Koddan otomatik 3 anlama sorusu üret.
|
|
134
|
+
|
|
135
|
+
Şablon tabanlı üretim — AI çağrısı gerektirmez, kod yapısından çıkarım.
|
|
136
|
+
Üretilen sorular her zaman genel, spesifik iş mantığına bağlı değil.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
code: Kaynak kodu (anonimleştirilmiş olabilir)
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
3 soruluk liste
|
|
143
|
+
"""
|
|
144
|
+
satirlar = code.splitlines()
|
|
145
|
+
fonksiyon_sayisi = sum(
|
|
146
|
+
1 for s in satirlar
|
|
147
|
+
if re.match(r'\s*(def |function |async function )', s)
|
|
148
|
+
)
|
|
149
|
+
kos_sayisi = sum(
|
|
150
|
+
1 for s in satirlar
|
|
151
|
+
if re.search(r'\b(if|else|elif|for|while|try|except|catch)\b', s)
|
|
152
|
+
)
|
|
153
|
+
donduruyor_mu = any("return " in s for s in satirlar)
|
|
154
|
+
|
|
155
|
+
sorular = [
|
|
156
|
+
"Bu kodu okuduğunuzda ana fonksiyonun/metodun birincil amacı ne?",
|
|
157
|
+
f"Bu kod {fonksiyon_sayisi} fonksiyon/metod içeriyor. "
|
|
158
|
+
f"Hangisi en kritik iş mantığını taşıyor ve neden?",
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
if kos_sayisi > 3:
|
|
162
|
+
sorular.append(
|
|
163
|
+
f"Kodda {kos_sayisi} dal/koşul var. Hangi koşul en önemli hata senaryosunu ele alıyor?"
|
|
164
|
+
)
|
|
165
|
+
elif donduruyor_mu:
|
|
166
|
+
sorular.append(
|
|
167
|
+
"Bu fonksiyon hangi koşulda beklenmedik bir değer döndürebilir? "
|
|
168
|
+
"Bu durumu nasıl debug ederdiniz?"
|
|
169
|
+
)
|
|
170
|
+
else:
|
|
171
|
+
sorular.append(
|
|
172
|
+
"Bu koda bir test yazmanız gerekse, önce hangi davranışı test ederdiniz?"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
return sorular[:3]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def start_session(
|
|
179
|
+
candidate_name: str,
|
|
180
|
+
file_path: str,
|
|
181
|
+
questions: list[str],
|
|
182
|
+
db_path: Path,
|
|
183
|
+
created_by: str = "system",
|
|
184
|
+
) -> int:
|
|
185
|
+
"""
|
|
186
|
+
Yeni mülakat oturumu başlat.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
candidate_name: Aday adı
|
|
190
|
+
file_path: Test edilen dosyanın yolu
|
|
191
|
+
questions: Sorulan soru listesi
|
|
192
|
+
db_path: SQLite veritabanı yolu
|
|
193
|
+
created_by: Oturumu başlatan kişi
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
Yeni oturum id'si
|
|
197
|
+
"""
|
|
198
|
+
with get_connection(db_path) as conn:
|
|
199
|
+
cur = conn.execute(
|
|
200
|
+
"""
|
|
201
|
+
INSERT INTO interview_sessions
|
|
202
|
+
(candidate_name, file_path, started_at, questions_json, created_by)
|
|
203
|
+
VALUES (?, ?, ?, ?, ?)
|
|
204
|
+
""",
|
|
205
|
+
(
|
|
206
|
+
candidate_name,
|
|
207
|
+
file_path,
|
|
208
|
+
int(time.time()),
|
|
209
|
+
json.dumps(questions, ensure_ascii=False),
|
|
210
|
+
created_by,
|
|
211
|
+
),
|
|
212
|
+
)
|
|
213
|
+
return cur.lastrowid or 0
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def submit_score(
|
|
217
|
+
session_id: int,
|
|
218
|
+
score: float,
|
|
219
|
+
evaluator_notes: str,
|
|
220
|
+
db_path: Path,
|
|
221
|
+
) -> dict:
|
|
222
|
+
"""
|
|
223
|
+
İnsan değerlendiricinin verdiği puanı kaydet.
|
|
224
|
+
|
|
225
|
+
Otomatik puanlama kasıtlı olarak yok — yanıltıcı olabileceğinden
|
|
226
|
+
bu fazda insan değerlendirmesi zorunlu tutulmuştur.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
session_id: Oturum id'si
|
|
230
|
+
score: 0.0–5.0 arası puan
|
|
231
|
+
evaluator_notes: Değerlendirici notları
|
|
232
|
+
db_path: SQLite veritabanı yolu
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Güncellenen oturum özeti
|
|
236
|
+
"""
|
|
237
|
+
if not (0.0 <= score <= 5.0):
|
|
238
|
+
raise ValueError(f"Skor 0.0–5.0 arasında olmalı, gelen: {score}")
|
|
239
|
+
|
|
240
|
+
su_an = int(time.time())
|
|
241
|
+
with get_connection(db_path) as conn:
|
|
242
|
+
cur = conn.execute(
|
|
243
|
+
"""
|
|
244
|
+
UPDATE interview_sessions
|
|
245
|
+
SET comprehension_score = ?,
|
|
246
|
+
evaluator_notes = ?,
|
|
247
|
+
completed_at = ?
|
|
248
|
+
WHERE id = ?
|
|
249
|
+
""",
|
|
250
|
+
(score, evaluator_notes, su_an, session_id),
|
|
251
|
+
)
|
|
252
|
+
if cur.rowcount == 0:
|
|
253
|
+
raise ValueError(f"Oturum #{session_id} bulunamadı.")
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
"session_id": session_id,
|
|
257
|
+
"comprehension_score": score,
|
|
258
|
+
"evaluator_notes": evaluator_notes,
|
|
259
|
+
"mesaj": "Değerlendirme kaydedildi.",
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def get_sessions(db_path: Path, limit: int = 20) -> list[dict]:
|
|
264
|
+
"""Geçmiş mülakat oturumlarını döndür."""
|
|
265
|
+
try:
|
|
266
|
+
with get_connection(db_path) as conn:
|
|
267
|
+
rows = conn.execute(
|
|
268
|
+
"""
|
|
269
|
+
SELECT id, candidate_name, file_path, started_at, completed_at,
|
|
270
|
+
comprehension_score, evaluator_notes, created_by
|
|
271
|
+
FROM interview_sessions
|
|
272
|
+
ORDER BY started_at DESC
|
|
273
|
+
LIMIT ?
|
|
274
|
+
""",
|
|
275
|
+
(limit,),
|
|
276
|
+
).fetchall()
|
|
277
|
+
except Exception:
|
|
278
|
+
return []
|
|
279
|
+
|
|
280
|
+
def _ts(ts: Optional[int]) -> Optional[str]:
|
|
281
|
+
if not ts:
|
|
282
|
+
return None
|
|
283
|
+
from datetime import datetime
|
|
284
|
+
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")
|
|
285
|
+
|
|
286
|
+
return [
|
|
287
|
+
{
|
|
288
|
+
"id": r["id"],
|
|
289
|
+
"aday": r["candidate_name"],
|
|
290
|
+
"dosya_yolu": r["file_path"],
|
|
291
|
+
"baslangic": _ts(r["started_at"]),
|
|
292
|
+
"bitis": _ts(r["completed_at"]),
|
|
293
|
+
"skor": r["comprehension_score"],
|
|
294
|
+
"notlar": r["evaluator_notes"],
|
|
295
|
+
"olusturan": r["created_by"],
|
|
296
|
+
}
|
|
297
|
+
for r in rows
|
|
298
|
+
]
|
codedna/onboarding.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Yeni katılan geliştiricilerin üretkenlik eğrisini ölçer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from codedna.db import get_connection
|
|
11
|
+
|
|
12
|
+
# Üretkenlik eşiği — bu skoru aşınca "ramp-up tamamlandı" sayılır
|
|
13
|
+
_VARSAYILAN_ESIK = 3.5
|
|
14
|
+
|
|
15
|
+
# Ramp-up tahmini için minimum commit sayısı
|
|
16
|
+
_MIN_COMMIT = 5
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class CommitNoktasi:
|
|
21
|
+
"""Tek commit için anlama skoru verisi."""
|
|
22
|
+
|
|
23
|
+
commit_hash: str
|
|
24
|
+
commit_no: int # yazar bazlı sıra numarası (1'den başlar)
|
|
25
|
+
tarih: datetime
|
|
26
|
+
understanding_score: Optional[float]
|
|
27
|
+
hafta_no: int # ilk committen itibaren geçen hafta sayısı
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class YazarEgri:
|
|
32
|
+
"""Tek yazar için onboarding eğrisi."""
|
|
33
|
+
|
|
34
|
+
yazar: str
|
|
35
|
+
toplam_commit: int
|
|
36
|
+
anlama_skoru_olan: int
|
|
37
|
+
ramp_up_hafta: Optional[float] # None = yeterli veri yok / eşik aşılmadı
|
|
38
|
+
son_ort_anlama: Optional[float] # son 5 commit ortalaması
|
|
39
|
+
noktalar: list[CommitNoktasi]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_author_timeline(author: str, db_path: Path) -> list[CommitNoktasi]:
|
|
43
|
+
"""
|
|
44
|
+
Bir yazarın tüm commit'lerini kronolojik anlama skoruyla döndür.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
author: Yazar adı (commits.author ile eşleşmeli)
|
|
48
|
+
db_path: SQLite veritabanı yolu
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
CommitNoktasi listesi, tarih sırasıyla
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
with get_connection(db_path) as conn:
|
|
55
|
+
rows = conn.execute(
|
|
56
|
+
"""
|
|
57
|
+
SELECT commit_hash, author, timestamp, understanding_score
|
|
58
|
+
FROM commits
|
|
59
|
+
WHERE author = ?
|
|
60
|
+
ORDER BY timestamp ASC
|
|
61
|
+
""",
|
|
62
|
+
(author,),
|
|
63
|
+
).fetchall()
|
|
64
|
+
except Exception:
|
|
65
|
+
return []
|
|
66
|
+
|
|
67
|
+
if not rows:
|
|
68
|
+
return []
|
|
69
|
+
|
|
70
|
+
ilk_ts = rows[0]["timestamp"] or 0
|
|
71
|
+
noktalar: list[CommitNoktasi] = []
|
|
72
|
+
|
|
73
|
+
for i, r in enumerate(rows):
|
|
74
|
+
ts = r["timestamp"] or ilk_ts
|
|
75
|
+
hafta = int((ts - ilk_ts) / (7 * 24 * 3600))
|
|
76
|
+
noktalar.append(
|
|
77
|
+
CommitNoktasi(
|
|
78
|
+
commit_hash=r["commit_hash"] or "",
|
|
79
|
+
commit_no=i + 1,
|
|
80
|
+
tarih=datetime.fromtimestamp(ts),
|
|
81
|
+
understanding_score=(
|
|
82
|
+
float(r["understanding_score"])
|
|
83
|
+
if r["understanding_score"] is not None
|
|
84
|
+
else None
|
|
85
|
+
),
|
|
86
|
+
hafta_no=hafta,
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return noktalar
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def estimate_ramp_up_weeks(
|
|
94
|
+
author: str,
|
|
95
|
+
db_path: Path,
|
|
96
|
+
threshold: float = _VARSAYILAN_ESIK,
|
|
97
|
+
) -> Optional[float]:
|
|
98
|
+
"""
|
|
99
|
+
Yazarın ortalama anlama skoru threshold'u aştığı ilk haftayı tahmin et.
|
|
100
|
+
|
|
101
|
+
Algoritma:
|
|
102
|
+
- Anlama skoru olan commit'ler 3'lü hareketli ortalama ile yumuşatılır
|
|
103
|
+
- Yumuşatılmış skor threshold'u aştığı ilk commit'in hafta numarası döndürülür
|
|
104
|
+
- Yeterli veri yoksa (< MIN_COMMIT anketli commit) None döndürülür
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
author: Yazar adı
|
|
108
|
+
db_path: SQLite veritabanı yolu
|
|
109
|
+
threshold: Üretkenlik eşiği (varsayılan 3.5/5)
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Ramp-up haftası veya None
|
|
113
|
+
"""
|
|
114
|
+
noktalar = get_author_timeline(author, db_path)
|
|
115
|
+
anketli = [n for n in noktalar if n.understanding_score is not None]
|
|
116
|
+
|
|
117
|
+
if len(anketli) < _MIN_COMMIT:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
# 3'lü hareketli ortalama
|
|
121
|
+
skorlar = [n.understanding_score for n in anketli] # type: ignore[misc]
|
|
122
|
+
for i in range(2, len(skorlar)):
|
|
123
|
+
pencere = skorlar[max(0, i - 2): i + 1]
|
|
124
|
+
ort = sum(pencere) / len(pencere)
|
|
125
|
+
if ort >= threshold:
|
|
126
|
+
return float(anketli[i].hafta_no)
|
|
127
|
+
|
|
128
|
+
return None # eşik hiç aşılmadı
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def get_author_curve(author: str, db_path: Path) -> YazarEgri:
|
|
132
|
+
"""
|
|
133
|
+
Tek yazar için tam onboarding eğrisi nesnesi oluştur.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
author: Yazar adı
|
|
137
|
+
db_path: SQLite veritabanı yolu
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
YazarEgri nesnesi
|
|
141
|
+
"""
|
|
142
|
+
noktalar = get_author_timeline(author, db_path)
|
|
143
|
+
anketli = [n for n in noktalar if n.understanding_score is not None]
|
|
144
|
+
|
|
145
|
+
ramp_up = estimate_ramp_up_weeks(author, db_path)
|
|
146
|
+
|
|
147
|
+
son_5 = [n.understanding_score for n in anketli[-5:] if n.understanding_score]
|
|
148
|
+
son_ort = sum(son_5) / len(son_5) if son_5 else None
|
|
149
|
+
|
|
150
|
+
return YazarEgri(
|
|
151
|
+
yazar=author,
|
|
152
|
+
toplam_commit=len(noktalar),
|
|
153
|
+
anlama_skoru_olan=len(anketli),
|
|
154
|
+
ramp_up_hafta=round(ramp_up, 1) if ramp_up is not None else None,
|
|
155
|
+
son_ort_anlama=round(son_ort, 2) if son_ort is not None else None,
|
|
156
|
+
noktalar=noktalar,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def get_all_authors(db_path: Path) -> list[str]:
|
|
161
|
+
"""DB'deki tüm benzersiz yazar listesini döndür."""
|
|
162
|
+
try:
|
|
163
|
+
with get_connection(db_path) as conn:
|
|
164
|
+
rows = conn.execute(
|
|
165
|
+
"SELECT DISTINCT author FROM commits WHERE author IS NOT NULL ORDER BY author"
|
|
166
|
+
).fetchall()
|
|
167
|
+
return [r["author"] for r in rows]
|
|
168
|
+
except Exception:
|
|
169
|
+
return []
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def team_onboarding_summary(db_path: Path) -> list[dict]:
|
|
173
|
+
"""
|
|
174
|
+
Takımdaki tüm yazarlar için ramp-up süresi özeti döndür.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
Yazar bazlı özet dict listesi, ramp_up_hafta'ya göre sıralı
|
|
178
|
+
"""
|
|
179
|
+
yazarlar = get_all_authors(db_path)
|
|
180
|
+
ozet: list[dict] = []
|
|
181
|
+
|
|
182
|
+
for yazar in yazarlar:
|
|
183
|
+
egri = get_author_curve(yazar, db_path)
|
|
184
|
+
ozet.append({
|
|
185
|
+
"yazar": yazar,
|
|
186
|
+
"toplam_commit": egri.toplam_commit,
|
|
187
|
+
"anlama_skoru_olan": egri.anlama_skoru_olan,
|
|
188
|
+
"ramp_up_hafta": egri.ramp_up_hafta,
|
|
189
|
+
"son_ort_anlama": egri.son_ort_anlama,
|
|
190
|
+
"yeterli_veri": egri.anlama_skoru_olan >= _MIN_COMMIT,
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
# Ramp-up süresi olan önce, sonra None olanlar
|
|
194
|
+
ozet.sort(key=lambda x: (x["ramp_up_hafta"] is None, x["ramp_up_hafta"] or 999))
|
|
195
|
+
return ozet
|
codedna/plan.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Plan yönetimi — lisans anahtarı ile plan seviyesi belirlenir."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Plan(str, Enum):
|
|
12
|
+
"""Kullanılabilir plan seviyeleri."""
|
|
13
|
+
|
|
14
|
+
FREE = "free"
|
|
15
|
+
PRO = "pro"
|
|
16
|
+
TEAM = "team"
|
|
17
|
+
ENTERPRISE = "enterprise"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Lisans dosyası yolu
|
|
21
|
+
_LISANS_YOLU = Path.home() / ".codedna" / "license.json"
|
|
22
|
+
|
|
23
|
+
# Özellik → izin verilen planlar eşlemesi
|
|
24
|
+
_OZELLIK_PLANLARI: dict[str, list[Plan]] = {
|
|
25
|
+
"unlimited_repos": [Plan.PRO, Plan.TEAM, Plan.ENTERPRISE],
|
|
26
|
+
"unlimited_files": [Plan.PRO, Plan.TEAM, Plan.ENTERPRISE],
|
|
27
|
+
"history_90d": [Plan.PRO, Plan.TEAM, Plan.ENTERPRISE],
|
|
28
|
+
"history_365d": [Plan.TEAM, Plan.ENTERPRISE],
|
|
29
|
+
"dashboard_access": [Plan.PRO, Plan.TEAM, Plan.ENTERPRISE],
|
|
30
|
+
"github_actions": [Plan.PRO, Plan.TEAM, Plan.ENTERPRISE],
|
|
31
|
+
"slack_notify": [Plan.TEAM, Plan.ENTERPRISE],
|
|
32
|
+
"bus_factor": [Plan.TEAM, Plan.ENTERPRISE],
|
|
33
|
+
"sprint_health": [Plan.TEAM, Plan.ENTERPRISE],
|
|
34
|
+
"ai_comparison": [Plan.ENTERPRISE],
|
|
35
|
+
"interview_tool": [Plan.ENTERPRISE],
|
|
36
|
+
# NatureCo CLI entegrasyonu — Pro+ özellik
|
|
37
|
+
"natureco_integration": [Plan.PRO, Plan.TEAM, Plan.ENTERPRISE],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Tüm planlarda erişilebilen özellikler (varsayılan)
|
|
41
|
+
_HERKESE_ACIK: set[str] = {
|
|
42
|
+
"scan", "history_7d", "cli_basic",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_current_plan() -> Plan:
|
|
47
|
+
"""
|
|
48
|
+
~/.codedna/license.json dosyasından plan oku.
|
|
49
|
+
|
|
50
|
+
Dosya yoksa FREE döndür.
|
|
51
|
+
Format: {"plan": "pro", "key": "...", "expires": "2027-01-01"}
|
|
52
|
+
"""
|
|
53
|
+
if not _LISANS_YOLU.exists():
|
|
54
|
+
return Plan.FREE
|
|
55
|
+
try:
|
|
56
|
+
veri = json.loads(_LISANS_YOLU.read_text(encoding="utf-8"))
|
|
57
|
+
return Plan(veri.get("plan", "free"))
|
|
58
|
+
except Exception:
|
|
59
|
+
return Plan.FREE
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def is_feature_available(feature: str, plan: Optional[Plan] = None) -> bool:
|
|
63
|
+
"""
|
|
64
|
+
Verilen özelliğin mevcut planda kullanılabilir olup olmadığını kontrol et.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
feature: Kontrol edilecek özellik adı
|
|
68
|
+
plan: Plan seviyesi (None ise mevcut plan okunur)
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Özellik kullanılabilirse True
|
|
72
|
+
"""
|
|
73
|
+
if plan is None:
|
|
74
|
+
plan = get_current_plan()
|
|
75
|
+
|
|
76
|
+
# Herkese açık özellikler
|
|
77
|
+
if feature in _HERKESE_ACIK:
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
return plan in _OZELLIK_PLANLARI.get(
|
|
81
|
+
feature,
|
|
82
|
+
[Plan.FREE, Plan.PRO, Plan.TEAM, Plan.ENTERPRISE],
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def activate_license(key: str) -> Plan:
|
|
87
|
+
"""
|
|
88
|
+
Lisans anahtarını doğrula ve aktif et.
|
|
89
|
+
|
|
90
|
+
Gerçek doğrulama Faz 9'da API üzerinden yapılacak.
|
|
91
|
+
Şimdilik anahtar formatına göre plan belirler:
|
|
92
|
+
- 'CDNA-PRO-...' → pro
|
|
93
|
+
- 'CDNA-TEAM-...' → team
|
|
94
|
+
- 'CDNA-ENT-...' → enterprise
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
key: Lisans anahtarı
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Aktif edilen plan seviyesi
|
|
101
|
+
"""
|
|
102
|
+
anahtar = key.upper().strip()
|
|
103
|
+
if anahtar.startswith("CDNA-PRO"):
|
|
104
|
+
plan = Plan.PRO
|
|
105
|
+
elif anahtar.startswith("CDNA-TEAM"):
|
|
106
|
+
plan = Plan.TEAM
|
|
107
|
+
elif anahtar.startswith("CDNA-ENT"):
|
|
108
|
+
plan = Plan.ENTERPRISE
|
|
109
|
+
else:
|
|
110
|
+
raise ValueError(f"Geçersiz lisans anahtarı formatı: {key!r}")
|
|
111
|
+
|
|
112
|
+
# Lisans dosyasını yaz
|
|
113
|
+
_LISANS_YOLU.parent.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
_LISANS_YOLU.write_text(
|
|
115
|
+
json.dumps({"plan": plan.value, "key": anahtar}, ensure_ascii=False, indent=2),
|
|
116
|
+
encoding="utf-8",
|
|
117
|
+
)
|
|
118
|
+
return plan
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def activate_demo_license(plan: Plan) -> Plan:
|
|
122
|
+
"""
|
|
123
|
+
Demo lisansı aktif et — gerçek key gerekmeden.
|
|
124
|
+
|
|
125
|
+
Development, demo, CI/CD ve test için.
|
|
126
|
+
Süresi 7 gün, sonra tekrar `codedna plan demo` ile uzatılabilir.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
plan: Hedef plan (free, pro, team, enterprise)
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Aktif edilen plan
|
|
133
|
+
"""
|
|
134
|
+
import secrets as _secrets
|
|
135
|
+
|
|
136
|
+
su_an = int(__import__("time").time())
|
|
137
|
+
gecerli = su_an + (7 * 24 * 3600) # 7 gün
|
|
138
|
+
|
|
139
|
+
# Rastgele demo key (gerçek formatı taklit eder)
|
|
140
|
+
demo_key = f"CDNA-{plan.value.upper()[:3]}-DEMO-{_secrets.token_urlsafe(16).upper()}"
|
|
141
|
+
|
|
142
|
+
_LISANS_YOLU.parent.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
_LISANS_YOLU.write_text(
|
|
144
|
+
json.dumps(
|
|
145
|
+
{
|
|
146
|
+
"plan": plan.value,
|
|
147
|
+
"key": demo_key,
|
|
148
|
+
"expires": __import__("datetime").datetime.fromtimestamp(gecerli).strftime("%Y-%m-%d"),
|
|
149
|
+
"demo": True,
|
|
150
|
+
"activated_at": su_an,
|
|
151
|
+
},
|
|
152
|
+
ensure_ascii=False,
|
|
153
|
+
indent=2,
|
|
154
|
+
),
|
|
155
|
+
encoding="utf-8",
|
|
156
|
+
)
|
|
157
|
+
return plan
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def get_plan_limits() -> dict[str, object]:
|
|
161
|
+
"""Mevcut planın limit değerlerini döndür."""
|
|
162
|
+
plan = get_current_plan()
|
|
163
|
+
limitler: dict[str, object] = {
|
|
164
|
+
Plan.FREE: {
|
|
165
|
+
"max_repos": 1, "max_files_scan": 50, "history_days": 7,
|
|
166
|
+
"dashboard_access": False, "github_actions": False,
|
|
167
|
+
},
|
|
168
|
+
Plan.PRO: {
|
|
169
|
+
"max_repos": -1, "max_files_scan": -1, "history_days": 90,
|
|
170
|
+
"dashboard_access": True, "github_actions": True,
|
|
171
|
+
},
|
|
172
|
+
Plan.TEAM: {
|
|
173
|
+
"max_repos": -1, "max_files_scan": -1, "history_days": 365,
|
|
174
|
+
"dashboard_access": True, "github_actions": True,
|
|
175
|
+
"slack_notify": True, "bus_factor": True,
|
|
176
|
+
},
|
|
177
|
+
Plan.ENTERPRISE: {
|
|
178
|
+
"max_repos": -1, "max_files_scan": -1, "history_days": -1,
|
|
179
|
+
"dashboard_access": True, "github_actions": True,
|
|
180
|
+
"slack_notify": True, "bus_factor": True,
|
|
181
|
+
"sprint_health": True, "ai_comparison": True,
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
return limitler.get(plan, limitler[Plan.FREE]) # type: ignore[return-value]
|