Fusión de las dos sesiones del 29-sep: el registro del webhook Jira FAC pasa de #71 a #84 (los #71–#83 son las sesiones del 21-ago al 29-sep) y PENDIENTES conserva los dos bloques

This commit is contained in:
Johann
2026-09-30 00:19:37 -06:00
7 changed files with 384 additions and 1 deletions
@@ -0,0 +1,42 @@
# Correo — Respuesta a Erika: token de Jira, límites de consumo y estado general
Borrador · 29-jul-2026 — **no enviado**
Responder a todos → Para: Erika Chávez · CC: Noé Rocha; Pedro Ayala
Asunto: (el del hilo, con `RE:`)
**Contexto:** Erika pregunta (29-jul, 1:25 pm) por comentarios sobre lo que entregó Pedro el 27-jul —token PAF por Drive + explicación de límites de consumo—, si se pudo ingresar y si se requiere algo adicional de su lado.
**Estrategia:** la pregunta de Erika abre la puerta para dar el panorama completo. El correo entrega dos cosas el mismo día (propuesta v1.3 y Excel de horas por etapa), reporta la prueba real del token con su resultado, y fecha los tres bloqueos que están del lado de Balam. Las fechas son el punto: el hueco en desarrollo del 28–29 existe porque la definición del flujo lleva en revisión interna desde el 24-jul y Azure desde la semana del 21-jul.
======================================================================
Buenas tardes Erika, Ing. Noé, Pedro:
Gracias Pedro por el token y por el detalle de los límites de consumo. Ya lo aterricé al diseño: como no hay tope mensual y lo que aplica son límites de velocidad, la integración se autorregula leyendo los headers `X-RateLimit-*` —baja el ritmo al acercarse al umbral y respeta el `Retry-After`—, así que no anticipo problema de consumo con el volumen del proceso. Que no exista dashboard no nos afecta, porque esa telemetría viaja en las respuestas.
Sobre el ingreso, ahí sí necesito tu apoyo: el token está siendo rechazado con 401. Ya descarté las causas de mi lado —la cadena está completa y bien formada (192 caracteres, con el prefijo que corresponde, sin espacios ni cortes), y probé con el correo que me confirmaste, tanto contra el sitio de Jira como por la ruta de `api.atlassian.com`. También lo validé contra el endpoint de cuenta de Atlassian, que no depende del sitio, y ahí también responde 401. Eso apunta a que la credencial ya no está activa.
¿Me ayudas a revisar en el administrador de tokens de tu cuenta si el token **PAF** sigue existiendo? Si se rotó o quedó reemplazado, con que lo regeneres y me lo compartas de nuevo lo pruebo de inmediato y les confirmo el mismo día.
Aprovecho una pregunta de diseño: convendría valorar si el token vive en una cuenta de servicio en lugar de en tu cuenta personal. Hoy todo lo que la plataforma escriba en Jira quedaría registrado a tu nombre, y una rotación tuya dejaría la integración sin acceso. Lo que ustedes decidan lo implemento igual, solo lo dejo sobre la mesa.
Aprovecho para dejarles el panorama completo. De mi lado va hoy:
- **La propuesta actualizada (v1.3)** con la integración Jira → BIND incorporada al alcance, que les envío en el hilo correspondiente.
- **El Excel de horas por etapa**, con las etapas 2 y 3 desglosadas como me lo pidió Erika para el conteo de facturación.
Y lo que está detenido esperando definición de su lado, con fecha para que sea fácil ubicar dónde está la fila:
1. **La definición del flujo de facturación**, en revisión interna desde la sesión del 24-jul. De ahí sale el estatus que dispara la creación de la cotización, y es la que tiene detenidas la capa de escritura y la automatización con Jira. Es la que más pesa.
2. **El acceso a Azure**, previsto para la semana del 21-jul. Sin él no puedo configurar el ambiente ni el CI/CD.
3. **El token de Jira**, lo que comento arriba.
En cuanto se liberen retomo de inmediato; el trabajo que no depende de ellas ya está hecho y verificado. Si les ayuda, puedo entrar a una sesión corta esta semana para cerrar la definición del flujo con Arturo y avanzar sin esperar la revisión completa.
Cuando toque la prueba en vivo de creación les aviso con anticipación para hacerla acompañados, como lo acordamos con el Ing. Noé.
Quedo a sus órdenes.
Saludos cordiales,
Johann Velázquez
@@ -0,0 +1,82 @@
# Plan — Webhook Jira (FAC) → vista previa automática de cotización BIND
_Creado: 2026-09-29 · Responsable: Johann · Contexto: [REGISTRO #84](../bitacora/REGISTRO.md) (nació como #71; renumerado al fusionar el 30-sep) · Antecedente técnico: [Investigacion-API-Jira.md §Bloque 3–4](Investigacion-API-Jira.md)_
## Objetivo
Que la plataforma se entere **en el momento** en que se crea (o avanza) un ticket de FAC y deje **preparada la vista previa (dry-run) de la cotización en BIND**, para que un humano solo revise y confirme con un clic.
**Fuera de alcance:** crear la cotización en BIND sin confirmación humana. ADR-001 sigue vigente: toda escritura en BIND producción = dry-run + confirmación humana + `Bind:Mode=Write` encendido solo durante la operación. La automatización total requeriría autorización escrita de Balam **y** enmienda de ADR-001, con reglas (solo clientes conocidos, IVA dentro del catálogo, tope de monto, interruptor de apagado).
## Decisiones previas (antes de escribir en Jira)
1. **¿Qué evento dispara?** Hay dos candidatos y no son equivalentes:
- `jira:issue_created`: el ticket nace. Es lo que se pidió el 29-sep.
- Transición a **`En proceso de facturación`**: es el disparador acordado en julio/agosto. Dura **~3 minutos** (FAC-100: 2m44s), así que un poller de 15 min se lo pierde; el webhook lo resuelve de raíz.
- **Propuesta:** escuchar `created` **y** `updated` (filtrado a cambio de estatus) en un solo webhook; la plataforma decide qué hacer con cada uno leyendo el changelog.
2. **Tickets de `Automation for Jira`** (10 de 69 al 10-ago, sin request type ni campos estructurados): el endpoint los recibe, pero los marca *"sin datos para cotizar"* en lugar de armar una vista previa.
3. **Autorización escrita de Pedro:** Johann confirma que existe (29-sep). **Falta archivarla en `fuentes/`** con fecha, y confirmar su alcance: ¿cubre solo crear el webhook/regla, o también crear un ticket de prueba en FAC?
## Fases
### Fase 1 — Verificación de permisos (solo lectura, riesgo cero)
- `GET /rest/api/3/myself`: el token es de Pedro y está vivo.
- `GET /rest/api/3/mypermissions?projectKey=FAC&permissions=ADMINISTER,ADMINISTER_PROJECTS`
- `ADMINISTER` = admin del sitio → **Vía A** (webhook de sistema).
- Solo `ADMINISTER_PROJECTS` → **Vía B** (regla de Automation del proyecto).
- Ninguno → Pedro lo crea en pantalla; nosotros solo damos los datos.
- `GET /rest/webhooks/1.0/webhook` (si Vía A): inventario de webhooks existentes, para no duplicar y para saber qué hay antes de tocar.
### Fase 2 — Endpoint en la plataforma (código nuestro, no toca Jira)
`POST /api/jira/webhook` en `Balam.Api/Jira/`:
- **Autenticación:** secreto compartido (Vía A: firma HMAC `X-Hub-Signature` si el sitio la ofrece; Vía B: header propio). Sin secreto válido → 401, sin leer el cuerpo. Secreto en user-secrets (`Jira:WebhookSecret`), nunca en el repo.
- **No confía en el payload:** solo extrae `issueKey`, valida que sea `FAC-\d+`, y **relee el ticket** con `JiraClient` (ReadOnly).
- **Idempotencia:** tabla/registro por `(issueKey, evento, timestamp)`; un reintento de Jira no genera una segunda vista previa.
- **Acción:** arma el body de `POST /api/Quotes` con el mismo mapeo del wizard (cliente del combo 11598, servicio, cantidad, IVA como tasa, moneda, días de crédito), lo registra en `WriteOperations` como `DryRun` y marca el ticket como **"Cotización lista para revisar"** en la bandeja. **Nunca llama a BIND en escritura.**
- **Responde rápido** (202) y procesa en segundo plano: Jira reintenta si el webhook tarda.
- **Pruebas** xUnit con `FakeHandler`: secreto inválido → 401; payload con clave ajena → 400; reintento → una sola vista previa; ticket de Automation sin campos → "sin datos"; jamás se invoca un método mutante de `BindClient`.
- El **poller** (JQL delta con solape, ADR-002) queda como respaldo: si la laptop está apagada y el webhook falla, el ticket se recupera en la siguiente pasada.
### Fase 3 — Exposición temporal con ngrok
- `ngrok http 5078` con **traffic policy** que solo deja pasar `POST /api/jira/webhook`; cualquier otra ruta → 403 en el borde. Login, cartera y emisión **no** quedan expuestos.
- Mientras el túnel esté abierto: API en `Bind:Mode=ReadOnly`.
- ⚠️ Con ngrok gratuito la URL cambia en cada reinicio y depende de la máquina de Johann encendida. Es **piloto**, no productivo. Productivo = dominio fijo de ngrok (de pago) o Azure (bloqueado por el cliente).
### Fase 4 — Alta del disparador en Jira (UNA escritura, auditada)
Operación puntual, numerada en la bitácora; **no** se cambia `Jira:Mode` de la app.
- **Vía A (preferida):** `POST /rest/webhooks/1.0/webhook` con `events: ["jira:issue_created", "jira:issue_updated"]`, `filters: { "issue-related-events-section": "project = FAC" }`, URL de ngrok y secreto. Reproducible, sin consumir cuota de Automation, reversible con `DELETE /rest/webhooks/1.0/webhook/{id}`.
- **Vía B:** regla de Automation del proyecto FAC: *Work item created* (y/o *Work item transitioned → En proceso de facturación*) → *Send web request* (POST, header del secreto, body `{"issueKey":"{{issue.key}}","evento":"{{webhookEvent}}"}`). Se crea en la UI de Automation (la API de reglas es menos estable). Consume cuota mensual de ejecuciones: verificar el plan con Pedro.
- Anotar en la bitácora: id del webhook/regla, URL, fecha, quién autorizó.
### Fase 5 — Prueba y reversa
- **Prueba:** un ticket de prueba en FAC (lo crea alguien de Balam, o Johann si la autorización lo cubre). Criterio de éxito: llega el aviso → aparece "lista para revisar" → `WriteOperations` tiene un `DryRun` → **BIND no recibió ninguna escritura** (verificar por lectura).
- **Reversa:** borrar webhook/regla (id anotado) + cerrar ngrok. El endpoint queda inerte sin secreto configurado.
## Riesgos
| Riesgo | Mitigación |
|---|---|
| API local expuesta a internet | Traffic policy de ngrok (una sola ruta), secreto, `Bind:Mode=ReadOnly` con túnel abierto |
| Escritura en el Jira productivo del cliente | Una sola operación, autorizada por escrito, numerada y reversible |
| Payload falsificado | Secreto/HMAC + relectura del ticket por API |
| Pérdida de eventos (laptop apagada, URL de ngrok cambió) | Poller de respaldo con checkpoint y solape |
| Cuota de Automation (Vía B) | Verificar plan; preferir Vía A |
| Dos sesiones de Claude Code levantando la API a la vez (29-sep) | Una sola sesión dueña de la API durante la prueba |
## Checklist
- [ ] Archivar la autorización escrita de Pedro en `fuentes/` y anotar su alcance
- [ ] Fase 1 — verificar identidad y permisos del token (solo GET)
- [ ] Decidir evento(s): creación, transición a *En proceso de facturación*, o ambos
- [ ] Fase 2 — endpoint + idempotencia + pruebas
- [ ] Fase 3 — ngrok con traffic policy de una sola ruta
- [ ] Fase 4 — alta del webhook/regla (operación numerada)
- [ ] Fase 5 — ticket de prueba, verificación y registro en bitácora
- [ ] Definir el destino productivo (ngrok de pago vs Azure)
+164
View File
@@ -0,0 +1,164 @@
# Piloto del webhook Jira FAC → plataforma (29-sep-2026). Autorizado por Pedro
# (vía Johann). Cada escritura en Jira es explícita, una por subcomando:
#
# url → lee la URL pública del túnel ngrok local (solo lectura)
# registrar → POST /rest/webhooks/1.0/webhook (ESCRIBE: 1 webhook de sitio)
# crear-ticket → POST /rest/api/3/issue (ESCRIBE: 1 ticket de prueba en FAC)
# esperar CLAVE → espera la detección en la BD local (solo lectura)
# borrar-ticket CLAVE → DELETE /rest/api/3/issue/CLAVE (ESCRIBE: borra el ticket de prueba)
# borrar-webhook → DELETE del webhook registrado (reversa total)
# mover URL_BASE → PUT del webhook a otra URL base (ESCRIBE), p. ej.
# https://paputec.mx/balam (el receptor vive en /api/jira/webhook)
# estado → webhooks de sitio actuales (solo lectura)
#
# Estado del piloto: webhook de sitio id 1 registrado el 29-sep apuntando al
# túnel ngrok de Johann; prueba FAC-169 creada, detectada y borrada.
#
# El token de Jira y el secreto del webhook nunca se imprimen ni se versionan:
# token → propuesta/token-jira.txt (gitignoreado) o $JIRA_TOKEN_FILE
# secreto → $JIRA_WEBHOOK_SECRET_FILE (default: ~/jira-webhook-secret.txt)
import base64, json, os, subprocess, sys, time, urllib.request, urllib.error
SITE = "https://balam-jsm-temp.atlassian.net"
EMAIL = "pedro.ayala@balamtalentoestrategico.com"
PEDRO = "712020:0cde8558-7794-468c-9e2c-73dfe63bd57b"
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HOME = os.path.expanduser("~")
TOKEN = open(os.environ.get("JIRA_TOKEN_FILE", os.path.join(REPO, "propuesta", "token-jira.txt")), encoding="utf-8").read().strip()
SECRETO = open(os.environ.get("JIRA_WEBHOOK_SECRET_FILE", os.path.join(HOME, "jira-webhook-secret.txt")), encoding="utf-8").read().strip()
AUTH = "Basic " + base64.b64encode(f"{EMAIL}:{TOKEN}".encode()).decode()
ESTADO = os.environ.get("JIRA_WEBHOOK_ESTADO", os.path.join(HOME, "jira-webhook-estado.json"))
def jira(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(SITE + path, data=data, method=method, headers={
"Authorization": AUTH, "Accept": "application/json", "Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=30) as r:
raw = r.read().decode("utf-8")
return r.status, (json.loads(raw) if raw else None)
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")[:500]
def estado(**cambios):
s = json.load(open(ESTADO, encoding="utf-8")) if os.path.exists(ESTADO) else {}
s.update(cambios)
json.dump(s, open(ESTADO, "w", encoding="utf-8"), indent=1)
return s
def url_publica():
for puerto in (4040, 4041, 4042):
try:
with urllib.request.urlopen(f"http://127.0.0.1:{puerto}/api/tunnels", timeout=3) as r:
for t in json.loads(r.read().decode())["tunnels"]:
if t["public_url"].startswith("https://") and "5079" in t["config"]["addr"]:
return t["public_url"]
except Exception:
pass
return None
def sql(consulta):
# Superusuario local del docker-compose: ve la tabla pese a RLS.
out = subprocess.run(["docker", "exec", "balam-postgres-dev", "psql", "-U", "balam", "-d", "balam",
"-At", "-F", " | ", "-c", consulta], capture_output=True, text=True, encoding="utf-8")
return out.stdout.strip() or out.stderr.strip()
cmd = sys.argv[1] if len(sys.argv) > 1 else "estado"
if cmd == "url":
print(url_publica() or "Sin túnel ngrok hacia 5079.")
elif cmd == "registrar":
base = url_publica()
if not base:
sys.exit("Sin túnel ngrok hacia 5079: no se registra nada.")
cuerpo = {
"name": "Balam plataforma · FAC ticket creado (piloto 29-sep)",
"url": f"{base}/api/jira/webhook?token={SECRETO}",
"events": ["jira:issue_created"],
"filters": {"issue-related-events-section": "project = FAC"},
"excludeBody": False,
"secret": SECRETO,
}
st, r = jira("POST", "/rest/webhooks/1.0/webhook", cuerpo)
if st == 400 and "secret" in str(r).lower():
cuerpo.pop("secret")
st, r = jira("POST", "/rest/webhooks/1.0/webhook", cuerpo)
if st in (200, 201) and isinstance(r, dict):
wid = r.get("self", "").rstrip("/").split("/")[-1]
estado(webhook_id=wid, webhook_url_base=base, registrado=time.strftime("%Y-%m-%d %H:%M:%S"),
con_secreto_hmac="secret" in cuerpo)
print(f"[{st}] webhook registrado · id={wid} · eventos={r.get('events')} · filtro={r.get('filters')} · "
f"secreto HMAC enviado={'secret' in cuerpo}")
else:
print(f"[{st}] {r}")
elif cmd == "crear-ticket":
adf = {"type": "doc", "version": 1, "content": [{"type": "paragraph", "content": [{"type": "text", "text":
"Ticket de PRUEBA de la plataforma Balam (29-sep-2026), autorizado por Pedro Ayala, para validar "
"que la plataforma detecta la creación de tickets por webhook. Se elimina en cuanto se confirma."}]}]}
st, r = jira("POST", "/rest/api/3/issue", {"fields": {
"project": {"key": "FAC"}, "issuetype": {"id": "10073"},
"summary": "[PRUEBA PLATAFORMA BALAM] Detección por webhook — se borra en minutos",
"description": adf, "assignee": {"accountId": PEDRO}}})
if st == 201:
estado(ticket=r["key"], ticket_id=r["id"], creado=time.strftime("%Y-%m-%d %H:%M:%S"))
print(f"[201] ticket de prueba creado: {r['key']} (id {r['id']})")
else:
print(f"[{st}] {r}")
elif cmd == "esperar":
clave = sys.argv[2]
for i in range(60):
fila = sql(f"""SELECT "Id","IssueKey","Verified","AuthMethod","JiraStatus",
to_char("JiraTimestampUtc" AT TIME ZONE 'America/Mexico_City','HH24:MI:SS.MS'),
to_char("ReceivedAtUtc" AT TIME ZONE 'America/Mexico_City','HH24:MI:SS.MS'),
round(extract(epoch from ("ReceivedAtUtc"-"JiraTimestampUtc"))::numeric,2),
coalesce("Error",'') FROM "JiraTicketEvents" WHERE "IssueKey"='{clave}'""")
if fila and "|" in fila:
print(f"DETECTADO tras ~{i}s de espera: id | llave | verificado | auth | estatus Jira | emitido (MX) | recibido (MX) | retraso s | error")
print(" " + fila)
break
time.sleep(1)
else:
print(f"Sin detección de {clave} en 60 s.")
elif cmd == "borrar-ticket":
clave = sys.argv[2]
st, r = jira("DELETE", f"/rest/api/3/issue/{clave}")
st2, _ = jira("GET", f"/rest/api/3/issue/{clave}")
estado(ticket_borrado=clave if st == 204 else None, borrado=time.strftime("%Y-%m-%d %H:%M:%S"))
print(f"[{st}] DELETE {clave} · verificación GET → {st2} (404 = ya no existe)")
elif cmd == "borrar-webhook":
wid = estado().get("webhook_id")
st, r = jira("DELETE", f"/rest/webhooks/1.0/webhook/{wid}")
print(f"[{st}] DELETE webhook {wid}")
elif cmd == "mover":
# Destino estable (paputec): solo HMAC, sin el secreto en la URL.
base = sys.argv[2].rstrip("/")
wid = estado().get("webhook_id") or "1"
st, r = jira("PUT", f"/rest/webhooks/1.0/webhook/{wid}", {
"name": "Balam plataforma · FAC ticket creado",
"url": f"{base}/api/jira/webhook",
"events": ["jira:issue_created"],
"filters": {"issue-related-events-section": "project = FAC"},
"excludeBody": False,
"secret": SECRETO,
})
if st in (200, 201):
estado(webhook_id=wid, webhook_url_base=base, movido=time.strftime("%Y-%m-%d %H:%M:%S"))
print(f"[{st}] webhook {wid} → {base}/api/jira/webhook" + ("" if st in (200, 201) else f" · {r}"))
elif cmd == "estado":
st, r = jira("GET", "/rest/webhooks/1.0/webhook")
print(f"[{st}] webhooks de sitio: {len(r) if isinstance(r, list) else r}")
for w in r if isinstance(r, list) else []:
print(f" - {w.get('name')} · habilitado={w.get('enabled')} · eventos={w.get('events')} · filtro={w.get('filters')}")
print("estado local:", json.dumps(estado(), ensure_ascii=False))