codedd-cli 0.1.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.
- codedd_cli/__init__.py +3 -0
- codedd_cli/__main__.py +19 -0
- codedd_cli/api/__init__.py +4 -0
- codedd_cli/api/client.py +118 -0
- codedd_cli/api/endpoints.py +44 -0
- codedd_cli/api/exceptions.py +25 -0
- codedd_cli/auditor/__init__.py +6 -0
- codedd_cli/auditor/architecture_analyzer.py +1241 -0
- codedd_cli/auditor/architecture_prompts.py +171 -0
- codedd_cli/auditor/complexity_analyzer.py +942 -0
- codedd_cli/auditor/dependency_scanner.py +2478 -0
- codedd_cli/auditor/file_auditor.py +572 -0
- codedd_cli/auditor/git_stats_collector.py +332 -0
- codedd_cli/auditor/response_parser.py +487 -0
- codedd_cli/auditor/vulnerability_validator.py +324 -0
- codedd_cli/auth/__init__.py +4 -0
- codedd_cli/auth/session.py +41 -0
- codedd_cli/auth/token_manager.py +87 -0
- codedd_cli/cli.py +69 -0
- codedd_cli/commands/__init__.py +1 -0
- codedd_cli/commands/audit_cmd.py +1877 -0
- codedd_cli/commands/audits_cmd.py +273 -0
- codedd_cli/commands/auth_cmd.py +230 -0
- codedd_cli/commands/config_cmd.py +454 -0
- codedd_cli/commands/scope_cmd.py +967 -0
- codedd_cli/config/__init__.py +4 -0
- codedd_cli/config/constants.py +22 -0
- codedd_cli/config/settings.py +369 -0
- codedd_cli/llm/__init__.py +1 -0
- codedd_cli/llm/key_manager.py +271 -0
- codedd_cli/models/__init__.py +5 -0
- codedd_cli/models/account.py +13 -0
- codedd_cli/models/audit.py +32 -0
- codedd_cli/models/local_directory.py +26 -0
- codedd_cli/scanner/__init__.py +18 -0
- codedd_cli/scanner/file_classifier.py +247 -0
- codedd_cli/scanner/file_walker.py +207 -0
- codedd_cli/scanner/line_counter.py +75 -0
- codedd_cli/utils/__init__.py +1 -0
- codedd_cli/utils/directory_validator.py +179 -0
- codedd_cli/utils/display.py +493 -0
- codedd_cli/utils/payload_inspector.py +180 -0
- codedd_cli/utils/security.py +14 -0
- codedd_cli/utils/validators.py +37 -0
- codedd_cli-0.1.0.dist-info/METADATA +276 -0
- codedd_cli-0.1.0.dist-info/RECORD +49 -0
- codedd_cli-0.1.0.dist-info/WHEEL +4 -0
- codedd_cli-0.1.0.dist-info/entry_points.txt +3 -0
- codedd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Collect git statistics from a local repository for CLI-driven audits.
|
|
3
|
+
|
|
4
|
+
Produces the same structure as the server's GitStatisticsCollector so that
|
|
5
|
+
POST /api/cli/audit/git-statistics/ can persist data for dashboards, velocity
|
|
6
|
+
aggregates, and developer expertise. Used when the repo is local (git directory)
|
|
7
|
+
and the server has no clone (cli:// root_path).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from collections import defaultdict
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from typing import Any, Callable, Optional
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _run_git(repo_path: str, cmd: list[str], timeout: int = 300) -> Optional[str]:
|
|
19
|
+
"""Run a git command in the repository; returns stdout or None on failure."""
|
|
20
|
+
try:
|
|
21
|
+
full_cmd = ["git", "-C", repo_path] + cmd
|
|
22
|
+
result = subprocess.run(
|
|
23
|
+
full_cmd,
|
|
24
|
+
capture_output=True,
|
|
25
|
+
text=True,
|
|
26
|
+
encoding="utf-8",
|
|
27
|
+
errors="replace",
|
|
28
|
+
timeout=timeout,
|
|
29
|
+
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
|
|
30
|
+
)
|
|
31
|
+
if result.returncode != 0:
|
|
32
|
+
return None
|
|
33
|
+
return result.stdout.strip() or None
|
|
34
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _default_branch(repo_path: str) -> str:
|
|
39
|
+
"""Return default branch (main or master)."""
|
|
40
|
+
branch = _run_git(repo_path, ["rev-parse", "--abbrev-ref", "HEAD"])
|
|
41
|
+
if branch and branch != "HEAD":
|
|
42
|
+
return branch
|
|
43
|
+
for name in ("main", "master"):
|
|
44
|
+
if _run_git(repo_path, ["rev-parse", "--verify", name]):
|
|
45
|
+
return name
|
|
46
|
+
return "main"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def collect_git_statistics(
|
|
50
|
+
repo_path: str,
|
|
51
|
+
repository_name: Optional[str] = None,
|
|
52
|
+
repository_url: Optional[str] = None,
|
|
53
|
+
on_debug: Optional[Callable[[str], None]] = None,
|
|
54
|
+
) -> Optional[dict[str, Any]]:
|
|
55
|
+
"""
|
|
56
|
+
Collect git statistics from a local repository path.
|
|
57
|
+
|
|
58
|
+
Returns a dict compatible with the server's GitStatistics model:
|
|
59
|
+
commit_history, author_stats, merge_stats, branch_stats, meta_info,
|
|
60
|
+
time_based_stats, release_stats, code_churn_stats, collaboration_stats.
|
|
61
|
+
Missing or unsupported metrics are returned as empty dicts.
|
|
62
|
+
|
|
63
|
+
Returns None if repo_path is not a git repo or collection fails.
|
|
64
|
+
"""
|
|
65
|
+
repo_path = os.path.abspath(repo_path)
|
|
66
|
+
git_dir = os.path.join(repo_path, ".git")
|
|
67
|
+
if not os.path.isdir(git_dir):
|
|
68
|
+
if on_debug:
|
|
69
|
+
on_debug(f"Not a git repository: {repo_path}")
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
def dbg(msg: str) -> None:
|
|
73
|
+
if on_debug:
|
|
74
|
+
on_debug(msg)
|
|
75
|
+
|
|
76
|
+
default_branch = _default_branch(repo_path)
|
|
77
|
+
dbg(f"Collecting git stats for branch {default_branch}")
|
|
78
|
+
|
|
79
|
+
# Commit history with file changes (for velocity and developer expertise)
|
|
80
|
+
commit_history = _collect_commit_history(repo_path, default_branch, dbg)
|
|
81
|
+
if commit_history is None:
|
|
82
|
+
commit_history = {"total_commits": 0, "commits": []}
|
|
83
|
+
|
|
84
|
+
# Author statistics (counts per author)
|
|
85
|
+
author_stats = _collect_author_stats(repo_path, default_branch, dbg)
|
|
86
|
+
time_based_stats = _derive_time_based_stats(commit_history)
|
|
87
|
+
code_churn_stats = _derive_code_churn_stats(commit_history)
|
|
88
|
+
collaboration_stats = _derive_collaboration_stats(commit_history)
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
"commit_history": commit_history,
|
|
92
|
+
"author_stats": author_stats or {},
|
|
93
|
+
"merge_stats": {},
|
|
94
|
+
"branch_stats": _collect_branch_stats(repo_path, dbg) or {},
|
|
95
|
+
"meta_info": _collect_meta_info(repo_path, default_branch) or {},
|
|
96
|
+
"time_based_stats": time_based_stats,
|
|
97
|
+
"release_stats": {},
|
|
98
|
+
"code_churn_stats": code_churn_stats,
|
|
99
|
+
"collaboration_stats": collaboration_stats,
|
|
100
|
+
"repository_name": repository_name,
|
|
101
|
+
"repository_url": repository_url,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _collect_commit_history(repo_path: str, default_branch: str, dbg: Callable[[str], None]) -> Optional[dict]:
|
|
106
|
+
"""Get commit history with file changes (same shape as server)."""
|
|
107
|
+
# One commit per block; format: HASH|||AUTHOR|||DATE|||SUBJECT|||PARENTS
|
|
108
|
+
fmt = "COMMIT_START\n%H|||%an <%ae>|||%aI|||%s|||%P"
|
|
109
|
+
out = _run_git(
|
|
110
|
+
repo_path,
|
|
111
|
+
["log", default_branch, "--numstat", "--date=iso-strict", f"--pretty=format:{fmt}"],
|
|
112
|
+
timeout=120,
|
|
113
|
+
)
|
|
114
|
+
if not out:
|
|
115
|
+
return {"total_commits": 0, "commits": []}
|
|
116
|
+
|
|
117
|
+
blocks = [b for b in out.split("COMMIT_START\n") if b.strip()]
|
|
118
|
+
commits = []
|
|
119
|
+
for block in blocks:
|
|
120
|
+
lines = block.strip().split("\n")
|
|
121
|
+
if not lines:
|
|
122
|
+
continue
|
|
123
|
+
parts = lines[0].split("|||")
|
|
124
|
+
if len(parts) < 4:
|
|
125
|
+
continue
|
|
126
|
+
hash_val = parts[0]
|
|
127
|
+
author = parts[1]
|
|
128
|
+
commit_date = parts[2]
|
|
129
|
+
message = parts[3]
|
|
130
|
+
file_changes = []
|
|
131
|
+
for line in lines[1:]:
|
|
132
|
+
if not line.strip():
|
|
133
|
+
continue
|
|
134
|
+
try:
|
|
135
|
+
added, deleted, filename = line.split("\t", 2)
|
|
136
|
+
added = int(added) if added != "-" else 0
|
|
137
|
+
deleted = int(deleted) if deleted != "-" else 0
|
|
138
|
+
file_changes.append({"filename": filename, "added": added, "deleted": deleted})
|
|
139
|
+
except (ValueError, TypeError):
|
|
140
|
+
continue
|
|
141
|
+
commits.append({
|
|
142
|
+
"hash": hash_val,
|
|
143
|
+
"author": author,
|
|
144
|
+
"date": commit_date,
|
|
145
|
+
"message": message,
|
|
146
|
+
"is_merge": len(parts) > 4 and bool(parts[4].strip()),
|
|
147
|
+
"file_changes": file_changes,
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
return {"total_commits": len(commits), "commits": commits}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _collect_author_stats(repo_path: str, default_branch: str, dbg: Callable[[str], None]) -> dict:
|
|
154
|
+
"""Get author commit counts (shortlog)."""
|
|
155
|
+
out = _run_git(repo_path, ["shortlog", "-sne", "--no-merges", default_branch])
|
|
156
|
+
if not out:
|
|
157
|
+
return {}
|
|
158
|
+
authors = {}
|
|
159
|
+
for line in out.split("\n"):
|
|
160
|
+
if not line.strip():
|
|
161
|
+
continue
|
|
162
|
+
try:
|
|
163
|
+
count, author = line.strip().split("\t", 1)
|
|
164
|
+
authors[author] = {"commits": int(count)}
|
|
165
|
+
except (ValueError, TypeError):
|
|
166
|
+
continue
|
|
167
|
+
return {"authors": authors} if authors else {}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _collect_branch_stats(repo_path: str, dbg: Callable[[str], None]) -> dict:
|
|
171
|
+
"""Basic branch list (local)."""
|
|
172
|
+
out = _run_git(repo_path, ["branch", "--format=%(refname:short)"])
|
|
173
|
+
if not out:
|
|
174
|
+
return {}
|
|
175
|
+
names = [n.strip() for n in out.split("\n") if n.strip()]
|
|
176
|
+
return {"total_branches": len(names), "branches": [{"name": n} for n in names]}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _collect_meta_info(repo_path: str, default_branch: str) -> dict:
|
|
180
|
+
"""Minimal repo meta."""
|
|
181
|
+
first = _run_git(repo_path, ["log", "--reverse", "--format=%aI", default_branch, "-1"])
|
|
182
|
+
latest = _run_git(repo_path, ["log", "--format=%aI", default_branch, "-1"])
|
|
183
|
+
return {
|
|
184
|
+
"first_commit_date": first,
|
|
185
|
+
"latest_commit_date": latest,
|
|
186
|
+
"default_branch": default_branch,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _parse_commit_datetime(raw: str) -> Optional[datetime]:
|
|
191
|
+
if not raw:
|
|
192
|
+
return None
|
|
193
|
+
try:
|
|
194
|
+
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
195
|
+
except ValueError:
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _derive_time_based_stats(commit_history: dict) -> dict:
|
|
200
|
+
commits = commit_history.get("commits", []) if isinstance(commit_history, dict) else []
|
|
201
|
+
if not commits:
|
|
202
|
+
return {}
|
|
203
|
+
|
|
204
|
+
commit_dates: list[datetime] = []
|
|
205
|
+
daily_commits_by_author: dict[str, dict[str, int]] = defaultdict(dict)
|
|
206
|
+
daily_totals: dict[str, int] = defaultdict(int)
|
|
207
|
+
weekly_totals: dict[str, int] = defaultdict(int)
|
|
208
|
+
|
|
209
|
+
for commit in commits:
|
|
210
|
+
when = _parse_commit_datetime(str(commit.get("date", "")))
|
|
211
|
+
if not when:
|
|
212
|
+
continue
|
|
213
|
+
commit_dates.append(when)
|
|
214
|
+
day_key = when.strftime("%Y-%m-%d")
|
|
215
|
+
week_key = when.strftime("%Y-W%W")
|
|
216
|
+
author = str(commit.get("author", "unknown")).strip() or "unknown"
|
|
217
|
+
|
|
218
|
+
daily_totals[day_key] += 1
|
|
219
|
+
weekly_totals[week_key] += 1
|
|
220
|
+
per_author = daily_commits_by_author[author]
|
|
221
|
+
per_author[day_key] = per_author.get(day_key, 0) + 1
|
|
222
|
+
|
|
223
|
+
if not commit_dates:
|
|
224
|
+
return {}
|
|
225
|
+
|
|
226
|
+
first_commit = min(commit_dates)
|
|
227
|
+
last_commit = max(commit_dates)
|
|
228
|
+
repo_days = max((last_commit - first_commit).days + 1, 1)
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
"commit_frequency": {
|
|
232
|
+
"total_commits": len(commit_dates),
|
|
233
|
+
"days_active": repo_days,
|
|
234
|
+
"avg_commits_per_day": len(commit_dates) / repo_days,
|
|
235
|
+
"avg_commits_per_week": len(commit_dates) / max(repo_days / 7, 1),
|
|
236
|
+
"most_active_day": max(daily_totals.items(), key=lambda x: x[1]) if daily_totals else None,
|
|
237
|
+
"most_active_week": max(weekly_totals.items(), key=lambda x: x[1]) if weekly_totals else None,
|
|
238
|
+
},
|
|
239
|
+
"activity_periods": {
|
|
240
|
+
"first_commit_date": first_commit.isoformat(),
|
|
241
|
+
"last_commit_date": last_commit.isoformat(),
|
|
242
|
+
"repository_age_days": repo_days,
|
|
243
|
+
"daily_commit_counts": dict(daily_totals),
|
|
244
|
+
"weekly_commit_counts": dict(weekly_totals),
|
|
245
|
+
"daily_commits_by_author": {k: dict(v) for k, v in daily_commits_by_author.items()},
|
|
246
|
+
"total_daily_commits": dict(daily_totals),
|
|
247
|
+
},
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _derive_code_churn_stats(commit_history: dict) -> dict:
|
|
252
|
+
commits = commit_history.get("commits", []) if isinstance(commit_history, dict) else []
|
|
253
|
+
if not commits:
|
|
254
|
+
return {}
|
|
255
|
+
|
|
256
|
+
per_file: dict[str, dict[str, int]] = defaultdict(lambda: {"commits": 0, "added": 0, "deleted": 0})
|
|
257
|
+
total_added = 0
|
|
258
|
+
total_deleted = 0
|
|
259
|
+
|
|
260
|
+
for commit in commits:
|
|
261
|
+
for change in commit.get("file_changes", []) or []:
|
|
262
|
+
filename = str(change.get("filename", "")).strip()
|
|
263
|
+
if not filename:
|
|
264
|
+
continue
|
|
265
|
+
added = int(change.get("added", 0) or 0)
|
|
266
|
+
deleted = int(change.get("deleted", 0) or 0)
|
|
267
|
+
per_file[filename]["commits"] += 1
|
|
268
|
+
per_file[filename]["added"] += added
|
|
269
|
+
per_file[filename]["deleted"] += deleted
|
|
270
|
+
total_added += added
|
|
271
|
+
total_deleted += deleted
|
|
272
|
+
|
|
273
|
+
most_modified = sorted(
|
|
274
|
+
(
|
|
275
|
+
{
|
|
276
|
+
"file_path": fp,
|
|
277
|
+
"commits": vals["commits"],
|
|
278
|
+
"added": vals["added"],
|
|
279
|
+
"deleted": vals["deleted"],
|
|
280
|
+
"churn": vals["added"] + vals["deleted"],
|
|
281
|
+
}
|
|
282
|
+
for fp, vals in per_file.items()
|
|
283
|
+
),
|
|
284
|
+
key=lambda row: (-row["commits"], -row["churn"]),
|
|
285
|
+
)[:25]
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
"churn_summary": {
|
|
289
|
+
"total_lines_added": total_added,
|
|
290
|
+
"total_lines_deleted": total_deleted,
|
|
291
|
+
"total_line_changes": total_added + total_deleted,
|
|
292
|
+
"files_touched": len(per_file),
|
|
293
|
+
},
|
|
294
|
+
"hotspots": {
|
|
295
|
+
"most_modified_files": most_modified,
|
|
296
|
+
},
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _derive_collaboration_stats(commit_history: dict) -> dict:
|
|
301
|
+
commits = commit_history.get("commits", []) if isinstance(commit_history, dict) else []
|
|
302
|
+
if not commits:
|
|
303
|
+
return {}
|
|
304
|
+
|
|
305
|
+
authors_by_file: dict[str, set[str]] = defaultdict(set)
|
|
306
|
+
coauthor_pairs: dict[tuple[str, str], int] = defaultdict(int)
|
|
307
|
+
|
|
308
|
+
for commit in commits:
|
|
309
|
+
author = str(commit.get("author", "unknown")).strip() or "unknown"
|
|
310
|
+
changed_files = commit.get("file_changes", []) or []
|
|
311
|
+
touched = []
|
|
312
|
+
for change in changed_files:
|
|
313
|
+
filename = str(change.get("filename", "")).strip()
|
|
314
|
+
if filename:
|
|
315
|
+
authors_by_file[filename].add(author)
|
|
316
|
+
touched.append(filename)
|
|
317
|
+
if len(touched) > 1:
|
|
318
|
+
# Commit-level pairing heuristic for shared file edits.
|
|
319
|
+
unique_files = sorted(set(touched))
|
|
320
|
+
for i in range(len(unique_files) - 1):
|
|
321
|
+
pair = (unique_files[i], unique_files[i + 1])
|
|
322
|
+
coauthor_pairs[pair] += 1
|
|
323
|
+
|
|
324
|
+
collaborative_files = sum(1 for authors in authors_by_file.values() if len(authors) > 1)
|
|
325
|
+
return {
|
|
326
|
+
"files_with_multiple_authors": collaborative_files,
|
|
327
|
+
"collaboration_ratio": (collaborative_files / len(authors_by_file)) if authors_by_file else 0,
|
|
328
|
+
"top_collaboration_pairs": [
|
|
329
|
+
{"file_pair": list(pair), "shared_commits": count}
|
|
330
|
+
for pair, count in sorted(coauthor_pairs.items(), key=lambda x: -x[1])[:20]
|
|
331
|
+
],
|
|
332
|
+
}
|