shotgun-sh 0.2.0__py3-none-any.whl → 0.2.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.
Potentially problematic release.
This version of shotgun-sh might be problematic. Click here for more details.
- shotgun/agents/config/constants.py +2 -1
- shotgun/agents/config/manager.py +68 -13
- shotgun/agents/config/models.py +11 -2
- shotgun/cli/config.py +6 -6
- shotgun/cli/feedback.py +4 -2
- shotgun/codebase/core/ingestor.py +47 -8
- shotgun/codebase/core/manager.py +7 -3
- shotgun/posthog_telemetry.py +10 -8
- shotgun/sentry_telemetry.py +3 -3
- shotgun/shotgun_web/__init__.py +19 -0
- shotgun/shotgun_web/client.py +138 -0
- shotgun/shotgun_web/constants.py +17 -0
- shotgun/shotgun_web/models.py +47 -0
- shotgun/telemetry.py +7 -4
- shotgun/tui/app.py +21 -7
- shotgun/tui/screens/feedback.py +2 -2
- shotgun/tui/screens/provider_config.py +61 -2
- shotgun/tui/screens/shotgun_auth.py +295 -0
- shotgun/tui/screens/welcome.py +176 -0
- {shotgun_sh-0.2.0.dist-info → shotgun_sh-0.2.1.dist-info}/METADATA +1 -1
- {shotgun_sh-0.2.0.dist-info → shotgun_sh-0.2.1.dist-info}/RECORD +24 -18
- {shotgun_sh-0.2.0.dist-info → shotgun_sh-0.2.1.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.2.0.dist-info → shotgun_sh-0.2.1.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.2.0.dist-info → shotgun_sh-0.2.1.dist-info}/licenses/LICENSE +0 -0
shotgun/agents/config/manager.py
CHANGED
|
@@ -12,6 +12,8 @@ from shotgun.utils import get_shotgun_home
|
|
|
12
12
|
|
|
13
13
|
from .constants import (
|
|
14
14
|
API_KEY_FIELD,
|
|
15
|
+
SHOTGUN_INSTANCE_ID_FIELD,
|
|
16
|
+
SUPABASE_JWT_FIELD,
|
|
15
17
|
ConfigSection,
|
|
16
18
|
)
|
|
17
19
|
from .models import (
|
|
@@ -60,10 +62,10 @@ class ConfigManager:
|
|
|
60
62
|
|
|
61
63
|
if not self.config_path.exists():
|
|
62
64
|
logger.info(
|
|
63
|
-
"Configuration file not found, creating new config
|
|
65
|
+
"Configuration file not found, creating new config at: %s",
|
|
64
66
|
self.config_path,
|
|
65
67
|
)
|
|
66
|
-
# Create new config with generated
|
|
68
|
+
# Create new config with generated shotgun_instance_id
|
|
67
69
|
self._config = self.initialize()
|
|
68
70
|
return self._config
|
|
69
71
|
|
|
@@ -71,6 +73,14 @@ class ConfigManager:
|
|
|
71
73
|
with open(self.config_path, encoding="utf-8") as f:
|
|
72
74
|
data = json.load(f)
|
|
73
75
|
|
|
76
|
+
# Migration: Rename user_id to shotgun_instance_id (config v2 -> v3)
|
|
77
|
+
if "user_id" in data and SHOTGUN_INSTANCE_ID_FIELD not in data:
|
|
78
|
+
data[SHOTGUN_INSTANCE_ID_FIELD] = data.pop("user_id")
|
|
79
|
+
data["config_version"] = 3
|
|
80
|
+
logger.info(
|
|
81
|
+
"Migrated config v2->v3: renamed user_id to shotgun_instance_id"
|
|
82
|
+
)
|
|
83
|
+
|
|
74
84
|
# Convert plain text secrets to SecretStr objects
|
|
75
85
|
self._convert_secrets_to_secretstr(data)
|
|
76
86
|
|
|
@@ -134,7 +144,7 @@ class ConfigManager:
|
|
|
134
144
|
logger.error(
|
|
135
145
|
"Failed to load configuration from %s: %s", self.config_path, e
|
|
136
146
|
)
|
|
137
|
-
logger.info("Creating new configuration with generated
|
|
147
|
+
logger.info("Creating new configuration with generated shotgun_instance_id")
|
|
138
148
|
self._config = self.initialize()
|
|
139
149
|
return self._config
|
|
140
150
|
|
|
@@ -148,9 +158,9 @@ class ConfigManager:
|
|
|
148
158
|
if self._config:
|
|
149
159
|
config = self._config
|
|
150
160
|
else:
|
|
151
|
-
# Create a new config with generated
|
|
161
|
+
# Create a new config with generated shotgun_instance_id
|
|
152
162
|
config = ShotgunConfig(
|
|
153
|
-
|
|
163
|
+
shotgun_instance_id=str(uuid.uuid4()),
|
|
154
164
|
)
|
|
155
165
|
|
|
156
166
|
# Ensure directory exists
|
|
@@ -276,15 +286,15 @@ class ConfigManager:
|
|
|
276
286
|
Returns:
|
|
277
287
|
Default ShotgunConfig
|
|
278
288
|
"""
|
|
279
|
-
# Generate unique
|
|
289
|
+
# Generate unique shotgun instance ID for new config
|
|
280
290
|
config = ShotgunConfig(
|
|
281
|
-
|
|
291
|
+
shotgun_instance_id=str(uuid.uuid4()),
|
|
282
292
|
)
|
|
283
293
|
self.save(config)
|
|
284
294
|
logger.info(
|
|
285
|
-
"Configuration initialized at %s with
|
|
295
|
+
"Configuration initialized at %s with shotgun_instance_id: %s",
|
|
286
296
|
self.config_path,
|
|
287
|
-
config.
|
|
297
|
+
config.shotgun_instance_id,
|
|
288
298
|
)
|
|
289
299
|
return config
|
|
290
300
|
|
|
@@ -292,6 +302,7 @@ class ConfigManager:
|
|
|
292
302
|
"""Convert plain text secrets in data to SecretStr objects."""
|
|
293
303
|
for section in ConfigSection:
|
|
294
304
|
if section.value in data and isinstance(data[section.value], dict):
|
|
305
|
+
# Convert API key
|
|
295
306
|
if (
|
|
296
307
|
API_KEY_FIELD in data[section.value]
|
|
297
308
|
and data[section.value][API_KEY_FIELD] is not None
|
|
@@ -299,11 +310,21 @@ class ConfigManager:
|
|
|
299
310
|
data[section.value][API_KEY_FIELD] = SecretStr(
|
|
300
311
|
data[section.value][API_KEY_FIELD]
|
|
301
312
|
)
|
|
313
|
+
# Convert supabase JWT (shotgun section only)
|
|
314
|
+
if (
|
|
315
|
+
section == ConfigSection.SHOTGUN
|
|
316
|
+
and SUPABASE_JWT_FIELD in data[section.value]
|
|
317
|
+
and data[section.value][SUPABASE_JWT_FIELD] is not None
|
|
318
|
+
):
|
|
319
|
+
data[section.value][SUPABASE_JWT_FIELD] = SecretStr(
|
|
320
|
+
data[section.value][SUPABASE_JWT_FIELD]
|
|
321
|
+
)
|
|
302
322
|
|
|
303
323
|
def _convert_secretstr_to_plain(self, data: dict[str, Any]) -> None:
|
|
304
324
|
"""Convert SecretStr objects in data to plain text for JSON serialization."""
|
|
305
325
|
for section in ConfigSection:
|
|
306
326
|
if section.value in data and isinstance(data[section.value], dict):
|
|
327
|
+
# Convert API key
|
|
307
328
|
if (
|
|
308
329
|
API_KEY_FIELD in data[section.value]
|
|
309
330
|
and data[section.value][API_KEY_FIELD] is not None
|
|
@@ -312,6 +333,18 @@ class ConfigManager:
|
|
|
312
333
|
data[section.value][API_KEY_FIELD] = data[section.value][
|
|
313
334
|
API_KEY_FIELD
|
|
314
335
|
].get_secret_value()
|
|
336
|
+
# Convert supabase JWT (shotgun section only)
|
|
337
|
+
if (
|
|
338
|
+
section == ConfigSection.SHOTGUN
|
|
339
|
+
and SUPABASE_JWT_FIELD in data[section.value]
|
|
340
|
+
and data[section.value][SUPABASE_JWT_FIELD] is not None
|
|
341
|
+
):
|
|
342
|
+
if hasattr(
|
|
343
|
+
data[section.value][SUPABASE_JWT_FIELD], "get_secret_value"
|
|
344
|
+
):
|
|
345
|
+
data[section.value][SUPABASE_JWT_FIELD] = data[section.value][
|
|
346
|
+
SUPABASE_JWT_FIELD
|
|
347
|
+
].get_secret_value()
|
|
315
348
|
|
|
316
349
|
def _ensure_provider_enum(self, provider: ProviderType | str) -> ProviderType:
|
|
317
350
|
"""Normalize provider values to ProviderType enum."""
|
|
@@ -376,14 +409,36 @@ class ConfigManager:
|
|
|
376
409
|
provider_enum = self._ensure_provider_enum(provider)
|
|
377
410
|
return (self._get_provider_config(config, provider_enum), False)
|
|
378
411
|
|
|
379
|
-
def
|
|
380
|
-
"""Get the
|
|
412
|
+
def get_shotgun_instance_id(self) -> str:
|
|
413
|
+
"""Get the shotgun instance ID from configuration.
|
|
381
414
|
|
|
382
415
|
Returns:
|
|
383
|
-
The unique
|
|
416
|
+
The unique shotgun instance ID string
|
|
417
|
+
"""
|
|
418
|
+
config = self.load()
|
|
419
|
+
return config.shotgun_instance_id
|
|
420
|
+
|
|
421
|
+
def update_shotgun_account(
|
|
422
|
+
self, api_key: str | None = None, supabase_jwt: str | None = None
|
|
423
|
+
) -> None:
|
|
424
|
+
"""Update Shotgun Account configuration.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
api_key: LiteLLM proxy API key (optional)
|
|
428
|
+
supabase_jwt: Supabase authentication JWT (optional)
|
|
384
429
|
"""
|
|
385
430
|
config = self.load()
|
|
386
|
-
|
|
431
|
+
|
|
432
|
+
if api_key is not None:
|
|
433
|
+
config.shotgun.api_key = SecretStr(api_key) if api_key else None
|
|
434
|
+
|
|
435
|
+
if supabase_jwt is not None:
|
|
436
|
+
config.shotgun.supabase_jwt = (
|
|
437
|
+
SecretStr(supabase_jwt) if supabase_jwt else None
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
self.save(config)
|
|
441
|
+
logger.info("Updated Shotgun Account configuration")
|
|
387
442
|
|
|
388
443
|
|
|
389
444
|
# Global singleton instance
|
shotgun/agents/config/models.py
CHANGED
|
@@ -149,6 +149,9 @@ class ShotgunAccountConfig(BaseModel):
|
|
|
149
149
|
"""Configuration for Shotgun Account (LiteLLM proxy)."""
|
|
150
150
|
|
|
151
151
|
api_key: SecretStr | None = None
|
|
152
|
+
supabase_jwt: SecretStr | None = Field(
|
|
153
|
+
default=None, description="Supabase authentication JWT"
|
|
154
|
+
)
|
|
152
155
|
|
|
153
156
|
|
|
154
157
|
class ShotgunConfig(BaseModel):
|
|
@@ -162,5 +165,11 @@ class ShotgunConfig(BaseModel):
|
|
|
162
165
|
default=None,
|
|
163
166
|
description="User-selected model",
|
|
164
167
|
)
|
|
165
|
-
|
|
166
|
-
|
|
168
|
+
shotgun_instance_id: str = Field(
|
|
169
|
+
description="Unique shotgun instance identifier (also used for anonymous telemetry)",
|
|
170
|
+
)
|
|
171
|
+
config_version: int = Field(default=3, description="Configuration schema version")
|
|
172
|
+
shown_welcome_screen: bool = Field(
|
|
173
|
+
default=False,
|
|
174
|
+
description="Whether the welcome screen has been shown to the user",
|
|
175
|
+
)
|
shotgun/cli/config.py
CHANGED
|
@@ -233,14 +233,14 @@ def _mask_value(value: str) -> str:
|
|
|
233
233
|
|
|
234
234
|
|
|
235
235
|
@app.command()
|
|
236
|
-
def
|
|
237
|
-
"""Get the anonymous
|
|
236
|
+
def get_shotgun_instance_id() -> None:
|
|
237
|
+
"""Get the anonymous shotgun instance ID from configuration."""
|
|
238
238
|
config_manager = get_config_manager()
|
|
239
239
|
|
|
240
240
|
try:
|
|
241
|
-
|
|
242
|
-
console.print(f"[green]
|
|
241
|
+
shotgun_instance_id = config_manager.get_shotgun_instance_id()
|
|
242
|
+
console.print(f"[green]Shotgun Instance ID:[/green] {shotgun_instance_id}")
|
|
243
243
|
except Exception as e:
|
|
244
|
-
logger.error(f"Error getting
|
|
245
|
-
console.print(f"❌ Failed to get
|
|
244
|
+
logger.error(f"Error getting shotgun instance ID: {e}")
|
|
245
|
+
console.print(f"❌ Failed to get shotgun instance ID: {str(e)}", style="red")
|
|
246
246
|
raise typer.Exit(1) from e
|
shotgun/cli/feedback.py
CHANGED
|
@@ -30,7 +30,7 @@ def send_feedback(
|
|
|
30
30
|
"""Initialize Shotgun configuration."""
|
|
31
31
|
config_manager = get_config_manager()
|
|
32
32
|
config_manager.load()
|
|
33
|
-
|
|
33
|
+
shotgun_instance_id = config_manager.get_shotgun_instance_id()
|
|
34
34
|
|
|
35
35
|
if not description:
|
|
36
36
|
console.print(
|
|
@@ -39,7 +39,9 @@ def send_feedback(
|
|
|
39
39
|
)
|
|
40
40
|
raise typer.Exit(1)
|
|
41
41
|
|
|
42
|
-
feedback = Feedback(
|
|
42
|
+
feedback = Feedback(
|
|
43
|
+
kind=kind, description=description, shotgun_instance_id=shotgun_instance_id
|
|
44
|
+
)
|
|
43
45
|
|
|
44
46
|
submit_feedback_survey(feedback)
|
|
45
47
|
|
|
@@ -18,15 +18,12 @@ from shotgun.logging_config import get_logger
|
|
|
18
18
|
logger = get_logger(__name__)
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
#
|
|
22
|
-
|
|
21
|
+
# Directories that should never be traversed during indexing
|
|
22
|
+
BASE_IGNORE_DIRECTORIES = {
|
|
23
23
|
".git",
|
|
24
24
|
"venv",
|
|
25
25
|
".venv",
|
|
26
26
|
"__pycache__",
|
|
27
|
-
"node_modules",
|
|
28
|
-
"build",
|
|
29
|
-
"dist",
|
|
30
27
|
".eggs",
|
|
31
28
|
".pytest_cache",
|
|
32
29
|
".mypy_cache",
|
|
@@ -36,6 +33,46 @@ IGNORE_PATTERNS = {
|
|
|
36
33
|
".vscode",
|
|
37
34
|
}
|
|
38
35
|
|
|
36
|
+
# Well-known build output directories to skip when determining source files
|
|
37
|
+
BUILD_ARTIFACT_DIRECTORIES = {
|
|
38
|
+
"node_modules",
|
|
39
|
+
".next",
|
|
40
|
+
".nuxt",
|
|
41
|
+
".vite",
|
|
42
|
+
".yarn",
|
|
43
|
+
".svelte-kit",
|
|
44
|
+
".output",
|
|
45
|
+
".turbo",
|
|
46
|
+
".parcel-cache",
|
|
47
|
+
".vercel",
|
|
48
|
+
".serverless",
|
|
49
|
+
"build",
|
|
50
|
+
"dist",
|
|
51
|
+
"out",
|
|
52
|
+
"tmp",
|
|
53
|
+
"coverage",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Default ignore patterns combines base directories and build artifacts
|
|
57
|
+
IGNORE_PATTERNS = BASE_IGNORE_DIRECTORIES | BUILD_ARTIFACT_DIRECTORIES
|
|
58
|
+
|
|
59
|
+
# Directory prefixes that should always be ignored
|
|
60
|
+
IGNORED_DIRECTORY_PREFIXES = (".",)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def should_ignore_directory(name: str, ignore_patterns: set[str] | None = None) -> bool:
|
|
64
|
+
"""Return True if the directory name should be ignored."""
|
|
65
|
+
patterns = IGNORE_PATTERNS if ignore_patterns is None else ignore_patterns
|
|
66
|
+
if name in patterns:
|
|
67
|
+
return True
|
|
68
|
+
return name.startswith(IGNORED_DIRECTORY_PREFIXES)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def is_path_ignored(path: Path, ignore_patterns: set[str] | None = None) -> bool:
|
|
72
|
+
"""Return True if any part of the path should be ignored."""
|
|
73
|
+
patterns = IGNORE_PATTERNS if ignore_patterns is None else ignore_patterns
|
|
74
|
+
return any(should_ignore_directory(part, patterns) for part in path.parts)
|
|
75
|
+
|
|
39
76
|
|
|
40
77
|
class Ingestor:
|
|
41
78
|
"""Handles all communication and ingestion with the Kuzu database."""
|
|
@@ -607,7 +644,9 @@ class SimpleGraphBuilder:
|
|
|
607
644
|
"""First pass: Walk directory to find packages and folders."""
|
|
608
645
|
dir_count = 0
|
|
609
646
|
for root_str, dirs, _ in os.walk(self.repo_path, topdown=True):
|
|
610
|
-
dirs[:] = [
|
|
647
|
+
dirs[:] = [
|
|
648
|
+
d for d in dirs if not should_ignore_directory(d, self.ignore_dirs)
|
|
649
|
+
]
|
|
611
650
|
root = Path(root_str)
|
|
612
651
|
relative_root = root.relative_to(self.repo_path)
|
|
613
652
|
|
|
@@ -740,7 +779,7 @@ class SimpleGraphBuilder:
|
|
|
740
779
|
root = Path(root_str)
|
|
741
780
|
|
|
742
781
|
# Skip ignored directories
|
|
743
|
-
if
|
|
782
|
+
if is_path_ignored(root, self.ignore_dirs):
|
|
744
783
|
continue
|
|
745
784
|
|
|
746
785
|
for filename in files:
|
|
@@ -757,7 +796,7 @@ class SimpleGraphBuilder:
|
|
|
757
796
|
root = Path(root_str)
|
|
758
797
|
|
|
759
798
|
# Skip ignored directories
|
|
760
|
-
if
|
|
799
|
+
if is_path_ignored(root, self.ignore_dirs):
|
|
761
800
|
continue
|
|
762
801
|
|
|
763
802
|
for filename in files:
|
shotgun/codebase/core/manager.py
CHANGED
|
@@ -51,9 +51,13 @@ class CodebaseFileHandler(FileSystemEventHandler):
|
|
|
51
51
|
self.pending_changes: list[FileChange] = []
|
|
52
52
|
self._lock = anyio.Lock()
|
|
53
53
|
# Import default ignore patterns from ingestor
|
|
54
|
-
from shotgun.codebase.core.ingestor import
|
|
54
|
+
from shotgun.codebase.core.ingestor import (
|
|
55
|
+
IGNORE_PATTERNS,
|
|
56
|
+
should_ignore_directory,
|
|
57
|
+
)
|
|
55
58
|
|
|
56
59
|
self.ignore_patterns = ignore_patterns or IGNORE_PATTERNS
|
|
60
|
+
self._should_ignore_directory = should_ignore_directory
|
|
57
61
|
|
|
58
62
|
def on_any_event(self, event: FileSystemEvent) -> None:
|
|
59
63
|
"""Handle any file system event."""
|
|
@@ -71,7 +75,7 @@ class CodebaseFileHandler(FileSystemEventHandler):
|
|
|
71
75
|
|
|
72
76
|
# Check if any parent directory should be ignored
|
|
73
77
|
for parent in path.parents:
|
|
74
|
-
if parent.name
|
|
78
|
+
if self._should_ignore_directory(parent.name, self.ignore_patterns):
|
|
75
79
|
logger.debug(
|
|
76
80
|
f"Ignoring file in ignored directory: {parent.name} - path: {src_path_str}"
|
|
77
81
|
)
|
|
@@ -106,7 +110,7 @@ class CodebaseFileHandler(FileSystemEventHandler):
|
|
|
106
110
|
)
|
|
107
111
|
dest_path = Path(dest_path_str)
|
|
108
112
|
for parent in dest_path.parents:
|
|
109
|
-
if parent.name
|
|
113
|
+
if self._should_ignore_directory(parent.name, self.ignore_patterns):
|
|
110
114
|
logger.debug(
|
|
111
115
|
f"Ignoring move to ignored directory: {parent.name} - dest_path: {dest_path_str}"
|
|
112
116
|
)
|
shotgun/posthog_telemetry.py
CHANGED
|
@@ -51,14 +51,14 @@ def setup_posthog_observability() -> bool:
|
|
|
51
51
|
# Store the client for later use
|
|
52
52
|
_posthog_client = posthog
|
|
53
53
|
|
|
54
|
-
# Set user context with anonymous
|
|
54
|
+
# Set user context with anonymous shotgun instance ID from config
|
|
55
55
|
try:
|
|
56
56
|
config_manager = get_config_manager()
|
|
57
|
-
|
|
57
|
+
shotgun_instance_id = config_manager.get_shotgun_instance_id()
|
|
58
58
|
|
|
59
59
|
# Identify the user in PostHog
|
|
60
60
|
posthog.identify( # type: ignore[attr-defined]
|
|
61
|
-
distinct_id=
|
|
61
|
+
distinct_id=shotgun_instance_id,
|
|
62
62
|
properties={
|
|
63
63
|
"version": __version__,
|
|
64
64
|
"environment": environment,
|
|
@@ -69,7 +69,9 @@ def setup_posthog_observability() -> bool:
|
|
|
69
69
|
posthog.disabled = False
|
|
70
70
|
posthog.personal_api_key = None # Not needed for event tracking
|
|
71
71
|
|
|
72
|
-
logger.debug(
|
|
72
|
+
logger.debug(
|
|
73
|
+
"PostHog user identified with anonymous ID: %s", shotgun_instance_id
|
|
74
|
+
)
|
|
73
75
|
except Exception as e:
|
|
74
76
|
logger.warning("Failed to set user context: %s", e)
|
|
75
77
|
|
|
@@ -99,9 +101,9 @@ def track_event(event_name: str, properties: dict[str, Any] | None = None) -> No
|
|
|
99
101
|
return
|
|
100
102
|
|
|
101
103
|
try:
|
|
102
|
-
# Get
|
|
104
|
+
# Get shotgun instance ID for tracking
|
|
103
105
|
config_manager = get_config_manager()
|
|
104
|
-
|
|
106
|
+
shotgun_instance_id = config_manager.get_shotgun_instance_id()
|
|
105
107
|
|
|
106
108
|
# Add version and environment to properties
|
|
107
109
|
if properties is None:
|
|
@@ -116,7 +118,7 @@ def track_event(event_name: str, properties: dict[str, Any] | None = None) -> No
|
|
|
116
118
|
|
|
117
119
|
# Track the event using PostHog's capture method
|
|
118
120
|
_posthog_client.capture(
|
|
119
|
-
distinct_id=
|
|
121
|
+
distinct_id=shotgun_instance_id, event=event_name, properties=properties
|
|
120
122
|
)
|
|
121
123
|
logger.debug("Tracked PostHog event: %s", event_name)
|
|
122
124
|
except Exception as e:
|
|
@@ -146,7 +148,7 @@ class FeedbackKind(StrEnum):
|
|
|
146
148
|
class Feedback(BaseModel):
|
|
147
149
|
kind: FeedbackKind
|
|
148
150
|
description: str
|
|
149
|
-
|
|
151
|
+
shotgun_instance_id: str
|
|
150
152
|
|
|
151
153
|
|
|
152
154
|
SURVEY_ID = "01999f81-9486-0000-4fa6-9632959f92f3"
|
shotgun/sentry_telemetry.py
CHANGED
|
@@ -59,13 +59,13 @@ def setup_sentry_observability() -> bool:
|
|
|
59
59
|
profiles_sample_rate=0.1 if environment == "production" else 1.0,
|
|
60
60
|
)
|
|
61
61
|
|
|
62
|
-
# Set user context with anonymous
|
|
62
|
+
# Set user context with anonymous shotgun instance ID from config
|
|
63
63
|
try:
|
|
64
64
|
from shotgun.agents.config import get_config_manager
|
|
65
65
|
|
|
66
66
|
config_manager = get_config_manager()
|
|
67
|
-
|
|
68
|
-
sentry_sdk.set_user({"id":
|
|
67
|
+
shotgun_instance_id = config_manager.get_shotgun_instance_id()
|
|
68
|
+
sentry_sdk.set_user({"id": shotgun_instance_id})
|
|
69
69
|
logger.debug("Sentry user context set with anonymous ID")
|
|
70
70
|
except Exception as e:
|
|
71
71
|
logger.warning("Failed to set Sentry user context: %s", e)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Shotgun Web API client for subscription and authentication."""
|
|
2
|
+
|
|
3
|
+
from .client import ShotgunWebClient, check_token_status, create_unification_token
|
|
4
|
+
from .models import (
|
|
5
|
+
TokenCreateRequest,
|
|
6
|
+
TokenCreateResponse,
|
|
7
|
+
TokenStatus,
|
|
8
|
+
TokenStatusResponse,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"ShotgunWebClient",
|
|
13
|
+
"create_unification_token",
|
|
14
|
+
"check_token_status",
|
|
15
|
+
"TokenCreateRequest",
|
|
16
|
+
"TokenCreateResponse",
|
|
17
|
+
"TokenStatus",
|
|
18
|
+
"TokenStatusResponse",
|
|
19
|
+
]
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""HTTP client for Shotgun Web API."""
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from shotgun.logging_config import get_logger
|
|
6
|
+
|
|
7
|
+
from .constants import (
|
|
8
|
+
SHOTGUN_WEB_BASE_URL,
|
|
9
|
+
UNIFICATION_TOKEN_CREATE_PATH,
|
|
10
|
+
UNIFICATION_TOKEN_STATUS_PATH,
|
|
11
|
+
)
|
|
12
|
+
from .models import (
|
|
13
|
+
TokenCreateRequest,
|
|
14
|
+
TokenCreateResponse,
|
|
15
|
+
TokenStatusResponse,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logger = get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ShotgunWebClient:
|
|
22
|
+
"""HTTP client for Shotgun Web API."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, base_url: str | None = None, timeout: float = 10.0):
|
|
25
|
+
"""Initialize Shotgun Web client.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
base_url: Base URL for Shotgun Web API. If None, uses SHOTGUN_WEB_BASE_URL
|
|
29
|
+
timeout: Request timeout in seconds
|
|
30
|
+
"""
|
|
31
|
+
self.base_url = base_url or SHOTGUN_WEB_BASE_URL
|
|
32
|
+
self.timeout = timeout
|
|
33
|
+
|
|
34
|
+
def create_unification_token(self, shotgun_instance_id: str) -> TokenCreateResponse:
|
|
35
|
+
"""Create a unification token for CLI authentication.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
shotgun_instance_id: UUID for this shotgun instance
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Token creation response with token and auth URL
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
httpx.HTTPError: If request fails
|
|
45
|
+
"""
|
|
46
|
+
url = f"{self.base_url}{UNIFICATION_TOKEN_CREATE_PATH}"
|
|
47
|
+
request_data = TokenCreateRequest(shotgun_instance_id=shotgun_instance_id)
|
|
48
|
+
|
|
49
|
+
logger.debug("Creating unification token for instance %s", shotgun_instance_id)
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
response = httpx.post(
|
|
53
|
+
url,
|
|
54
|
+
json=request_data.model_dump(),
|
|
55
|
+
timeout=self.timeout,
|
|
56
|
+
)
|
|
57
|
+
response.raise_for_status()
|
|
58
|
+
|
|
59
|
+
data = response.json()
|
|
60
|
+
result = TokenCreateResponse.model_validate(data)
|
|
61
|
+
|
|
62
|
+
logger.info(
|
|
63
|
+
"Successfully created unification token, expires in %d seconds",
|
|
64
|
+
result.expires_in_seconds,
|
|
65
|
+
)
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
except httpx.HTTPError as e:
|
|
69
|
+
logger.error("Failed to create unification token: %s", e)
|
|
70
|
+
raise
|
|
71
|
+
|
|
72
|
+
def check_token_status(self, token: str) -> TokenStatusResponse:
|
|
73
|
+
"""Check token status and get keys if completed.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
token: Unification token to check
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Token status response with status and keys (if completed)
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
httpx.HTTPStatusError: If token not found (404) or expired (410)
|
|
83
|
+
httpx.HTTPError: For other request failures
|
|
84
|
+
"""
|
|
85
|
+
url = f"{self.base_url}{UNIFICATION_TOKEN_STATUS_PATH.format(token=token)}"
|
|
86
|
+
|
|
87
|
+
logger.debug("Checking status for token %s...", token[:8])
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
response = httpx.get(url, timeout=self.timeout)
|
|
91
|
+
response.raise_for_status()
|
|
92
|
+
|
|
93
|
+
data = response.json()
|
|
94
|
+
result = TokenStatusResponse.model_validate(data)
|
|
95
|
+
|
|
96
|
+
logger.debug("Token status: %s", result.status)
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
except httpx.HTTPStatusError as e:
|
|
100
|
+
if e.response.status_code == 404:
|
|
101
|
+
logger.error("Token not found: %s", token[:8])
|
|
102
|
+
elif e.response.status_code == 410:
|
|
103
|
+
logger.error("Token expired: %s", token[:8])
|
|
104
|
+
raise
|
|
105
|
+
except httpx.HTTPError as e:
|
|
106
|
+
logger.error("Failed to check token status: %s", e)
|
|
107
|
+
raise
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# Convenience functions for standalone use
|
|
111
|
+
def create_unification_token(shotgun_instance_id: str) -> TokenCreateResponse:
|
|
112
|
+
"""Create a unification token.
|
|
113
|
+
|
|
114
|
+
Convenience function that creates a client and calls create_unification_token.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
shotgun_instance_id: UUID for this shotgun instance
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Token creation response
|
|
121
|
+
"""
|
|
122
|
+
client = ShotgunWebClient()
|
|
123
|
+
return client.create_unification_token(shotgun_instance_id)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def check_token_status(token: str) -> TokenStatusResponse:
|
|
127
|
+
"""Check token status.
|
|
128
|
+
|
|
129
|
+
Convenience function that creates a client and calls check_token_status.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
token: Unification token to check
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Token status response
|
|
136
|
+
"""
|
|
137
|
+
client = ShotgunWebClient()
|
|
138
|
+
return client.check_token_status(token)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Constants for Shotgun Web API."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
# Shotgun Web API base URL
|
|
6
|
+
# Default to production URL, can be overridden with environment variable
|
|
7
|
+
SHOTGUN_WEB_BASE_URL = os.environ.get(
|
|
8
|
+
"SHOTGUN_WEB_BASE_URL", "https://api-701197220809.us-east1.run.app"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
# API endpoints
|
|
12
|
+
UNIFICATION_TOKEN_CREATE_PATH = "/api/unification/token/create" # noqa: S105
|
|
13
|
+
UNIFICATION_TOKEN_STATUS_PATH = "/api/unification/token/{token}/status" # noqa: S105
|
|
14
|
+
|
|
15
|
+
# Polling configuration
|
|
16
|
+
DEFAULT_POLL_INTERVAL_SECONDS = 3
|
|
17
|
+
DEFAULT_TOKEN_TIMEOUT_SECONDS = 1800 # 30 minutes
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Pydantic models for Shotgun Web API."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TokenStatus(StrEnum):
|
|
9
|
+
"""Token status enum matching API specification."""
|
|
10
|
+
|
|
11
|
+
PENDING = "pending"
|
|
12
|
+
COMPLETED = "completed"
|
|
13
|
+
AWAITING_PAYMENT = "awaiting_payment"
|
|
14
|
+
EXPIRED = "expired"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TokenCreateRequest(BaseModel):
|
|
18
|
+
"""Request model for creating a unification token."""
|
|
19
|
+
|
|
20
|
+
shotgun_instance_id: str = Field(
|
|
21
|
+
description="CLI-provided UUID for shotgun instance"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TokenCreateResponse(BaseModel):
|
|
26
|
+
"""Response model for token creation."""
|
|
27
|
+
|
|
28
|
+
token: str = Field(description="Secure authentication token")
|
|
29
|
+
auth_url: str = Field(description="Web authentication URL for user to complete")
|
|
30
|
+
expires_in_seconds: int = Field(description="Token expiration time in seconds")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TokenStatusResponse(BaseModel):
|
|
34
|
+
"""Response model for token status check."""
|
|
35
|
+
|
|
36
|
+
status: TokenStatus = Field(description="Current token status")
|
|
37
|
+
supabase_key: str | None = Field(
|
|
38
|
+
default=None,
|
|
39
|
+
description="Supabase user JWT (only returned when status=completed)",
|
|
40
|
+
)
|
|
41
|
+
litellm_key: str | None = Field(
|
|
42
|
+
default=None,
|
|
43
|
+
description="LiteLLM virtual key (only returned when status=completed)",
|
|
44
|
+
)
|
|
45
|
+
message: str | None = Field(
|
|
46
|
+
default=None, description="Human-readable status message"
|
|
47
|
+
)
|