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.
Files changed (49) hide show
  1. codedd_cli/__init__.py +3 -0
  2. codedd_cli/__main__.py +19 -0
  3. codedd_cli/api/__init__.py +4 -0
  4. codedd_cli/api/client.py +118 -0
  5. codedd_cli/api/endpoints.py +44 -0
  6. codedd_cli/api/exceptions.py +25 -0
  7. codedd_cli/auditor/__init__.py +6 -0
  8. codedd_cli/auditor/architecture_analyzer.py +1241 -0
  9. codedd_cli/auditor/architecture_prompts.py +171 -0
  10. codedd_cli/auditor/complexity_analyzer.py +942 -0
  11. codedd_cli/auditor/dependency_scanner.py +2478 -0
  12. codedd_cli/auditor/file_auditor.py +572 -0
  13. codedd_cli/auditor/git_stats_collector.py +332 -0
  14. codedd_cli/auditor/response_parser.py +487 -0
  15. codedd_cli/auditor/vulnerability_validator.py +324 -0
  16. codedd_cli/auth/__init__.py +4 -0
  17. codedd_cli/auth/session.py +41 -0
  18. codedd_cli/auth/token_manager.py +87 -0
  19. codedd_cli/cli.py +69 -0
  20. codedd_cli/commands/__init__.py +1 -0
  21. codedd_cli/commands/audit_cmd.py +1877 -0
  22. codedd_cli/commands/audits_cmd.py +273 -0
  23. codedd_cli/commands/auth_cmd.py +230 -0
  24. codedd_cli/commands/config_cmd.py +454 -0
  25. codedd_cli/commands/scope_cmd.py +967 -0
  26. codedd_cli/config/__init__.py +4 -0
  27. codedd_cli/config/constants.py +22 -0
  28. codedd_cli/config/settings.py +369 -0
  29. codedd_cli/llm/__init__.py +1 -0
  30. codedd_cli/llm/key_manager.py +271 -0
  31. codedd_cli/models/__init__.py +5 -0
  32. codedd_cli/models/account.py +13 -0
  33. codedd_cli/models/audit.py +32 -0
  34. codedd_cli/models/local_directory.py +26 -0
  35. codedd_cli/scanner/__init__.py +18 -0
  36. codedd_cli/scanner/file_classifier.py +247 -0
  37. codedd_cli/scanner/file_walker.py +207 -0
  38. codedd_cli/scanner/line_counter.py +75 -0
  39. codedd_cli/utils/__init__.py +1 -0
  40. codedd_cli/utils/directory_validator.py +179 -0
  41. codedd_cli/utils/display.py +493 -0
  42. codedd_cli/utils/payload_inspector.py +180 -0
  43. codedd_cli/utils/security.py +14 -0
  44. codedd_cli/utils/validators.py +37 -0
  45. codedd_cli-0.1.0.dist-info/METADATA +276 -0
  46. codedd_cli-0.1.0.dist-info/RECORD +49 -0
  47. codedd_cli-0.1.0.dist-info/WHEEL +4 -0
  48. codedd_cli-0.1.0.dist-info/entry_points.txt +3 -0
  49. 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
+ }