scc-cli 1.5.3__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 scc-cli might be problematic. Click here for more details.
- scc_cli/__init__.py +15 -0
- scc_cli/audit/__init__.py +37 -0
- scc_cli/audit/parser.py +191 -0
- scc_cli/audit/reader.py +180 -0
- scc_cli/auth.py +145 -0
- scc_cli/claude_adapter.py +485 -0
- scc_cli/cli.py +311 -0
- scc_cli/cli_common.py +190 -0
- scc_cli/cli_helpers.py +244 -0
- scc_cli/commands/__init__.py +20 -0
- scc_cli/commands/admin.py +708 -0
- scc_cli/commands/audit.py +246 -0
- scc_cli/commands/config.py +528 -0
- scc_cli/commands/exceptions.py +696 -0
- scc_cli/commands/init.py +272 -0
- scc_cli/commands/launch/__init__.py +73 -0
- scc_cli/commands/launch/app.py +1247 -0
- scc_cli/commands/launch/render.py +309 -0
- scc_cli/commands/launch/sandbox.py +135 -0
- scc_cli/commands/launch/workspace.py +339 -0
- scc_cli/commands/org/__init__.py +49 -0
- scc_cli/commands/org/_builders.py +264 -0
- scc_cli/commands/org/app.py +41 -0
- scc_cli/commands/org/import_cmd.py +267 -0
- scc_cli/commands/org/init_cmd.py +269 -0
- scc_cli/commands/org/schema_cmd.py +76 -0
- scc_cli/commands/org/status_cmd.py +157 -0
- scc_cli/commands/org/update_cmd.py +330 -0
- scc_cli/commands/org/validate_cmd.py +138 -0
- scc_cli/commands/support.py +323 -0
- scc_cli/commands/team.py +910 -0
- scc_cli/commands/worktree/__init__.py +72 -0
- scc_cli/commands/worktree/_helpers.py +57 -0
- scc_cli/commands/worktree/app.py +170 -0
- scc_cli/commands/worktree/container_commands.py +385 -0
- scc_cli/commands/worktree/context_commands.py +61 -0
- scc_cli/commands/worktree/session_commands.py +128 -0
- scc_cli/commands/worktree/worktree_commands.py +734 -0
- scc_cli/config.py +647 -0
- scc_cli/confirm.py +20 -0
- scc_cli/console.py +562 -0
- scc_cli/contexts.py +394 -0
- scc_cli/core/__init__.py +68 -0
- scc_cli/core/constants.py +101 -0
- scc_cli/core/errors.py +297 -0
- scc_cli/core/exit_codes.py +91 -0
- scc_cli/core/workspace.py +57 -0
- scc_cli/deprecation.py +54 -0
- scc_cli/deps.py +189 -0
- scc_cli/docker/__init__.py +127 -0
- scc_cli/docker/core.py +467 -0
- scc_cli/docker/credentials.py +726 -0
- scc_cli/docker/launch.py +595 -0
- scc_cli/doctor/__init__.py +105 -0
- scc_cli/doctor/checks/__init__.py +166 -0
- scc_cli/doctor/checks/cache.py +314 -0
- scc_cli/doctor/checks/config.py +107 -0
- scc_cli/doctor/checks/environment.py +182 -0
- scc_cli/doctor/checks/json_helpers.py +157 -0
- scc_cli/doctor/checks/organization.py +264 -0
- scc_cli/doctor/checks/worktree.py +278 -0
- scc_cli/doctor/render.py +365 -0
- scc_cli/doctor/types.py +66 -0
- scc_cli/evaluation/__init__.py +27 -0
- scc_cli/evaluation/apply_exceptions.py +207 -0
- scc_cli/evaluation/evaluate.py +97 -0
- scc_cli/evaluation/models.py +80 -0
- scc_cli/git.py +84 -0
- scc_cli/json_command.py +166 -0
- scc_cli/json_output.py +159 -0
- scc_cli/kinds.py +65 -0
- scc_cli/marketplace/__init__.py +123 -0
- scc_cli/marketplace/adapter.py +74 -0
- scc_cli/marketplace/compute.py +377 -0
- scc_cli/marketplace/constants.py +87 -0
- scc_cli/marketplace/managed.py +135 -0
- scc_cli/marketplace/materialize.py +846 -0
- scc_cli/marketplace/normalize.py +548 -0
- scc_cli/marketplace/render.py +281 -0
- scc_cli/marketplace/resolve.py +459 -0
- scc_cli/marketplace/schema.py +506 -0
- scc_cli/marketplace/sync.py +279 -0
- scc_cli/marketplace/team_cache.py +195 -0
- scc_cli/marketplace/team_fetch.py +689 -0
- scc_cli/marketplace/trust.py +244 -0
- scc_cli/models/__init__.py +41 -0
- scc_cli/models/exceptions.py +273 -0
- scc_cli/models/plugin_audit.py +434 -0
- scc_cli/org_templates.py +269 -0
- scc_cli/output_mode.py +167 -0
- scc_cli/panels.py +113 -0
- scc_cli/platform.py +350 -0
- scc_cli/profiles.py +960 -0
- scc_cli/remote.py +443 -0
- scc_cli/schemas/__init__.py +1 -0
- scc_cli/schemas/org-v1.schema.json +456 -0
- scc_cli/schemas/team-config.v1.schema.json +163 -0
- scc_cli/services/__init__.py +1 -0
- scc_cli/services/git/__init__.py +79 -0
- scc_cli/services/git/branch.py +151 -0
- scc_cli/services/git/core.py +216 -0
- scc_cli/services/git/hooks.py +108 -0
- scc_cli/services/git/worktree.py +444 -0
- scc_cli/services/workspace/__init__.py +36 -0
- scc_cli/services/workspace/resolver.py +223 -0
- scc_cli/services/workspace/suspicious.py +200 -0
- scc_cli/sessions.py +425 -0
- scc_cli/setup.py +589 -0
- scc_cli/source_resolver.py +470 -0
- scc_cli/stats.py +378 -0
- scc_cli/stores/__init__.py +13 -0
- scc_cli/stores/exception_store.py +251 -0
- scc_cli/subprocess_utils.py +88 -0
- scc_cli/teams.py +383 -0
- scc_cli/templates/__init__.py +2 -0
- scc_cli/templates/org/__init__.py +0 -0
- scc_cli/templates/org/minimal.json +19 -0
- scc_cli/templates/org/reference.json +74 -0
- scc_cli/templates/org/strict.json +38 -0
- scc_cli/templates/org/teams.json +42 -0
- scc_cli/templates/statusline.sh +75 -0
- scc_cli/theme.py +348 -0
- scc_cli/ui/__init__.py +154 -0
- scc_cli/ui/branding.py +68 -0
- scc_cli/ui/chrome.py +401 -0
- scc_cli/ui/dashboard/__init__.py +62 -0
- scc_cli/ui/dashboard/_dashboard.py +794 -0
- scc_cli/ui/dashboard/loaders.py +452 -0
- scc_cli/ui/dashboard/models.py +185 -0
- scc_cli/ui/dashboard/orchestrator.py +735 -0
- scc_cli/ui/formatters.py +444 -0
- scc_cli/ui/gate.py +350 -0
- scc_cli/ui/git_interactive.py +869 -0
- scc_cli/ui/git_render.py +176 -0
- scc_cli/ui/help.py +157 -0
- scc_cli/ui/keys.py +615 -0
- scc_cli/ui/list_screen.py +437 -0
- scc_cli/ui/picker.py +763 -0
- scc_cli/ui/prompts.py +201 -0
- scc_cli/ui/quick_resume.py +116 -0
- scc_cli/ui/wizard.py +576 -0
- scc_cli/update.py +680 -0
- scc_cli/utils/__init__.py +39 -0
- scc_cli/utils/fixit.py +264 -0
- scc_cli/utils/fuzzy.py +124 -0
- scc_cli/utils/locks.py +114 -0
- scc_cli/utils/ttl.py +376 -0
- scc_cli/validate.py +455 -0
- scc_cli-1.5.3.dist-info/METADATA +401 -0
- scc_cli-1.5.3.dist-info/RECORD +153 -0
- scc_cli-1.5.3.dist-info/WHEEL +4 -0
- scc_cli-1.5.3.dist-info/entry_points.txt +2 -0
- scc_cli-1.5.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
"""Team config fetching for federated configurations (Phase 2).
|
|
2
|
+
|
|
3
|
+
This module provides:
|
|
4
|
+
- TeamFetchResult: Result of fetching a team configuration
|
|
5
|
+
- fetch_team_config(): Main entry point with ConfigSource dispatch
|
|
6
|
+
- save_team_config_cache(): Save fetched config to cache
|
|
7
|
+
- load_team_config_cache(): Load config from cache
|
|
8
|
+
|
|
9
|
+
Fetching Flow:
|
|
10
|
+
1. Dispatch to appropriate fetcher based on ConfigSource type
|
|
11
|
+
2. Clone/fetch the config from remote source
|
|
12
|
+
3. Validate against team-config schema
|
|
13
|
+
4. Save to cache with metadata
|
|
14
|
+
5. Return TeamFetchResult with config and version info
|
|
15
|
+
|
|
16
|
+
Source Types:
|
|
17
|
+
- GitHub: Clone repo, read config file from path
|
|
18
|
+
- Git: Clone generic git repo, read config file
|
|
19
|
+
- URL: HTTP GET request with ETag support
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import subprocess
|
|
26
|
+
import tempfile
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import TYPE_CHECKING, Any
|
|
31
|
+
|
|
32
|
+
import requests
|
|
33
|
+
|
|
34
|
+
from scc_cli.marketplace.team_cache import (
|
|
35
|
+
DEFAULT_TTL,
|
|
36
|
+
MAX_STALE_AGE,
|
|
37
|
+
TeamCacheMeta,
|
|
38
|
+
get_team_config_cache_path,
|
|
39
|
+
get_team_meta_cache_path,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
from scc_cli.marketplace.schema import (
|
|
44
|
+
ConfigSource,
|
|
45
|
+
ConfigSourceGit,
|
|
46
|
+
ConfigSourceGitHub,
|
|
47
|
+
ConfigSourceURL,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
52
|
+
# Result Types
|
|
53
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class TeamFetchResult:
|
|
58
|
+
"""Result of fetching a team configuration.
|
|
59
|
+
|
|
60
|
+
Attributes:
|
|
61
|
+
success: Whether fetch was successful
|
|
62
|
+
team_config: Parsed team config dict (None if failed)
|
|
63
|
+
source_type: Type of source (github, git, url)
|
|
64
|
+
source_url: Normalized source URL
|
|
65
|
+
commit_sha: Git commit SHA (for git/github sources)
|
|
66
|
+
etag: HTTP ETag (for URL sources)
|
|
67
|
+
branch: Git branch (for git/github sources)
|
|
68
|
+
error: Error message (if failed)
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
success: bool
|
|
72
|
+
source_type: str
|
|
73
|
+
source_url: str
|
|
74
|
+
team_config: dict[str, Any] | None = None
|
|
75
|
+
commit_sha: str | None = None
|
|
76
|
+
etag: str | None = None
|
|
77
|
+
branch: str | None = None
|
|
78
|
+
error: str | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class FallbackFetchResult:
|
|
83
|
+
"""Result of fetching with cache fallback support.
|
|
84
|
+
|
|
85
|
+
Extends TeamFetchResult with additional metadata about cache usage
|
|
86
|
+
and staleness warnings to inform the user.
|
|
87
|
+
|
|
88
|
+
Attributes:
|
|
89
|
+
result: The underlying TeamFetchResult
|
|
90
|
+
used_cache: True if result came from cache (not fresh fetch)
|
|
91
|
+
is_stale: True if cache is past TTL but within MAX_STALE_AGE
|
|
92
|
+
staleness_warning: Human-readable warning about stale data
|
|
93
|
+
cache_meta: Metadata about the cached config (if used)
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
result: TeamFetchResult
|
|
97
|
+
used_cache: bool = False
|
|
98
|
+
is_stale: bool = False
|
|
99
|
+
staleness_warning: str | None = None
|
|
100
|
+
cache_meta: TeamCacheMeta | None = None
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def success(self) -> bool:
|
|
104
|
+
"""Delegate to underlying result."""
|
|
105
|
+
return self.result.success
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def team_config(self) -> dict[str, Any] | None:
|
|
109
|
+
"""Delegate to underlying result."""
|
|
110
|
+
return self.result.team_config
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def error(self) -> str | None:
|
|
114
|
+
"""Delegate to underlying result."""
|
|
115
|
+
return self.result.error
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
119
|
+
# Main Entry Point
|
|
120
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def fetch_team_config(
|
|
124
|
+
source: ConfigSource,
|
|
125
|
+
team_name: str,
|
|
126
|
+
cache_root: Path | None = None,
|
|
127
|
+
) -> TeamFetchResult:
|
|
128
|
+
"""Fetch team config from ConfigSource with dispatch.
|
|
129
|
+
|
|
130
|
+
Dispatches to appropriate fetcher based on source type:
|
|
131
|
+
- github: Clone GitHub repo and read config
|
|
132
|
+
- git: Clone generic git repo and read config
|
|
133
|
+
- url: HTTP GET with ETag support
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
source: ConfigSource defining where to fetch from
|
|
137
|
+
team_name: Team name for cache key
|
|
138
|
+
cache_root: Cache directory (defaults to XDG cache)
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
TeamFetchResult with config data or error
|
|
142
|
+
"""
|
|
143
|
+
# Import here to avoid circular imports
|
|
144
|
+
from scc_cli.marketplace.schema import (
|
|
145
|
+
ConfigSourceGit,
|
|
146
|
+
ConfigSourceGitHub,
|
|
147
|
+
ConfigSourceURL,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Dispatch based on source type
|
|
151
|
+
if isinstance(source, ConfigSourceGitHub):
|
|
152
|
+
result = _fetch_from_github(source, team_name)
|
|
153
|
+
elif isinstance(source, ConfigSourceGit):
|
|
154
|
+
result = _fetch_from_git(source, team_name)
|
|
155
|
+
elif isinstance(source, ConfigSourceURL):
|
|
156
|
+
result = _fetch_from_url(source, team_name)
|
|
157
|
+
else:
|
|
158
|
+
return TeamFetchResult(
|
|
159
|
+
success=False,
|
|
160
|
+
source_type="unknown",
|
|
161
|
+
source_url="",
|
|
162
|
+
error=f"Unknown source type: {type(source).__name__}",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Save to cache on success
|
|
166
|
+
if result.success:
|
|
167
|
+
save_team_config_cache(result, team_name, cache_root)
|
|
168
|
+
|
|
169
|
+
return result
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def fetch_team_config_with_fallback(
|
|
173
|
+
source: ConfigSource,
|
|
174
|
+
team_name: str,
|
|
175
|
+
cache_root: Path | None = None,
|
|
176
|
+
) -> FallbackFetchResult:
|
|
177
|
+
"""Fetch team config with graceful degradation to cache.
|
|
178
|
+
|
|
179
|
+
Implements the freshness model:
|
|
180
|
+
- Fresh (age < DEFAULT_TTL): Use cached config directly, skip fetch
|
|
181
|
+
- Stale (TTL < age < MAX_STALE_AGE): Try fetch, fallback to cache on failure
|
|
182
|
+
- Expired (age > MAX_STALE_AGE): Must fetch, no fallback allowed
|
|
183
|
+
|
|
184
|
+
This is the recommended entry point for production use as it provides
|
|
185
|
+
resilience against network failures while maintaining freshness guarantees.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
source: ConfigSource defining where to fetch from
|
|
189
|
+
team_name: Team name for cache key
|
|
190
|
+
cache_root: Cache directory (defaults to XDG cache)
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
FallbackFetchResult with config, cache status, and staleness warnings
|
|
194
|
+
"""
|
|
195
|
+
# Step 1: Check if we have cached config
|
|
196
|
+
cached = load_team_config_cache(team_name, cache_root)
|
|
197
|
+
|
|
198
|
+
if cached is not None:
|
|
199
|
+
config, meta = cached
|
|
200
|
+
|
|
201
|
+
# Case A: Cache is fresh - use directly, no fetch needed
|
|
202
|
+
if meta.is_fresh(DEFAULT_TTL):
|
|
203
|
+
return FallbackFetchResult(
|
|
204
|
+
result=TeamFetchResult(
|
|
205
|
+
success=True,
|
|
206
|
+
source_type=meta.source_type,
|
|
207
|
+
source_url=meta.source_url,
|
|
208
|
+
team_config=config,
|
|
209
|
+
commit_sha=meta.commit_sha,
|
|
210
|
+
etag=meta.etag,
|
|
211
|
+
branch=meta.branch,
|
|
212
|
+
),
|
|
213
|
+
used_cache=True,
|
|
214
|
+
is_stale=False,
|
|
215
|
+
cache_meta=meta,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Case B: Cache is stale but within MAX_STALE_AGE - try fetch, fallback on failure
|
|
219
|
+
if meta.is_within_max_stale_age(MAX_STALE_AGE):
|
|
220
|
+
# Try to fetch fresh config
|
|
221
|
+
result = fetch_team_config(source, team_name, cache_root)
|
|
222
|
+
|
|
223
|
+
if result.success:
|
|
224
|
+
# Fresh fetch succeeded - return it
|
|
225
|
+
return FallbackFetchResult(
|
|
226
|
+
result=result,
|
|
227
|
+
used_cache=False,
|
|
228
|
+
is_stale=False,
|
|
229
|
+
)
|
|
230
|
+
else:
|
|
231
|
+
# Fetch failed - fallback to stale cache with warning
|
|
232
|
+
age_hours = int(meta.age.total_seconds() / 3600)
|
|
233
|
+
staleness_warning = (
|
|
234
|
+
f"Using cached config from {age_hours}h ago (fetch failed: {result.error}). "
|
|
235
|
+
f"Cache will expire in {int((MAX_STALE_AGE - meta.age).total_seconds() / 3600)}h."
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
return FallbackFetchResult(
|
|
239
|
+
result=TeamFetchResult(
|
|
240
|
+
success=True,
|
|
241
|
+
source_type=meta.source_type,
|
|
242
|
+
source_url=meta.source_url,
|
|
243
|
+
team_config=config,
|
|
244
|
+
commit_sha=meta.commit_sha,
|
|
245
|
+
etag=meta.etag,
|
|
246
|
+
branch=meta.branch,
|
|
247
|
+
),
|
|
248
|
+
used_cache=True,
|
|
249
|
+
is_stale=True,
|
|
250
|
+
staleness_warning=staleness_warning,
|
|
251
|
+
cache_meta=meta,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# Case C: Cache is expired (> MAX_STALE_AGE) - fall through to force fetch
|
|
255
|
+
|
|
256
|
+
# Step 2: No usable cache - must fetch
|
|
257
|
+
result = fetch_team_config(source, team_name, cache_root)
|
|
258
|
+
|
|
259
|
+
if not result.success and cached is not None:
|
|
260
|
+
# We had an expired cache but fetch also failed
|
|
261
|
+
_, meta = cached
|
|
262
|
+
age_days = int(meta.age.total_seconds() / 86400)
|
|
263
|
+
result = TeamFetchResult(
|
|
264
|
+
success=False,
|
|
265
|
+
source_type=result.source_type,
|
|
266
|
+
source_url=result.source_url,
|
|
267
|
+
error=(
|
|
268
|
+
f"{result.error}. "
|
|
269
|
+
f"Cached config ({age_days}d old) has expired beyond MAX_STALE_AGE ({MAX_STALE_AGE.days}d) "
|
|
270
|
+
f"and cannot be used as fallback."
|
|
271
|
+
),
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
return FallbackFetchResult(
|
|
275
|
+
result=result,
|
|
276
|
+
used_cache=False,
|
|
277
|
+
is_stale=False,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
282
|
+
# GitHub Fetching
|
|
283
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _fetch_from_github(
|
|
287
|
+
source: ConfigSourceGitHub,
|
|
288
|
+
team_name: str,
|
|
289
|
+
) -> TeamFetchResult:
|
|
290
|
+
"""Fetch team config from GitHub repository.
|
|
291
|
+
|
|
292
|
+
Constructs HTTPS clone URL and delegates to _clone_and_read_config.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
source: GitHub config source
|
|
296
|
+
team_name: Team name for logging
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
TeamFetchResult with config or error
|
|
300
|
+
"""
|
|
301
|
+
# Construct GitHub clone URL
|
|
302
|
+
clone_url = f"https://github.com/{source.owner}/{source.repo}.git"
|
|
303
|
+
source_url = f"github.com/{source.owner}/{source.repo}"
|
|
304
|
+
|
|
305
|
+
# Determine branch and path
|
|
306
|
+
branch = source.branch if source.branch else "main"
|
|
307
|
+
config_path = source.path if source.path else "team-config.json"
|
|
308
|
+
|
|
309
|
+
try:
|
|
310
|
+
config, commit_sha, error = _clone_and_read_config(
|
|
311
|
+
clone_url,
|
|
312
|
+
branch=branch,
|
|
313
|
+
path=config_path,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
if error:
|
|
317
|
+
return TeamFetchResult(
|
|
318
|
+
success=False,
|
|
319
|
+
source_type="github",
|
|
320
|
+
source_url=source_url,
|
|
321
|
+
error=error,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
return TeamFetchResult(
|
|
325
|
+
success=True,
|
|
326
|
+
team_config=config,
|
|
327
|
+
source_type="github",
|
|
328
|
+
source_url=source_url,
|
|
329
|
+
commit_sha=commit_sha,
|
|
330
|
+
branch=branch,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
except Exception as e:
|
|
334
|
+
return TeamFetchResult(
|
|
335
|
+
success=False,
|
|
336
|
+
source_type="github",
|
|
337
|
+
source_url=source_url,
|
|
338
|
+
error=str(e),
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
343
|
+
# Generic Git Fetching
|
|
344
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _fetch_from_git(
|
|
348
|
+
source: ConfigSourceGit,
|
|
349
|
+
team_name: str,
|
|
350
|
+
) -> TeamFetchResult:
|
|
351
|
+
"""Fetch team config from generic Git repository.
|
|
352
|
+
|
|
353
|
+
Uses provided URL directly for cloning.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
source: Git config source
|
|
357
|
+
team_name: Team name for logging
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
TeamFetchResult with config or error
|
|
361
|
+
"""
|
|
362
|
+
clone_url = source.url
|
|
363
|
+
|
|
364
|
+
# Normalize source URL for display (remove protocol, .git suffix)
|
|
365
|
+
source_url = clone_url
|
|
366
|
+
if source_url.startswith("https://"):
|
|
367
|
+
source_url = source_url[8:]
|
|
368
|
+
elif source_url.startswith("git@"):
|
|
369
|
+
source_url = source_url[4:].replace(":", "/", 1)
|
|
370
|
+
if source_url.endswith(".git"):
|
|
371
|
+
source_url = source_url[:-4]
|
|
372
|
+
|
|
373
|
+
# Determine branch and path
|
|
374
|
+
branch = source.branch if source.branch else "main"
|
|
375
|
+
config_path = source.path if source.path else "team-config.json"
|
|
376
|
+
|
|
377
|
+
try:
|
|
378
|
+
config, commit_sha, error = _clone_and_read_config(
|
|
379
|
+
clone_url,
|
|
380
|
+
branch=branch,
|
|
381
|
+
path=config_path,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
if error:
|
|
385
|
+
return TeamFetchResult(
|
|
386
|
+
success=False,
|
|
387
|
+
source_type="git",
|
|
388
|
+
source_url=source_url,
|
|
389
|
+
error=error,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
return TeamFetchResult(
|
|
393
|
+
success=True,
|
|
394
|
+
team_config=config,
|
|
395
|
+
source_type="git",
|
|
396
|
+
source_url=source_url,
|
|
397
|
+
commit_sha=commit_sha,
|
|
398
|
+
branch=branch,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
except Exception as e:
|
|
402
|
+
return TeamFetchResult(
|
|
403
|
+
success=False,
|
|
404
|
+
source_type="git",
|
|
405
|
+
source_url=source_url,
|
|
406
|
+
error=str(e),
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
411
|
+
# URL Fetching
|
|
412
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _fetch_from_url(
|
|
416
|
+
source: ConfigSourceURL,
|
|
417
|
+
team_name: str,
|
|
418
|
+
) -> TeamFetchResult:
|
|
419
|
+
"""Fetch team config from HTTPS URL.
|
|
420
|
+
|
|
421
|
+
Supports ETag for cache validation and custom headers.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
source: URL config source
|
|
425
|
+
team_name: Team name for logging
|
|
426
|
+
|
|
427
|
+
Returns:
|
|
428
|
+
TeamFetchResult with config or error
|
|
429
|
+
"""
|
|
430
|
+
url = source.url
|
|
431
|
+
|
|
432
|
+
# Normalize source URL for display
|
|
433
|
+
source_url = url
|
|
434
|
+
if source_url.startswith("https://"):
|
|
435
|
+
source_url = source_url[8:]
|
|
436
|
+
|
|
437
|
+
# Build headers
|
|
438
|
+
headers: dict[str, str] = {}
|
|
439
|
+
if source.headers:
|
|
440
|
+
headers.update(source.headers)
|
|
441
|
+
|
|
442
|
+
try:
|
|
443
|
+
response = requests.get(url, headers=headers, timeout=30)
|
|
444
|
+
|
|
445
|
+
# Handle error status codes
|
|
446
|
+
if response.status_code == 404:
|
|
447
|
+
return TeamFetchResult(
|
|
448
|
+
success=False,
|
|
449
|
+
source_type="url",
|
|
450
|
+
source_url=source_url,
|
|
451
|
+
error=(
|
|
452
|
+
f"HTTP 404: Team config not found at {url}. "
|
|
453
|
+
"Verify the URL is correct and the config file exists."
|
|
454
|
+
),
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
if response.status_code == 401:
|
|
458
|
+
return TeamFetchResult(
|
|
459
|
+
success=False,
|
|
460
|
+
source_type="url",
|
|
461
|
+
source_url=source_url,
|
|
462
|
+
error=(
|
|
463
|
+
f"HTTP 401: Unauthorized access to {url}. "
|
|
464
|
+
"Add authentication headers in config_source or check credentials."
|
|
465
|
+
),
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
if response.status_code == 403:
|
|
469
|
+
return TeamFetchResult(
|
|
470
|
+
success=False,
|
|
471
|
+
source_type="url",
|
|
472
|
+
source_url=source_url,
|
|
473
|
+
error=(
|
|
474
|
+
f"HTTP 403: Access denied to {url}. Check permissions or firewall settings."
|
|
475
|
+
),
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
if response.status_code != 200:
|
|
479
|
+
return TeamFetchResult(
|
|
480
|
+
success=False,
|
|
481
|
+
source_type="url",
|
|
482
|
+
source_url=source_url,
|
|
483
|
+
error=(
|
|
484
|
+
f"HTTP {response.status_code}: Failed to fetch team config from {url}. "
|
|
485
|
+
"Check if the server is reachable and the URL is correct."
|
|
486
|
+
),
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
# Parse JSON response
|
|
490
|
+
try:
|
|
491
|
+
config = response.json()
|
|
492
|
+
except json.JSONDecodeError as e:
|
|
493
|
+
return TeamFetchResult(
|
|
494
|
+
success=False,
|
|
495
|
+
source_type="url",
|
|
496
|
+
source_url=source_url,
|
|
497
|
+
error=(
|
|
498
|
+
f"Invalid JSON in team config: {e}. "
|
|
499
|
+
"Check that the file contains valid JSON (try a JSON validator)."
|
|
500
|
+
),
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
# Extract ETag
|
|
504
|
+
etag = response.headers.get("ETag")
|
|
505
|
+
|
|
506
|
+
return TeamFetchResult(
|
|
507
|
+
success=True,
|
|
508
|
+
team_config=config,
|
|
509
|
+
source_type="url",
|
|
510
|
+
source_url=source_url,
|
|
511
|
+
etag=etag,
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
except requests.RequestException as e:
|
|
515
|
+
return TeamFetchResult(
|
|
516
|
+
success=False,
|
|
517
|
+
source_type="url",
|
|
518
|
+
source_url=source_url,
|
|
519
|
+
error=(
|
|
520
|
+
f"Network error fetching team config: {e}. "
|
|
521
|
+
"Check network connection, VPN status, and firewall settings."
|
|
522
|
+
),
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
527
|
+
# Git Clone Helper
|
|
528
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _clone_and_read_config(
|
|
532
|
+
clone_url: str,
|
|
533
|
+
branch: str = "main",
|
|
534
|
+
path: str = "team-config.json",
|
|
535
|
+
cache_dir: Path | None = None,
|
|
536
|
+
) -> tuple[dict[str, Any] | None, str | None, str | None]:
|
|
537
|
+
"""Clone git repo and read config file.
|
|
538
|
+
|
|
539
|
+
Args:
|
|
540
|
+
clone_url: Git clone URL
|
|
541
|
+
branch: Branch to checkout
|
|
542
|
+
path: Path to config file within repo
|
|
543
|
+
cache_dir: Cache directory for clone (uses temp if None)
|
|
544
|
+
|
|
545
|
+
Returns:
|
|
546
|
+
Tuple of (config_dict, commit_sha, error_message)
|
|
547
|
+
"""
|
|
548
|
+
# Use temp directory for clone
|
|
549
|
+
with tempfile.TemporaryDirectory(prefix="scc_team_") as tmp_dir:
|
|
550
|
+
target_dir = Path(tmp_dir) / "repo"
|
|
551
|
+
|
|
552
|
+
# Clone with shallow depth
|
|
553
|
+
cmd = [
|
|
554
|
+
"git",
|
|
555
|
+
"clone",
|
|
556
|
+
"--depth",
|
|
557
|
+
"1",
|
|
558
|
+
"--branch",
|
|
559
|
+
branch,
|
|
560
|
+
"--",
|
|
561
|
+
clone_url,
|
|
562
|
+
str(target_dir),
|
|
563
|
+
]
|
|
564
|
+
|
|
565
|
+
try:
|
|
566
|
+
result = subprocess.run(
|
|
567
|
+
cmd,
|
|
568
|
+
capture_output=True,
|
|
569
|
+
text=True,
|
|
570
|
+
timeout=120,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
if result.returncode != 0:
|
|
574
|
+
return (None, None, f"Git clone failed: {result.stderr}")
|
|
575
|
+
|
|
576
|
+
except FileNotFoundError:
|
|
577
|
+
return (None, None, "Git not available")
|
|
578
|
+
except subprocess.TimeoutExpired:
|
|
579
|
+
return (None, None, "Git clone timed out")
|
|
580
|
+
|
|
581
|
+
# Get commit SHA
|
|
582
|
+
sha_result = subprocess.run(
|
|
583
|
+
["git", "-C", str(target_dir), "rev-parse", "HEAD"],
|
|
584
|
+
capture_output=True,
|
|
585
|
+
text=True,
|
|
586
|
+
)
|
|
587
|
+
commit_sha = sha_result.stdout.strip() if sha_result.returncode == 0 else None
|
|
588
|
+
|
|
589
|
+
# Read config file
|
|
590
|
+
config_path = target_dir / path
|
|
591
|
+
|
|
592
|
+
if not config_path.exists():
|
|
593
|
+
return (None, commit_sha, f"Config file not found: {path}")
|
|
594
|
+
|
|
595
|
+
try:
|
|
596
|
+
config_text = config_path.read_text(encoding="utf-8")
|
|
597
|
+
config = json.loads(config_text)
|
|
598
|
+
return (config, commit_sha, None)
|
|
599
|
+
except json.JSONDecodeError as e:
|
|
600
|
+
return (None, commit_sha, f"Invalid JSON in config: {e}")
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
604
|
+
# Cache Operations
|
|
605
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def save_team_config_cache(
|
|
609
|
+
result: TeamFetchResult,
|
|
610
|
+
team_name: str,
|
|
611
|
+
cache_root: Path | None = None,
|
|
612
|
+
) -> None:
|
|
613
|
+
"""Save fetched team config to cache.
|
|
614
|
+
|
|
615
|
+
Creates two files:
|
|
616
|
+
- {team_name}.json: The team config
|
|
617
|
+
- {team_name}.meta.json: Metadata about the fetch
|
|
618
|
+
|
|
619
|
+
Args:
|
|
620
|
+
result: Successful fetch result
|
|
621
|
+
team_name: Team name for cache key
|
|
622
|
+
cache_root: Cache root directory
|
|
623
|
+
"""
|
|
624
|
+
if not result.success or result.team_config is None:
|
|
625
|
+
return
|
|
626
|
+
|
|
627
|
+
config_path = get_team_config_cache_path(team_name, cache_root)
|
|
628
|
+
meta_path = get_team_meta_cache_path(team_name, cache_root)
|
|
629
|
+
|
|
630
|
+
# Ensure directory exists
|
|
631
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
632
|
+
|
|
633
|
+
# Save config
|
|
634
|
+
config_path.write_text(
|
|
635
|
+
json.dumps(result.team_config, indent=2),
|
|
636
|
+
encoding="utf-8",
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
# Save metadata
|
|
640
|
+
meta = TeamCacheMeta(
|
|
641
|
+
team_name=team_name,
|
|
642
|
+
source_type=result.source_type,
|
|
643
|
+
source_url=result.source_url,
|
|
644
|
+
fetched_at=datetime.now(timezone.utc),
|
|
645
|
+
commit_sha=result.commit_sha,
|
|
646
|
+
etag=result.etag,
|
|
647
|
+
branch=result.branch,
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
meta_path.write_text(
|
|
651
|
+
json.dumps(meta.to_dict(), indent=2),
|
|
652
|
+
encoding="utf-8",
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def load_team_config_cache(
|
|
657
|
+
team_name: str,
|
|
658
|
+
cache_root: Path | None = None,
|
|
659
|
+
) -> tuple[dict[str, Any], TeamCacheMeta] | None:
|
|
660
|
+
"""Load team config from cache.
|
|
661
|
+
|
|
662
|
+
Args:
|
|
663
|
+
team_name: Team name to load
|
|
664
|
+
cache_root: Cache root directory
|
|
665
|
+
|
|
666
|
+
Returns:
|
|
667
|
+
Tuple of (config_dict, cache_meta) or None if not cached
|
|
668
|
+
"""
|
|
669
|
+
config_path = get_team_config_cache_path(team_name, cache_root)
|
|
670
|
+
meta_path = get_team_meta_cache_path(team_name, cache_root)
|
|
671
|
+
|
|
672
|
+
# Check if both files exist
|
|
673
|
+
if not config_path.exists() or not meta_path.exists():
|
|
674
|
+
return None
|
|
675
|
+
|
|
676
|
+
try:
|
|
677
|
+
# Load config
|
|
678
|
+
config_text = config_path.read_text(encoding="utf-8")
|
|
679
|
+
config = json.loads(config_text)
|
|
680
|
+
|
|
681
|
+
# Load metadata
|
|
682
|
+
meta_text = meta_path.read_text(encoding="utf-8")
|
|
683
|
+
meta_dict = json.loads(meta_text)
|
|
684
|
+
meta = TeamCacheMeta.from_dict(meta_dict)
|
|
685
|
+
|
|
686
|
+
return (config, meta)
|
|
687
|
+
|
|
688
|
+
except (json.JSONDecodeError, KeyError):
|
|
689
|
+
return None
|