siesa-agents 2.1.72-qa.2 → 2.1.72-qa.21

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.
Files changed (49) hide show
  1. package/bmad/bmm/config.yaml +33 -0
  2. package/claude/skills/sa-agent-sre-sentinel/SKILL.md +180 -0
  3. package/claude/skills/sa-aplicar/SKILL.md +268 -0
  4. package/claude/skills/sa-auditar-servicio/SKILL.md +255 -0
  5. package/claude/skills/sa-nueva-transversal/SKILL.md +317 -0
  6. package/claude/skills/sa-nuevo-ambiente/SKILL.md +147 -0
  7. package/claude/skills/sa-nuevo-servicio/SKILL.md +530 -0
  8. package/claude/skills/sa-onboard-db/SKILL.md +122 -0
  9. package/claude/skills/sa-qa-data-generator/SKILL.md +200 -51
  10. package/claude/skills/sa-quality-process/SKILL.md +6 -0
  11. package/claude/skills/sa-registrar-permisos/SKILL.md +233 -0
  12. package/package.json +1 -1
  13. package/siesa-agents/bmm/workflows/3-solutioning/quality-process/prompts/prompt_design_test.md +1037 -60
  14. package/siesa-agents/bmm/workflows/3-solutioning/quality-process/prompts/prompt_dor_gate.md +419 -379
  15. package/siesa-agents/bmm/workflows/3-solutioning/quality-process/prompts/prompt_e2e_executor.md +273 -0
  16. package/siesa-agents/bmm/workflows/3-solutioning/quality-process/prompts/prompt_playwright_impl.md +359 -355
  17. package/siesa-agents/bmm/workflows/3-solutioning/quality-process/prompts/prompt_test_plan.md +73 -111
  18. package/siesa-agents/bmm/workflows/3-solutioning/quality-process/workflow.md +1320 -997
  19. package/siesa-agents/resources/playwright-kit/.env.example +42 -0
  20. package/siesa-agents/resources/playwright-kit/README.md +228 -0
  21. package/siesa-agents/resources/playwright-kit/allure/categories.json +26 -0
  22. package/siesa-agents/resources/playwright-kit/global-setup.ts +34 -0
  23. package/siesa-agents/resources/playwright-kit/helpers/blazor-e2e-helpers.ts +417 -0
  24. package/siesa-agents/resources/playwright-kit/helpers/react-e2e-helpers.ts +297 -0
  25. package/siesa-agents/resources/playwright-kit/package-lock.json +850 -0
  26. package/siesa-agents/resources/playwright-kit/package.json +27 -0
  27. package/siesa-agents/resources/playwright-kit/playwright.config.ts +60 -0
  28. package/siesa-agents/resources/playwright-kit/proposed/.gitkeep +0 -0
  29. package/siesa-agents/resources/playwright-kit/reporters/agiletest-reporter.ts +252 -0
  30. package/siesa-agents/resources/playwright-kit/specs/.gitkeep +0 -0
  31. package/siesa-agents/resources/playwright-kit/specs/architecture-brief-siesa-erp.md +85 -0
  32. package/siesa-agents/resources/playwright-kit/specs/architecture-brief-siesa-react.md +109 -0
  33. package/siesa-agents/resources/playwright-kit/specs/architecture-brief-template.md +69 -0
  34. package/siesa-agents/resources/playwright-kit/tests/seed-example.spec.ts +95 -0
  35. package/siesa-agents/resources/playwright-kit/tools/locator-overlay.js +1306 -0
  36. package/siesa-agents/resources/playwright-kit/tools/locator-recorder.spec.ts +986 -0
  37. package/siesa-agents/scripts/__pycache__/bmad_to_agiletest.cpython-36.pyc +0 -0
  38. package/siesa-agents/scripts/bmad_to_agiletest.py +352 -337
  39. package/siesa-agents/scripts/converters/convert_csv_to_test_design.py +327 -0
  40. package/siesa-agents/scripts/converters/convert_excel_to_test_design.py +360 -0
  41. package/siesa-agents/scripts/converters/convert_md_to_test_design.py +359 -0
  42. package/siesa-agents/scripts/converters/convert_pdf_to_test_design.py +412 -0
  43. package/siesa-agents/scripts/converters/export_yaml_to_csv.py +149 -0
  44. package/siesa-agents/scripts/converters/export_yaml_to_excel.py +233 -0
  45. package/siesa-agents/scripts/converters/export_yaml_to_markdown.py +172 -0
  46. package/siesa-agents/scripts/converters/export_yaml_to_pdf.py +297 -0
  47. package/siesa-agents/scripts/merge_test_design.py +106 -106
  48. package/siesa-agents/scripts/playwright/yaml-to-playwright-spec.ts +203 -0
  49. package/siesa-agents/scripts/requirements.txt +7 -0
@@ -1,22 +1,31 @@
1
1
  """
2
- bmad_to_agiletest.py — Parser + Cargador de artefactos BMAD V6.0 → AgileTest/Jira
2
+ bmad_to_agiletest.py — Parser + Cargador de artefactos BMAD V7.8 → AgileTest/Jira
3
3
 
4
- Lee un archivo bmad-v6-*-test-design.md (documento unificado BMAD V6.0) y extrae:
5
- - Stories desde Seccion I (Gatekeeper)
6
- - FACs Gherkin desde Seccion II (Features & FAC)
7
- - Test Cases desde Seccion IV (Matriz 360°)
8
- - Traceability desde Apendice
9
- Luego puede crear todo en Jira/AgileTest via API REST.
4
+ Lee el `test-design.yml` (fuente única de verdad, BMAD V7.8) con yaml.safe_load y extrae:
5
+ - Stories desde `gatekeeper[]`
6
+ - FACs Gherkin desde `features[].fac[]`
7
+ - Test Cases desde `test_matrix[]` (con `steps[]` atómicos: paso/accion/resultado/datos)
8
+ - Traceability desde `traceability[]`
9
+ Luego puede crear todo en Jira/AgileTest via API REST. Cada `step` lleva su `data` (datos por
10
+ paso del YAML), de modo que los Test Steps en AgileTest quedan con su testData real.
10
11
 
11
12
  Uso:
12
13
  python bmad_to_agiletest.py --dry-run # Parsea y muestra resumen
13
14
  python bmad_to_agiletest.py --dry-run --detail # Muestra cada TC con steps
14
15
  python bmad_to_agiletest.py --dry-run --export # Exporta a JSON
15
- python bmad_to_agiletest.py --input mi-archivo.md --dry-run # Archivo custom
16
+ python bmad_to_agiletest.py --input test-design.yml --dry-run # Archivo custom (.yml)
16
17
  python bmad_to_agiletest.py --create # Crea en Jira/AgileTest
17
18
 
19
+ Requiere: PyYAML (`pip install pyyaml`).
20
+
21
+ Codificación: toda la E/S de archivos (YAML in, JSON/state out) es UTF-8.
22
+ Salida: el progreso y los resúmenes van a stdout; para consumo por máquina usa
23
+ `--export` (escribe bmad_parsed_data.json) en vez de parsear stdout.
24
+ Exit codes: 0 = OK · 1 = error genérico/credenciales faltantes · 2 = entrada inválida
25
+ (archivo no encontrado / YAML mal formado) · 3 = error de state-dir.
26
+
18
27
  Autor: Juan Manuel Reina Montoya — Lider QA, SIESA
19
- Fecha: 29 marzo 2026 | Actualizado: 09 abril 2026 (soporte BMAD V6.0)
28
+ Fecha: 29 marzo 2026 | Actualizado: 11 junio 2026 (V7.8 lectura YAML vía yaml.safe_load)
20
29
  """
