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:
@@ -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)
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user