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
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""GitHub PR'larına otomatik CodeDNA analiz yorumu bırakır."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
# GitHub API temel URL'i
|
|
12
|
+
_GH_API = "https://api.github.com"
|
|
13
|
+
|
|
14
|
+
# Yorumda kullanılan gizli marker — mevcut CodeDNA yorumunu bulmak için
|
|
15
|
+
_YORUM_MARKERI = "<!-- codedna-bot-comment -->"
|
|
16
|
+
|
|
17
|
+
# Güvenlik: token asla loglanmaz veya hata mesajlarında görünmez
|
|
18
|
+
GITHUB_TOKEN_ENV = "GITHUB_TOKEN"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Yorum formatlama
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
def format_pr_comment(
|
|
26
|
+
scan_sonuclari: list,
|
|
27
|
+
debt_ozeti: Optional[dict] = None,
|
|
28
|
+
) -> str:
|
|
29
|
+
"""
|
|
30
|
+
Analiz sonuçlarını GitHub Markdown yorumuna dönüştür.
|
|
31
|
+
|
|
32
|
+
Okunabilirlik için 2000 karakter altında tutulur.
|
|
33
|
+
ℹ️ teknik borç disclaimer'ı dahil.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
scan_sonuclari: FileAnalysisResult listesi
|
|
37
|
+
debt_ozeti: RepoBorcu özeti (opsiyonel)
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Markdown formatında yorum metni
|
|
41
|
+
"""
|
|
42
|
+
if not scan_sonuclari:
|
|
43
|
+
return f"{_YORUM_MARKERI}\n## 🧬 CodeDNA Analizi\n\nDeğişen dosya bulunamadı.\n"
|
|
44
|
+
|
|
45
|
+
toplam = len(scan_sonuclari)
|
|
46
|
+
ort_ai = sum(s.ai_probability for s in scan_sonuclari) / toplam
|
|
47
|
+
yuksek_risk = [s for s in scan_sonuclari if s.ai_probability >= 0.7]
|
|
48
|
+
|
|
49
|
+
# Risk seviyesi
|
|
50
|
+
if ort_ai >= 0.7:
|
|
51
|
+
risk_emoji = "🔴"
|
|
52
|
+
risk_label = "YÜKSEK"
|
|
53
|
+
elif ort_ai >= 0.4:
|
|
54
|
+
risk_emoji = "🟡"
|
|
55
|
+
risk_label = "ORTA"
|
|
56
|
+
else:
|
|
57
|
+
risk_emoji = "🟢"
|
|
58
|
+
risk_label = "DÜŞÜK"
|
|
59
|
+
|
|
60
|
+
satirlar = [
|
|
61
|
+
_YORUM_MARKERI,
|
|
62
|
+
"## 🧬 CodeDNA Analizi",
|
|
63
|
+
"",
|
|
64
|
+
f"| Metrik | Değer |",
|
|
65
|
+
f"|--------|-------|",
|
|
66
|
+
f"| Analiz edilen dosya | {toplam} |",
|
|
67
|
+
f"| Ort. AI olasılığı | %{ort_ai * 100:.0f} |",
|
|
68
|
+
f"| Risk seviyesi | {risk_emoji} {risk_label} |",
|
|
69
|
+
f"| Yüksek riskli dosya (≥%70) | {len(yuksek_risk)} |",
|
|
70
|
+
"",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
# En riskli 3 dosya
|
|
74
|
+
en_riskli = sorted(scan_sonuclari, key=lambda s: s.ai_probability, reverse=True)[:3]
|
|
75
|
+
if en_riskli:
|
|
76
|
+
satirlar.append("### ⚠️ Dikkat Gerektiren Dosyalar")
|
|
77
|
+
satirlar.append("")
|
|
78
|
+
satirlar.append("| Dosya | AI Olasılığı | Karmaşıklık |")
|
|
79
|
+
satirlar.append("|-------|-------------|------------|")
|
|
80
|
+
for s in en_riskli:
|
|
81
|
+
try:
|
|
82
|
+
from pathlib import Path
|
|
83
|
+
kisa_yol = "/".join(Path(s.file_path).parts[-2:])
|
|
84
|
+
except Exception:
|
|
85
|
+
kisa_yol = s.file_path[-40:]
|
|
86
|
+
satirlar.append(
|
|
87
|
+
f"| `{kisa_yol}` | %{s.ai_probability * 100:.0f} | {s.complexity_label} |"
|
|
88
|
+
)
|
|
89
|
+
satirlar.append("")
|
|
90
|
+
|
|
91
|
+
# Teknik borç (varsa)
|
|
92
|
+
if debt_ozeti:
|
|
93
|
+
toplam_saat = debt_ozeti.get("toplam_debt_saatleri", 0)
|
|
94
|
+
satirlar.append(f"### 💰 Teknik Borç Tahmini")
|
|
95
|
+
satirlar.append(f"")
|
|
96
|
+
satirlar.append(f"Tahmini borç: **{toplam_saat:.1f} saat**")
|
|
97
|
+
satirlar.append(f"")
|
|
98
|
+
satirlar.append(
|
|
99
|
+
"> ℹ️ *Bu bir tahmin modelidir — anlama skoru, AI olasılığı ve "
|
|
100
|
+
"karmaşıklık ağırlıklarına dayanır. Kesin muhasebe verisi değildir.*"
|
|
101
|
+
)
|
|
102
|
+
satirlar.append("")
|
|
103
|
+
|
|
104
|
+
# Footer
|
|
105
|
+
satirlar += [
|
|
106
|
+
"---",
|
|
107
|
+
"<sub>🧬 [CodeDNA](https://github.com/codedna/codedna) · "
|
|
108
|
+
"Detaylı analiz için `codedna dashboard` çalıştırın.</sub>",
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
yorum = "\n".join(satirlar)
|
|
112
|
+
|
|
113
|
+
# 2000 karakter limitini aş
|
|
114
|
+
if len(yorum) > 2000:
|
|
115
|
+
ozet = "\n".join(satirlar[:20])
|
|
116
|
+
yorum = (
|
|
117
|
+
ozet + "\n\n"
|
|
118
|
+
"*... (kısaltıldı — tam rapor için CodeDNA dashboard'a bakın)*\n\n"
|
|
119
|
+
f"---\n<sub>🧬 CodeDNA</sub>\n{_YORUM_MARKERI}"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return yorum
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# GitHub API işlemleri
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def _gh_istek(
|
|
130
|
+
method: str,
|
|
131
|
+
url: str,
|
|
132
|
+
token: str,
|
|
133
|
+
veri: Optional[dict] = None,
|
|
134
|
+
) -> dict:
|
|
135
|
+
"""
|
|
136
|
+
GitHub API'ye kimlik doğrulamalı istek gönder.
|
|
137
|
+
|
|
138
|
+
Güvenlik: token sadece Authorization header'ında kullanılır,
|
|
139
|
+
asla loglanmaz veya hata mesajına eklenmez.
|
|
140
|
+
"""
|
|
141
|
+
govde = json.dumps(veri).encode("utf-8") if veri else None
|
|
142
|
+
istek = urllib.request.Request(
|
|
143
|
+
url,
|
|
144
|
+
data=govde,
|
|
145
|
+
method=method,
|
|
146
|
+
headers={
|
|
147
|
+
"Authorization": f"Bearer {token}",
|
|
148
|
+
"Accept": "application/vnd.github+json",
|
|
149
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
150
|
+
"Content-Type": "application/json",
|
|
151
|
+
},
|
|
152
|
+
)
|
|
153
|
+
try:
|
|
154
|
+
with urllib.request.urlopen(istek, timeout=15) as yanit:
|
|
155
|
+
return json.loads(yanit.read())
|
|
156
|
+
except urllib.error.HTTPError as e:
|
|
157
|
+
govde_str = e.read().decode("utf-8", errors="replace")
|
|
158
|
+
# Token'ı hata mesajına ASLA ekleme
|
|
159
|
+
raise RuntimeError(f"GitHub API hatası ({e.code}): {govde_str[:300]}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def find_existing_comment(
|
|
163
|
+
repo: str,
|
|
164
|
+
pr_number: int,
|
|
165
|
+
token: str,
|
|
166
|
+
) -> Optional[int]:
|
|
167
|
+
"""
|
|
168
|
+
Mevcut CodeDNA bot yorumunun ID'sini bul (marker ile).
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
repo: "owner/repo" formatında repo adı
|
|
172
|
+
pr_number: PR numarası
|
|
173
|
+
token: GitHub token
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Yorum ID'si veya yoksa None
|
|
177
|
+
"""
|
|
178
|
+
url = f"{_GH_API}/repos/{repo}/issues/{pr_number}/comments?per_page=100"
|
|
179
|
+
try:
|
|
180
|
+
yorumlar = _gh_istek("GET", url, token)
|
|
181
|
+
except Exception:
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
if not isinstance(yorumlar, list):
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
for yorum in yorumlar:
|
|
188
|
+
body = yorum.get("body", "")
|
|
189
|
+
if _YORUM_MARKERI in body:
|
|
190
|
+
return yorum.get("id")
|
|
191
|
+
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def post_or_update_comment(
|
|
196
|
+
repo: str,
|
|
197
|
+
pr_number: int,
|
|
198
|
+
body: str,
|
|
199
|
+
token: str,
|
|
200
|
+
) -> dict:
|
|
201
|
+
"""
|
|
202
|
+
PR'a CodeDNA yorumu gönder veya mevcut yorumu güncelle.
|
|
203
|
+
|
|
204
|
+
Spam önleme: Her push'ta yeni yorum açılmaz — mevcut CodeDNA
|
|
205
|
+
yorumu bulunur ve PATCH ile güncellenir. Yoksa yeni POST yapılır.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
repo: "owner/repo" formatında repo adı
|
|
209
|
+
pr_number: PR numarası
|
|
210
|
+
body: Yorum metni (Markdown)
|
|
211
|
+
token: GitHub token
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
GitHub API yanıtı
|
|
215
|
+
"""
|
|
216
|
+
mevcut_id = find_existing_comment(repo, pr_number, token)
|
|
217
|
+
|
|
218
|
+
if mevcut_id:
|
|
219
|
+
# Mevcut yorumu güncelle — spam yaratma
|
|
220
|
+
url = f"{_GH_API}/repos/{repo}/issues/comments/{mevcut_id}"
|
|
221
|
+
return _gh_istek("PATCH", url, token, {"body": body})
|
|
222
|
+
else:
|
|
223
|
+
# İlk defa yorum bırak
|
|
224
|
+
url = f"{_GH_API}/repos/{repo}/issues/{pr_number}/comments"
|
|
225
|
+
return _gh_istek("POST", url, token, {"body": body})
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
# GitHub Actions ortamından PR bilgisi otomatik algıla
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
def github_actions_pr_bilgisi() -> Optional[tuple[str, int]]:
|
|
233
|
+
"""
|
|
234
|
+
GitHub Actions ortamından repo adı ve PR numarasını otomatik algıla.
|
|
235
|
+
|
|
236
|
+
GITHUB_REPOSITORY ve GITHUB_EVENT_PATH ortam değişkenlerini kullanır.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
(repo, pr_number) çifti veya algılanamıyorsa None
|
|
240
|
+
"""
|
|
241
|
+
repo = os.environ.get("GITHUB_REPOSITORY")
|
|
242
|
+
event_path = os.environ.get("GITHUB_EVENT_PATH")
|
|
243
|
+
|
|
244
|
+
if not repo or not event_path:
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
with open(event_path, encoding="utf-8") as f:
|
|
249
|
+
event_veri = json.load(f)
|
|
250
|
+
pr_number = (
|
|
251
|
+
event_veri.get("pull_request", {}).get("number")
|
|
252
|
+
or event_veri.get("number")
|
|
253
|
+
)
|
|
254
|
+
if pr_number:
|
|
255
|
+
return repo, int(pr_number)
|
|
256
|
+
except Exception:
|
|
257
|
+
pass
|
|
258
|
+
|
|
259
|
+
return None
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Jira sprint verilerini CodeDNA sprint kayıtlarıyla eşleştirir."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import secrets
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
# Webhook secret dosyası
|
|
14
|
+
_SECRET_DOSYASI = Path.home() / ".codedna" / "jira_secret.txt"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Webhook Secret Yönetimi
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
def get_or_create_secret() -> str:
|
|
22
|
+
"""
|
|
23
|
+
Jira webhook secret'ını oku veya yoksa yeni oluştur.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Hex formatında 32-byte secret
|
|
27
|
+
"""
|
|
28
|
+
if _SECRET_DOSYASI.exists():
|
|
29
|
+
return _SECRET_DOSYASI.read_text().strip()
|
|
30
|
+
secret = secrets.token_hex(32)
|
|
31
|
+
_SECRET_DOSYASI.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
_SECRET_DOSYASI.write_text(secret)
|
|
33
|
+
return secret
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def rotate_secret() -> str:
|
|
37
|
+
"""Webhook secret'ı yenile ve yeni değeri döndür."""
|
|
38
|
+
yeni = secrets.token_hex(32)
|
|
39
|
+
_SECRET_DOSYASI.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
_SECRET_DOSYASI.write_text(yeni)
|
|
41
|
+
return yeni
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def verify_signature(payload_bytes: bytes, signature_header: str, secret: str) -> bool:
|
|
45
|
+
"""
|
|
46
|
+
Jira webhook imzasını HMAC-SHA256 ile doğrula.
|
|
47
|
+
|
|
48
|
+
Jira imzası formatı: "sha256=<hex_digest>"
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
payload_bytes: Ham HTTP body
|
|
52
|
+
signature_header: X-Hub-Signature-256 başlık değeri
|
|
53
|
+
secret: Webhook secret
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
İmza geçerliyse True
|
|
57
|
+
"""
|
|
58
|
+
if not signature_header.startswith("sha256="):
|
|
59
|
+
return False
|
|
60
|
+
beklenen_imza = signature_header[7:]
|
|
61
|
+
hesaplanan = hmac.new(
|
|
62
|
+
secret.encode("utf-8"),
|
|
63
|
+
payload_bytes,
|
|
64
|
+
hashlib.sha256,
|
|
65
|
+
).hexdigest()
|
|
66
|
+
return hmac.compare_digest(hesaplanan, beklenen_imza)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Webhook İşleme
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def handle_jira_webhook(payload: dict[str, Any], db_path: Path) -> dict[str, Any]:
|
|
74
|
+
"""
|
|
75
|
+
Jira webhook payload'unu işle, sprint kaydı oluştur/güncelle.
|
|
76
|
+
|
|
77
|
+
Desteklenen event türleri:
|
|
78
|
+
- sprint_started: Yeni sprint başladı → kayıt oluştur
|
|
79
|
+
- sprint_closed: Sprint kapandı → sağlık skoru hesapla + güncelle
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
payload: Jira webhook JSON payload'u
|
|
83
|
+
db_path: SQLite veritabanı yolu
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
İşlem sonucu sözlüğü
|
|
87
|
+
"""
|
|
88
|
+
event_turu = payload.get("webhookEvent", "")
|
|
89
|
+
sprint_verisi = payload.get("sprint", {})
|
|
90
|
+
|
|
91
|
+
if not sprint_verisi:
|
|
92
|
+
return {"durum": "atlandı", "neden": "sprint verisi yok"}
|
|
93
|
+
|
|
94
|
+
sprint_adi = sprint_verisi.get("name", "Jira Sprint")
|
|
95
|
+
baslangic_str = sprint_verisi.get("startDate") or sprint_verisi.get("activatedDate")
|
|
96
|
+
bitis_str = sprint_verisi.get("endDate") or sprint_verisi.get("completeDate")
|
|
97
|
+
|
|
98
|
+
# Tarihleri parse et
|
|
99
|
+
baslangic = _tarih_parse(baslangic_str)
|
|
100
|
+
bitis = _tarih_parse(bitis_str)
|
|
101
|
+
|
|
102
|
+
if not baslangic or not bitis:
|
|
103
|
+
return {"durum": "hata", "neden": "tarih bilgisi eksik"}
|
|
104
|
+
|
|
105
|
+
if event_turu in ("sprint_started", "jira:sprint_created"):
|
|
106
|
+
# Sprint başladı — kayıt oluştur (sağlık skoru hesaplanmadı henüz)
|
|
107
|
+
from codedna.db import save_sprint
|
|
108
|
+
sprint_id = save_sprint(
|
|
109
|
+
sprint_name=sprint_adi,
|
|
110
|
+
start_date=int(baslangic.timestamp()),
|
|
111
|
+
end_date=int(bitis.timestamp()),
|
|
112
|
+
total_lines_ai=0,
|
|
113
|
+
total_lines_human=0,
|
|
114
|
+
avg_understanding=None,
|
|
115
|
+
debt_delta_hours=None,
|
|
116
|
+
health_score=None,
|
|
117
|
+
db_path=db_path,
|
|
118
|
+
)
|
|
119
|
+
return {
|
|
120
|
+
"durum": "oluşturuldu",
|
|
121
|
+
"sprint_id": sprint_id,
|
|
122
|
+
"sprint_adi": sprint_adi,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
elif event_turu in ("sprint_closed", "jira:sprint_completed"):
|
|
126
|
+
# Sprint kapandı — sağlık skoru hesapla
|
|
127
|
+
from codedna.sprint_health import calculate_sprint_health, save_sprint_result
|
|
128
|
+
from codedna.git_hook import find_git_root
|
|
129
|
+
|
|
130
|
+
repo_koku = find_git_root() or Path.cwd()
|
|
131
|
+
try:
|
|
132
|
+
sonuc = calculate_sprint_health(repo_koku, db_path, baslangic, bitis, sprint_adi)
|
|
133
|
+
sprint_id = save_sprint_result(sonuc, db_path)
|
|
134
|
+
return {
|
|
135
|
+
"durum": "tamamlandı",
|
|
136
|
+
"sprint_id": sprint_id,
|
|
137
|
+
"sprint_adi": sprint_adi,
|
|
138
|
+
"health_score": sonuc.health_score,
|
|
139
|
+
"durum_etiketi": sonuc.durum,
|
|
140
|
+
}
|
|
141
|
+
except Exception as e:
|
|
142
|
+
return {"durum": "hata", "neden": str(e)}
|
|
143
|
+
|
|
144
|
+
return {"durum": "atlandı", "neden": f"desteklenmeyen event: {event_turu}"}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _tarih_parse(tarih_str: Optional[str]) -> Optional[datetime]:
|
|
148
|
+
"""
|
|
149
|
+
Jira tarih string'lerini datetime'a çevir.
|
|
150
|
+
Desteklenen formatlar: ISO 8601 ile çeşitli varyantlar.
|
|
151
|
+
"""
|
|
152
|
+
if not tarih_str:
|
|
153
|
+
return None
|
|
154
|
+
formatlar = [
|
|
155
|
+
"%Y-%m-%dT%H:%M:%S.%f%z",
|
|
156
|
+
"%Y-%m-%dT%H:%M:%S%z",
|
|
157
|
+
"%Y-%m-%dT%H:%M:%S.%f",
|
|
158
|
+
"%Y-%m-%dT%H:%M:%S",
|
|
159
|
+
"%Y-%m-%d",
|
|
160
|
+
]
|
|
161
|
+
for fmt in formatlar:
|
|
162
|
+
try:
|
|
163
|
+
return datetime.strptime(tarih_str[:len(fmt) + 5], fmt)
|
|
164
|
+
except ValueError:
|
|
165
|
+
continue
|
|
166
|
+
return None
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Lemon Squeezy checkout ve abonelik webhook entegrasyonu."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# Ortam değişkenleri — koda gömülmez
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
LEMONSQUEEZY_API_KEY = os.environ.get("LEMONSQUEEZY_API_KEY")
|
|
17
|
+
LEMONSQUEEZY_WEBHOOK_SECRET = os.environ.get("LEMONSQUEEZY_WEBHOOK_SECRET")
|
|
18
|
+
LEMONSQUEEZY_STORE_ID = os.environ.get("LEMONSQUEEZY_STORE_ID")
|
|
19
|
+
|
|
20
|
+
# Plan → Lemon Squeezy variant ID eşlemesi (gerçek ID'ler .env'den)
|
|
21
|
+
PLAN_VARIANT_MAP: dict[str, Optional[str]] = {
|
|
22
|
+
"pro": os.environ.get("LS_VARIANT_PRO"),
|
|
23
|
+
"team": os.environ.get("LS_VARIANT_TEAM"),
|
|
24
|
+
"enterprise": os.environ.get("LS_VARIANT_ENTERPRISE"),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# Lemon Squeezy API temel URL'i
|
|
28
|
+
_LS_API_BASE = "https://api.lemonsqueezy.com/v1"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Webhook imza doğrulama
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
def verify_webhook_signature(payload: bytes, signature: str) -> bool:
|
|
36
|
+
"""
|
|
37
|
+
Lemon Squeezy webhook imzasını HMAC-SHA256 ile doğrula.
|
|
38
|
+
|
|
39
|
+
Lemon Squeezy, X-Signature header'ında hex digest gönderir.
|
|
40
|
+
hmac.compare_digest ile timing-safe karşılaştırma yapılır.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
payload: Ham HTTP body (bytes)
|
|
44
|
+
signature: X-Signature header değeri
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
İmza geçerliyse True
|
|
48
|
+
"""
|
|
49
|
+
secret = LEMONSQUEEZY_WEBHOOK_SECRET
|
|
50
|
+
if not secret:
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
hesaplanan = hmac.new(
|
|
54
|
+
secret.encode("utf-8"),
|
|
55
|
+
payload,
|
|
56
|
+
hashlib.sha256,
|
|
57
|
+
).hexdigest()
|
|
58
|
+
|
|
59
|
+
return hmac.compare_digest(hesaplanan, signature)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Checkout URL oluşturma
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
def create_checkout_url(
|
|
67
|
+
plan: str,
|
|
68
|
+
user_email: str,
|
|
69
|
+
user_id: int,
|
|
70
|
+
) -> str:
|
|
71
|
+
"""
|
|
72
|
+
Lemon Squeezy Checkout API'sine istek at ve hosted checkout URL'ini döndür.
|
|
73
|
+
|
|
74
|
+
custom_data içine user_id gömülür — webhook'ta hangi kullanıcıya ait
|
|
75
|
+
olduğunu eşleştirmek için kullanılır.
|
|
76
|
+
|
|
77
|
+
Dokümantasyon: https://docs.lemonsqueezy.com/api/checkouts
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
plan: "pro" | "team" | "enterprise"
|
|
81
|
+
user_email: Kullanıcının e-posta adresi (checkout'ta ön doldurulur)
|
|
82
|
+
user_id: Kullanıcı ID'si (webhook eşleştirmesi için)
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Checkout URL string'i
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
ValueError: API anahtarı veya variant ID eksikse
|
|
89
|
+
RuntimeError: API isteği başarısız olursa
|
|
90
|
+
"""
|
|
91
|
+
import urllib.request
|
|
92
|
+
|
|
93
|
+
api_key = LEMONSQUEEZY_API_KEY
|
|
94
|
+
store_id = LEMONSQUEEZY_STORE_ID
|
|
95
|
+
variant_id = PLAN_VARIANT_MAP.get(plan)
|
|
96
|
+
|
|
97
|
+
if not api_key:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
"LEMONSQUEEZY_API_KEY ortam değişkeni tanımlanmamış. "
|
|
100
|
+
".env.example dosyasına bakın."
|
|
101
|
+
)
|
|
102
|
+
if not store_id:
|
|
103
|
+
raise ValueError("LEMONSQUEEZY_STORE_ID ortam değişkeni tanımlanmamış.")
|
|
104
|
+
if not variant_id:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"LS_VARIANT_{plan.upper()} ortam değişkeni tanımlanmamış."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
istek_govdesi = json.dumps({
|
|
110
|
+
"data": {
|
|
111
|
+
"type": "checkouts",
|
|
112
|
+
"attributes": {
|
|
113
|
+
"checkout_data": {
|
|
114
|
+
"email": user_email,
|
|
115
|
+
"custom": {"user_id": str(user_id)},
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
"relationships": {
|
|
119
|
+
"store": {
|
|
120
|
+
"data": {"type": "stores", "id": str(store_id)}
|
|
121
|
+
},
|
|
122
|
+
"variant": {
|
|
123
|
+
"data": {"type": "variants", "id": str(variant_id)}
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
}).encode("utf-8")
|
|
128
|
+
|
|
129
|
+
istek = urllib.request.Request(
|
|
130
|
+
f"{_LS_API_BASE}/checkouts",
|
|
131
|
+
data=istek_govdesi,
|
|
132
|
+
headers={
|
|
133
|
+
"Authorization": f"Bearer {api_key}",
|
|
134
|
+
"Content-Type": "application/vnd.api+json",
|
|
135
|
+
"Accept": "application/vnd.api+json",
|
|
136
|
+
},
|
|
137
|
+
method="POST",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
with urllib.request.urlopen(istek, timeout=10) as yanit:
|
|
142
|
+
veri = json.loads(yanit.read())
|
|
143
|
+
return veri["data"]["attributes"]["url"]
|
|
144
|
+
except urllib.error.HTTPError as e:
|
|
145
|
+
hata_govdesi = e.read().decode("utf-8", errors="replace")
|
|
146
|
+
raise RuntimeError(
|
|
147
|
+
f"Lemon Squeezy API hatası ({e.code}): {hata_govdesi}"
|
|
148
|
+
)
|
|
149
|
+
except Exception as e:
|
|
150
|
+
raise RuntimeError(f"Checkout URL oluşturulamadı: {e}")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# Webhook event işleme
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
# LS event → (plan, subscription_status) eşlemesi
|
|
158
|
+
_EVENT_DURUM_MAP: dict[str, tuple[Optional[str], str]] = {
|
|
159
|
+
"subscription_created": (None, "active"), # plan custom_data'dan alınır
|
|
160
|
+
"subscription_updated": (None, "active"),
|
|
161
|
+
"subscription_cancelled": (None, "cancelled"), # plan hemen düşürülmez
|
|
162
|
+
"subscription_expired": ("free", "none"),
|
|
163
|
+
"subscription_payment_failed": (None, "past_due"),
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def handle_subscription_webhook(
|
|
168
|
+
payload: dict,
|
|
169
|
+
db_path: Path,
|
|
170
|
+
) -> dict:
|
|
171
|
+
"""
|
|
172
|
+
Lemon Squeezy webhook event'lerini işle ve kullanıcı planını güncelle.
|
|
173
|
+
|
|
174
|
+
Tüm event'lerde custom_data.user_id ile kullanıcı eşleştirilir.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
payload: Webhook JSON payload'u
|
|
178
|
+
db_path: Auth DB yolu
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
İşlem sonucu sözlüğü
|
|
182
|
+
"""
|
|
183
|
+
from codedna.auth import update_user_plan, init_auth_db
|
|
184
|
+
|
|
185
|
+
init_auth_db(db_path)
|
|
186
|
+
|
|
187
|
+
event_turu = payload.get("meta", {}).get("event_name", "")
|
|
188
|
+
if event_turu not in _EVENT_DURUM_MAP:
|
|
189
|
+
return {"durum": "atlandı", "event": event_turu}
|
|
190
|
+
|
|
191
|
+
# Kullanıcı ID'sini custom_data'dan al
|
|
192
|
+
custom_data = payload.get("meta", {}).get("custom_data", {})
|
|
193
|
+
user_id_str = custom_data.get("user_id") or custom_data.get("user_id")
|
|
194
|
+
if not user_id_str:
|
|
195
|
+
return {"durum": "hata", "neden": "custom_data.user_id eksik"}
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
user_id = int(user_id_str)
|
|
199
|
+
except (ValueError, TypeError):
|
|
200
|
+
return {"durum": "hata", "neden": f"Geçersiz user_id: {user_id_str!r}"}
|
|
201
|
+
|
|
202
|
+
# Abonelik verilerini al
|
|
203
|
+
abonelik = payload.get("data", {}).get("attributes", {})
|
|
204
|
+
customer_id = str(abonelik.get("customer_id", "")) or None
|
|
205
|
+
subscription_id = str(payload.get("data", {}).get("id", "")) or None
|
|
206
|
+
|
|
207
|
+
# Planı belirle
|
|
208
|
+
yeni_plan, yeni_durum = _EVENT_DURUM_MAP[event_turu]
|
|
209
|
+
|
|
210
|
+
# subscription_created/updated için plan variant'tan belirle
|
|
211
|
+
if yeni_plan is None:
|
|
212
|
+
variant_id = str(abonelik.get("variant_id", ""))
|
|
213
|
+
# Variant ID → plan eşlemesi (ters yön)
|
|
214
|
+
for plan_adi, vid in PLAN_VARIANT_MAP.items():
|
|
215
|
+
if vid and vid == variant_id:
|
|
216
|
+
yeni_plan = plan_adi
|
|
217
|
+
break
|
|
218
|
+
if yeni_plan is None:
|
|
219
|
+
yeni_plan = "pro" # bilinmeyen variant → pro varsayılan
|
|
220
|
+
|
|
221
|
+
update_user_plan(
|
|
222
|
+
user_id=user_id,
|
|
223
|
+
plan=yeni_plan,
|
|
224
|
+
subscription_status=yeni_durum,
|
|
225
|
+
customer_id=customer_id,
|
|
226
|
+
subscription_id=subscription_id,
|
|
227
|
+
db_path=db_path,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
"durum": "güncellendi",
|
|
232
|
+
"user_id": user_id,
|
|
233
|
+
"yeni_plan": yeni_plan,
|
|
234
|
+
"subscription_status": yeni_durum,
|
|
235
|
+
"event": event_turu,
|
|
236
|
+
}
|