commons-metrics 0.0.14__py3-none-any.whl → 0.0.16__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.
@@ -1,6 +1,9 @@
1
1
  from .util import Util
2
2
  from .database import DatabaseConnection
3
3
  from .repositories import ComponentRepository
4
+ from .update_design_components import UpdateDesignSystemComponents
5
+ from .github_api_client import GitHubAPIClient
6
+ from .azure_devops_client import AzureDevOpsClient
4
7
 
5
- __all__ = ['Util', 'DatabaseConnection', 'ComponentRepository']
6
- __version__ = '0.0.14'
8
+ __all__ = ['Util', 'DatabaseConnection', 'ComponentRepository', 'UpdateDesignSystemComponents', 'GitHubAPIClient', 'AzureDevOpsClient']
9
+ __version__ = '0.0.16'
@@ -0,0 +1,432 @@
1
+ import requests
2
+ import time
3
+ import json
4
+ import re
5
+ from typing import List, Dict, Optional
6
+ from requests.exceptions import HTTPError, Timeout, ConnectionError
7
+ import urllib3
8
+ from .commons_repos_client import extract_package_version_from_pubspec, extract_package_version_from_package_json
9
+
10
+ # Suprimir warnings de SSL para Azure DevOps con certificados corporativos
11
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
12
+
13
+
14
+ class AzureDevOpsClient:
15
+ """
16
+ Cliente para interactuar con la API de Azure DevOps
17
+ """
18
+
19
+ def __init__(self, token: str, organization: str = "grupobancolombia",
20
+ project_id: str = "b267af7c-3233-4ad1-97b3-91083943100d"):
21
+ """
22
+ Inicializa el cliente de Azure DevOps API
23
+
24
+ Args:
25
+ token: Bearer token de Azure DevOps
26
+ organization: Nombre de la organización (default: 'grupobancolombia')
27
+ project_id: ID del proyecto VSTI (default: ID de Vicepresidencia Servicios de Tecnología)
28
+ """
29
+ self.token = token
30
+ self.organization = organization
31
+ self.project_id = project_id
32
+
33
+ # URLs de Azure DevOps
34
+ self.base_url = f"https://{organization}.visualstudio.com/"
35
+ self.search_url = f"https://{organization}.almsearch.visualstudio.com/"
36
+ self.api_path = "_apis/"
37
+
38
+ # Headers de autenticación
39
+ self.headers = {
40
+ "Accept": "application/json;api-version=5.0-preview.1",
41
+ "Authorization": f"Bearer {token}",
42
+ "Content-Type": "application/json"
43
+ }
44
+
45
+ # Configuración
46
+ self.max_results_per_page = 1000
47
+ self.verify_ssl = False # Desactivar para certificados corporativos
48
+ self.excluded_repos = ["deprecated", "test", "prueba", "poc", "jubilado"]
49
+
50
+ def _requests_with_retries(self, method: str, url: str,
51
+ headers: Dict = None, params: Dict = None,
52
+ json_body: Dict = None, timeout: tuple = (10, 300),
53
+ retries: int = 5, backoff_factor: float = 0.5):
54
+ """
55
+ Realiza peticiones HTTP con reintentos automáticos
56
+
57
+ Args:
58
+ method: Método HTTP ('GET' o 'POST')
59
+ url: URL a consultar
60
+ headers: Headers de la petición
61
+ params: Parámetros de la petición (para GET)
62
+ json_body: Body JSON (para POST)
63
+ timeout: Timeout de conexión y lectura
64
+ retries: Número de reintentos
65
+ backoff_factor: Factor de espera exponencial
66
+
67
+ Returns:
68
+ Response object
69
+ """
70
+ headers = headers or self.headers
71
+
72
+ for attempt in range(retries):
73
+ try:
74
+ if method.upper() == 'GET':
75
+ response = requests.get(
76
+ url,
77
+ headers=headers,
78
+ params=params,
79
+ timeout=timeout,
80
+ verify=self.verify_ssl
81
+ )
82
+ elif method.upper() == 'POST':
83
+ response = requests.post(
84
+ url,
85
+ headers=headers,
86
+ json=json_body,
87
+ timeout=timeout,
88
+ verify=self.verify_ssl
89
+ )
90
+ else:
91
+ raise ValueError(f"Método HTTP no soportado: {method}")
92
+
93
+ response.raise_for_status()
94
+ return response
95
+
96
+ except (Timeout, ConnectionError, HTTPError) as e:
97
+ if isinstance(e, HTTPError) and e.response.status_code < 500:
98
+ raise e
99
+ if attempt < retries - 1:
100
+ wait_time = backoff_factor * (2 ** attempt)
101
+ print(f"⚠️ Intento {attempt + 1}/{retries} fallido. Reintentando en {wait_time}s...")
102
+ time.sleep(wait_time)
103
+ else:
104
+ raise ConnectionError(f"No se pudo conectar a {url} después de {retries} intentos.")
105
+
106
+ def _search_repos_with_file(self, filename: str,
107
+ project_filter: str = "Vicepresidencia Servicios de Tecnología") -> List[Dict]:
108
+ """
109
+ Busca repositorios que contengan un archivo específico
110
+
111
+ Args:
112
+ filename: Nombre del archivo a buscar (ej: 'pubspec.yaml', 'package.json')
113
+ project_filter: Filtro de proyecto de Azure DevOps
114
+
115
+ Returns:
116
+ Lista de repositorios con nombre, ID y metadata
117
+ """
118
+ print(f"🔍 Buscando repositorios en Azure DevOps con archivo '{filename}'...")
119
+
120
+ search_expression = f'file:{filename}'
121
+ all_repo_info = set()
122
+ skip_results = 0
123
+
124
+ api_url = f"{self.search_url}{self.project_id}/{self.api_path}search/codeQueryResults"
125
+
126
+ while True:
127
+ json_body = {
128
+ "searchText": search_expression,
129
+ "skipResults": skip_results,
130
+ "takeResults": self.max_results_per_page,
131
+ "summarizedHitCountsNeeded": True,
132
+ "searchFilters": {
133
+ "ProjectFilters": [project_filter]
134
+ },
135
+ "filters": [],
136
+ "includeSuggestions": False,
137
+ "sortOptions": []
138
+ }
139
+
140
+ try:
141
+ response = self._requests_with_retries(
142
+ "POST",
143
+ api_url,
144
+ json_body=json_body
145
+ )
146
+
147
+ # Validar el status code antes de parsear
148
+ if response.status_code != 200:
149
+ print(f"❌ Error HTTP {response.status_code}: {response.text[:500]}")
150
+ break
151
+
152
+ # Intentar parsear el JSON
153
+ try:
154
+ data = response.json()
155
+ except json.JSONDecodeError as json_err:
156
+ print(f"❌ Error parseando respuesta JSON: {json_err}")
157
+ print(f" Respuesta raw (primeros 500 caracteres): {response.text[:500]}")
158
+ break
159
+
160
+ results_wrapper = data.get("results", {})
161
+
162
+ # Manejar diferentes estructuras de respuesta
163
+ if isinstance(results_wrapper, dict):
164
+ results = results_wrapper.get("values", [])
165
+ elif isinstance(results_wrapper, list):
166
+ results = results_wrapper
167
+ else:
168
+ results = []
169
+
170
+ if not results:
171
+ break
172
+
173
+ # Extraer información de repositorios y rutas de archivos
174
+ for result in results:
175
+ if not isinstance(result, dict):
176
+ continue
177
+
178
+ repo_data = result.get("repository", {})
179
+ file_path = result.get("path", filename) # Obtener la ruta completa del archivo
180
+
181
+ if isinstance(repo_data, dict):
182
+ repo_name = repo_data.get("name")
183
+ repo_id = repo_data.get("id")
184
+ elif isinstance(repo_data, str):
185
+ repo_name = repo_data
186
+ repo_id = result.get("repositoryId")
187
+ else:
188
+ continue
189
+
190
+ if repo_name and repo_id:
191
+ # Guardar repo_id, repo_name y file_path
192
+ all_repo_info.add((repo_name, repo_id, file_path))
193
+
194
+ # Verificar si hay más páginas
195
+ if len(results) < self.max_results_per_page:
196
+ break
197
+
198
+ skip_results += len(results)
199
+
200
+ except Exception as e:
201
+ print(f"❌ Error en búsqueda de Azure: {e}")
202
+ break
203
+
204
+ # Filtrar repositorios excluidos
205
+ filtered_repos = []
206
+ for repo_name, repo_id, file_path in all_repo_info:
207
+ repo_lower = repo_name.lower()
208
+ should_exclude = any(word.lower() in repo_lower for word in self.excluded_repos)
209
+
210
+ if not should_exclude:
211
+ filtered_repos.append({
212
+ "name": repo_name,
213
+ "id": repo_id,
214
+ "file_path": file_path, # Agregar la ruta del archivo encontrado
215
+ "source": "azure"
216
+ })
217
+
218
+ print(f"✅ Encontrados {len(filtered_repos)} repositorios en Azure DevOps")
219
+ return sorted(filtered_repos, key=lambda r: r['name'])
220
+
221
+ def get_repo_files(self, repo_id: str, extension: str = None,
222
+ exclude_patterns: List[str] = None) -> List[Dict]:
223
+ """
224
+ Obtiene todos los archivos de un repositorio
225
+
226
+ Args:
227
+ repo_id: ID del repositorio en Azure DevOps
228
+ extension: Extensión a filtrar (ej: '.dart', '.ts')
229
+ exclude_patterns: Lista de patrones a excluir (ej: ['test', '.spec.'])
230
+
231
+ Returns:
232
+ Lista de archivos con path, objectId y metadata
233
+ """
234
+ exclude_patterns = exclude_patterns or []
235
+ items_url = f"{self.base_url}{self.project_id}/{self.api_path}git/repositories/{repo_id}/items"
236
+ params = {
237
+ "recursionLevel": "full",
238
+ "api-version": "7.1"
239
+ }
240
+
241
+ try:
242
+ response = self._requests_with_retries(
243
+ 'GET',
244
+ items_url,
245
+ params=params,
246
+ timeout=(10, 600)
247
+ )
248
+
249
+ all_items = response.json().get("value", [])
250
+ filtered_files = []
251
+
252
+ for item in all_items:
253
+ # Solo archivos (blobs)
254
+ if item.get("gitObjectType") != "blob":
255
+ continue
256
+
257
+ path = item.get("path", "")
258
+ path_lower = path.lower()
259
+
260
+ # Filtrar por extensión si se especifica
261
+ if extension and not path_lower.endswith(extension.lower()):
262
+ continue
263
+
264
+ # Aplicar patrones de exclusión
265
+ should_exclude = any(pattern.lower() in path_lower for pattern in exclude_patterns)
266
+ if should_exclude:
267
+ continue
268
+
269
+ filtered_files.append({
270
+ 'name': path.split('/')[-1],
271
+ 'path': path,
272
+ 'objectId': item.get('objectId'),
273
+ 'url': item.get('url', '')
274
+ })
275
+
276
+ return filtered_files
277
+
278
+ except Exception as e:
279
+ print(f"❌ Error obteniendo archivos del repo {repo_id}: {e}")
280
+ return []
281
+
282
+ def get_file_content(self, repo_id: str, blob_id: str) -> Optional[str]:
283
+ """
284
+ Obtiene el contenido de un archivo por su blob ID
285
+
286
+ Args:
287
+ repo_id: ID del repositorio
288
+ blob_id: ID del blob (archivo)
289
+
290
+ Returns:
291
+ Contenido del archivo como string, o None si hay error
292
+ """
293
+ if not blob_id:
294
+ return None
295
+
296
+ content_url = f"{self.base_url}{self.project_id}/{self.api_path}git/repositories/{repo_id}/blobs/{blob_id}"
297
+ params = {
298
+ "api-version": "7.1",
299
+ "$format": "octetstream"
300
+ }
301
+
302
+ try:
303
+ response = self._requests_with_retries(
304
+ 'GET',
305
+ content_url,
306
+ params=params,
307
+ timeout=(10, 180)
308
+ )
309
+ return response.text
310
+
311
+ except Exception as e:
312
+ print(f"⚠️ Error obteniendo contenido de archivo: {e}")
313
+ return None
314
+
315
+ def _get_file_content_by_path(self, repo_id: str, file_path: str) -> Optional[str]:
316
+ """
317
+ Obtiene el contenido de un archivo por su ruta
318
+
319
+ Args:
320
+ repo_id: ID del repositorio
321
+ file_path: Ruta del archivo en el repositorio
322
+
323
+ Returns:
324
+ Contenido del archivo como string, o None si hay error
325
+ """
326
+ # Primero obtener el objectId del archivo
327
+ items_url = f"{self.base_url}{self.project_id}/{self.api_path}git/repositories/{repo_id}/items"
328
+ params = {
329
+ "path": file_path,
330
+ "api-version": "7.1"
331
+ }
332
+
333
+ try:
334
+ response = self._requests_with_retries(
335
+ 'GET',
336
+ items_url,
337
+ params=params,
338
+ timeout=(10, 60)
339
+ )
340
+
341
+ item_data = response.json()
342
+ object_id = item_data.get('objectId')
343
+
344
+ if object_id:
345
+ return self.get_file_content(repo_id, object_id)
346
+
347
+ return None
348
+
349
+ except HTTPError as e:
350
+ # Si es 404, el archivo no existe (es normal, no mostrar error)
351
+ if e.response.status_code == 404:
352
+ return None
353
+ # Otros errores HTTP sí se muestran
354
+ print(f"⚠️ Error HTTP {e.response.status_code} obteniendo {file_path}")
355
+ return None
356
+ except Exception as e:
357
+ # Otros errores generales
358
+ print(f"⚠️ Error obteniendo archivo por ruta: {e}")
359
+ return None
360
+
361
+ def search_projects_with_bds(self, platform: str, design_system_name: str = None) -> List[Dict]:
362
+ """
363
+ Busca proyectos que usan el sistema de diseño BDS (mobile o web)
364
+
365
+ Args:
366
+ platform: 'mobile' o 'web'
367
+ design_system_name: Nombre del paquete del sistema de diseño
368
+ Si no se proporciona, usa valores por defecto:
369
+ - mobile: "bds_mobile"
370
+ - web: "@bancolombia/design-system-web"
371
+
372
+ Returns:
373
+ Lista de proyectos con información completa
374
+
375
+ Raises:
376
+ ValueError: Si platform no es 'mobile' o 'web'
377
+ """
378
+ # Validar plataforma
379
+ if platform not in ['mobile', 'web']:
380
+ raise ValueError(f"Platform debe ser 'mobile' o 'web', se recibió: {platform}")
381
+
382
+ # Configurar valores según la plataforma
383
+ if platform == 'mobile':
384
+ config_file = 'pubspec.yaml'
385
+ default_package_name = 'bds_mobile'
386
+ extract_version_method = extract_package_version_from_pubspec
387
+ else: # web
388
+ config_file = 'package.json'
389
+ default_package_name = '@bancolombia/design-system-web'
390
+ extract_version_method = extract_package_version_from_package_json
391
+
392
+ # Usar nombre de paquete por defecto si no se proporciona
393
+ package_name = design_system_name or default_package_name
394
+
395
+ # Buscar repositorios con el archivo de configuración
396
+ repos = self._search_repos_with_file(config_file)
397
+
398
+ projects = []
399
+ for idx, repo in enumerate(repos, 1):
400
+ print(f" 📦 [{idx}/{len(repos)}] Verificando {repo['name']}...")
401
+
402
+ # Usar la ruta del archivo encontrado por la búsqueda (puede estar en subcarpetas)
403
+ file_path = repo.get('file_path', config_file)
404
+
405
+ # Obtener contenido del archivo usando la ruta completa
406
+ file_content = self._get_file_content_by_path(repo['id'], file_path)
407
+
408
+ if not file_content:
409
+ continue
410
+
411
+ # Verificar si usa BDS
412
+ if package_name not in file_content:
413
+ continue
414
+
415
+ # Extraer versión
416
+ version = extract_version_method(file_content, package_name)
417
+
418
+ project_info = {
419
+ 'name': repo['name'],
420
+ 'full_name': f"azure/{repo['name']}",
421
+ 'owner': 'azure',
422
+ 'repo_url': f"{self.base_url}{self.project_id}/_git/{repo['name']}",
423
+ 'file_path': file_path,
424
+ 'bds_version': version,
425
+ 'source': 'azure',
426
+ 'repo_id': repo['id']
427
+ }
428
+
429
+ projects.append(project_info)
430
+
431
+ print(f"\n✅ Total proyectos {platform} con BDS en Azure: {len(projects)}")
432
+ return projects
@@ -0,0 +1,114 @@
1
+ """
2
+ Módulo con funciones compartidas entre clientes de repositorios (GitHub, Azure DevOps)
3
+ """
4
+ import re
5
+ import json
6
+ from typing import Optional, Dict
7
+
8
+
9
+ def extract_package_version_from_pubspec(content: str, package_name: str) -> Optional[Dict]:
10
+ """
11
+ Extrae la versión de un paquete del contenido de pubspec.yaml
12
+
13
+ Args:
14
+ content: Contenido del archivo pubspec.yaml
15
+ package_name: Nombre del paquete a buscar (ej: 'bds_mobile')
16
+
17
+ Returns:
18
+ Diccionario con versión completa y major, o None
19
+ """
20
+ if not content:
21
+ return None
22
+
23
+ # Patrones para diferentes formatos de versión (con posibles espacios antes)
24
+ # IMPORTANTE: debe estar en sección dependencies: para evitar falsos positivos
25
+ simple_patterns = [
26
+ # Formato simple: bds_mobile: ^8.127.0 (en la misma línea)
27
+ rf'^\s*{package_name}\s*:\s*\^?([0-9]+\.[0-9]+\.[0-9]+)',
28
+ # Formato con >= o ~
29
+ rf'^\s*{package_name}\s*:\s*>=([0-9]+\.[0-9]+\.[0-9]+)',
30
+ rf'^\s*{package_name}\s*:\s*~>([0-9]+\.[0-9]+\.[0-9]+)',
31
+ ]
32
+
33
+ for pattern in simple_patterns:
34
+ match = re.search(pattern, content, re.MULTILINE)
35
+ if match:
36
+ version = match.group(1)
37
+ major_version = version.split('.')[0]
38
+ return {
39
+ 'full_version': version,
40
+ 'major_version': major_version
41
+ }
42
+
43
+ # Formato hosted con artifactory (múltiples variantes)
44
+ hosted_patterns = [
45
+ # Patrón 1: hosted simple (una línea)
46
+ rf'^(\s*){package_name}\s*:\s*$\s*\1\s+hosted:\s*https?://.*?$\s*\1\s+version:\s*["\']?\^?([0-9]+\.[0-9]+\.[0-9]+)',
47
+ # Patrón 2: hosted con name/url (multilínea)
48
+ rf'^(\s*){package_name}\s*:\s*$\s*\1\s+hosted:\s*$.*?\s*\1\s+version:\s*["\']?\^?([0-9]+\.[0-9]+\.[0-9]+)',
49
+ ]
50
+
51
+ for hosted_pattern in hosted_patterns:
52
+ hosted_match = re.search(hosted_pattern, content, re.MULTILINE | re.DOTALL)
53
+ if hosted_match:
54
+ # El último grupo siempre es la versión
55
+ version = hosted_match.groups()[-1]
56
+ major_version = version.split('.')[0]
57
+ return {
58
+ 'full_version': version,
59
+ 'major_version': major_version
60
+ }
61
+
62
+ return None
63
+
64
+
65
+ def extract_package_version_from_package_json(content: str, package_name: str) -> Optional[Dict]:
66
+ """
67
+ Extrae la versión de un paquete del contenido de package.json
68
+
69
+ Args:
70
+ content: Contenido del archivo package.json
71
+ package_name: Nombre del paquete a buscar (ej: '@bancolombia/design-system-web')
72
+
73
+ Returns:
74
+ Diccionario con versión completa y major, o None
75
+ """
76
+ if not content:
77
+ return None
78
+
79
+ try:
80
+ # Intentar parsear como JSON
81
+ package_data = json.loads(content)
82
+
83
+ # Buscar en dependencies y devDependencies
84
+ for dep_key in ['dependencies', 'devDependencies']:
85
+ if dep_key in package_data:
86
+ deps = package_data[dep_key]
87
+ if package_name in deps:
88
+ version_str = deps[package_name]
89
+
90
+ # Extraer versión semántica (eliminar ^, ~, >=, etc.)
91
+ version_match = re.search(r'([0-9]+\.[0-9]+\.[0-9]+)', version_str)
92
+ if version_match:
93
+ version = version_match.group(1)
94
+ major_version = version.split('.')[0]
95
+ return {
96
+ 'full_version': version,
97
+ 'major_version': major_version
98
+ }
99
+ except json.JSONDecodeError:
100
+ # Si falla el parseo JSON, intentar con regex
101
+ pass
102
+
103
+ # Fallback: buscar con regex
104
+ pattern = rf'"{re.escape(package_name)}"\s*:\s*"[\^~>=<]*([0-9]+\.[0-9]+\.[0-9]+)'
105
+ match = re.search(pattern, content)
106
+ if match:
107
+ version = match.group(1)
108
+ major_version = version.split('.')[0]
109
+ return {
110
+ 'full_version': version,
111
+ 'major_version': major_version
112
+ }
113
+
114
+ return None