recce-cloud 1.32.0__py3-none-any.whl → 1.33.1__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.
- recce_cloud/VERSION +1 -1
- recce_cloud/api/client.py +245 -2
- recce_cloud/auth/__init__.py +21 -0
- recce_cloud/auth/callback_server.py +128 -0
- recce_cloud/auth/login.py +281 -0
- recce_cloud/auth/profile.py +131 -0
- recce_cloud/auth/templates/error.html +58 -0
- recce_cloud/auth/templates/success.html +58 -0
- recce_cloud/cli.py +661 -33
- recce_cloud/commands/__init__.py +1 -0
- recce_cloud/commands/diagnostics.py +174 -0
- recce_cloud/config/__init__.py +19 -0
- recce_cloud/config/project_config.py +187 -0
- recce_cloud/config/resolver.py +137 -0
- recce_cloud/services/__init__.py +1 -0
- recce_cloud/services/diagnostic_service.py +380 -0
- recce_cloud/upload.py +211 -0
- {recce_cloud-1.32.0.dist-info → recce_cloud-1.33.1.dist-info}/METADATA +112 -2
- recce_cloud-1.33.1.dist-info/RECORD +37 -0
- recce_cloud-1.32.0.dist-info/RECORD +0 -24
- {recce_cloud-1.32.0.dist-info → recce_cloud-1.33.1.dist-info}/WHEEL +0 -0
- {recce_cloud-1.32.0.dist-info → recce_cloud-1.33.1.dist-info}/entry_points.txt +0 -0
recce_cloud/cli.py
CHANGED
|
@@ -7,6 +7,7 @@ import logging
|
|
|
7
7
|
import os
|
|
8
8
|
import subprocess
|
|
9
9
|
import sys
|
|
10
|
+
from typing import Optional
|
|
10
11
|
|
|
11
12
|
import click
|
|
12
13
|
from rich.console import Console
|
|
@@ -15,6 +16,7 @@ from rich.logging import RichHandler
|
|
|
15
16
|
from recce_cloud import __version__
|
|
16
17
|
from recce_cloud.artifact import get_adapter_type, verify_artifacts_path
|
|
17
18
|
from recce_cloud.ci_providers import CIDetector
|
|
19
|
+
from recce_cloud.commands.diagnostics import doctor
|
|
18
20
|
from recce_cloud.delete import (
|
|
19
21
|
delete_existing_session,
|
|
20
22
|
delete_with_platform_apis,
|
|
@@ -49,12 +51,403 @@ def cloud_cli():
|
|
|
49
51
|
pass
|
|
50
52
|
|
|
51
53
|
|
|
54
|
+
# Register commands from command modules
|
|
55
|
+
cloud_cli.add_command(doctor)
|
|
56
|
+
|
|
57
|
+
|
|
52
58
|
@cloud_cli.command()
|
|
53
59
|
def version():
|
|
54
60
|
"""Show the version of recce-cloud."""
|
|
55
61
|
click.echo(__version__)
|
|
56
62
|
|
|
57
63
|
|
|
64
|
+
@cloud_cli.command()
|
|
65
|
+
@click.option(
|
|
66
|
+
"--token",
|
|
67
|
+
default=None,
|
|
68
|
+
help="API token for authentication (for headless/CI environments)",
|
|
69
|
+
)
|
|
70
|
+
@click.option(
|
|
71
|
+
"--status",
|
|
72
|
+
is_flag=True,
|
|
73
|
+
help="Check current login status without modifying credentials",
|
|
74
|
+
)
|
|
75
|
+
def login(token, status):
|
|
76
|
+
"""
|
|
77
|
+
Authenticate with Recce Cloud.
|
|
78
|
+
|
|
79
|
+
By default, opens a browser for OAuth authentication. The browser flow
|
|
80
|
+
securely exchanges credentials using RSA encryption.
|
|
81
|
+
|
|
82
|
+
\b
|
|
83
|
+
Examples:
|
|
84
|
+
# Browser-based OAuth login (recommended)
|
|
85
|
+
recce-cloud login
|
|
86
|
+
|
|
87
|
+
# Check if already logged in
|
|
88
|
+
recce-cloud login --status
|
|
89
|
+
|
|
90
|
+
# Direct token authentication (for headless/CI environments)
|
|
91
|
+
recce-cloud login --token <your-api-token>
|
|
92
|
+
"""
|
|
93
|
+
from recce_cloud.auth.login import (
|
|
94
|
+
check_login_status,
|
|
95
|
+
get_api_token,
|
|
96
|
+
login_with_browser,
|
|
97
|
+
login_with_token,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
console = Console()
|
|
101
|
+
|
|
102
|
+
# Status check mode
|
|
103
|
+
if status:
|
|
104
|
+
is_logged_in, email = check_login_status()
|
|
105
|
+
if is_logged_in:
|
|
106
|
+
console.print(f"[green]✓[/green] Logged in as [cyan]{email or 'Unknown'}[/cyan]")
|
|
107
|
+
token_value = get_api_token()
|
|
108
|
+
if token_value:
|
|
109
|
+
masked = f"{token_value[:8]}...{token_value[-4:]}" if len(token_value) > 12 else "***"
|
|
110
|
+
console.print(f" Token: {masked} (valid)")
|
|
111
|
+
else:
|
|
112
|
+
console.print("[yellow]Not logged in[/yellow]")
|
|
113
|
+
console.print("Run 'recce-cloud login' to authenticate")
|
|
114
|
+
sys.exit(0 if is_logged_in else 1)
|
|
115
|
+
|
|
116
|
+
# Check if already logged in
|
|
117
|
+
is_logged_in, email = check_login_status()
|
|
118
|
+
if is_logged_in:
|
|
119
|
+
console.print(f"[green]✓[/green] Already logged in as [cyan]{email or 'Unknown'}[/cyan]")
|
|
120
|
+
if not click.confirm("Do you want to re-authenticate?", default=False):
|
|
121
|
+
sys.exit(0)
|
|
122
|
+
|
|
123
|
+
# Direct token authentication mode
|
|
124
|
+
if token:
|
|
125
|
+
if login_with_token(token):
|
|
126
|
+
sys.exit(0)
|
|
127
|
+
else:
|
|
128
|
+
sys.exit(1)
|
|
129
|
+
|
|
130
|
+
# Browser OAuth flow
|
|
131
|
+
if login_with_browser():
|
|
132
|
+
sys.exit(0)
|
|
133
|
+
else:
|
|
134
|
+
console.print()
|
|
135
|
+
console.print("[yellow]Tip:[/yellow] For headless environments, use 'recce-cloud login --token <token>'")
|
|
136
|
+
sys.exit(1)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@cloud_cli.command()
|
|
140
|
+
def logout():
|
|
141
|
+
"""
|
|
142
|
+
Remove stored Recce Cloud credentials.
|
|
143
|
+
|
|
144
|
+
Clears the API token from ~/.recce/profile.yml.
|
|
145
|
+
"""
|
|
146
|
+
from recce_cloud.auth.login import logout as do_logout
|
|
147
|
+
from recce_cloud.auth.profile import get_profile_path
|
|
148
|
+
|
|
149
|
+
console = Console()
|
|
150
|
+
|
|
151
|
+
do_logout()
|
|
152
|
+
console.print("[green]✓[/green] Logged out successfully")
|
|
153
|
+
console.print(f" Credentials removed from {get_profile_path()}")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@cloud_cli.command()
|
|
157
|
+
@click.option(
|
|
158
|
+
"--org",
|
|
159
|
+
help="Organization name or slug to bind to",
|
|
160
|
+
)
|
|
161
|
+
@click.option(
|
|
162
|
+
"--project",
|
|
163
|
+
help="Project name or slug to bind to",
|
|
164
|
+
)
|
|
165
|
+
@click.option(
|
|
166
|
+
"--status",
|
|
167
|
+
is_flag=True,
|
|
168
|
+
help="Show current project binding without modifying",
|
|
169
|
+
)
|
|
170
|
+
@click.option(
|
|
171
|
+
"--clear",
|
|
172
|
+
is_flag=True,
|
|
173
|
+
help="Remove current project binding",
|
|
174
|
+
)
|
|
175
|
+
def init(org, project, status, clear):
|
|
176
|
+
"""
|
|
177
|
+
Bind current directory to a Recce Cloud project.
|
|
178
|
+
|
|
179
|
+
Creates a .recce/config file that stores the org/project binding.
|
|
180
|
+
Subsequent commands will auto-detect this binding.
|
|
181
|
+
|
|
182
|
+
\b
|
|
183
|
+
Examples:
|
|
184
|
+
# Interactive mode: Select org and project
|
|
185
|
+
recce-cloud init
|
|
186
|
+
|
|
187
|
+
# Explicit mode: Direct binding (for scripts/CI)
|
|
188
|
+
recce-cloud init --org myorg --project my-dbt-project
|
|
189
|
+
|
|
190
|
+
# Check current binding
|
|
191
|
+
recce-cloud init --status
|
|
192
|
+
|
|
193
|
+
# Remove binding
|
|
194
|
+
recce-cloud init --clear
|
|
195
|
+
"""
|
|
196
|
+
from recce_cloud.api.client import RecceCloudClient
|
|
197
|
+
from recce_cloud.api.exceptions import RecceCloudException
|
|
198
|
+
from recce_cloud.auth.login import get_user_info
|
|
199
|
+
from recce_cloud.auth.profile import get_api_token
|
|
200
|
+
from recce_cloud.config.project_config import (
|
|
201
|
+
add_to_gitignore,
|
|
202
|
+
clear_project_binding,
|
|
203
|
+
get_config_path,
|
|
204
|
+
get_project_binding,
|
|
205
|
+
save_project_binding,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
console = Console()
|
|
209
|
+
|
|
210
|
+
# Check authentication first
|
|
211
|
+
token = get_api_token()
|
|
212
|
+
if not token:
|
|
213
|
+
console.print("[red]Error:[/red] Not logged in")
|
|
214
|
+
console.print("Run 'recce-cloud login' first")
|
|
215
|
+
sys.exit(1)
|
|
216
|
+
|
|
217
|
+
# Status check mode
|
|
218
|
+
if status:
|
|
219
|
+
binding = get_project_binding()
|
|
220
|
+
if binding:
|
|
221
|
+
console.print(f"[green]✓[/green] Bound to [cyan]{binding['org']}/{binding['project']}[/cyan]")
|
|
222
|
+
if binding.get("bound_at"):
|
|
223
|
+
console.print(f" Bound at: {binding['bound_at']}")
|
|
224
|
+
if binding.get("bound_by"):
|
|
225
|
+
console.print(f" Bound by: {binding['bound_by']}")
|
|
226
|
+
console.print(f" Config file: {get_config_path()}")
|
|
227
|
+
else:
|
|
228
|
+
console.print("[yellow]Not bound to any project[/yellow]")
|
|
229
|
+
console.print("Run 'recce-cloud init' to bind this directory")
|
|
230
|
+
sys.exit(0 if binding else 1)
|
|
231
|
+
|
|
232
|
+
# Clear mode
|
|
233
|
+
if clear:
|
|
234
|
+
if clear_project_binding():
|
|
235
|
+
console.print("[green]✓[/green] Project binding removed")
|
|
236
|
+
else:
|
|
237
|
+
console.print("[yellow]No project binding to remove[/yellow]")
|
|
238
|
+
sys.exit(0)
|
|
239
|
+
|
|
240
|
+
# Validate flag combinations
|
|
241
|
+
if (org and not project) or (project and not org):
|
|
242
|
+
console.print("[red]Error:[/red] Both --org and --project must be provided together")
|
|
243
|
+
sys.exit(1)
|
|
244
|
+
|
|
245
|
+
# Get user email for binding metadata
|
|
246
|
+
user_info = get_user_info(token)
|
|
247
|
+
user_email = user_info.get("email") if user_info else None
|
|
248
|
+
|
|
249
|
+
# Explicit mode: Direct binding
|
|
250
|
+
if org and project:
|
|
251
|
+
# Validate org/project exist
|
|
252
|
+
try:
|
|
253
|
+
api = RecceCloudClient(token)
|
|
254
|
+
org_obj = api.get_organization(org)
|
|
255
|
+
if not org_obj:
|
|
256
|
+
console.print(f"[red]Error:[/red] Organization '{org}' not found")
|
|
257
|
+
sys.exit(1)
|
|
258
|
+
|
|
259
|
+
# Use org ID for project lookup (API requires ID)
|
|
260
|
+
project_obj = api.get_project(org_obj.get("id"), project)
|
|
261
|
+
if not project_obj:
|
|
262
|
+
console.print(f"[red]Error:[/red] Project '{project}' not found in organization '{org}'")
|
|
263
|
+
sys.exit(1)
|
|
264
|
+
|
|
265
|
+
# Use slug for storage (more stable than name)
|
|
266
|
+
org_slug = org_obj.get("slug", org)
|
|
267
|
+
project_slug = project_obj.get("slug", project)
|
|
268
|
+
|
|
269
|
+
save_project_binding(org_slug, project_slug, user_email)
|
|
270
|
+
console.print(f"[green]✓[/green] Bound to [cyan]{org_slug}/{project_slug}[/cyan]")
|
|
271
|
+
console.print(f" Config saved to {get_config_path()}")
|
|
272
|
+
|
|
273
|
+
# Offer to add to .gitignore
|
|
274
|
+
if click.confirm("Add .recce/ to .gitignore?", default=True):
|
|
275
|
+
if add_to_gitignore():
|
|
276
|
+
console.print("[green]✓[/green] Added .recce/ to .gitignore")
|
|
277
|
+
else:
|
|
278
|
+
console.print(" .recce/ already in .gitignore")
|
|
279
|
+
|
|
280
|
+
sys.exit(0)
|
|
281
|
+
|
|
282
|
+
except RecceCloudException as e:
|
|
283
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
284
|
+
sys.exit(1)
|
|
285
|
+
|
|
286
|
+
# Interactive mode: Select org → project
|
|
287
|
+
try:
|
|
288
|
+
api = RecceCloudClient(token)
|
|
289
|
+
|
|
290
|
+
# List organizations
|
|
291
|
+
console.print("Fetching organizations...")
|
|
292
|
+
orgs = api.list_organizations()
|
|
293
|
+
|
|
294
|
+
if not orgs:
|
|
295
|
+
console.print("[yellow]No organizations found[/yellow]")
|
|
296
|
+
console.print("Please create an organization at https://cloud.datarecce.io first")
|
|
297
|
+
sys.exit(1)
|
|
298
|
+
|
|
299
|
+
# Build org choices: (id for API, name for config, display_name for UI)
|
|
300
|
+
org_choices = []
|
|
301
|
+
for o in orgs:
|
|
302
|
+
org_id = o.get("id")
|
|
303
|
+
org_name = o.get("name") or o.get("slug") or str(org_id)
|
|
304
|
+
display_name = o.get("display_name") or org_name
|
|
305
|
+
org_choices.append((org_id, org_name, display_name))
|
|
306
|
+
|
|
307
|
+
# Select organization
|
|
308
|
+
console.print()
|
|
309
|
+
console.print("[cyan]Select organization:[/cyan]")
|
|
310
|
+
for i, (_, _, display_name) in enumerate(org_choices, 1):
|
|
311
|
+
console.print(f" {i}. {display_name}")
|
|
312
|
+
|
|
313
|
+
org_idx = click.prompt("Enter number", type=click.IntRange(1, len(org_choices)))
|
|
314
|
+
selected_org_id, selected_org_name, selected_org_display = org_choices[org_idx - 1]
|
|
315
|
+
|
|
316
|
+
# List projects (use org_id for API call)
|
|
317
|
+
console.print()
|
|
318
|
+
console.print(f"Fetching projects for {selected_org_display}...")
|
|
319
|
+
projects = api.list_projects(selected_org_id)
|
|
320
|
+
|
|
321
|
+
if not projects:
|
|
322
|
+
console.print(f"[yellow]No projects found in {selected_org_display}[/yellow]")
|
|
323
|
+
console.print("Please create a project at https://cloud.datarecce.io first")
|
|
324
|
+
sys.exit(1)
|
|
325
|
+
|
|
326
|
+
# Build project choices: (name for config, display_name for UI)
|
|
327
|
+
# Filter out archived projects
|
|
328
|
+
project_choices = []
|
|
329
|
+
for p in projects:
|
|
330
|
+
# Skip archived projects (check status field and archived flags)
|
|
331
|
+
if p.get("status") == "archived" or p.get("archived") or p.get("is_archived"):
|
|
332
|
+
continue
|
|
333
|
+
project_name = p.get("name") or p.get("slug") or str(p.get("id"))
|
|
334
|
+
display_name = p.get("display_name") or project_name
|
|
335
|
+
project_choices.append((project_name, display_name))
|
|
336
|
+
|
|
337
|
+
if not project_choices:
|
|
338
|
+
console.print(f"[yellow]No active projects found in {selected_org_display}[/yellow]")
|
|
339
|
+
console.print("Please create a project at https://cloud.datarecce.io first")
|
|
340
|
+
sys.exit(1)
|
|
341
|
+
|
|
342
|
+
# Select project
|
|
343
|
+
console.print()
|
|
344
|
+
console.print("[cyan]Select project:[/cyan]")
|
|
345
|
+
for i, (_, display_name) in enumerate(project_choices, 1):
|
|
346
|
+
console.print(f" {i}. {display_name}")
|
|
347
|
+
|
|
348
|
+
project_idx = click.prompt("Enter number", type=click.IntRange(1, len(project_choices)))
|
|
349
|
+
selected_project_name, selected_project_display = project_choices[project_idx - 1]
|
|
350
|
+
|
|
351
|
+
# Save binding (use names for config, not IDs)
|
|
352
|
+
save_project_binding(selected_org_name, selected_project_name, user_email)
|
|
353
|
+
console.print()
|
|
354
|
+
console.print(f"[green]✓[/green] Bound to [cyan]{selected_org_name}/{selected_project_name}[/cyan]")
|
|
355
|
+
console.print(f" Config saved to {get_config_path()}")
|
|
356
|
+
|
|
357
|
+
# Offer to add to .gitignore
|
|
358
|
+
if click.confirm("Add .recce/ to .gitignore?", default=True):
|
|
359
|
+
if add_to_gitignore():
|
|
360
|
+
console.print("[green]✓[/green] Added .recce/ to .gitignore")
|
|
361
|
+
else:
|
|
362
|
+
console.print(" .recce/ already in .gitignore")
|
|
363
|
+
|
|
364
|
+
except RecceCloudException as e:
|
|
365
|
+
console.print(f"[red]Error:[/red] Failed to fetch data from Recce Cloud: {e}")
|
|
366
|
+
sys.exit(1)
|
|
367
|
+
except Exception as e:
|
|
368
|
+
logger.debug("Unexpected error during init: %s", e, exc_info=True)
|
|
369
|
+
console.print(f"[red]Error:[/red] An unexpected error occurred: {e}")
|
|
370
|
+
console.print(" Try running 'recce-cloud login' again or check your network connection.")
|
|
371
|
+
sys.exit(1)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _get_production_session_id(console: Console, token: str) -> Optional[str]:
|
|
375
|
+
"""
|
|
376
|
+
Fetch the production session ID from Recce Cloud.
|
|
377
|
+
|
|
378
|
+
Returns the session ID if found, None otherwise (with error message printed).
|
|
379
|
+
"""
|
|
380
|
+
from recce_cloud.api.client import RecceCloudClient
|
|
381
|
+
from recce_cloud.api.exceptions import RecceCloudException
|
|
382
|
+
from recce_cloud.config.project_config import get_project_binding
|
|
383
|
+
|
|
384
|
+
# Get project binding
|
|
385
|
+
binding = get_project_binding()
|
|
386
|
+
if not binding:
|
|
387
|
+
# Check environment variables as fallback
|
|
388
|
+
env_org = os.environ.get("RECCE_ORG")
|
|
389
|
+
env_project = os.environ.get("RECCE_PROJECT")
|
|
390
|
+
if env_org and env_project:
|
|
391
|
+
binding = {"org": env_org, "project": env_project}
|
|
392
|
+
else:
|
|
393
|
+
console.print("[red]Error:[/red] No project binding found")
|
|
394
|
+
console.print("Run 'recce-cloud init' to bind this directory to a project")
|
|
395
|
+
return None
|
|
396
|
+
|
|
397
|
+
org_slug = binding.get("org")
|
|
398
|
+
project_slug = binding.get("project")
|
|
399
|
+
|
|
400
|
+
try:
|
|
401
|
+
client = RecceCloudClient(token)
|
|
402
|
+
|
|
403
|
+
# Get org and project IDs
|
|
404
|
+
org_info = client.get_organization(org_slug)
|
|
405
|
+
if not org_info:
|
|
406
|
+
console.print(f"[red]Error:[/red] Organization '{org_slug}' not found")
|
|
407
|
+
return None
|
|
408
|
+
org_id = org_info.get("id")
|
|
409
|
+
if not org_id:
|
|
410
|
+
console.print(f"[red]Error:[/red] Organization '{org_slug}' response missing ID")
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
project_info = client.get_project(org_id, project_slug)
|
|
414
|
+
if not project_info:
|
|
415
|
+
console.print(f"[red]Error:[/red] Project '{project_slug}' not found")
|
|
416
|
+
return None
|
|
417
|
+
project_id = project_info.get("id")
|
|
418
|
+
if not project_id:
|
|
419
|
+
console.print(f"[red]Error:[/red] Project '{project_slug}' response missing ID")
|
|
420
|
+
return None
|
|
421
|
+
|
|
422
|
+
# List sessions and find production session
|
|
423
|
+
sessions = client.list_sessions(org_id, project_id)
|
|
424
|
+
for session in sessions:
|
|
425
|
+
if session.get("is_base"):
|
|
426
|
+
session_id = session.get("id")
|
|
427
|
+
if not session_id:
|
|
428
|
+
console.print("[red]Error:[/red] Production session found but has no ID")
|
|
429
|
+
return None
|
|
430
|
+
session_name = session.get("name") or "(unnamed)"
|
|
431
|
+
session_id_display = session_id[:8] if len(session_id) >= 8 else session_id
|
|
432
|
+
console.print(
|
|
433
|
+
f"[cyan]Info:[/cyan] Found production session '{session_name}' (ID: {session_id_display}...)"
|
|
434
|
+
)
|
|
435
|
+
return session_id
|
|
436
|
+
|
|
437
|
+
console.print("[red]Error:[/red] No production session found")
|
|
438
|
+
console.print("Create a production session first using 'recce-cloud upload --type prod' or via CI pipeline")
|
|
439
|
+
return None
|
|
440
|
+
|
|
441
|
+
except RecceCloudException as e:
|
|
442
|
+
console.print(f"[red]Error:[/red] Failed to fetch sessions: {e}")
|
|
443
|
+
return None
|
|
444
|
+
except Exception as e:
|
|
445
|
+
logger.debug("Unexpected error in _get_production_session_id: %s", e, exc_info=True)
|
|
446
|
+
console.print(f"[red]Error:[/red] Unexpected error: {e}")
|
|
447
|
+
console.print(" Check your network connection and try again.")
|
|
448
|
+
return None
|
|
449
|
+
|
|
450
|
+
|
|
58
451
|
@cloud_cli.command()
|
|
59
452
|
@click.option(
|
|
60
453
|
"--target-path",
|
|
@@ -68,6 +461,18 @@ def version():
|
|
|
68
461
|
help="Recce Cloud session ID to upload artifacts to (or use RECCE_SESSION_ID env var). "
|
|
69
462
|
"If not provided, session will be created automatically using platform-specific APIs (GitHub/GitLab).",
|
|
70
463
|
)
|
|
464
|
+
@click.option(
|
|
465
|
+
"--session-name",
|
|
466
|
+
help="Session name to look up or create. If a session with this name exists, "
|
|
467
|
+
"uploads to it; otherwise prompts to create a new session (use --yes to skip prompt).",
|
|
468
|
+
)
|
|
469
|
+
@click.option(
|
|
470
|
+
"--yes",
|
|
471
|
+
"-y",
|
|
472
|
+
"skip_confirmation",
|
|
473
|
+
is_flag=True,
|
|
474
|
+
help="Skip confirmation prompts (auto-create session if not found).",
|
|
475
|
+
)
|
|
71
476
|
@click.option(
|
|
72
477
|
"--cr",
|
|
73
478
|
type=int,
|
|
@@ -84,13 +489,13 @@ def version():
|
|
|
84
489
|
is_flag=True,
|
|
85
490
|
help="Show what would be uploaded without actually uploading",
|
|
86
491
|
)
|
|
87
|
-
def upload(target_path, session_id, cr, session_type, dry_run):
|
|
492
|
+
def upload(target_path, session_id, session_name, skip_confirmation, cr, session_type, dry_run):
|
|
88
493
|
"""
|
|
89
494
|
Upload dbt artifacts (manifest.json, catalog.json) to Recce Cloud.
|
|
90
495
|
|
|
91
496
|
\b
|
|
92
497
|
Authentication (auto-detected):
|
|
93
|
-
- RECCE_API_TOKEN (for --session-id workflow)
|
|
498
|
+
- RECCE_API_TOKEN env var or 'recce-cloud login' profile (for --session-id/--session-name workflow)
|
|
94
499
|
- GITHUB_TOKEN (GitHub Actions)
|
|
95
500
|
- CI_JOB_TOKEN (GitLab CI)
|
|
96
501
|
|
|
@@ -102,9 +507,15 @@ def upload(target_path, session_id, cr, session_type, dry_run):
|
|
|
102
507
|
# Upload production metadata from main branch
|
|
103
508
|
recce-cloud upload --type prod
|
|
104
509
|
|
|
105
|
-
# Upload to specific session
|
|
510
|
+
# Upload to specific session by ID
|
|
106
511
|
recce-cloud upload --session-id abc123
|
|
107
512
|
|
|
513
|
+
# Upload by session name (creates if not exists)
|
|
514
|
+
recce-cloud upload --session-name "my-evaluation-session"
|
|
515
|
+
|
|
516
|
+
# Auto-create session without confirmation
|
|
517
|
+
recce-cloud upload --session-name "new-session" --yes
|
|
518
|
+
|
|
108
519
|
# Custom target path
|
|
109
520
|
recce-cloud upload --target-path custom-target
|
|
110
521
|
"""
|
|
@@ -204,8 +615,15 @@ def upload(target_path, session_id, cr, session_type, dry_run):
|
|
|
204
615
|
# Display upload summary
|
|
205
616
|
console.print("[cyan]Upload Workflow:[/cyan]")
|
|
206
617
|
if session_id:
|
|
207
|
-
console.print(" • Upload to existing session")
|
|
618
|
+
console.print(" • Upload to existing session by ID")
|
|
208
619
|
console.print(f" • Session ID: {session_id}")
|
|
620
|
+
elif session_name:
|
|
621
|
+
console.print(" • Upload by session name (lookup or create)")
|
|
622
|
+
console.print(f" • Session Name: {session_name}")
|
|
623
|
+
if skip_confirmation:
|
|
624
|
+
console.print(" • Auto-create if not exists (--yes flag)")
|
|
625
|
+
else:
|
|
626
|
+
console.print(" • Will prompt before creating if not exists")
|
|
209
627
|
else:
|
|
210
628
|
console.print(" • Auto-create session and upload")
|
|
211
629
|
if ci_info and ci_info.platform in ["github-actions", "gitlab-ci"]:
|
|
@@ -223,32 +641,236 @@ def upload(target_path, session_id, cr, session_type, dry_run):
|
|
|
223
641
|
console.print("[green]✓[/green] Dry run completed successfully")
|
|
224
642
|
sys.exit(0)
|
|
225
643
|
|
|
226
|
-
# 5. Choose upload workflow based on
|
|
644
|
+
# 5. Choose upload workflow based on provided options
|
|
645
|
+
# Priority: --session-id > --session-name > platform-specific auto-detection
|
|
227
646
|
if session_id:
|
|
228
647
|
# Generic workflow: Upload to existing session using session ID
|
|
229
|
-
# This workflow requires RECCE_API_TOKEN
|
|
230
|
-
|
|
648
|
+
# This workflow requires RECCE_API_TOKEN or logged-in profile
|
|
649
|
+
from recce_cloud.auth.profile import get_api_token
|
|
650
|
+
|
|
651
|
+
token = os.getenv("RECCE_API_TOKEN") or get_api_token()
|
|
231
652
|
if not token:
|
|
232
|
-
console.print("[red]Error:[/red] No RECCE_API_TOKEN provided")
|
|
233
|
-
console.print("
|
|
653
|
+
console.print("[red]Error:[/red] No RECCE_API_TOKEN provided and not logged in")
|
|
654
|
+
console.print("Either set RECCE_API_TOKEN environment variable or run 'recce-cloud login' first")
|
|
234
655
|
sys.exit(2)
|
|
235
656
|
|
|
236
657
|
upload_to_existing_session(console, token, session_id, manifest_path, catalog_path, adapter_type, target_path)
|
|
658
|
+
elif session_name:
|
|
659
|
+
# Session name workflow: Look up session by name, create if not exists
|
|
660
|
+
# This workflow requires RECCE_API_TOKEN or logged-in profile, plus org/project config
|
|
661
|
+
from recce_cloud.auth.profile import get_api_token
|
|
662
|
+
from recce_cloud.upload import upload_with_session_name
|
|
663
|
+
|
|
664
|
+
token = os.getenv("RECCE_API_TOKEN") or get_api_token()
|
|
665
|
+
if not token:
|
|
666
|
+
console.print("[red]Error:[/red] No RECCE_API_TOKEN provided and not logged in")
|
|
667
|
+
console.print("Either set RECCE_API_TOKEN environment variable or run 'recce-cloud login' first")
|
|
668
|
+
sys.exit(2)
|
|
669
|
+
|
|
670
|
+
upload_with_session_name(
|
|
671
|
+
console,
|
|
672
|
+
token,
|
|
673
|
+
session_name,
|
|
674
|
+
manifest_path,
|
|
675
|
+
catalog_path,
|
|
676
|
+
adapter_type,
|
|
677
|
+
target_path,
|
|
678
|
+
skip_confirmation=skip_confirmation,
|
|
679
|
+
)
|
|
237
680
|
else:
|
|
238
|
-
#
|
|
681
|
+
# GitHub Action or GitLab CI/CD workflow: Use platform APIs to create session and upload
|
|
239
682
|
# This workflow MUST use CI job tokens (CI_JOB_TOKEN or GITHUB_TOKEN)
|
|
240
683
|
if not ci_info or not ci_info.access_token:
|
|
241
|
-
|
|
242
|
-
|
|
684
|
+
# If --type prod is specified outside CI, fetch the production session and upload to it
|
|
685
|
+
if session_type == "prod":
|
|
686
|
+
from recce_cloud.auth.profile import get_api_token
|
|
687
|
+
|
|
688
|
+
token = os.getenv("RECCE_API_TOKEN") or get_api_token()
|
|
689
|
+
if not token:
|
|
690
|
+
console.print("[red]Error:[/red] No RECCE_API_TOKEN provided and not logged in")
|
|
691
|
+
console.print("Either set RECCE_API_TOKEN environment variable or run 'recce-cloud login' first")
|
|
692
|
+
sys.exit(2)
|
|
693
|
+
|
|
694
|
+
# Fetch the production session ID
|
|
695
|
+
prod_session_id = _get_production_session_id(console, token)
|
|
696
|
+
if not prod_session_id:
|
|
697
|
+
sys.exit(2)
|
|
698
|
+
|
|
699
|
+
upload_to_existing_session(
|
|
700
|
+
console, token, prod_session_id, manifest_path, catalog_path, adapter_type, target_path
|
|
701
|
+
)
|
|
702
|
+
else:
|
|
703
|
+
console.print("[red]Error:[/red] Platform-specific upload requires CI environment")
|
|
704
|
+
console.print(
|
|
705
|
+
"Either run in GitHub Actions/GitLab CI or provide --session-id/--session-name for generic upload"
|
|
706
|
+
)
|
|
707
|
+
sys.exit(2)
|
|
708
|
+
else:
|
|
709
|
+
token = ci_info.access_token
|
|
710
|
+
if ci_info.platform == "github-actions":
|
|
711
|
+
console.print("[cyan]Info:[/cyan] Using GITHUB_TOKEN for platform-specific authentication")
|
|
712
|
+
elif ci_info.platform == "gitlab-ci":
|
|
713
|
+
console.print("[cyan]Info:[/cyan] Using CI_JOB_TOKEN for platform-specific authentication")
|
|
714
|
+
|
|
715
|
+
upload_with_platform_apis(console, token, ci_info, manifest_path, catalog_path, adapter_type, target_path)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
@cloud_cli.command(name="list")
|
|
719
|
+
@click.option(
|
|
720
|
+
"--type",
|
|
721
|
+
"session_type",
|
|
722
|
+
type=click.Choice(["cr", "prod", "dev"]),
|
|
723
|
+
help="Filter by session type (prod=base, cr=has PR link, dev=other)",
|
|
724
|
+
)
|
|
725
|
+
@click.option(
|
|
726
|
+
"--json",
|
|
727
|
+
"output_json",
|
|
728
|
+
is_flag=True,
|
|
729
|
+
help="Output in JSON format",
|
|
730
|
+
)
|
|
731
|
+
def list_sessions_cmd(session_type, output_json):
|
|
732
|
+
"""
|
|
733
|
+
List sessions in the configured Recce Cloud project.
|
|
734
|
+
|
|
735
|
+
\b
|
|
736
|
+
Requires:
|
|
737
|
+
- RECCE_API_TOKEN env var or 'recce-cloud login'
|
|
738
|
+
- Project binding via 'recce-cloud init' or RECCE_ORG/RECCE_PROJECT env vars
|
|
739
|
+
|
|
740
|
+
\b
|
|
741
|
+
Examples:
|
|
742
|
+
# List all sessions
|
|
743
|
+
recce-cloud list
|
|
744
|
+
|
|
745
|
+
# List only production sessions
|
|
746
|
+
recce-cloud list --type prod
|
|
747
|
+
|
|
748
|
+
# Output as JSON
|
|
749
|
+
recce-cloud list --json
|
|
750
|
+
"""
|
|
751
|
+
import json
|
|
752
|
+
|
|
753
|
+
from rich.table import Table
|
|
754
|
+
|
|
755
|
+
from recce_cloud.api.client import RecceCloudClient
|
|
756
|
+
from recce_cloud.auth.profile import get_api_token
|
|
757
|
+
from recce_cloud.config.resolver import ConfigurationError, resolve_config
|
|
758
|
+
|
|
759
|
+
console = Console()
|
|
760
|
+
|
|
761
|
+
# 1. Get API token
|
|
762
|
+
token = os.getenv("RECCE_API_TOKEN") or get_api_token()
|
|
763
|
+
if not token:
|
|
764
|
+
console.print("[red]Error:[/red] No RECCE_API_TOKEN provided and not logged in")
|
|
765
|
+
console.print("Either set RECCE_API_TOKEN environment variable or run 'recce-cloud login' first")
|
|
766
|
+
sys.exit(2)
|
|
767
|
+
|
|
768
|
+
# 2. Resolve org/project configuration
|
|
769
|
+
try:
|
|
770
|
+
config = resolve_config()
|
|
771
|
+
org = config.org
|
|
772
|
+
project = config.project
|
|
773
|
+
except ConfigurationError as e:
|
|
774
|
+
console.print("[red]Error:[/red] Could not resolve org/project configuration")
|
|
775
|
+
console.print(f"Reason: {e}")
|
|
776
|
+
console.print()
|
|
777
|
+
console.print("Run 'recce-cloud init' to bind this directory to a project,")
|
|
778
|
+
console.print("or set RECCE_ORG and RECCE_PROJECT environment variables")
|
|
779
|
+
sys.exit(2)
|
|
780
|
+
|
|
781
|
+
# 3. Initialize client and resolve IDs
|
|
782
|
+
try:
|
|
783
|
+
client = RecceCloudClient(token)
|
|
784
|
+
|
|
785
|
+
org_info = client.get_organization(org)
|
|
786
|
+
if not org_info:
|
|
787
|
+
console.print(f"[red]Error:[/red] Organization '{org}' not found or you don't have access")
|
|
788
|
+
sys.exit(2)
|
|
789
|
+
org_id = org_info.get("id")
|
|
790
|
+
if not org_id:
|
|
791
|
+
console.print(f"[red]Error:[/red] Organization '{org}' response missing ID")
|
|
243
792
|
sys.exit(2)
|
|
244
793
|
|
|
245
|
-
|
|
246
|
-
if
|
|
247
|
-
console.print("[
|
|
248
|
-
|
|
249
|
-
|
|
794
|
+
project_info = client.get_project(org_id, project)
|
|
795
|
+
if not project_info:
|
|
796
|
+
console.print(f"[red]Error:[/red] Project '{project}' not found in organization '{org}'")
|
|
797
|
+
sys.exit(2)
|
|
798
|
+
project_id = project_info.get("id")
|
|
799
|
+
if not project_id:
|
|
800
|
+
console.print(f"[red]Error:[/red] Project '{project}' response missing ID")
|
|
801
|
+
sys.exit(2)
|
|
802
|
+
|
|
803
|
+
except Exception as e:
|
|
804
|
+
logger.debug("Failed to initialize client for list_sessions: %s", e, exc_info=True)
|
|
805
|
+
console.print(f"[red]Error:[/red] Failed to initialize: {e}")
|
|
806
|
+
console.print(" Check your authentication and network connection.")
|
|
807
|
+
sys.exit(2)
|
|
808
|
+
|
|
809
|
+
# Helper to derive session type from fields:
|
|
810
|
+
# - prod: is_base = True
|
|
811
|
+
# - cr: pr_link is not null
|
|
812
|
+
# - dev: everything else
|
|
813
|
+
def get_session_type(s):
|
|
814
|
+
if s.get("is_base"):
|
|
815
|
+
return "prod"
|
|
816
|
+
elif s.get("pr_link"):
|
|
817
|
+
return "cr"
|
|
818
|
+
else:
|
|
819
|
+
return "dev"
|
|
250
820
|
|
|
251
|
-
|
|
821
|
+
# 4. List sessions
|
|
822
|
+
try:
|
|
823
|
+
sessions = client.list_sessions(org_id, project_id)
|
|
824
|
+
|
|
825
|
+
if session_type:
|
|
826
|
+
sessions = [s for s in sessions if get_session_type(s) == session_type]
|
|
827
|
+
except Exception as e:
|
|
828
|
+
console.print(f"[red]Error:[/red] Failed to list sessions: {e}")
|
|
829
|
+
sys.exit(2)
|
|
830
|
+
|
|
831
|
+
# 5. Output results
|
|
832
|
+
if output_json:
|
|
833
|
+
console.print(json.dumps(sessions, indent=2, default=str))
|
|
834
|
+
sys.exit(0)
|
|
835
|
+
|
|
836
|
+
if not sessions:
|
|
837
|
+
console.print("[yellow]No sessions found[/yellow]")
|
|
838
|
+
if session_type:
|
|
839
|
+
console.print(f"(filtered by type: {session_type})")
|
|
840
|
+
sys.exit(0)
|
|
841
|
+
|
|
842
|
+
# Display as table
|
|
843
|
+
console.print(f"[cyan]Organization:[/cyan] {org}")
|
|
844
|
+
console.print(f"[cyan]Project:[/cyan] {project}")
|
|
845
|
+
console.print()
|
|
846
|
+
|
|
847
|
+
table = Table(title=f"Sessions ({len(sessions)} total)")
|
|
848
|
+
table.add_column("Name", style="cyan", no_wrap=True)
|
|
849
|
+
table.add_column("ID", style="dim")
|
|
850
|
+
table.add_column("Type", style="green")
|
|
851
|
+
table.add_column("Created At")
|
|
852
|
+
table.add_column("Adapter")
|
|
853
|
+
|
|
854
|
+
for session in sessions:
|
|
855
|
+
name = session.get("name", "-")
|
|
856
|
+
session_id = session.get("id", "-")
|
|
857
|
+
s_type = get_session_type(session)
|
|
858
|
+
created_at = session.get("created_at", "-")
|
|
859
|
+
if created_at and created_at != "-":
|
|
860
|
+
# Format datetime if present
|
|
861
|
+
try:
|
|
862
|
+
from datetime import datetime
|
|
863
|
+
|
|
864
|
+
dt = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
|
|
865
|
+
created_at = dt.strftime("%Y-%m-%d %H:%M")
|
|
866
|
+
except (ValueError, AttributeError):
|
|
867
|
+
pass
|
|
868
|
+
adapter = session.get("adapter_type", "-")
|
|
869
|
+
|
|
870
|
+
table.add_row(name or "(unnamed)", session_id, s_type, created_at, adapter or "-")
|
|
871
|
+
|
|
872
|
+
console.print(table)
|
|
873
|
+
sys.exit(0)
|
|
252
874
|
|
|
253
875
|
|
|
254
876
|
@cloud_cli.command()
|
|
@@ -286,7 +908,7 @@ def download(target_path, session_id, prod, dry_run, force):
|
|
|
286
908
|
|
|
287
909
|
\b
|
|
288
910
|
Authentication (auto-detected):
|
|
289
|
-
- RECCE_API_TOKEN (for --session-id workflow)
|
|
911
|
+
- RECCE_API_TOKEN env var or 'recce-cloud login' profile (for --session-id workflow)
|
|
290
912
|
- GITHUB_TOKEN (GitHub Actions)
|
|
291
913
|
- CI_JOB_TOKEN (GitLab CI)
|
|
292
914
|
|
|
@@ -411,11 +1033,13 @@ def download(target_path, session_id, prod, dry_run, force):
|
|
|
411
1033
|
# 3. Choose download workflow based on whether session_id is provided
|
|
412
1034
|
if session_id:
|
|
413
1035
|
# Generic workflow: Download from existing session using session ID
|
|
414
|
-
# This workflow requires RECCE_API_TOKEN
|
|
415
|
-
|
|
1036
|
+
# This workflow requires RECCE_API_TOKEN or logged-in profile
|
|
1037
|
+
from recce_cloud.auth.profile import get_api_token
|
|
1038
|
+
|
|
1039
|
+
token = os.getenv("RECCE_API_TOKEN") or get_api_token()
|
|
416
1040
|
if not token:
|
|
417
|
-
console.print("[red]Error:[/red] No RECCE_API_TOKEN provided")
|
|
418
|
-
console.print("
|
|
1041
|
+
console.print("[red]Error:[/red] No RECCE_API_TOKEN provided and not logged in")
|
|
1042
|
+
console.print("Either set RECCE_API_TOKEN environment variable or run 'recce-cloud login' first")
|
|
419
1043
|
sys.exit(2)
|
|
420
1044
|
|
|
421
1045
|
download_from_existing_session(console, token, session_id, target_path, force)
|
|
@@ -463,7 +1087,7 @@ def delete(session_id, dry_run, force):
|
|
|
463
1087
|
|
|
464
1088
|
\b
|
|
465
1089
|
Authentication (auto-detected):
|
|
466
|
-
- RECCE_API_TOKEN (for --session-id workflow)
|
|
1090
|
+
- RECCE_API_TOKEN env var or 'recce-cloud login' profile (for --session-id workflow)
|
|
467
1091
|
- GITHUB_TOKEN (GitHub Actions)
|
|
468
1092
|
- CI_JOB_TOKEN (GitLab CI)
|
|
469
1093
|
|
|
@@ -576,11 +1200,13 @@ def delete(session_id, dry_run, force):
|
|
|
576
1200
|
# 4. Choose delete workflow based on whether session_id is provided
|
|
577
1201
|
if session_id:
|
|
578
1202
|
# Generic workflow: Delete from existing session using session ID
|
|
579
|
-
# This workflow requires RECCE_API_TOKEN
|
|
580
|
-
|
|
1203
|
+
# This workflow requires RECCE_API_TOKEN or logged-in profile
|
|
1204
|
+
from recce_cloud.auth.profile import get_api_token
|
|
1205
|
+
|
|
1206
|
+
token = os.getenv("RECCE_API_TOKEN") or get_api_token()
|
|
581
1207
|
if not token:
|
|
582
|
-
console.print("[red]Error:[/red] No RECCE_API_TOKEN provided")
|
|
583
|
-
console.print("
|
|
1208
|
+
console.print("[red]Error:[/red] No RECCE_API_TOKEN provided and not logged in")
|
|
1209
|
+
console.print("Either set RECCE_API_TOKEN environment variable or run 'recce-cloud login' first")
|
|
584
1210
|
sys.exit(2)
|
|
585
1211
|
|
|
586
1212
|
delete_existing_session(console, token, session_id)
|
|
@@ -648,7 +1274,7 @@ def report(repo, since, until, base_branch, merged_only, output):
|
|
|
648
1274
|
|
|
649
1275
|
\b
|
|
650
1276
|
Authentication:
|
|
651
|
-
- Requires RECCE_API_TOKEN
|
|
1277
|
+
- Requires RECCE_API_TOKEN env var or 'recce-cloud login' profile
|
|
652
1278
|
|
|
653
1279
|
\b
|
|
654
1280
|
Examples:
|
|
@@ -669,11 +1295,13 @@ def report(repo, since, until, base_branch, merged_only, output):
|
|
|
669
1295
|
"""
|
|
670
1296
|
console = Console()
|
|
671
1297
|
|
|
672
|
-
# Check for API token
|
|
673
|
-
|
|
1298
|
+
# Check for API token (env var or logged-in profile)
|
|
1299
|
+
from recce_cloud.auth.profile import get_api_token
|
|
1300
|
+
|
|
1301
|
+
token = os.getenv("RECCE_API_TOKEN") or get_api_token()
|
|
674
1302
|
if not token:
|
|
675
|
-
console.print("[red]Error:[/red] RECCE_API_TOKEN
|
|
676
|
-
console.print("
|
|
1303
|
+
console.print("[red]Error:[/red] No RECCE_API_TOKEN provided and not logged in")
|
|
1304
|
+
console.print("Either set RECCE_API_TOKEN environment variable or run 'recce-cloud login' first")
|
|
677
1305
|
sys.exit(2)
|
|
678
1306
|
|
|
679
1307
|
# Auto-detect repo from git remote if not provided
|