21
30
 
22
31
  import re
@@ -53,8 +62,8 @@ def _http_with_retry(fn, max_retries=4, base_delay=1.5):
53
62
  # ============================================================
54
63
 
55
64
  CONFIG = {
56
- # Archivo BMAD V6.0 (ruta relativa al script)
57
- 'input_file': 'bmad-v6-segment-test-design.md',
65
+ # Archivo BMAD V7.8 — test-design.yml (ruta relativa al script)
66
+ 'input_file': 'test-design.yml',
58
67
 
59
68
  # Jira
60
69
  'jira_base_url': 'https://siesa-team.atlassian.net',
@@ -82,8 +91,15 @@ CONFIG = {
82
91
  'P2': 'Media',
83
92
  'P3': 'Baja',
84
93
  },
94
+
95
+ # Campos Jira obligatorios adicionales por proyecto (vacío = ninguno).
96
+ # Se llena desde config.yaml -> agiletest.extra_fields (ej. CON: timetracking).
97
+ 'extra_fields': {},
85
98
  }
86
99
 
100
+ # Diagnóstico detallado (--verbose)
101
+ VERBOSE = False
102
+
87
103
  # Normalizacion de niveles de test
88
104
  LEVEL_NORMALIZE = {
89
105
  'BE-Integration': 'Integration (TI)',
@@ -134,93 +150,104 @@ class TestCase:
134
150
  steps: list = field(default_factory=list)
135
151
 
136
152
  # ============================================================
137
- # Parser: Metadata del documento
153
+ # Loader: test-design.yml (BMAD V7.8) via yaml.safe_load
138
154
  # ============================================================
139
155
 
140
- def parse_metadata(content: str) -> dict:
141
- """Extrae metadata del header del documento BMAD V6.0."""
142
- meta = {'feature': '', 'feature_code': '', 'date': '', 'author': ''}
143
-
144
- m = re.search(r'\*\*M[oó]dulo/Feature:\*\*\s*(.+)', content)
145
- if m:
146
- meta['feature'] = m.group(1).strip()
156
+ def load_design(yaml_path: str) -> dict:
157
+ """Carga el test-design.yml (V7.8) como dict. Requiere PyYAML."""
158
+ try:
159
+ import yaml
160
+ except ImportError:
161
+ print(' ERROR: falta PyYAML. Instala con: pip install -r requirements.txt (o: pip install pyyaml)')
162
+ sys.exit(1)
163
+ with open(yaml_path, 'r', encoding='utf-8') as f:
164
+ data = yaml.safe_load(f)
165
+ if not isinstance(data, dict):
166
+ raise ValueError('test-design.yml no tiene un mapeo (dict) en la raiz — revisa el YAML')
167
+ return data
147
168
 
148
- m = re.search(r'\*\*Feature Code:\*\*\s*(.+)', content)
149
- if m:
150
- meta['feature_code'] = m.group(1).strip()
151
169
 
152
- m = re.search(r'\*\*Fecha del Dise[nñ]o:\*\*\s*(.+)', content)
153
- if not m:
154
- m = re.search(r'\*\*Fecha.*?:\*\*\s*(.+)', content)
155
- if m:
156
- meta['date'] = m.group(1).strip()
170
+ def validate_design(design: dict):
171
+ """Validación blanda del test-design.yml (V7.8). Solo advierte, no aborta:
172
+ el procesamiento real decide qué hacer con secciones vacías.
157
173
 
158
- return meta
174
+ - H-F5-11: avisa si la versión declarada no parece V7.8.
175
+ - H-F5-15: avisa si faltan las secciones de las que dependen los builders.
176
+ """
177
+ md = design.get('metadata', {}) or {}
178
+ version = str(md.get('version') or md.get('schema_version') or design.get('version') or '').strip()
179
+ if version and '7.8' not in version:
180
+ safe_print(f' [WARN] El test-design.yml declara versión "{version}"; este script está calibrado para V7.8.')
181
+
182
+ required = ('gatekeeper', 'test_matrix')
183
+ faltantes = [k for k in required if not design.get(k)]
184
+ if faltantes:
185
+ safe_print(f' [WARN] Secciones ausentes o vacías en el YAML: {", ".join(faltantes)} '
186
+ f'(se esperan gatekeeper[] para Stories y test_matrix[] para Test Cases).')
187
+
188
+
189
+ def build_metadata(design: dict) -> dict:
190
+ """Deriva meta {feature, feature_code, date, author, mode} desde metadata + features."""
191
+ md = design.get('metadata', {}) or {}
192
+ features = design.get('features', []) or []
193
+ project = md.get('project_name') or md.get('project') or ''
194
+ # feature_code: un solo feature -> su code; varios (Modo Completo) -> el proyecto
195
+ if len(features) == 1 and features[0].get('code'):
196
+ feature_code = features[0]['code']
197
+ feature = features[0].get('name') or project
198
+ else:
199
+ feature_code = project or 'BMAD'
200
+ feature = project or 'BMAD'
201
+ return {
202
+ 'feature': feature,
203
+ 'feature_code': feature_code,
204
+ 'date': str(md.get('generated_date', '')),
205
+ 'author': '',
206
+ 'mode': md.get('mode', ''),
207
+ }
159
208
 
160
209
  # ============================================================
161
- # Parser: Seccion I — Gatekeeper (Stories)
210
+ # Builder: gatekeeper[] -> Stories
162
211
  # ============================================================
163
212
 
164
- def parse_stories(content: str, feature_code: str) -> list:
165
- """Extrae stories de la tabla del Gatekeeper (Seccion I).
213
+ def build_stories(design: dict) -> list:
214
+ """Construye Stories desde `gatekeeper[]` del YAML V7.8.
166
215
 
167
- Soporta dos formatos de story_id:
168
- - Clasico BMAD: FEATURE-E001-S001 (ej: SEGM-SEGMT-E001-S001)
169
- - Quality-process: Story X.Y (ej: Story 2.3)
216
+ Cada entrada: {story_id, name, feature, classification, justification}.
217
+ Clasificacion BL ('BL' / 'Logica de Negocio' / '✅') -> risk_level High.
170
218
  """
171
219
  stories = []
172
-
173
- # Buscar seccion I — tolerar distintos terminadores de sección
174
- gk_match = re.search(
175
- r'## I\.\s*REPORTE DEL GATEKEEPER.*?\n(.*?)(?=\n---\s*\n\n## II|\n## Resumen|\n# FASE 2|\Z)',
176
- content, re.DOTALL
177
- )
178
- if not gk_match:
179
- return stories
180
-
181
- section = gk_match.group(1)
182
-
183
- for line in section.split('\n'):
184
- line = line.strip()
185
- if not line.startswith('|'):
220
+ for e in design.get('gatekeeper', []) or []:
221
+ if not isinstance(e, dict):
186
222
  continue
187
- cols = [c.strip() for c in line.split('|')[1:-1]]
188
- if len(cols) < 3:
189
- continue
190
- story_id = cols[0].strip()
191
-
192
- # Detectar formato
193
- is_classic = bool(re.search(r'E\d+-S\d+', story_id)) # FEATURE-E001-S001
194
- is_qp = bool(re.match(r'Story\s+\d+\.\d+', story_id)) # Story 2.3
195
-
196
- if not is_classic and not is_qp:
223
+ story_id = str(e.get('story_id') or e.get('id') or '').strip()
224
+ if not story_id:
197
225
  continue
198
-
199
- title = cols[1].strip()
200
- classification = cols[2].strip()
201
- justification = cols[3].strip() if len(cols) > 3 else ''
226
+ title = str(e.get('name', '')).strip()
227
+ justification = str(e.get('justification', '')).strip()
228
+ classification = str(e.get('classification', '')).strip()
229
+ feature = str(e.get('feature', '')).strip()
202
230
 
203
231
  # Epic ID
204
- if is_classic:
205
- epic_match = re.search(r'(.*-E\d+)', story_id)
206
- epic_id = epic_match.group(1) if epic_match else ''
232
+ if re.search(r'E\d+-S\d+', story_id):
233
+ em = re.search(r'(.*-E\d+)', story_id)
234
+ epic_id = em.group(1) if em else ''
207
235
  else:
208
- epic_num_match = re.match(r'Story\s+(\d+)\.\d+', story_id)
209
- epic_id = f'Epic {epic_num_match.group(1)}' if epic_num_match else ''
236
+ em = re.match(r'(?:Story\s+)?(\d+)\.\d+', story_id)
237
+ epic_id = f'Epic {em.group(1)}' if em else (feature or '')
210
238
 
211
- # Layer
239
+ # Layer (heuristica por contenido)
240
+ lower_all = (title + ' ' + justification + ' ' + feature).lower()
212
241
  layer = 'Backend'
213
- lower_all = (title + justification).lower()
214
242
  if any(kw in lower_all for kw in [
215
243
  'frontend', 'react', 'ui ', 'typescript', 'hook', 'page',
216
244
  'drawer', 'form', 'navigation', 'fe+be', 'fe ',
217
245
  ]):
218
246
  layer = 'Frontend'
219
247
 
220
- # Clasificación BL: soporta "BL", "Lógica de Negocio", "Logica de Negocio", "✅"
221
248
  is_bl = (
222
249
  'BL' in classification or
223
- 'gica de Negocio' in classification or # Lógica / Logica
250
+ 'gica de Negocio' in classification or # Logica / Lógica de Negocio
224
251
  '✅' in classification
225
252
  )
226
253
 
@@ -233,258 +260,178 @@ def parse_stories(content: str, feature_code: str) -> list:
233
260
  layer=layer,
234
261
  risk_level='High' if is_bl else 'Low',
235
262
  ))
236
-
237
263
  return stories
238
264
 
239
265
  # ============================================================
240
- # Parser: Seccion II FACs Gherkin
266
+ # Builder: features[].fac[] -> FACs Gherkin
241
267
  # ============================================================
242
268
 
243
- def parse_facs(content: str) -> dict:
244
- """Extrae FAC Gherkin de Seccion II. Retorna dict: fac_id -> gherkin_text."""
269
+ def build_facs(design: dict) -> dict:
270
+ """Construye dict fac_id -> texto Gherkin desde `features[].fac[]`."""
245
271
  facs = {}
246
-
247
- # Buscar todos los Scenario dentro de bloques ```gherkin
248
- gherkin_blocks = re.findall(r'```gherkin\s*\n(.*?)```', content, re.DOTALL)
249
-
250
- for block in gherkin_blocks:
251
- # Separar scenarios
252
- scenarios = re.split(r'\n\s*(?=Scenario(?:\s+Outline)?:)', block)
253
- for scenario in scenarios:
254
- scenario = scenario.strip()
255
- if not scenario.startswith('Scenario'):
272
+ for feat in design.get('features', []) or []:
273
+ if not isinstance(feat, dict):
274
+ continue
275
+ for fac in feat.get('fac', []) or []:
276
+ if not isinstance(fac, dict):
256
277
  continue
257
- # Extraer FAC ID del titulo
258
- title_match = re.match(r'Scenario(?:\s+Outline)?:\s*(F\d+-(?:FAC|NF)-\d+)\s*', scenario)
259
- if title_match:
260
- fac_id = title_match.group(1)
261
- facs[fac_id] = scenario
262
-
278
+ fid = str(fac.get('id', '')).strip()
279
+ gherkin = str(fac.get('gherkin', '')).strip()
280
+ if fid:
281
+ facs[fid] = gherkin
263
282
  return facs
264
283
 
265
284
  # ============================================================
266
- # Parser: Seccion IV Matriz 360° (Test Cases)
285
+ # Builder: test_matrix[] -> Test Cases (con steps atomicos)
267
286
  # ============================================================
268
287
 
269
- def parse_matrix_row(cols: list) -> TestCase:
270
- """Parsea una fila de la matriz 360° (12 columnas) en un TestCase."""
271
- # Columnas: ID | Funcionalidad | Epicas | Nivel | Tecnica | Escenario |
272
- # Precondiciones | Pasos | Resultado Esperado | Riesgo (IxP) | Prioridad | Estrategia
273
- tc_id = cols[0].strip()
274
- feature = cols[1].strip() if len(cols) > 1 else ''
275
- epics = cols[2].strip() if len(cols) > 2 else ''
276
- level_raw = cols[3].strip() if len(cols) > 3 else ''
277
- technique = cols[4].strip() if len(cols) > 4 else ''
278
- scenario = cols[5].strip() if len(cols) > 5 else ''
279
- preconditions = cols[6].strip() if len(cols) > 6 else ''
280
- pasos = cols[7].strip() if len(cols) > 7 else ''
281
- expected = cols[8].strip() if len(cols) > 8 else ''
282
- risk_score = cols[9].strip() if len(cols) > 9 else ''
283
- priority = cols[10].strip() if len(cols) > 10 else ''
284
- strategy = cols[11].strip() if len(cols) > 11 else ''
285
-
286
- # Normalizar nivel
287
- level = LEVEL_NORMALIZE.get(level_raw, level_raw)
288
-
289
- # Construir steps: Precondiciones -> Setup, Pasos -> Action, Expected -> Result
290
- steps = []
291
- if preconditions and preconditions != '—' and preconditions != '-':
292
- steps.append(TestStep(
293
- action=f'SETUP: {preconditions}',
294
- expected_result='Precondiciones verificadas',
295
- ))
296
- if pasos:
297
- # Separar pasos numerados (1. xxx 2. yyy)
298
- numbered = re.split(r'\d+\.\s+', pasos)
299
- numbered = [p.strip() for p in numbered if p.strip()]
300
- if len(numbered) > 1:
301
- # Multiples pasos: uno por cada numerado, el ultimo lleva el expected
302
- for i, paso in enumerate(numbered):
303
- if i < len(numbered) - 1:
304
- steps.append(TestStep(
305
- action=paso,
306
- expected_result='Paso ejecutado correctamente',
307
- ))
308
- else:
309
- steps.append(TestStep(
310
- action=paso,
311
- expected_result=expected,
312
- ))
313
- else:
314
- steps.append(TestStep(
315
- action=pasos,
316
- expected_result=expected,
317
- ))
318
- elif expected:
319
- steps.append(TestStep(
320
- action=scenario or 'Ejecutar escenario',
321
- expected_result=expected,
322
- ))
323
-
324
- # Extraer story refs del campo Epicas
325
- source_stories = []
326
- story_refs = re.findall(r'E\d+-S\d+', epics)
327
- # Se enriquecen despues con feature_code prefix
328
-
329
- # Construir titulo: escenario como titulo descriptivo
330
- title = scenario if scenario else tc_id
331
-
332
- # Raw body para descripcion en Jira
333
- raw_body = (
334
- f'Escenario: {scenario}\n'
335
- f'Precondiciones: {preconditions}\n'
336
- f'Pasos: {pasos}\n'
337
- f'Resultado Esperado: {expected}\n'
338
- f'Tecnica: {technique}\n'
339
- f'Riesgo: {risk_score}'
340
- )
341
-
342
- return TestCase(
343
- tc_id=tc_id,
344
- title=title,
345
- priority=priority,
346
- level=level,
347
- feature=feature,
348
- epics=epics,
349
- technique=technique,
350
- scenario=scenario,
351
- preconditions=preconditions,
352
- risk_score=risk_score,
353
- strategy=strategy,
354
- source_stories=source_stories,
355
- raw_body=raw_body,
356
- steps=steps,
357
- )
288
+ def build_test_cases(design: dict) -> list:
289
+ """Construye Test Cases desde `test_matrix[]` (V7.8).
358
290
 
359
-
360
- def parse_test_cases(content: str) -> list:
361
- """Extrae Test Cases de Seccion IV (Matriz 360°)."""
291
+ Cada caso trae `steps[]` como array de {paso, accion, resultado, datos}. Cada step
292
+ se mapea a un TestStep con su `data` (datos por paso) insumo del testData de AgileTest.
293
+ """
362
294
  test_cases = []
363
-
364
- # Buscar seccion IV
365
- matrix_match = re.search(
366
- r'## IV\.\s*MATRIZ INTEGRAL.*?\n(.*?)(?=\n---\s*\n\n## V|\Z)',
367
- content, re.DOTALL
368
- )
369
- if not matrix_match:
370
- return test_cases
371
-
372
- section = matrix_match.group(1)
373
-
374
- # Parsear todas las filas de tabla que empiecen con | TC-
375
- for line in section.split('\n'):
376
- line = line.strip()
377
- if not line.startswith('|'):
295
+ for c in design.get('test_matrix', []) or []:
296
+ if not isinstance(c, dict):
378
297
  continue
379
- # Saltar headers y separadores
380
- if '---' in line or 'ID' in line.split('|')[1]:
298
+ tc_id = str(c.get('id', '')).strip()
299
+ if not tc_id:
381
300
  continue
382
301
 
383
- cols = [c.strip() for c in line.split('|')[1:-1]]
384
- if len(cols) < 8:
385
- continue
386
- # Verificar que la primera columna es un TC ID
387
- if not cols[0].startswith('TC-'):
388
- continue
302
+ feature = str(c.get('feature', '')).strip()
303
+ epics_raw = c.get('epics', [])
304
+ if isinstance(epics_raw, list):
305
+ epics = ', '.join(str(x) for x in epics_raw)
306
+ else:
307
+ epics = str(epics_raw or '')
308
+ modo = str(c.get('mode', '') or '').strip()
309
+ level_raw = str(c.get('level', '') or '').strip()
310
+ tipo_interfaz = str(c.get('interface_type', '') or '').strip()
311
+ technique = str(c.get('technique', '') or '').strip()
312
+ scenario = str(c.get('scenario', '') or '').strip()
313
+ preconditions = str(c.get('preconditions', '') or '').strip()
314
+ expected = str(c.get('expected_result', '') or '').strip()
315
+ risk_score = str(c.get('risk', '') or '').strip()
316
+ priority = str(c.get('priority', '') or '').strip()
317
+ level = LEVEL_NORMALIZE.get(level_raw, level_raw)
318
+
319
+ # Steps: precondiciones como SETUP + un step por cada paso atomico del YAML
320
+ steps = []
321
+ if preconditions and preconditions not in ('—', '-'):
322
+ steps.append(TestStep(
323
+ action=f'SETUP: {preconditions}',
324
+ expected_result='Precondiciones verificadas',
325
+ ))
326
+ for s in c.get('steps', []) or []:
327
+ if isinstance(s, dict):
328
+ accion = str(s.get('accion', '') or '').strip()
329
+ resultado = str(s.get('resultado', '') or '').strip()
330
+ datos = str(s.get('datos', '') or '').strip()
331
+ else:
332
+ # tolerancia: step como string suelto
333
+ accion, resultado, datos = str(s).strip(), '', ''
334
+ if accion:
335
+ steps.append(TestStep(
336
+ action=accion,
337
+ expected_result=resultado or expected,
338
+ data=datos,
339
+ ))
340
+ # Fallback: si el YAML no trajo pasos atomicos pero hay resultado esperado
341
+ if not any(not st.action.startswith('SETUP:') for st in steps) and expected:
342
+ steps.append(TestStep(
343
+ action=scenario or 'Ejecutar escenario',
344
+ expected_result=expected,
345
+ ))
389
346
 
390
- tc = parse_matrix_row(cols)
391
- test_cases.append(tc)
347
+ title = scenario if scenario else tc_id
348
+ raw_body = (
349
+ f'Escenario: {scenario}\n'
350
+ f'Precondiciones: {preconditions}\n'
351
+ f'Resultado Esperado: {expected}\n'
352
+ f'Tecnica: {technique}\n'
353
+ + (f'Modo: {modo}\n' if modo else '')
354
+ + (f'Tipo Interfaz: {tipo_interfaz}\n' if tipo_interfaz else '')
355
+ + f'Riesgo: {risk_score}'
356
+ )
392
357
 
358
+ test_cases.append(TestCase(
359
+ tc_id=tc_id,
360
+ title=title,
361
+ priority=priority,
362
+ level=level,
363
+ feature=feature,
364
+ epics=epics,
365
+ technique=technique,
366
+ scenario=scenario,
367
+ preconditions=preconditions,
368
+ risk_score=risk_score,
369
+ strategy=tipo_interfaz,
370
+ source_stories=[],
371
+ raw_body=raw_body,
372
+ steps=steps,
373
+ ))
393
374
  return test_cases
394
375
 
395
376
  # ============================================================
396
- # Parser: Apendice Traceability
377
+ # Builder: traceability[] -> mapping requirement -> [tc_ids]
397
378
  # ============================================================
398
379
 
399
- def _parse_traceability_classic(section: str, feature_code: str) -> dict:
400
- """Formato clásico BMAD: '- E001-S001 (name) → TC-P0-CRUD-001, ...'"""
380
+ def build_traceability(design: dict) -> dict:
381
+ """Construye mapping requisito/criterio -> [tc_ids] desde `traceability[]`."""
401
382
  mapping = {}
402
- line_pattern = re.compile(
403
- r'-\s*(E\d+-S\d+)\s*\(.*?\)\s*(?:→|->)\s*(.+?)$', re.MULTILINE
404
- )
405
- for m in line_pattern.finditer(section):
406
- story_id = f'{feature_code}-{m.group(1)}'
407
- tc_ids = re.findall(r'TC-[A-Z0-9]+-[\w]+-\d+', m.group(2))
408
- tc_ids += [t for t in re.findall(r'TC-[A-Z0-9]+-\d+', m.group(2))
409
- if t not in tc_ids]
410
- if tc_ids:
411
- mapping.setdefault(story_id, [])
412
- mapping[story_id] = list(set(mapping[story_id] + tc_ids))
383
+ for t in design.get('traceability', []) or []:
384
+ if not isinstance(t, dict):
385
+ continue
386
+ req = str(t.get('requirement', '') or '').strip()
387
+ if not req:
388
+ continue
389
+ cases = t.get('cases', []) or []
390
+ mapping.setdefault(req, [])
391
+ for cid in cases:
392
+ cid = str(cid).strip()
393
+ if cid and cid not in mapping[req]:
394
+ mapping[req].append(cid)
413
395
  return mapping
414
396
 
415
397
 
416
- def _parse_traceability_tree(section: str) -> dict:
417
- """Formato árbol quality-process:
418
- F2 Client Management
419
- ├── Story 2.1 (List & Search) TC-F2-01, TC-F2-02
420
- ├── Story 1.2 (Navigation)
421
- │ ├── TC-F1-01 (desc)
422
- └── ...
398
+ def augment_trace_with_stories(trace_map: dict, stories: list, test_cases: list):
399
+ """Agrega al trace_map los vinculos story_id -> [tc_ids] inferidos de la columna
400
+ `epics` de cada caso, para que el link TC -> Requisito (gatekeeper) funcione aunque
401
+ el bloque `traceability` apunte a FACs en vez de a historias.
423
402
  """
424
- mapping = {}
425
- current_story = None
426
- current_tcs = []
427
-
428
- for line in section.split('\n'):
429
- # Detectar línea que menciona una Story
430
- story_match = re.search(r'\bStory\s+(\d+\.\d+)\b', line)
431
- if story_match:
432
- # Cerrar story anterior
433
- if current_story and current_tcs:
434
- mapping.setdefault(current_story, [])
435
- mapping[current_story] = list(set(mapping[current_story] + current_tcs))
436
-
437
- current_story = f'Story {story_match.group(1)}'
438
- current_tcs = []
439
-
440
- # TCs en la misma línea: Story 2.1 → TC-F2-01, TC-F2-02
441
- arrow_match = re.search(r'(?:→|->)\s*(.+)$', line)
442
- if arrow_match:
443
- current_tcs.extend(re.findall(r'TC-[A-Z0-9]+-\d+', arrow_match.group(1)))
444
-
445
- elif current_story:
446
- # TCs en líneas hijo: │ ├── TC-F1-01 (desc)
447
- tcs = re.findall(r'TC-[A-Z0-9]+-\d+', line)
448
- current_tcs.extend(tcs)
449
-
450
- # Guardar el último story
451
- if current_story and current_tcs:
452
- mapping.setdefault(current_story, [])
453
- mapping[current_story] = list(set(mapping[current_story] + current_tcs))
454
-
455
- return mapping
403
+ story_ids = {s.story_id for s in stories}
456
404
 
405
+ def norm(sid):
406
+ m = re.search(r'(\d+\.\d+)', sid)
407
+ return m.group(1) if m else sid
457
408
 
458
- def parse_traceability(content: str, feature_code: str) -> dict:
459
- """Extrae mapping Story → TCs desde el Apéndice de Trazabilidad.
409
+ norm_index = {norm(sid): sid for sid in story_ids}
460
410
 
461
- Soporta dos formatos:
462
- - Clásico BMAD: '- E001-S001 (name) → TC-P0-CRUD-001'
463
- - Quality-process: árbol con ├── y Story X.Y
464
- """
465
- # Buscar sección del apéndice (varios posibles encabezados)
466
- trace_match = re.search(
467
- r'### Funcionalidad.*?(?:Casos de Prueba|Historias|Features).*?\n(.*?)(?=\n### Trazabilidad|\n### Requerimientos|\Z)',
468
- content, re.DOTALL
469
- )
470
- if not trace_match:
471
- trace_match = re.search(
472
- r'(?:APÉNDICE|APENDICE).*?TRAZABILIDAD.*?\n(.*?)(?=\n### Trazabilidad|\n### Requerimientos|\Z)',
473
- content, re.DOTALL
474
- )
475
- if not trace_match:
476
- return {}
477
-
478
- section = trace_match.group(1)
411
+ for tc in test_cases:
412
+ if not tc.epics:
413
+ continue
414
+ refs = re.findall(r'E\d+-S\d+', tc.epics) + re.findall(r'\b\d+\.\d+\b', tc.epics)
415
+ for ref in refs:
416
+ sid = ref if ref in story_ids else norm_index.get(norm(ref))
417
+ if not sid:
418
+ continue
419
+ trace_map.setdefault(sid, [])
420
+ if tc.tc_id not in trace_map[sid]:
421
+ trace_map[sid].append(tc.tc_id)
422
+ return trace_map
479
423
 
480
- # Intentar formato clásico primero
481
- mapping = _parse_traceability_classic(section, feature_code)
424
+ # ============================================================
425
+ # (legacy) Parser: Seccion II — FACs Gherkin
426
+ # ============================================================
482
427
 
483
- # Si no encontró nada, intentar formato árbol quality-process
484
- if not mapping:
485
- mapping = _parse_traceability_tree(section)
428
+ # ============================================================
429
+ # (legacy) Parser: Seccion IV — Matriz 360° (Test Cases)
430
+ # ============================================================
486
431
 
487
- return mapping
432
+ # ============================================================
433
+ # (legacy) Parser: Apendice — Traceability
434
+ # ============================================================
488
435
 
489
436
  # ============================================================
490
437
  # Enriquecimiento: propagar traceability y FACs a TCs
@@ -503,12 +450,17 @@ def enrich_test_cases(test_cases: list, trace_map: dict, facs: dict, feature_cod
503
450
 
504
451
  # 2. Inferir story refs del campo epics de cada TC
505
452
  for tc in test_cases:
506
- if tc.epics:
507
- refs = re.findall(r'E\d+-S\d+', tc.epics)
508
- for ref in refs:
509
- full_id = f'{feature_code}-{ref}'
510
- if full_id not in tc.source_stories:
511
- tc.source_stories.append(full_id)
453
+ if not tc.epics:
454
+ continue
455
+ # Formato clasico: E001-S001
456
+ for ref in re.findall(r'E\d+-S\d+', tc.epics):
457
+ full_id = f'{feature_code}-{ref}'
458
+ if full_id not in tc.source_stories:
459
+ tc.source_stories.append(full_id)
460
+ # Formato V7.8: "Epic 2 / 2.1, 2.5" -> historias 2.1, 2.5 (numero pelado)
461
+ for ref in re.findall(r'\b\d+\.\d+\b', tc.epics):
462
+ if ref not in tc.source_stories:
463
+ tc.source_stories.append(ref)
512
464
 
513
465
  # 3. Normalizar niveles
514
466
  for tc in test_cases:
@@ -518,27 +470,24 @@ def enrich_test_cases(test_cases: list, trace_map: dict, facs: dict, feature_cod
518
470
  # Enriquecimiento: epic names en stories
519
471
  # ============================================================
520
472
 
521
- def enrich_stories_from_traceability(content: str, stories: list):
522
- """Extrae nombres de Feature/Epic del texto de traceability y los asigna a stories."""
523
- # Buscar lineas como: **F1 — Gestion del Ciclo de Vida de Segmentos**
524
- feature_blocks = re.findall(
525
- r'\*\*(F\d+)\s*(?:—|--)\s*(.+?)\*\*', content
526
- )
527
- feature_names = {fid: fname.strip() for fid, fname in feature_blocks}
528
-
529
- # Buscar en traceability lineas como:
530
- # - E001-S001 (Entities & EF Core) -> ...
531
- epic_names = {}
532
- for m in re.finditer(r'-\s*(E\d+-S\d+)\s*\((.+?)\)', content):
533
- epic_names[m.group(1)] = m.group(2).strip()
473
+ def enrich_stories_from_features(design: dict, stories: list):
474
+ """Asigna epic_name a las stories usando los nombres de `features[]` del YAML."""
475
+ feature_names = {}
476
+ for feat in design.get('features', []) or []:
477
+ if not isinstance(feat, dict):
478
+ continue
479
+ code = str(feat.get('code', '') or '').strip()
480
+ name = str(feat.get('name', '') or '').strip()
481
+ if code and name:
482
+ feature_names[code] = name
534
483
 
535
484
  for s in stories:
536
- # Buscar nombre de epic
537
- suffix = re.search(r'E\d+-S\d+', s.story_id)
538
- if suffix:
539
- key = suffix.group(0)
540
- if key in epic_names and not s.epic_name:
541
- s.epic_name = epic_names[key]
485
+ if s.epic_name:
486
+ continue
487
+ # epic_id tipo "VNT-E001" -> prefijo de feature antes del primer '-'
488
+ code = s.epic_id.split('-')[0] if s.epic_id else ''
489
+ if code in feature_names:
490
+ s.epic_name = feature_names[code]
542
491
 
543
492
  # ============================================================
544
493
  # API: Jira REST
@@ -550,6 +499,9 @@ class JiraAPI:
550
499
  self.email = config['jira_user_email']
551
500
  self.token = config['jira_api_token']
552
501
  self.project = config['jira_project_key']
502
+ # Campos Jira obligatorios por proyecto (ej. CON exige timetracking.originalEstimate).
503
+ # Se mergean en cada create_issue; vacío por defecto (HCM no los requiere).
504
+ self.extra_fields = config.get('extra_fields') or {}
553
505
 
554
506
  def _headers(self):
555
507
  import base64
@@ -578,8 +530,12 @@ class JiraAPI:
578
530
  except Exception:
579
531
  return []
580
532
 
581
- def create_issue(self, summary, description, issue_type_id, priority='Media', labels=None):
582
- """Crea un issue en Jira y retorna el ID y key."""
533
+ def create_issue(self, summary, description, issue_type_id, priority='Media', labels=None, extra_fields=None):
534
+ """Crea un issue en Jira y retorna el ID y key.
535
+
536
+ extra_fields: dict opcional de campos Jira adicionales (ej. timetracking).
537
+ Si es None, usa self.extra_fields (de config.yaml agiletest.extra_fields).
538
+ """
583
539
  import urllib.request
584
540
  fields = {
585
541
  'project': {'key': self.project},
@@ -592,6 +548,10 @@ class JiraAPI:
592
548
  }
593
549
  if priority:
594
550
  fields['priority'] = {'name': priority}
551
+ # Mergear campos obligatorios por proyecto (ej. CON: timetracking.originalEstimate)
552
+ merged_extra = extra_fields if extra_fields is not None else self.extra_fields
553
+ if merged_extra:
554
+ fields.update(merged_extra)
595
555
  payload = {'fields': fields}
596
556
  if labels:
597
557
  payload['fields']['labels'] = labels
@@ -857,7 +817,7 @@ def build_plan_tc_map(test_cases):
857
817
  def print_summary(stories, test_cases, trace_map, facs, meta):
858
818
  """Muestra resumen del parseo."""
859
819
  safe_print('\n' + '=' * 70)
860
- safe_print(' BMAD V6.0 -> AgileTest -- Resumen del parseo')
820
+ safe_print(' BMAD V7.8 (YAML) -> AgileTest -- Resumen del parseo')
861
821
  safe_print('=' * 70)
862
822
 
863
823
  safe_print(f'\n Feature: {meta.get("feature", "?")}')
@@ -1025,6 +985,14 @@ def create_in_agiletest(stories, test_cases, trace_map, meta, config, state_dir=
1025
985
  feature_label = (meta.get('feature_code', '') or 'BMAD').replace('-', '_').replace(' ', '_').replace('&', 'and')
1026
986
  if state_dir is None:
1027
987
  state_dir = os.path.dirname(os.path.abspath(__file__))
988
+ # H-F5-13: garantizar que el state-dir existe y es escribible; fallar con mensaje claro
989
+ try:
990
+ os.makedirs(state_dir, exist_ok=True)
991
+ if not os.access(state_dir, os.W_OK):
992
+ raise OSError('sin permiso de escritura')
993
+ except OSError as e:
994
+ safe_print(f' ERROR: no se puede usar el state-dir "{state_dir}": {e}')
995
+ sys.exit(3)
1028
996
  state_path = os.path.join(state_dir, f'bmad_state_{feature_label}.json')
1029
997
  state = load_state(state_path)
1030
998
 
@@ -1425,15 +1393,32 @@ def load_bmm_config(config_path: str):
1425
1393
  # Capa 1: config.yaml público (sin secretos)
1426
1394
  try:
1427
1395
  with open(config_path, 'r', encoding='utf-8') as f:
1428
- _extract_agiletest_section(f.read(), 'config.yaml', include_secrets=False)
1396
+ content = f.read()
1397
+ _extract_agiletest_section(content, 'config.yaml', include_secrets=False)
1398
+ # extra_fields es una estructura anidada: se parsea con YAML, no con regex.
1399
+ try:
1400
+ import yaml
1401
+ cfg = yaml.safe_load(content)
1402
+ at = (cfg.get('agiletest') or {}) if isinstance(cfg, dict) else {}
1403
+ if isinstance(at, dict) and isinstance(at.get('extra_fields'), dict):
1404
+ CONFIG['extra_fields'] = at['extra_fields']
1405
+ except Exception:
1406
+ pass # extra_fields es opcional; si el YAML falla, se ignora
1429
1407
  except Exception as e:
1430
1408
  safe_print(f' [WARN] No se pudo leer {config_path}: {e}')
1431
1409
 
1432
1410
  # Capa 2: config.secrets.yaml (gitignoreado, tiene los tokens)
1433
- # Buscar en _siesa-agents/bmm/ (relativo al directorio del script)
1411
+ # Buscar primero junto al --config provisto, luego relativo al script (_siesa-agents/bmm/).
1434
1412
  script_dir = os.path.dirname(os.path.abspath(__file__))
1435
- secrets_path = os.path.normpath(os.path.join(script_dir, '..', 'bmm', 'config.secrets.yaml'))
1436
- if os.path.exists(secrets_path):
1413
+ secrets_candidates = [
1414
+ os.path.join(config_dir, 'config.secrets.yaml'),
1415
+ os.path.normpath(os.path.join(script_dir, '..', 'bmm', 'config.secrets.yaml')),
1416
+ ]
1417
+ secrets_path = next((p for p in secrets_candidates if os.path.exists(p)), None)
1418
+ if VERBOSE:
1419
+ safe_print(f' [VERBOSE] config.yaml: {config_path}')
1420
+ safe_print(f' [VERBOSE] config.secrets.yaml: {secrets_path or "(no encontrado)"}')
1421
+ if secrets_path:
1437
1422
  try:
1438
1423
  with open(secrets_path, 'r', encoding='utf-8') as f:
1439
1424
  _extract_agiletest_section(f.read(), 'config.secrets.yaml', include_secrets=True)
@@ -1457,14 +1442,14 @@ def load_bmm_config(config_path: str):
1457
1442
 
1458
1443
  def main():
1459
1444
  parser = argparse.ArgumentParser(
1460
- description='BMAD V6.0 -> AgileTest: Parser + Cargador',
1445
+ description='BMAD V7.8 (YAML) -> AgileTest: Parser + Cargador',
1461
1446
  formatter_class=argparse.RawDescriptionHelpFormatter,
1462
1447
  epilog="""
1463
1448
  Ejemplos:
1464
1449
  python bmad_to_agiletest.py --dry-run Parsea y muestra resumen
1465
1450
  python bmad_to_agiletest.py --dry-run --detail Muestra cada TC con steps
1466
1451
  python bmad_to_agiletest.py --dry-run --export Exporta a JSON
1467
- python bmad_to_agiletest.py --input mi-archivo.md --dry-run
1452
+ python bmad_to_agiletest.py --input test-design.yml --dry-run
1468
1453
  python bmad_to_agiletest.py --create Crea todo en Jira/AgileTest
1469
1454
  """,
1470
1455
  )
@@ -1477,45 +1462,69 @@ Ejemplos:
1477
1462
  parser.add_argument('--feature-filter', default=None,
1478
1463
  help='Filtrar por feature key (ej: F4, F1). Solo procesa TCs de esa feature')
1479
1464
  parser.add_argument('--input', default=CONFIG['input_file'],
1480
- help='Ruta al archivo test-design.md generado por quality-process')
1465
+ help='Ruta al archivo test-design.yml generado por quality-process')
1481
1466
  parser.add_argument('--config', default=None,
1482
1467
  help='Ruta a _bmad/bmm/config.yaml (para leer sección agiletest)')
1468
+ parser.add_argument('--jira-url', default=None,
1469
+ help='URL base de Jira (sobreescribe config.yaml). Ej: https://tu-org.atlassian.net')
1470
+ parser.add_argument('--project-key', default=None,
1471
+ help='Project key de Jira (sobreescribe config.yaml). Ej: CON, COM, MAN')
1483
1472
  parser.add_argument('--state-dir', default=None,
1484
1473
  help='Directorio donde guardar el state file (default: directorio del input)')
1474
+ parser.add_argument('--verbose', action='store_true',
1475
+ help='Imprime detalle de diagnóstico (rutas de config/secrets resueltas, instancia destino)')
1485
1476
 
1486
1477
  args = parser.parse_args()
1478
+ global VERBOSE
1479
+ VERBOSE = args.verbose
1487
1480
 
1488
1481
  # Cargar config YAML si se proveyó (sobreescribe CONFIG con valores no-secretos)
1489
1482
  if args.config:
1490
1483
  load_bmm_config(args.config)
1491
1484
 
1485
+ # Flags CLI: tienen prioridad sobre config.yaml para identificar la instancia Jira destino
1486
+ if args.jira_url:
1487
+ CONFIG['jira_base_url'] = args.jira_url
1488
+ if args.project_key:
1489
+ CONFIG['jira_project_key'] = args.project_key
1490
+
1492
1491
  # Resolver ruta
1493
1492
  script_dir = os.path.dirname(os.path.abspath(__file__))
1494
1493
  input_path = os.path.join(script_dir, args.input) if not os.path.isabs(args.input) else args.input
1495
1494
 
1496
1495
  if not os.path.exists(input_path):
1497
1496
  print(f' ERROR: Archivo no encontrado: {input_path}')
1498
- sys.exit(1)
1497
+ sys.exit(2)
1499
1498
 
1500
- # Leer archivo completo
1501
- print(f'Parseando: {os.path.basename(input_path)}')
1502
- with open(input_path, 'r', encoding='utf-8') as f:
1503
- content = f.read()
1499
+ if VERBOSE:
1500
+ safe_print(f' [VERBOSE] Instancia destino: {CONFIG["jira_base_url"]} | proyecto {CONFIG["jira_project_key"]}')
1501
+ safe_print(f' [VERBOSE] Input: {input_path}')
1504
1502
 
1505
- # Parsear metadata
1506
- meta = parse_metadata(content)
1503
+ # Cargar el test-design.yml (V7.8) via yaml.safe_load
1504
+ print(f'Cargando YAML: {os.path.basename(input_path)}')
1505
+ try:
1506
+ design = load_design(input_path)
1507
+ except Exception as e:
1508
+ print(f' ERROR: YAML inválido en {os.path.basename(input_path)}: {e}')
1509
+ sys.exit(2)
1510
+ validate_design(design)
1511
+
1512
+ # Metadata
1513
+ meta = build_metadata(design)
1507
1514
  feature_code = meta.get('feature_code', 'UNKNOWN')
1508
- print(f' Feature: {meta.get("feature", "?")} ({feature_code})')
1515
+ print(f' Feature: {meta.get("feature", "?")} ({feature_code}) | Modo: {meta.get("mode", "?")}')
1509
1516
 
1510
- # Parsear todas las secciones
1511
- stories = parse_stories(content, feature_code)
1512
- facs = parse_facs(content)
1513
- test_cases = parse_test_cases(content)
1514
- trace_map = parse_traceability(content, feature_code)
1517
+ # Construir entidades desde el YAML
1518
+ stories = build_stories(design)
1519
+ facs = build_facs(design)
1520
+ test_cases = build_test_cases(design)
1521
+ trace_map = build_traceability(design)
1515
1522
 
1516
1523
  # Enriquecer datos cruzados
1517
1524
  enrich_test_cases(test_cases, trace_map, facs, feature_code)
1518
- enrich_stories_from_traceability(content, stories)
1525
+ enrich_stories_from_features(design, stories)
1526
+ # Link story -> TC inferido de la columna epics (para TC -> Requisito en el create)
1527
+ augment_trace_with_stories(trace_map, stories, test_cases)
1519
1528
 
1520
1529
  # Filtrado por feature (--pilot usa F4, --feature-filter usa lo indicado)
1521
1530
  feat_filter = None
@@ -1554,6 +1563,12 @@ Ejemplos:
1554
1563
  export_json(stories, test_cases, trace_map, facs, meta, json_path)
1555
1564
 
1556
1565
  if args.create:
1566
+ # H-F5-04: avisar si se creará en la instancia por defecto sin override explícito
1567
+ if not (args.config or args.jira_url or args.project_key):
1568
+ safe_print('\n [WARN] No se especificó --config, --jira-url ni --project-key.')
1569
+ safe_print(f' Se usará la instancia por defecto: {CONFIG["jira_base_url"]} (proyecto {CONFIG["jira_project_key"]}).')
1570
+ safe_print(' Si NO es tu instancia, cancela y re-ejecuta con --jira-url/--project-key o --config.')
1571
+
1557
1572
  missing = []
1558
1573
  if not CONFIG.get('jira_api_token'):
1559
1574
  missing.append('jira_api_token (env: JIRA_API_TOKEN)')