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,967 @@
|
|
|
1
|
+
"""
|
|
2
|
+
``codedd scope`` sub-commands: add, remove, list, clear, status, confirm, sync.
|
|
3
|
+
|
|
4
|
+
Manages the local directories that will be included in the active audit.
|
|
5
|
+
|
|
6
|
+
Workflow:
|
|
7
|
+
1. ``codedd audits select`` – pick a group or single audit
|
|
8
|
+
2. ``codedd scope add /path/to/repo`` – add directories (validates git)
|
|
9
|
+
3. ``codedd scope list`` – review the scope
|
|
10
|
+
4. ``codedd scope remove 2`` – drop a directory by number
|
|
11
|
+
5. ``codedd scope confirm`` – scan, preview, and register with CodeDD
|
|
12
|
+
6. ``codedd scope sync`` – compare local vs. remote, detect changes
|
|
13
|
+
|
|
14
|
+
For **group audits** the user may add 1-n directories.
|
|
15
|
+
For **single audits** exactly 1 directory is allowed.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
import typer
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
25
|
+
from rich.prompt import Confirm, IntPrompt
|
|
26
|
+
|
|
27
|
+
from codedd_cli.api.client import CodeDDClient
|
|
28
|
+
from codedd_cli.api.endpoints import Endpoints
|
|
29
|
+
from codedd_cli.auth.session import require_auth
|
|
30
|
+
from codedd_cli.config.settings import ConfigManager
|
|
31
|
+
from codedd_cli.scanner.file_walker import scan_repository
|
|
32
|
+
from codedd_cli.utils.directory_validator import validate_directory
|
|
33
|
+
from codedd_cli.utils.display import (
|
|
34
|
+
print_audit_scope_cloud_info,
|
|
35
|
+
print_error,
|
|
36
|
+
print_info,
|
|
37
|
+
print_success,
|
|
38
|
+
print_warning,
|
|
39
|
+
prompt_deleted_audits_action,
|
|
40
|
+
render_audits_table,
|
|
41
|
+
render_diff_table,
|
|
42
|
+
render_scope_table,
|
|
43
|
+
render_scope_table_with_sync,
|
|
44
|
+
render_validation_result,
|
|
45
|
+
SYMBOL_FAIL,
|
|
46
|
+
SYMBOL_OK,
|
|
47
|
+
)
|
|
48
|
+
from codedd_cli.commands.audits_cmd import _fetch_audits
|
|
49
|
+
from codedd_cli.utils.payload_inspector import review_payload, review_request
|
|
50
|
+
|
|
51
|
+
console = Console()
|
|
52
|
+
scope_app = typer.Typer(no_args_is_help=True)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _require_active_audit(cfg: ConfigManager) -> None:
|
|
56
|
+
"""Abort if no audit has been selected."""
|
|
57
|
+
if not cfg.active_audit_uuid:
|
|
58
|
+
print_error(
|
|
59
|
+
"No active audit selected. "
|
|
60
|
+
"Run [bold cyan]codedd audits select[/bold cyan] first."
|
|
61
|
+
)
|
|
62
|
+
raise typer.Exit(code=1)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _enforce_single_audit_limit(cfg: ConfigManager) -> bool:
|
|
66
|
+
"""
|
|
67
|
+
For single audits, check that we haven't already reached the 1-directory limit.
|
|
68
|
+
|
|
69
|
+
Returns True if adding another directory is allowed, False otherwise.
|
|
70
|
+
"""
|
|
71
|
+
if cfg.active_audit_type == "single" and len(cfg.scope_directories) >= 1:
|
|
72
|
+
print_error(
|
|
73
|
+
"Single audits support exactly [bold]1[/bold] directory. "
|
|
74
|
+
"Remove the existing one first with [bold cyan]codedd scope remove 1[/bold cyan]."
|
|
75
|
+
)
|
|
76
|
+
return False
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# codedd scope add <path> [<path> ...]
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
@scope_app.command("add")
|
|
85
|
+
@require_auth
|
|
86
|
+
def add_directory(
|
|
87
|
+
paths: list[str] = typer.Argument(
|
|
88
|
+
...,
|
|
89
|
+
help="One or more absolute paths to local Git repositories.",
|
|
90
|
+
),
|
|
91
|
+
) -> None:
|
|
92
|
+
"""
|
|
93
|
+
Add local Git repository directories to the audit scope.
|
|
94
|
+
|
|
95
|
+
Each path is validated to ensure it:
|
|
96
|
+
- exists and is readable
|
|
97
|
+
- is the root of a Git repository
|
|
98
|
+
- has at least one commit
|
|
99
|
+
|
|
100
|
+
For single audits, only 1 directory is allowed.
|
|
101
|
+
For group audits, you may add multiple directories.
|
|
102
|
+
"""
|
|
103
|
+
cfg = ConfigManager()
|
|
104
|
+
_require_active_audit(cfg)
|
|
105
|
+
|
|
106
|
+
added_count = 0
|
|
107
|
+
|
|
108
|
+
for raw_path in paths:
|
|
109
|
+
# Single audit limit check
|
|
110
|
+
if not _enforce_single_audit_limit(cfg):
|
|
111
|
+
break
|
|
112
|
+
|
|
113
|
+
console.print(f"\n[dim]Validating:[/dim] {raw_path}")
|
|
114
|
+
|
|
115
|
+
# Run validation
|
|
116
|
+
dir_info = validate_directory(raw_path)
|
|
117
|
+
render_validation_result(dir_info)
|
|
118
|
+
|
|
119
|
+
if not dir_info.is_valid:
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
# Add to scope (ConfigManager handles de-duplication)
|
|
123
|
+
was_added = cfg.add_scope_directory(
|
|
124
|
+
path=dir_info.path,
|
|
125
|
+
repo_name=dir_info.repo_name,
|
|
126
|
+
branch=dir_info.branch,
|
|
127
|
+
commit_hash=dir_info.commit_hash,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if was_added:
|
|
131
|
+
added_count += 1
|
|
132
|
+
else:
|
|
133
|
+
print_warning(f" [yellow]Skipped (already in scope):[/yellow] {dir_info.path}")
|
|
134
|
+
|
|
135
|
+
console.print()
|
|
136
|
+
|
|
137
|
+
if added_count:
|
|
138
|
+
print_success(f"{added_count} director{'y' if added_count == 1 else 'ies'} added to scope")
|
|
139
|
+
|
|
140
|
+
# Show current scope
|
|
141
|
+
dirs = cfg.scope_directories
|
|
142
|
+
if dirs:
|
|
143
|
+
console.print()
|
|
144
|
+
render_scope_table(dirs, cfg.active_audit_name)
|
|
145
|
+
|
|
146
|
+
# Next-step hints
|
|
147
|
+
console.print()
|
|
148
|
+
console.print("[dim]Next steps:[/dim]")
|
|
149
|
+
console.print(" [dim]1.[/dim] Add more directories [bold cyan]codedd scope add <path>[/bold cyan]")
|
|
150
|
+
console.print(" [dim]2.[/dim] Confirm selection [bold cyan]codedd scope confirm[/bold cyan]")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# codedd scope remove <number>
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
@scope_app.command("remove")
|
|
158
|
+
@require_auth
|
|
159
|
+
def remove_directory(
|
|
160
|
+
number: Optional[int] = typer.Argument(
|
|
161
|
+
None,
|
|
162
|
+
help="Number of the directory to remove (from 'codedd scope list'). Omit for interactive.",
|
|
163
|
+
),
|
|
164
|
+
) -> None:
|
|
165
|
+
"""
|
|
166
|
+
Remove a directory from the audit scope by its number.
|
|
167
|
+
|
|
168
|
+
Run ``codedd scope list`` to see the numbered list.
|
|
169
|
+
"""
|
|
170
|
+
cfg = ConfigManager()
|
|
171
|
+
_require_active_audit(cfg)
|
|
172
|
+
|
|
173
|
+
dirs = cfg.scope_directories
|
|
174
|
+
if not dirs:
|
|
175
|
+
print_info("Scope is empty — nothing to remove.")
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
# Interactive mode if no number given
|
|
179
|
+
if number is None:
|
|
180
|
+
render_scope_table(dirs, cfg.active_audit_name)
|
|
181
|
+
console.print()
|
|
182
|
+
number = IntPrompt.ask(
|
|
183
|
+
"Enter the number of the directory to remove",
|
|
184
|
+
choices=[str(i) for i in range(1, len(dirs) + 1)],
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Convert 1-based user input to 0-based index
|
|
188
|
+
index = number - 1
|
|
189
|
+
|
|
190
|
+
if index < 0 or index >= len(dirs):
|
|
191
|
+
print_error(f"Invalid number. Must be between 1 and {len(dirs)}.")
|
|
192
|
+
raise typer.Exit(code=1)
|
|
193
|
+
|
|
194
|
+
removed_entry = dirs[index]
|
|
195
|
+
cfg.remove_scope_directory(index)
|
|
196
|
+
|
|
197
|
+
print_success(f"Removed [bold]{removed_entry.get('repo_name', removed_entry['path'])}[/bold] from scope")
|
|
198
|
+
|
|
199
|
+
# Show remaining scope
|
|
200
|
+
remaining = cfg.scope_directories
|
|
201
|
+
if remaining:
|
|
202
|
+
console.print()
|
|
203
|
+
render_scope_table(remaining, cfg.active_audit_name)
|
|
204
|
+
else:
|
|
205
|
+
print_info("Scope is now empty.")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
# codedd scope list
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
@scope_app.command("list")
|
|
213
|
+
@require_auth
|
|
214
|
+
def list_directories() -> None:
|
|
215
|
+
"""
|
|
216
|
+
Show all directories currently in the audit scope.
|
|
217
|
+
"""
|
|
218
|
+
cfg = ConfigManager()
|
|
219
|
+
_require_active_audit(cfg)
|
|
220
|
+
|
|
221
|
+
dirs = cfg.scope_directories
|
|
222
|
+
audit_type = cfg.active_audit_type
|
|
223
|
+
|
|
224
|
+
# Header with audit info
|
|
225
|
+
console.print(
|
|
226
|
+
f"\n[bold]Audit:[/bold] {cfg.active_audit_name} "
|
|
227
|
+
f"[dim]({audit_type})[/dim] "
|
|
228
|
+
f"[dim]{cfg.active_audit_uuid[:8]}…[/dim]"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
limit_info = "1 directory" if audit_type == "single" else "1-n directories"
|
|
232
|
+
console.print(f"[bold]Limit:[/bold] {limit_info}\n")
|
|
233
|
+
|
|
234
|
+
render_scope_table(dirs, cfg.active_audit_name)
|
|
235
|
+
|
|
236
|
+
if dirs:
|
|
237
|
+
console.print()
|
|
238
|
+
remaining_slots = "unlimited" if audit_type == "group" else str(max(0, 1 - len(dirs)))
|
|
239
|
+
print_info(f"Directories: {len(dirs)} | Remaining slots: {remaining_slots}")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
# codedd scope clear
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
@scope_app.command("clear")
|
|
247
|
+
@require_auth
|
|
248
|
+
def clear_directories() -> None:
|
|
249
|
+
"""
|
|
250
|
+
Remove all directories from the audit scope.
|
|
251
|
+
"""
|
|
252
|
+
cfg = ConfigManager()
|
|
253
|
+
_require_active_audit(cfg)
|
|
254
|
+
|
|
255
|
+
dirs = cfg.scope_directories
|
|
256
|
+
if not dirs:
|
|
257
|
+
print_info("Scope is already empty.")
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
render_scope_table(dirs, cfg.active_audit_name)
|
|
261
|
+
console.print()
|
|
262
|
+
|
|
263
|
+
confirmed = Confirm.ask(
|
|
264
|
+
f"Remove all [bold]{len(dirs)}[/bold] director{'y' if len(dirs) == 1 else 'ies'} from scope?",
|
|
265
|
+
default=False,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
if confirmed:
|
|
269
|
+
cfg.clear_scope_directories()
|
|
270
|
+
print_success("Scope cleared.")
|
|
271
|
+
else:
|
|
272
|
+
print_info("Cancelled.")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
# codedd scope status
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
@scope_app.command("status")
|
|
280
|
+
@require_auth
|
|
281
|
+
def scope_status() -> None:
|
|
282
|
+
"""
|
|
283
|
+
Show a summary of the current audit scope readiness.
|
|
284
|
+
"""
|
|
285
|
+
cfg = ConfigManager()
|
|
286
|
+
_require_active_audit(cfg)
|
|
287
|
+
|
|
288
|
+
dirs = cfg.scope_directories
|
|
289
|
+
audit_type = cfg.active_audit_type
|
|
290
|
+
audit_name = cfg.active_audit_name
|
|
291
|
+
|
|
292
|
+
console.print(f"\n[bold]Audit:[/bold] {audit_name} [dim]({audit_type})[/dim]")
|
|
293
|
+
console.print(f"[bold]UUID:[/bold] {cfg.active_audit_uuid}")
|
|
294
|
+
console.print(f"[bold]Directories:[/bold] {len(dirs)}")
|
|
295
|
+
|
|
296
|
+
if not dirs:
|
|
297
|
+
console.print()
|
|
298
|
+
print_warning("No directories added. Use [bold cyan]codedd scope add <path>[/bold cyan] to begin.")
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
# Re-validate all directories (they could have changed since being added)
|
|
302
|
+
console.print("\n[dim]Re-validating directories…[/dim]\n")
|
|
303
|
+
all_valid = True
|
|
304
|
+
for entry in dirs:
|
|
305
|
+
dir_info = validate_directory(entry["path"])
|
|
306
|
+
render_validation_result(dir_info)
|
|
307
|
+
if not dir_info.is_valid:
|
|
308
|
+
all_valid = False
|
|
309
|
+
|
|
310
|
+
console.print()
|
|
311
|
+
|
|
312
|
+
if all_valid:
|
|
313
|
+
print_success(
|
|
314
|
+
f"Scope is ready — {len(dirs)} valid director{'y' if len(dirs) == 1 else 'ies'}."
|
|
315
|
+
)
|
|
316
|
+
else:
|
|
317
|
+
print_error(
|
|
318
|
+
"Some directories failed validation. "
|
|
319
|
+
"Fix or remove them before proceeding."
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
# Show sync state
|
|
323
|
+
any_confirmed = any(d.get("confirmed") for d in dirs)
|
|
324
|
+
any_dirty = any(d.get("needs_reconfirm") for d in dirs)
|
|
325
|
+
|
|
326
|
+
if any_dirty:
|
|
327
|
+
console.print()
|
|
328
|
+
print_warning(
|
|
329
|
+
"Scope has local changes. Run [bold cyan]codedd scope sync[/bold cyan] "
|
|
330
|
+
"or [bold cyan]codedd scope confirm[/bold cyan] to update."
|
|
331
|
+
)
|
|
332
|
+
elif any_confirmed:
|
|
333
|
+
console.print()
|
|
334
|
+
print_info("Scope is registered with CodeDD. Run [bold cyan]codedd scope sync[/bold cyan] to check for changes.")
|
|
335
|
+
|
|
336
|
+
console.print()
|
|
337
|
+
render_scope_table_with_sync(dirs, audit_name)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ---------------------------------------------------------------------------
|
|
341
|
+
# codedd scope confirm
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
@scope_app.command("confirm")
|
|
345
|
+
@require_auth
|
|
346
|
+
def confirm_scope(
|
|
347
|
+
show: bool = typer.Option(
|
|
348
|
+
False,
|
|
349
|
+
"--show",
|
|
350
|
+
"-s",
|
|
351
|
+
help="Write the API payload to a file and open it for review before sending.",
|
|
352
|
+
),
|
|
353
|
+
) -> None:
|
|
354
|
+
"""
|
|
355
|
+
Scan all directories, build metadata, and register the scope with CodeDD.
|
|
356
|
+
|
|
357
|
+
This command:
|
|
358
|
+
1. Re-validates every directory in the scope.
|
|
359
|
+
2. Walks each directory to classify files and count lines of code.
|
|
360
|
+
3. Builds a metadata-only payload (no source code).
|
|
361
|
+
4. With --show: writes the payload to a .txt file for inspection.
|
|
362
|
+
5. Asks for confirmation, then sends the metadata to CodeDD.
|
|
363
|
+
"""
|
|
364
|
+
cfg = ConfigManager()
|
|
365
|
+
_require_active_audit(cfg)
|
|
366
|
+
|
|
367
|
+
dirs = cfg.scope_directories
|
|
368
|
+
if not dirs:
|
|
369
|
+
print_error("No directories in scope. Use [bold cyan]codedd scope add <path>[/bold cyan] first.")
|
|
370
|
+
raise typer.Exit(code=1)
|
|
371
|
+
|
|
372
|
+
audit_uuid = cfg.active_audit_uuid
|
|
373
|
+
audit_type = cfg.active_audit_type
|
|
374
|
+
audit_name = cfg.active_audit_name
|
|
375
|
+
|
|
376
|
+
console.print(f"\n[bold]Audit:[/bold] {audit_name} [dim]({audit_type})[/dim]")
|
|
377
|
+
console.print(f"[bold]Directories:[/bold] {len(dirs)}\n")
|
|
378
|
+
|
|
379
|
+
# Phase 1: Re-validate all directories
|
|
380
|
+
console.print("[dim]Phase 1 — Validating directories…[/dim]\n")
|
|
381
|
+
all_valid = True
|
|
382
|
+
for entry in dirs:
|
|
383
|
+
dir_info = validate_directory(entry["path"])
|
|
384
|
+
render_validation_result(dir_info)
|
|
385
|
+
if not dir_info.is_valid:
|
|
386
|
+
all_valid = False
|
|
387
|
+
|
|
388
|
+
if not all_valid:
|
|
389
|
+
console.print()
|
|
390
|
+
print_error("Some directories failed validation. Fix or remove them before confirming.")
|
|
391
|
+
raise typer.Exit(code=1)
|
|
392
|
+
|
|
393
|
+
console.print()
|
|
394
|
+
|
|
395
|
+
# Phase 2: Scan each directory
|
|
396
|
+
console.print("[dim]Phase 2 — Scanning repositories…[/dim]\n")
|
|
397
|
+
scan_results = []
|
|
398
|
+
|
|
399
|
+
for entry in dirs:
|
|
400
|
+
repo_path = entry["path"]
|
|
401
|
+
repo_name = entry.get("repo_name", Path(repo_path).name)
|
|
402
|
+
|
|
403
|
+
with Progress(
|
|
404
|
+
SpinnerColumn(),
|
|
405
|
+
TextColumn("[progress.description]{task.description}"),
|
|
406
|
+
console=console,
|
|
407
|
+
transient=True,
|
|
408
|
+
) as progress:
|
|
409
|
+
task = progress.add_task(f"Scanning [bold]{repo_name}[/bold]…", total=None)
|
|
410
|
+
|
|
411
|
+
def _progress_cb(current: int, total: int, file_path: str) -> None:
|
|
412
|
+
progress.update(task, description=f"Scanning [bold]{repo_name}[/bold] ({current}/{total})")
|
|
413
|
+
|
|
414
|
+
result = scan_repository(repo_path, progress_callback=_progress_cb)
|
|
415
|
+
scan_results.append(result)
|
|
416
|
+
|
|
417
|
+
# Display summary for this repo
|
|
418
|
+
console.print(
|
|
419
|
+
f" [bold green]{SYMBOL_OK}[/bold green] [bold]{result.repo_name}[/bold] "
|
|
420
|
+
f"[dim]{result.branch} @ {result.commit_hash}[/dim] "
|
|
421
|
+
f"files: {result.total_files} "
|
|
422
|
+
f"LoC: {result.total_lines_of_code:,} "
|
|
423
|
+
f"docs: {result.total_lines_of_doc:,}"
|
|
424
|
+
)
|
|
425
|
+
if result.errors:
|
|
426
|
+
print_warning(f" {len(result.errors)} file(s) had scan errors (skipped)")
|
|
427
|
+
|
|
428
|
+
console.print()
|
|
429
|
+
|
|
430
|
+
# Phase 3: Build the API payload
|
|
431
|
+
payload = _build_payload(audit_uuid, audit_type, scan_results)
|
|
432
|
+
|
|
433
|
+
# Phase 4: Summary + confirmation (--show writes & opens the payload file)
|
|
434
|
+
total_files = sum(r.total_files for r in scan_results)
|
|
435
|
+
total_loc = sum(r.total_lines_of_code for r in scan_results)
|
|
436
|
+
|
|
437
|
+
console.print(
|
|
438
|
+
f"[bold]Summary:[/bold] {len(scan_results)} repositor{'y' if len(scan_results) == 1 else 'ies'} | "
|
|
439
|
+
f"{total_files:,} files | {total_loc:,} lines of code\n"
|
|
440
|
+
)
|
|
441
|
+
print_info(
|
|
442
|
+
"Only [bold]file paths, types, and line counts[/bold] will be sent to CodeDD. "
|
|
443
|
+
"[bold]No source code.[/bold]"
|
|
444
|
+
)
|
|
445
|
+
console.print()
|
|
446
|
+
|
|
447
|
+
if show:
|
|
448
|
+
# --show: write payload to file, open it, ask for confirmation
|
|
449
|
+
confirmed = review_payload(
|
|
450
|
+
payload,
|
|
451
|
+
command_label="Scope Registration",
|
|
452
|
+
context_note="This file contains ONLY metadata (file paths, types, line counts). No source code content is included.",
|
|
453
|
+
confirm_prompt="Register this scope with CodeDD?",
|
|
454
|
+
)
|
|
455
|
+
else:
|
|
456
|
+
confirmed = Confirm.ask("Register this scope with CodeDD?", default=True)
|
|
457
|
+
|
|
458
|
+
if not confirmed:
|
|
459
|
+
print_info("Cancelled. Scope was not sent.")
|
|
460
|
+
return
|
|
461
|
+
|
|
462
|
+
# Phase 6: Send to CodeDD
|
|
463
|
+
console.print()
|
|
464
|
+
with Progress(
|
|
465
|
+
SpinnerColumn(),
|
|
466
|
+
TextColumn("[progress.description]{task.description}"),
|
|
467
|
+
console=console,
|
|
468
|
+
transient=True,
|
|
469
|
+
) as progress:
|
|
470
|
+
send_task = progress.add_task("Registering scope with CodeDD…", total=None)
|
|
471
|
+
|
|
472
|
+
with CodeDDClient(config=cfg) as client:
|
|
473
|
+
resp = client.post(Endpoints.REGISTER_SCOPE, json=payload)
|
|
474
|
+
|
|
475
|
+
if resp.status_code == 401:
|
|
476
|
+
print_error("Authentication failed. Run [bold cyan]codedd auth login[/bold cyan].")
|
|
477
|
+
raise typer.Exit(code=1)
|
|
478
|
+
|
|
479
|
+
if resp.status_code not in (200, 201):
|
|
480
|
+
msg = "Failed to register scope"
|
|
481
|
+
try:
|
|
482
|
+
body = resp.json()
|
|
483
|
+
msg = body.get("message", msg)
|
|
484
|
+
except Exception:
|
|
485
|
+
pass
|
|
486
|
+
print_error(msg)
|
|
487
|
+
raise typer.Exit(code=1)
|
|
488
|
+
|
|
489
|
+
body = resp.json()
|
|
490
|
+
if body.get("status") != "success":
|
|
491
|
+
print_error(body.get("message", "Unknown error from server"))
|
|
492
|
+
raise typer.Exit(code=1)
|
|
493
|
+
|
|
494
|
+
# Mark all scope entries as confirmed
|
|
495
|
+
cfg.mark_scope_confirmed()
|
|
496
|
+
|
|
497
|
+
# Display results
|
|
498
|
+
console.print()
|
|
499
|
+
print_success(f"Scope registered — {body.get('message', '')}")
|
|
500
|
+
|
|
501
|
+
sub_audits = body.get("sub_audits", [])
|
|
502
|
+
for sa in sub_audits:
|
|
503
|
+
status_icon = f"[bold green]{SYMBOL_OK}[/bold green]" if sa.get("status") == "ok" else f"[bold red]{SYMBOL_FAIL}[/bold red]"
|
|
504
|
+
console.print(
|
|
505
|
+
f" {status_icon} [bold]{sa.get('repo_name', '—')}[/bold] "
|
|
506
|
+
f"audit: [dim]{sa.get('audit_uuid', '—')[:12]}…[/dim] "
|
|
507
|
+
f"files: {sa.get('files_registered', 0)} "
|
|
508
|
+
f"LoC: {sa.get('total_lines_of_code', 0):,}"
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
# Point user to the cloud app to manage scope, pay, etc. (after sub-audit list)
|
|
512
|
+
print_audit_scope_cloud_info(cfg.active_audit_uuid, cfg.active_audit_type)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
# ---------------------------------------------------------------------------
|
|
516
|
+
# codedd scope sync
|
|
517
|
+
# ---------------------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
@scope_app.command("sync")
|
|
520
|
+
@require_auth
|
|
521
|
+
def sync_scope(
|
|
522
|
+
show: bool = typer.Option(
|
|
523
|
+
False,
|
|
524
|
+
"--show",
|
|
525
|
+
"-s",
|
|
526
|
+
help="Write each API request to a file and open it for review before sending.",
|
|
527
|
+
),
|
|
528
|
+
) -> None:
|
|
529
|
+
"""
|
|
530
|
+
Compare the local directory state with what is registered in CodeDD.
|
|
531
|
+
|
|
532
|
+
This command:
|
|
533
|
+
1. Fetches the registered file list from CodeDD for each CLI sub-audit.
|
|
534
|
+
2. Re-scans each local directory to build the current file list.
|
|
535
|
+
3. Computes a diff (added / removed / changed files) per repository.
|
|
536
|
+
4. If changes are detected, marks the scope as *needs_reconfirm*.
|
|
537
|
+
5. Optionally re-confirms the scope (re-runs the register flow).
|
|
538
|
+
"""
|
|
539
|
+
cfg = ConfigManager()
|
|
540
|
+
_require_active_audit(cfg)
|
|
541
|
+
|
|
542
|
+
dirs = cfg.scope_directories
|
|
543
|
+
if not dirs:
|
|
544
|
+
print_error("No directories in scope. Use [bold cyan]codedd scope add <path>[/bold cyan] first.")
|
|
545
|
+
raise typer.Exit(code=1)
|
|
546
|
+
|
|
547
|
+
audit_uuid = cfg.active_audit_uuid
|
|
548
|
+
audit_name = cfg.active_audit_name
|
|
549
|
+
|
|
550
|
+
console.print(f"\n[bold]Audit:[/bold] {audit_name} [dim]({cfg.active_audit_type})[/dim]")
|
|
551
|
+
console.print(f"[bold]Directories:[/bold] {len(dirs)}\n")
|
|
552
|
+
|
|
553
|
+
# Phase 1: Fetch remote state from CodeDD
|
|
554
|
+
console.print("[dim]Phase 1 — Fetching registered scope from CodeDD…[/dim]\n")
|
|
555
|
+
|
|
556
|
+
if show:
|
|
557
|
+
confirmed = review_request(
|
|
558
|
+
"GET",
|
|
559
|
+
Endpoints.SCOPE_FILES,
|
|
560
|
+
params={"audit_uuid": audit_uuid},
|
|
561
|
+
command_label="Scope Sync — Fetch Files",
|
|
562
|
+
context_note="This request fetches the list of files currently registered for your audit. Only the audit UUID is sent.",
|
|
563
|
+
confirm_prompt="Proceed with this request to CodeDD?",
|
|
564
|
+
)
|
|
565
|
+
if not confirmed:
|
|
566
|
+
print_info("Cancelled.")
|
|
567
|
+
raise typer.Exit(code=0)
|
|
568
|
+
|
|
569
|
+
with Progress(
|
|
570
|
+
SpinnerColumn(),
|
|
571
|
+
TextColumn("[progress.description]{task.description}"),
|
|
572
|
+
console=console,
|
|
573
|
+
transient=True,
|
|
574
|
+
) as progress:
|
|
575
|
+
progress.add_task("Contacting CodeDD…", total=None)
|
|
576
|
+
|
|
577
|
+
with CodeDDClient(config=cfg) as client:
|
|
578
|
+
resp = client.get(Endpoints.SCOPE_FILES, params={"audit_uuid": audit_uuid})
|
|
579
|
+
|
|
580
|
+
if resp.status_code == 401:
|
|
581
|
+
print_error("Authentication failed. Run [bold cyan]codedd auth login[/bold cyan].")
|
|
582
|
+
raise typer.Exit(code=1)
|
|
583
|
+
|
|
584
|
+
# Parse body for message (even on error) to detect "audit not found"
|
|
585
|
+
try:
|
|
586
|
+
body = resp.json() if resp.content else {}
|
|
587
|
+
except Exception:
|
|
588
|
+
body = {}
|
|
589
|
+
msg = body.get("message", "") or ""
|
|
590
|
+
|
|
591
|
+
# Audit itself (group/single) no longer exists on CodeDD — show message and list available audits
|
|
592
|
+
def _is_audit_not_found_response() -> bool:
|
|
593
|
+
if resp.status_code == 404:
|
|
594
|
+
return True
|
|
595
|
+
low = msg.lower()
|
|
596
|
+
return "audit not found" in low or "access denied or audit not found" in low
|
|
597
|
+
|
|
598
|
+
if resp.status_code != 200 or body.get("status") != "success":
|
|
599
|
+
if _is_audit_not_found_response():
|
|
600
|
+
print_warning("The audit no longer exists on CodeDD (deleted or not found).")
|
|
601
|
+
console.print()
|
|
602
|
+
_show_available_audits_and_exit(cfg, show=show)
|
|
603
|
+
else:
|
|
604
|
+
print_error(msg or "Failed to fetch scope data from CodeDD")
|
|
605
|
+
raise typer.Exit(code=1)
|
|
606
|
+
|
|
607
|
+
remote_sub_audits = body.get("sub_audits", [])
|
|
608
|
+
|
|
609
|
+
# Build a lookup: repo_name -> remote file list
|
|
610
|
+
remote_by_name: dict[str, list[dict]] = {}
|
|
611
|
+
for sa in remote_sub_audits:
|
|
612
|
+
remote_by_name[sa.get("repo_name", "")] = sa.get("files", [])
|
|
613
|
+
|
|
614
|
+
# Detect repos missing from remote (sub-audit not in API response)
|
|
615
|
+
local_names = {d.get("repo_name") for d in dirs}
|
|
616
|
+
remote_names = set(remote_by_name.keys())
|
|
617
|
+
deleted_remotely = local_names - remote_names
|
|
618
|
+
|
|
619
|
+
if deleted_remotely:
|
|
620
|
+
for name in deleted_remotely:
|
|
621
|
+
print_warning(f" Repository [bold]{name}[/bold] was deleted from CodeDD (no longer in scope)")
|
|
622
|
+
console.print()
|
|
623
|
+
|
|
624
|
+
# When audit exists but no sub_audits on remote (scope empty), skip Phase 2 and show panel
|
|
625
|
+
scope_empty = len(remote_sub_audits) == 0
|
|
626
|
+
|
|
627
|
+
total_has_diff = False
|
|
628
|
+
repo_diffs: list[tuple[str, dict, int]] = [] # (repo_name, diff, remote_file_count)
|
|
629
|
+
total_loc_sync = 0 # Sum of local LoC across scanned repos
|
|
630
|
+
total_for_audit_files = 0 # Sum of files selected for audit on CodeDD (remote)
|
|
631
|
+
total_for_audit_loc = 0 # Sum of LoC selected for audit on CodeDD (remote)
|
|
632
|
+
|
|
633
|
+
if scope_empty:
|
|
634
|
+
repos_deleted_on_codedd = set(local_names)
|
|
635
|
+
console.print()
|
|
636
|
+
else:
|
|
637
|
+
# Phase 2: Re-scan local directories; separate "deleted on remote" from normal diffs
|
|
638
|
+
console.print("[dim]Phase 2 — Scanning local directories…[/dim]\n")
|
|
639
|
+
|
|
640
|
+
repos_cleared_on_remote: set[str] = set() # in remote_by_name but 0 files (audit cleared)
|
|
641
|
+
|
|
642
|
+
for entry in dirs:
|
|
643
|
+
repo_path = entry["path"]
|
|
644
|
+
repo_name = entry.get("repo_name", Path(repo_path).name)
|
|
645
|
+
|
|
646
|
+
# Skip if not known on the server (it was never confirmed)
|
|
647
|
+
if repo_name not in remote_by_name:
|
|
648
|
+
print_info(f" [bold]{repo_name}[/bold] — not yet registered; skipping diff")
|
|
649
|
+
continue
|
|
650
|
+
|
|
651
|
+
with Progress(
|
|
652
|
+
SpinnerColumn(),
|
|
653
|
+
TextColumn("[progress.description]{task.description}"),
|
|
654
|
+
console=console,
|
|
655
|
+
transient=True,
|
|
656
|
+
) as progress:
|
|
657
|
+
progress.add_task(f"Scanning [bold]{repo_name}[/bold]…", total=None)
|
|
658
|
+
scan_result = scan_repository(repo_path)
|
|
659
|
+
|
|
660
|
+
# Build local file map: relative_path -> { file_type, lines_of_code, lines_of_doc }
|
|
661
|
+
local_files: dict[str, dict] = {}
|
|
662
|
+
for fm in scan_result.files:
|
|
663
|
+
local_files[fm.relative_path] = {
|
|
664
|
+
"file_type": fm.file_type,
|
|
665
|
+
"lines_of_code": fm.lines_of_code,
|
|
666
|
+
"lines_of_doc": fm.lines_of_doc,
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
# Build remote file map
|
|
670
|
+
remote_files: dict[str, dict] = {}
|
|
671
|
+
for rf in remote_by_name[repo_name]:
|
|
672
|
+
remote_files[rf["relative_path"]] = rf
|
|
673
|
+
|
|
674
|
+
remote_count = len(remote_files)
|
|
675
|
+
|
|
676
|
+
local_loc = scan_result.total_lines_of_code
|
|
677
|
+
remote_list = remote_by_name[repo_name]
|
|
678
|
+
# Selected for audit on CodeDD (what the web UI shows)
|
|
679
|
+
for_audit_files = sum(1 for rf in remote_list if rf.get("selected_for_audit", True))
|
|
680
|
+
for_audit_loc = sum(
|
|
681
|
+
rf.get("lines_of_code", 0)
|
|
682
|
+
for rf in remote_list
|
|
683
|
+
if rf.get("selected_for_audit", True)
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
# Audit deleted/cleared on CodeDD (0 files on remote) — do not treat as normal diff
|
|
687
|
+
if remote_count == 0:
|
|
688
|
+
repos_cleared_on_remote.add(repo_name)
|
|
689
|
+
status_label = "[bold yellow]deleted on CodeDD[/bold yellow]"
|
|
690
|
+
console.print(
|
|
691
|
+
f" {status_label} [bold]{repo_name}[/bold] "
|
|
692
|
+
f"{len(local_files)} files, {local_loc:,} LoC [dim](none on CodeDD)[/dim]"
|
|
693
|
+
)
|
|
694
|
+
total_loc_sync += local_loc
|
|
695
|
+
continue
|
|
696
|
+
|
|
697
|
+
# Normal diff
|
|
698
|
+
diff = _compute_diff(local_files, remote_files)
|
|
699
|
+
has_diff = bool(diff["added"] or diff["removed"] or diff["changed"])
|
|
700
|
+
|
|
701
|
+
if has_diff:
|
|
702
|
+
total_has_diff = True
|
|
703
|
+
repo_diffs.append((repo_name, diff, remote_count))
|
|
704
|
+
|
|
705
|
+
status_label = "[bold yellow]changes detected[/bold yellow]" if has_diff else "[bold green]in sync[/bold green]"
|
|
706
|
+
# One clear line: local scope → what's for audit on CodeDD (matches web UI)
|
|
707
|
+
console.print(
|
|
708
|
+
f" {status_label} [bold]{repo_name}[/bold] "
|
|
709
|
+
f"{len(local_files)} files, {local_loc:,} LoC → "
|
|
710
|
+
f"[cyan]{for_audit_files} file{'s' if for_audit_files != 1 else ''}, {for_audit_loc:,} LoC for audit[/cyan]"
|
|
711
|
+
)
|
|
712
|
+
total_loc_sync += local_loc
|
|
713
|
+
total_for_audit_files += for_audit_files
|
|
714
|
+
total_for_audit_loc += for_audit_loc
|
|
715
|
+
|
|
716
|
+
# All repos that are "deleted" on CodeDD: missing from API or 0 files
|
|
717
|
+
repos_deleted_on_codedd = deleted_remotely | repos_cleared_on_remote
|
|
718
|
+
|
|
719
|
+
if total_loc_sync > 0:
|
|
720
|
+
total_line = f"\n [dim]Total: {total_loc_sync:,} LoC → {total_for_audit_files} file{'s' if total_for_audit_files != 1 else ''}, {total_for_audit_loc:,} LoC for audit on CodeDD[/dim]"
|
|
721
|
+
console.print(total_line)
|
|
722
|
+
console.print()
|
|
723
|
+
|
|
724
|
+
# Handle deleted-on-CodeDD audits: prompt and act (Option 1 or 2)
|
|
725
|
+
if repos_deleted_on_codedd:
|
|
726
|
+
choice = prompt_deleted_audits_action(sorted(repos_deleted_on_codedd))
|
|
727
|
+
|
|
728
|
+
# 1 or 3 = accept deletion (remove from local scope); 2 or 4 = restore to CodeDD
|
|
729
|
+
if choice in (1, 3):
|
|
730
|
+
# Confirm remote audit deletion: remove these repos from local scope
|
|
731
|
+
dirs_list = cfg.scope_directories
|
|
732
|
+
indices_to_remove = [i for i, d in enumerate(dirs_list) if d.get("repo_name") in repos_deleted_on_codedd]
|
|
733
|
+
for i in sorted(indices_to_remove, reverse=True):
|
|
734
|
+
cfg.remove_scope_directory(i)
|
|
735
|
+
print_success(
|
|
736
|
+
f"Removed {len(indices_to_remove)} audit(s) from local scope to match CodeDD."
|
|
737
|
+
)
|
|
738
|
+
console.print()
|
|
739
|
+
# If scope is now empty, show state and stop
|
|
740
|
+
if not cfg.scope_directories:
|
|
741
|
+
render_scope_table_with_sync(cfg.scope_directories, cfg.active_audit_name)
|
|
742
|
+
return
|
|
743
|
+
# Continue to Phase 3/4 for any remaining repos with normal diffs
|
|
744
|
+
else:
|
|
745
|
+
# Restore local audit scope and sync to CodeDD (re-register)
|
|
746
|
+
console.print()
|
|
747
|
+
_run_reconfirm(cfg, show=show)
|
|
748
|
+
# Show final state and exit; no need to run Phase 3/4
|
|
749
|
+
console.print()
|
|
750
|
+
render_scope_table_with_sync(cfg.scope_directories, cfg.active_audit_name)
|
|
751
|
+
return
|
|
752
|
+
|
|
753
|
+
# Phase 3: Display diffs (skips full table when remote has 0 files; limits rows otherwise)
|
|
754
|
+
if repo_diffs:
|
|
755
|
+
console.print("[dim]Phase 3 — Diff details[/dim]\n")
|
|
756
|
+
for repo_name, diff, remote_count in repo_diffs:
|
|
757
|
+
render_diff_table(repo_name, diff, remote_file_count=remote_count)
|
|
758
|
+
console.print()
|
|
759
|
+
|
|
760
|
+
# Phase 4: Update config and prompt
|
|
761
|
+
now_iso = datetime.now(timezone.utc).isoformat()
|
|
762
|
+
|
|
763
|
+
if total_has_diff:
|
|
764
|
+
cfg.mark_scope_needs_reconfirm(last_sync_iso=now_iso)
|
|
765
|
+
print_warning("Local files have changed since last registration.")
|
|
766
|
+
console.print()
|
|
767
|
+
|
|
768
|
+
confirmed = Confirm.ask("Re-confirm scope now?", default=False)
|
|
769
|
+
if confirmed:
|
|
770
|
+
# Delegate to the confirm flow
|
|
771
|
+
console.print()
|
|
772
|
+
_run_reconfirm(cfg, show=show)
|
|
773
|
+
else:
|
|
774
|
+
print_info(
|
|
775
|
+
"Scope marked as [bold yellow]needs re-confirm[/bold yellow]. "
|
|
776
|
+
"Run [bold cyan]codedd scope confirm[/bold cyan] when ready."
|
|
777
|
+
)
|
|
778
|
+
else:
|
|
779
|
+
cfg.update_scope_last_sync(now_iso)
|
|
780
|
+
print_success("All registered repositories are in sync with local directories.")
|
|
781
|
+
|
|
782
|
+
# Show final state
|
|
783
|
+
console.print()
|
|
784
|
+
render_scope_table_with_sync(cfg.scope_directories, cfg.active_audit_name)
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
# ---------------------------------------------------------------------------
|
|
788
|
+
# Helpers for confirm and sync
|
|
789
|
+
# ---------------------------------------------------------------------------
|
|
790
|
+
|
|
791
|
+
def _show_available_audits_and_exit(cfg: ConfigManager, show: bool = False) -> None:
|
|
792
|
+
"""
|
|
793
|
+
Fetch and display the list of audits available on CodeDD, then exit.
|
|
794
|
+
Used when the active audit no longer exists (deleted on server).
|
|
795
|
+
"""
|
|
796
|
+
if show:
|
|
797
|
+
confirmed = review_request(
|
|
798
|
+
"GET",
|
|
799
|
+
Endpoints.LIST_AUDITS,
|
|
800
|
+
params={"offset": 0, "limit": 50},
|
|
801
|
+
command_label="Audits List (After Scope Not Found)",
|
|
802
|
+
context_note="This request fetches your list of audits from CodeDD. Only pagination params are sent.",
|
|
803
|
+
confirm_prompt="Proceed with this request to CodeDD?",
|
|
804
|
+
)
|
|
805
|
+
if not confirmed:
|
|
806
|
+
print_info("Cancelled.")
|
|
807
|
+
raise typer.Exit(code=0)
|
|
808
|
+
|
|
809
|
+
console.print("[dim]Fetching your audits from CodeDD…[/dim]\n")
|
|
810
|
+
try:
|
|
811
|
+
with CodeDDClient(config=cfg) as client:
|
|
812
|
+
single_audits, group_audits, total = _fetch_audits(client, limit=50)
|
|
813
|
+
except typer.Exit:
|
|
814
|
+
raise
|
|
815
|
+
except Exception as e:
|
|
816
|
+
print_error(str(e))
|
|
817
|
+
raise typer.Exit(code=1)
|
|
818
|
+
|
|
819
|
+
if not single_audits and not group_audits:
|
|
820
|
+
print_info("No other audits found. Create an audit at [bold cyan]https://www.codedd.ai[/bold cyan]")
|
|
821
|
+
else:
|
|
822
|
+
active_uuid = (cfg.active_audit_uuid or "").strip() or None
|
|
823
|
+
render_audits_table(single_audits, group_audits, total, active_audit_uuid=active_uuid)
|
|
824
|
+
console.print()
|
|
825
|
+
print_info("Run [bold cyan]codedd audits select[/bold cyan] to choose another audit.")
|
|
826
|
+
|
|
827
|
+
raise typer.Exit(code=0)
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _build_payload(audit_uuid: str, audit_type: str, scan_results: list) -> dict:
|
|
831
|
+
"""Build the JSON payload for the scope registration endpoint."""
|
|
832
|
+
return {
|
|
833
|
+
"audit_uuid": audit_uuid,
|
|
834
|
+
"audit_type": audit_type,
|
|
835
|
+
"repositories": [r.to_dict() for r in scan_results],
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _compute_diff(
|
|
840
|
+
local_files: dict[str, dict],
|
|
841
|
+
remote_files: dict[str, dict],
|
|
842
|
+
) -> dict:
|
|
843
|
+
"""
|
|
844
|
+
Compare local scan results against the remote (CodeDD) file list.
|
|
845
|
+
|
|
846
|
+
Args:
|
|
847
|
+
local_files: ``{relative_path: {file_type, lines_of_code, lines_of_doc}}``
|
|
848
|
+
remote_files: ``{relative_path: {file_type, lines_of_code, lines_of_doc, selected_for_audit}}``
|
|
849
|
+
|
|
850
|
+
Returns:
|
|
851
|
+
Dict with keys ``added``, ``removed``, ``changed``.
|
|
852
|
+
"""
|
|
853
|
+
local_paths = set(local_files.keys())
|
|
854
|
+
remote_paths = set(remote_files.keys())
|
|
855
|
+
|
|
856
|
+
added = [
|
|
857
|
+
{"path": p, "lines_of_code": local_files[p].get("lines_of_code", 0)}
|
|
858
|
+
for p in sorted(local_paths - remote_paths)
|
|
859
|
+
]
|
|
860
|
+
|
|
861
|
+
removed = [
|
|
862
|
+
{"path": p, "lines_of_code": remote_files[p].get("lines_of_code", 0)}
|
|
863
|
+
for p in sorted(remote_paths - local_paths)
|
|
864
|
+
]
|
|
865
|
+
|
|
866
|
+
changed = []
|
|
867
|
+
for p in sorted(local_paths & remote_paths):
|
|
868
|
+
old_loc = remote_files[p].get("lines_of_code", 0)
|
|
869
|
+
new_loc = local_files[p].get("lines_of_code", 0)
|
|
870
|
+
old_doc = remote_files[p].get("lines_of_doc", 0)
|
|
871
|
+
new_doc = local_files[p].get("lines_of_doc", 0)
|
|
872
|
+
if old_loc != new_loc or old_doc != new_doc:
|
|
873
|
+
changed.append({"path": p, "old_loc": old_loc, "new_loc": new_loc})
|
|
874
|
+
|
|
875
|
+
return {"added": added, "removed": removed, "changed": changed}
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _run_reconfirm(cfg: ConfigManager, show: bool = False) -> bool:
|
|
879
|
+
"""
|
|
880
|
+
Execute the scope re-confirmation flow: scan, build payload, send to
|
|
881
|
+
CodeDD. Reuses the same logic as ``confirm_scope`` but is called
|
|
882
|
+
programmatically after a ``sync`` detects changes.
|
|
883
|
+
|
|
884
|
+
When ``show`` is True, writes the registration payload to a file and
|
|
885
|
+
asks for confirmation before sending (same as ``scope confirm --show``).
|
|
886
|
+
|
|
887
|
+
Returns True if re-registration succeeded, False otherwise.
|
|
888
|
+
"""
|
|
889
|
+
dirs = cfg.scope_directories
|
|
890
|
+
audit_uuid = cfg.active_audit_uuid
|
|
891
|
+
audit_type = cfg.active_audit_type
|
|
892
|
+
|
|
893
|
+
# Scan
|
|
894
|
+
scan_results = []
|
|
895
|
+
for entry in dirs:
|
|
896
|
+
repo_path = entry["path"]
|
|
897
|
+
repo_name = entry.get("repo_name", Path(repo_path).name)
|
|
898
|
+
|
|
899
|
+
with Progress(
|
|
900
|
+
SpinnerColumn(),
|
|
901
|
+
TextColumn("[progress.description]{task.description}"),
|
|
902
|
+
console=console,
|
|
903
|
+
transient=True,
|
|
904
|
+
) as progress:
|
|
905
|
+
progress.add_task(f"Scanning [bold]{repo_name}[/bold]…", total=None)
|
|
906
|
+
result = scan_repository(repo_path)
|
|
907
|
+
scan_results.append(result)
|
|
908
|
+
|
|
909
|
+
console.print(
|
|
910
|
+
f" [bold green]{SYMBOL_OK}[/bold green] [bold]{result.repo_name}[/bold] "
|
|
911
|
+
f"files: {result.total_files} "
|
|
912
|
+
f"LoC: {result.total_lines_of_code:,}"
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
console.print()
|
|
916
|
+
payload = _build_payload(audit_uuid, audit_type, scan_results)
|
|
917
|
+
|
|
918
|
+
if show:
|
|
919
|
+
confirmed = review_payload(
|
|
920
|
+
payload,
|
|
921
|
+
command_label="Scope Re-registration",
|
|
922
|
+
context_note="This file contains ONLY metadata (file paths, types, line counts). No source code content is included.",
|
|
923
|
+
confirm_prompt="Re-register this scope with CodeDD?",
|
|
924
|
+
)
|
|
925
|
+
if not confirmed:
|
|
926
|
+
print_info("Cancelled. Scope was not re-registered.")
|
|
927
|
+
return False
|
|
928
|
+
|
|
929
|
+
# Send
|
|
930
|
+
with Progress(
|
|
931
|
+
SpinnerColumn(),
|
|
932
|
+
TextColumn("[progress.description]{task.description}"),
|
|
933
|
+
console=console,
|
|
934
|
+
transient=True,
|
|
935
|
+
) as progress:
|
|
936
|
+
progress.add_task("Re-registering scope with CodeDD…", total=None)
|
|
937
|
+
with CodeDDClient(config=cfg) as client:
|
|
938
|
+
resp = client.post(Endpoints.REGISTER_SCOPE, json=payload)
|
|
939
|
+
|
|
940
|
+
if resp.status_code not in (200, 201):
|
|
941
|
+
msg = "Failed to re-register scope"
|
|
942
|
+
try:
|
|
943
|
+
body = resp.json()
|
|
944
|
+
msg = body.get("message", msg)
|
|
945
|
+
# Surface per-repo errors so the user can debug
|
|
946
|
+
sub_audits = body.get("sub_audits", [])
|
|
947
|
+
for sa in sub_audits:
|
|
948
|
+
if sa.get("status") != "ok" and sa.get("error"):
|
|
949
|
+
print_error(
|
|
950
|
+
f" {sa.get('repo_name', '?')}: {sa['error']}"
|
|
951
|
+
)
|
|
952
|
+
except Exception:
|
|
953
|
+
pass
|
|
954
|
+
print_error(msg)
|
|
955
|
+
return False
|
|
956
|
+
|
|
957
|
+
body = resp.json()
|
|
958
|
+
if body.get("status") != "success":
|
|
959
|
+
print_error(body.get("message", "Unknown error from server"))
|
|
960
|
+
return False
|
|
961
|
+
|
|
962
|
+
cfg.mark_scope_confirmed()
|
|
963
|
+
print_success(f"Scope re-registered — {body.get('message', '')}")
|
|
964
|
+
|
|
965
|
+
# Point user to the cloud app to manage scope, pay, etc.
|
|
966
|
+
print_audit_scope_cloud_info(cfg.active_audit_uuid, cfg.active_audit_type)
|
|
967
|
+
return True
|