diff --git a/src/content/docs-es/notifications.mdx b/src/content/docs-es/notifications.mdx index 68b4ade4..a7a22f74 100644 --- a/src/content/docs-es/notifications.mdx +++ b/src/content/docs-es/notifications.mdx @@ -33,9 +33,9 @@ Puedes tener hasta 10 canales. Cada uno envía el mensaje en el formato que espe | Tipo | Lo que recibes | |---|---| -| **Slack** | Un mensaje con el título como enlace al portal, una línea con el evento, el estado del run y el código de error, y el resumen del run cuando lo tiene. | -| **Microsoft Teams** | Una adaptive card con el título, el evento, el estado y el código de error, el resumen y un botón **Open in NaN**. | -| **Discord** | Un embed con el título como enlace, el evento, el estado y el código de error, y el resumen. Nunca menciona a nadie. | +| **Slack** | Un mensaje con el título como enlace al portal, el resumen del run cuando lo tiene, un botón al portal (**View run**, **Open automation** o **View triggers**) y una línea con el evento, el estado del run y el código de error. Una aprobación añade sus detalles y un botón verde **Review and approve**: mira [Mensajes de aprobación](#mensajes-de-aprobación). | +| **Microsoft Teams** | Una adaptive card con el título, el evento, el estado y el código de error, el resumen y un botón al portal: **Review and approve** en una aprobación, **View run**, **Open automation** o **View triggers** en los demás (los resúmenes y los mensajes de prueba mantienen **Open in NaN**). Una aprobación añade sus detalles: mira [Mensajes de aprobación](#mensajes-de-aprobación). | +| **Discord** | Un embed con el título como enlace, el evento, el estado y el código de error, el resumen y un enlace en negrita al portal (los webhooks de Discord no pueden mostrar botones). Una aprobación añade sus detalles: mira [Mensajes de aprobación](#mensajes-de-aprobación). Nunca menciona a nadie. | | **HTTPS** | Un `POST` JSON firmado a tu endpoint, con el [esquema documentado](#contenido-del-mensaje). | ### Consigue la URL de un webhook de Slack @@ -80,6 +80,19 @@ Un run cancelado nunca notifica, ni tampoco un run que lanzas tú (fuera de una El mismo evento se envía una sola vez por canal, aunque el canal esté suscrito a él para todas las automatizaciones y para esa en concreto. +### Mensajes de aprobación + +Un mensaje `approval.pending` te dice qué está esperando, para que decidas si abrirlo ya: + +- la automatización y su workspace; +- cuándo caduca la aprobación, como hora UTC y cuánto falta, por ejemplo `2026-10-15 10:00 UTC (in 24 h)`; +- los tipos de acción que la automatización manda a aprobación; +- hasta 5 acciones que esperan aprobación, cada una con su tipo y su destino: `github_pr_comment on acme/api#342`, `git_push to branch fix/login`, `github_add_labels on acme/api#12: 2 labels`. Si hay más, la lista termina con `and 3 more`. + +Su botón **Review and approve** (un enlace en negrita en Discord) abre el run en el portal con el foco en las acciones propuestas: el enlace termina en `?approval=1` y sigue funcionando si antes tienes que iniciar sesión. Allí revisas las acciones completas, con el texto de los comentarios y el diff, y pulsas **Approve and apply** o **Reject**. Nunca se aprueba desde Slack, Teams o Discord: el mensaje solo te lleva al portal. + +Si la aprobación ya se decidió o caducó antes de enviar el mensaje, el mensaje dice que ya no está pendiente y el botón pasa a ser **View run**. La página del run muestra lo mismo: lo que pasó, sin Approve ni Reject. + ## Gestiona los canales en el portal Tus canales están en los ajustes de [cloud.nan.builders](https://cloud.nan.builders/): abre **Settings** y luego **Notifications**, justo debajo de **Secrets**. Los ajustes de cada automatización también tienen una tarjeta **Notifications** que lista los canales que la cubren, con **Manage notifications** para venir aquí. @@ -239,9 +252,13 @@ Un canal **HTTPS** recibe peticiones `POST` con `Content-Type: application/json` "phase": "propose", "summary": "Reviewed PR #42: two issues found in pkg/foo." }, + "workspace": { "id": "…", "name": "backend-ws" }, "trigger": null, "reason": null, + "approval": null, "url": "https://cloud.nan.builders/workspaces/…/runs/…", + "actions_url": "https://cloud.nan.builders/workspaces/…/runs/…", + "approve_url": null, "text": "Run failed: PR review (agent_failed)" } ``` @@ -255,9 +272,49 @@ Un canal **HTTPS** recibe peticiones `POST` con `Content-Type: application/json` | `run.summary` | El resumen del run tal como lo muestra el portal, con los secretos ya enmascarados y sin caracteres de control, como mucho 280 caracteres. Solo en `run.finished` y `run.failed`. | | `trigger` | `{ "id", "kind" }` en `trigger.failing`. | | `reason` | `automation.disabled`: `member`, `workspace_deleted` o `secret_deleted`. `trigger.failing`: `bad_signature`. | +| `workspace` | En los eventos de run (`approval.pending`, `run.failed`, `run.finished`): `{ "id", "name" }` del workspace del run (`name` es `null` si el workspace ya no existe). Si no, `null`. | | `url` | Dónde mirar en el portal: el run en los eventos de run, la automatización en los demás. | +| `actions_url` | Adónde lleva el botón de los mensajes de Slack, Teams y Discord: el run con el foco en sus acciones propuestas (`?approval=1`) en `approval.pending`, el run en `run.failed` y `run.finished`, la automatización en `automation.disabled` y sus triggers en `trigger.failing`. | +| `approve_url` | En `approval.pending`, mientras la aprobación sigue esperando: el mismo enlace que `actions_url`. Si no, `null`. Se aprueba en el portal, nunca con una petición a este enlace. | +| `approval` | En `approval.pending`: `state`, `expires_at`, `approval_kinds`, `actions` y `actions_total`. Si no, `null`. | +| `approval.state` | `pending` mientras espera tu decisión. Si el mensaje sale cuando la aprobación ya se decidió o caducó: `approved`, `rejected`, `cancelled` o `expired`, y `approve_url` es `null`. | +| `approval.expires_at` | Cuándo caduca la aprobación (UTC), o `null` si no caduca. | +| `approval.approval_kinds` | Los tipos de acción que la automatización manda a aprobación. | +| `approval.actions` | Hasta 5 acciones que esperan aprobación: `kind`, `target` (`owner/repo#number`, la rama de un `git_push`, el repositorio de un `github_create_pr`) y un `text` de una línea. Nunca el texto de un comentario, un título, un mensaje de commit, una etiqueta ni el diff. | +| `approval.actions_total` | Cuántas acciones esperan aprobación en total. Puede ser mayor que la longitud de `actions`, que se queda en 5. | | `text` | Un título de una línea, en inglés, por ejemplo `Approval needed: PR review`, `Run timed out: PR review` o `Automation disabled: PR review (its workspace was deleted)`. | +El cuerpo de `approval.pending` añade el workspace, la aprobación y los dos enlaces: + +```json +{ + "version": 1, + "id": "…", + "event": "approval.pending", + "created_at": "2026-10-14T10:00:00Z", + "automation": { "id": "…", "name": "PR review" }, + "workspace": { "id": "…", "name": "backend-ws" }, + "run": { "id": "…", "state": "awaiting_approval", "error_code": null, "phase": "propose", "summary": null }, + "trigger": null, + "reason": null, + "approval": { + "state": "pending", + "expires_at": "2026-10-15T10:00:00Z", + "approval_kinds": ["git_push", "github_pr_comment", "github_add_labels"], + "actions": [ + { "kind": "github_pr_comment", "target": "helmcode/nan-cloud-api#342", "text": "github_pr_comment on helmcode/nan-cloud-api#342" }, + { "kind": "git_push", "target": "nan/fix-tests", "text": "git_push to branch nan/fix-tests" }, + { "kind": "github_add_labels", "target": "helmcode/nan-cloud-api#12", "text": "github_add_labels on helmcode/nan-cloud-api#12: 2 labels" } + ], + "actions_total": 3 + }, + "url": "https://cloud.nan.builders/workspaces/…/runs/…", + "actions_url": "https://cloud.nan.builders/workspaces/…/runs/…?approval=1", + "approve_url": "https://cloud.nan.builders/workspaces/…/runs/…?approval=1", + "text": "Approval needed: PR review" +} +``` + Un **resumen** (`digest`) agrupa varias notificaciones: ```json @@ -316,9 +373,10 @@ Los mensajes llevan nombres, estados, códigos de error, ids y enlaces. **Nunca* - el valor de ningún secreto, ni la URL de un canal; - el cuerpo ni las cabeceras de la petición de webhook que lanzó un run; -- la salida del agente, más allá del resumen corto del run (como mucho 280 caracteres, con los secretos enmascarados). +- la salida del agente, más allá del resumen corto del run (como mucho 280 caracteres, con los secretos enmascarados); +- el texto, el título ni el mensaje de commit de las acciones propuestas, sus etiquetas ni el diff: una aprobación solo lista tipos y destinos. -El texto que escribes tú (el nombre de una automatización, un resumen) se escapa para cada proveedor: no puede mencionar a nadie, avisar a un rol ni formar un enlace. +El texto que escribes tú (el nombre de una automatización o de un workspace, un resumen, una rama) se escapa para cada proveedor: no puede mencionar a nadie, avisar a un rol ni formar un enlace. ### Qué URLs se aceptan diff --git a/src/content/docs/notifications.mdx b/src/content/docs/notifications.mdx index adf8878f..b8377cd1 100644 --- a/src/content/docs/notifications.mdx +++ b/src/content/docs/notifications.mdx @@ -33,9 +33,9 @@ You can have up to 10 channels. Each one sends a message in the format its provi | Kind | What you get | |---|---| -| **Slack** | A message with the title as a link to the portal, a line with the event, the run state and the error code, and the run's summary when it has one. | -| **Microsoft Teams** | An adaptive card with the title, the event, state and error code, the summary and an **Open in NaN** button. | -| **Discord** | An embed with the title as a link, the event, state and error code, and the summary. It never mentions anyone. | +| **Slack** | A message with the title as a link to the portal, the run's summary when it has one, a button to the portal (**View run**, **Open automation** or **View triggers**) and a line with the event, the run state and the error code. An approval adds its details and a green **Review and approve** button: see [Approval messages](#approval-messages). | +| **Microsoft Teams** | An adaptive card with the title, the event, state and error code, the summary and a button to the portal: **Review and approve** on an approval, **View run**, **Open automation** or **View triggers** on the others (digests and test messages keep **Open in NaN**). An approval adds its details: see [Approval messages](#approval-messages). | +| **Discord** | An embed with the title as a link, the event, state and error code, the summary and a bold link to the portal (Discord webhooks cannot show buttons). An approval adds its details: see [Approval messages](#approval-messages). It never mentions anyone. | | **HTTPS** | A signed JSON `POST` to your endpoint, with the [documented schema](#payload). | ### Get a Slack webhook URL @@ -80,6 +80,19 @@ A cancelled run never notifies, and neither does a run you start yourself (outsi The same event is sent once per channel, even when the channel is subscribed to it for all automations and for that one. +### Approval messages + +An `approval.pending` message tells you what is waiting, so you can decide whether to open it now: + +- the automation and its workspace; +- when the approval expires, as a UTC time and how long is left, for example `2026-10-15 10:00 UTC (in 24 h)`; +- the action kinds the automation sends for approval; +- up to 5 actions waiting for approval, each as its kind and target: `github_pr_comment on acme/api#342`, `git_push to branch fix/login`, `github_add_labels on acme/api#12: 2 labels`. When there are more, the list ends with `and 3 more`. + +Its **Review and approve** button (a bold link in Discord) opens the run in the portal with the proposed actions in view and focused: the link ends in `?approval=1`, and it still works when you have to sign in first. There you review the full actions, with comment bodies and the diff, and press **Approve and apply** or **Reject**. You never approve from Slack, Teams or Discord: the message only takes you to the portal. + +If the approval was decided or expired before the message went out, the message says it is no longer pending and the button becomes **View run**. The run page shows the same: what happened, without Approve or Reject. + ## Manage channels in the portal Your channels are in your settings at [cloud.nan.builders](https://cloud.nan.builders/): open **Settings**, then **Notifications**, right below **Secrets**. Each automation's settings also have a **Notifications** card that lists the channels covering it, with **Manage notifications** to come here. @@ -239,9 +252,13 @@ An **HTTPS** channel receives `POST` requests with `Content-Type: application/js "phase": "propose", "summary": "Reviewed PR #42: two issues found in pkg/foo." }, + "workspace": { "id": "…", "name": "backend-ws" }, "trigger": null, "reason": null, + "approval": null, "url": "https://cloud.nan.builders/workspaces/…/runs/…", + "actions_url": "https://cloud.nan.builders/workspaces/…/runs/…", + "approve_url": null, "text": "Run failed: PR review (agent_failed)" } ``` @@ -255,9 +272,49 @@ An **HTTPS** channel receives `POST` requests with `Content-Type: application/js | `run.summary` | The run's summary as the portal shows it, with secrets already masked and control characters removed, at most 280 characters. Only on `run.finished` and `run.failed`. | | `trigger` | `{ "id", "kind" }` on `trigger.failing`. | | `reason` | `automation.disabled`: `member`, `workspace_deleted` or `secret_deleted`. `trigger.failing`: `bad_signature`. | +| `workspace` | On run events (`approval.pending`, `run.failed`, `run.finished`): `{ "id", "name" }` of the run's workspace (`name` is `null` when the workspace no longer exists). `null` otherwise. | | `url` | Where to look in the portal: the run for run events, the automation for the others. | +| `actions_url` | Where the button of the Slack, Teams and Discord messages goes: the run with its proposed actions focused (`?approval=1`) on `approval.pending`, the run on `run.failed` and `run.finished`, the automation on `automation.disabled`, and its triggers on `trigger.failing`. | +| `approve_url` | On `approval.pending`, while the approval is still waiting: the same link as `actions_url`. `null` otherwise. You approve in the portal, never with a request to this link. | +| `approval` | On `approval.pending`: `state`, `expires_at`, `approval_kinds`, `actions` and `actions_total`. `null` otherwise. | +| `approval.state` | `pending` while it waits for you. When the message goes out after the approval was decided or expired: `approved`, `rejected`, `cancelled` or `expired`, and `approve_url` is `null`. | +| `approval.expires_at` | When the approval expires (UTC), or `null` if it has no expiry. | +| `approval.approval_kinds` | The action kinds the automation sends for approval. | +| `approval.actions` | Up to 5 actions waiting for approval: `kind`, `target` (`owner/repo#number`, the branch of a `git_push`, the repository of a `github_create_pr`) and a one-line `text`. Never a comment body, a title, a commit message, a label or the diff. | +| `approval.actions_total` | How many actions wait for approval in total. It can be larger than the length of `actions`, which stops at 5. | | `text` | A one-line title, for example `Approval needed: PR review`, `Run timed out: PR review` or `Automation disabled: PR review (its workspace was deleted)`. | +An `approval.pending` body adds the workspace, the approval and both links: + +```json +{ + "version": 1, + "id": "…", + "event": "approval.pending", + "created_at": "2026-10-14T10:00:00Z", + "automation": { "id": "…", "name": "PR review" }, + "workspace": { "id": "…", "name": "backend-ws" }, + "run": { "id": "…", "state": "awaiting_approval", "error_code": null, "phase": "propose", "summary": null }, + "trigger": null, + "reason": null, + "approval": { + "state": "pending", + "expires_at": "2026-10-15T10:00:00Z", + "approval_kinds": ["git_push", "github_pr_comment", "github_add_labels"], + "actions": [ + { "kind": "github_pr_comment", "target": "helmcode/nan-cloud-api#342", "text": "github_pr_comment on helmcode/nan-cloud-api#342" }, + { "kind": "git_push", "target": "nan/fix-tests", "text": "git_push to branch nan/fix-tests" }, + { "kind": "github_add_labels", "target": "helmcode/nan-cloud-api#12", "text": "github_add_labels on helmcode/nan-cloud-api#12: 2 labels" } + ], + "actions_total": 3 + }, + "url": "https://cloud.nan.builders/workspaces/…/runs/…", + "actions_url": "https://cloud.nan.builders/workspaces/…/runs/…?approval=1", + "approve_url": "https://cloud.nan.builders/workspaces/…/runs/…?approval=1", + "text": "Approval needed: PR review" +} +``` + A **digest** groups several notifications: ```json @@ -316,9 +373,10 @@ Messages carry names, states, error codes, ids and links. They **never** carry: - the value of any secret, or a channel URL; - the body or headers of the webhook request that started a run; -- the agent's output, beyond the run's short summary (at most 280 characters, with secrets masked). +- the agent's output, beyond the run's short summary (at most 280 characters, with secrets masked); +- the bodies, titles and commit messages of proposed actions, their labels or the diff: an approval lists only kinds and targets. -Member text (an automation's name, a summary) is escaped for each provider: it cannot mention anyone, ping a role or form a link. +Member text (an automation's or workspace's name, a summary, a branch) is escaped for each provider: it cannot mention anyone, ping a role or form a link. ### Which URLs are accepted diff --git a/src/tests/fixtures/notifications/discord_approval_pending.json b/src/tests/fixtures/notifications/discord_approval_pending.json new file mode 100644 index 00000000..4b6b7f0b --- /dev/null +++ b/src/tests/fixtures/notifications/discord_approval_pending.json @@ -0,0 +1,52 @@ +{ + "allowed_mentions": { + "parse": [] + }, + "content": null, + "embeds": [ + { + "color": 16098851, + "description": "**Proposed actions waiting for approval (3)**\n• github\\_pr\\_comment on helmcode/nan\\-cloud\\-api\\#342\n• git\\_push to branch nan/fix\\-tests\n• github\\_add\\_labels on helmcode/nan\\-cloud\\-api\\#12\\: 2 labels", + "fields": [ + { + "inline": true, + "name": "Automation", + "value": "PR review" + }, + { + "inline": true, + "name": "Workspace", + "value": "backend\\-ws" + }, + { + "inline": true, + "name": "Expires", + "value": "2026\\-10\\-15 10\\:00 UTC \\(in 24 h\\)" + }, + { + "inline": false, + "name": "Needs approval", + "value": "git\\_push, github\\_pr\\_comment, github\\_add\\_labels" + }, + { + "inline": true, + "name": "Event", + "value": "approval.pending" + }, + { + "inline": true, + "name": "State", + "value": "awaiting_approval" + }, + { + "inline": false, + "name": "Next step", + "value": "**[Review and approve](https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222?approval=1)**" + } + ], + "timestamp": "2026-10-14T10:00:00Z", + "title": "Approval needed: PR review", + "url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222?approval=1" + } + ] +} diff --git a/src/tests/fixtures/notifications/discord_run_failed.json b/src/tests/fixtures/notifications/discord_run_failed.json index 71e677e2..342c9a3a 100644 --- a/src/tests/fixtures/notifications/discord_run_failed.json +++ b/src/tests/fixtures/notifications/discord_run_failed.json @@ -22,6 +22,11 @@ "inline": true, "name": "Error", "value": "agent_failed" + }, + { + "inline": false, + "name": "Next step", + "value": "**[View run](https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222)**" } ], "timestamp": "2026-10-14T10:00:00Z", diff --git a/src/tests/fixtures/notifications/https_approval_pending.json b/src/tests/fixtures/notifications/https_approval_pending.json index aabff7ea..0b9c8718 100644 --- a/src/tests/fixtures/notifications/https_approval_pending.json +++ b/src/tests/fixtures/notifications/https_approval_pending.json @@ -1,4 +1,33 @@ { + "actions_url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222?approval=1", + "approval": { + "state": "pending", + "expires_at": "2026-10-15T10:00:00Z", + "approval_kinds": [ + "git_push", + "github_pr_comment", + "github_add_labels" + ], + "actions": [ + { + "kind": "github_pr_comment", + "target": "helmcode/nan-cloud-api#342", + "text": "github_pr_comment on helmcode/nan-cloud-api#342" + }, + { + "kind": "git_push", + "target": "nan/fix-tests", + "text": "git_push to branch nan/fix-tests" + }, + { + "kind": "github_add_labels", + "target": "helmcode/nan-cloud-api#12", + "text": "github_add_labels on helmcode/nan-cloud-api#12: 2 labels" + } + ], + "actions_total": 3 + }, + "approve_url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222?approval=1", "automation": { "id": "11111111-1111-4111-8111-111111111111", "name": "PR review" @@ -17,5 +46,9 @@ "text": "Approval needed: PR review", "trigger": null, "url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222", - "version": 1 + "version": 1, + "workspace": { + "id": "33333333-3333-4333-8333-333333333333", + "name": "backend-ws" + } } diff --git a/src/tests/fixtures/notifications/https_automation_disabled.json b/src/tests/fixtures/notifications/https_automation_disabled.json index 1440fd56..e46d51af 100644 --- a/src/tests/fixtures/notifications/https_automation_disabled.json +++ b/src/tests/fixtures/notifications/https_automation_disabled.json @@ -1,4 +1,7 @@ { + "actions_url": "https://cloud.nan.builders/automations/11111111-1111-4111-8111-111111111111", + "approval": null, + "approve_url": null, "automation": { "id": "11111111-1111-4111-8111-111111111111", "name": "PR review" @@ -11,5 +14,6 @@ "text": "Automation disabled: PR review (its workspace was deleted)", "trigger": null, "url": "https://cloud.nan.builders/automations/11111111-1111-4111-8111-111111111111", - "version": 1 + "version": 1, + "workspace": null } diff --git a/src/tests/fixtures/notifications/https_run_failed.json b/src/tests/fixtures/notifications/https_run_failed.json index b1f89e11..67d32af8 100644 --- a/src/tests/fixtures/notifications/https_run_failed.json +++ b/src/tests/fixtures/notifications/https_run_failed.json @@ -1,4 +1,7 @@ { + "actions_url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222", + "approval": null, + "approve_url": null, "automation": { "id": "11111111-1111-4111-8111-111111111111", "name": "PR review" @@ -17,5 +20,9 @@ "text": "Run failed: PR review (agent_failed)", "trigger": null, "url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222", - "version": 1 + "version": 1, + "workspace": { + "id": "33333333-3333-4333-8333-333333333333", + "name": null + } } diff --git a/src/tests/fixtures/notifications/https_run_finished.json b/src/tests/fixtures/notifications/https_run_finished.json index 9f23c6ee..b9364f3c 100644 --- a/src/tests/fixtures/notifications/https_run_finished.json +++ b/src/tests/fixtures/notifications/https_run_finished.json @@ -1,4 +1,7 @@ { + "actions_url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222", + "approval": null, + "approve_url": null, "automation": { "id": "11111111-1111-4111-8111-111111111111", "name": "PR review" @@ -17,5 +20,9 @@ "text": "Run succeeded: PR review", "trigger": null, "url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222", - "version": 1 + "version": 1, + "workspace": { + "id": "33333333-3333-4333-8333-333333333333", + "name": null + } } diff --git a/src/tests/fixtures/notifications/https_run_timed_out.json b/src/tests/fixtures/notifications/https_run_timed_out.json index f27dcb62..195a4488 100644 --- a/src/tests/fixtures/notifications/https_run_timed_out.json +++ b/src/tests/fixtures/notifications/https_run_timed_out.json @@ -1,4 +1,7 @@ { + "actions_url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222", + "approval": null, + "approve_url": null, "automation": { "id": "11111111-1111-4111-8111-111111111111", "name": "PR review" @@ -17,5 +20,9 @@ "text": "Run timed out: PR review", "trigger": null, "url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222", - "version": 1 + "version": 1, + "workspace": { + "id": "33333333-3333-4333-8333-333333333333", + "name": null + } } diff --git a/src/tests/fixtures/notifications/https_trigger_failing.json b/src/tests/fixtures/notifications/https_trigger_failing.json index ac68f037..2a341bd6 100644 --- a/src/tests/fixtures/notifications/https_trigger_failing.json +++ b/src/tests/fixtures/notifications/https_trigger_failing.json @@ -1,4 +1,7 @@ { + "actions_url": "https://cloud.nan.builders/automations/11111111-1111-4111-8111-111111111111?tab=triggers", + "approval": null, + "approve_url": null, "automation": { "id": "11111111-1111-4111-8111-111111111111", "name": "PR review" @@ -14,5 +17,6 @@ "kind": "webhook" }, "url": "https://cloud.nan.builders/automations/11111111-1111-4111-8111-111111111111", - "version": 1 + "version": 1, + "workspace": null } diff --git a/src/tests/fixtures/notifications/slack_approval_pending.json b/src/tests/fixtures/notifications/slack_approval_pending.json new file mode 100644 index 00000000..8f18e93c --- /dev/null +++ b/src/tests/fixtures/notifications/slack_approval_pending.json @@ -0,0 +1,70 @@ +{ + "blocks": [ + { + "text": { + "text": "*\u003chttps://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222?approval=1|Approval needed: PR review\u003e*", + "type": "mrkdwn" + }, + "type": "section" + }, + { + "fields": [ + { + "text": "*Automation*\nPR review", + "type": "mrkdwn" + }, + { + "text": "*Workspace*\nbackend-ws", + "type": "mrkdwn" + }, + { + "text": "*Expires*\n2026-10-15 10:00 UTC (in 24 h)", + "type": "mrkdwn" + }, + { + "text": "*Needs approval*\ngit_push, github_pr_comment, github_add_labels", + "type": "mrkdwn" + } + ], + "type": "section" + }, + { + "text": { + "emoji": false, + "text": "Proposed actions waiting for approval (3):\n• github_pr_comment on helmcode/nan-cloud-api#342\n• git_push to branch nan/fix-tests\n• github_add_labels on helmcode/nan-cloud-api#12: 2 labels", + "type": "plain_text" + }, + "type": "section" + }, + { + "elements": [ + { + "action_id": "nan_open", + "style": "primary", + "text": { + "emoji": false, + "text": "Review and approve", + "type": "plain_text" + }, + "type": "button", + "url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222?approval=1" + } + ], + "type": "actions" + }, + { + "elements": [ + { + "text": "`approval.pending`", + "type": "mrkdwn" + }, + { + "text": "state: awaiting_approval", + "type": "mrkdwn" + } + ], + "type": "context" + } + ], + "text": "Approval needed: PR review" +} diff --git a/src/tests/fixtures/notifications/slack_run_failed.json b/src/tests/fixtures/notifications/slack_run_failed.json index 67adb7f5..56a947ac 100644 --- a/src/tests/fixtures/notifications/slack_run_failed.json +++ b/src/tests/fixtures/notifications/slack_run_failed.json @@ -7,6 +7,29 @@ }, "type": "section" }, + { + "text": { + "emoji": false, + "text": "Reviewed PR #42: two issues found in pkg/foo.", + "type": "plain_text" + }, + "type": "section" + }, + { + "elements": [ + { + "action_id": "nan_open", + "text": { + "emoji": false, + "text": "View run", + "type": "plain_text" + }, + "type": "button", + "url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222" + } + ], + "type": "actions" + }, { "elements": [ { @@ -23,14 +46,6 @@ } ], "type": "context" - }, - { - "text": { - "emoji": false, - "text": "Reviewed PR #42: two issues found in pkg/foo.", - "type": "plain_text" - }, - "type": "section" } ], "text": "Run failed: PR review (agent_failed)" diff --git a/src/tests/fixtures/notifications/teams_approval_pending.json b/src/tests/fixtures/notifications/teams_approval_pending.json new file mode 100644 index 00000000..d20b868e --- /dev/null +++ b/src/tests/fixtures/notifications/teams_approval_pending.json @@ -0,0 +1,115 @@ +{ + "attachments": [ + { + "content": { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "actions": [ + { + "style": "positive", + "title": "Review and approve", + "type": "Action.OpenUrl", + "url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222?approval=1" + } + ], + "body": [ + { + "inlines": [ + { + "size": "Medium", + "text": "Approval needed: PR review", + "type": "TextRun", + "weight": "Bolder" + } + ], + "type": "RichTextBlock" + }, + { + "inlines": [ + { + "text": "Automation: PR review", + "type": "TextRun" + } + ], + "type": "RichTextBlock" + }, + { + "inlines": [ + { + "text": "Workspace: backend-ws", + "type": "TextRun" + } + ], + "type": "RichTextBlock" + }, + { + "facts": [ + { + "title": "Event", + "value": "approval.pending" + }, + { + "title": "State", + "value": "awaiting_approval" + }, + { + "title": "Expires", + "value": "2026-10-15 10:00 UTC (in 24 h)" + } + ], + "type": "FactSet" + }, + { + "inlines": [ + { + "text": "Needs approval: git_push, github_pr_comment, github_add_labels", + "type": "TextRun" + } + ], + "type": "RichTextBlock" + }, + { + "inlines": [ + { + "text": "Proposed actions waiting for approval (3):", + "type": "TextRun" + } + ], + "type": "RichTextBlock" + }, + { + "inlines": [ + { + "text": "• github_pr_comment on helmcode/nan-cloud-api#342", + "type": "TextRun" + } + ], + "type": "RichTextBlock" + }, + { + "inlines": [ + { + "text": "• git_push to branch nan/fix-tests", + "type": "TextRun" + } + ], + "type": "RichTextBlock" + }, + { + "inlines": [ + { + "text": "• github_add_labels on helmcode/nan-cloud-api#12: 2 labels", + "type": "TextRun" + } + ], + "type": "RichTextBlock" + } + ], + "type": "AdaptiveCard", + "version": "1.4" + }, + "contentType": "application/vnd.microsoft.card.adaptive", + "contentUrl": null + } + ], + "type": "message" +} diff --git a/src/tests/fixtures/notifications/teams_run_failed.json b/src/tests/fixtures/notifications/teams_run_failed.json index 7c7ec606..44850925 100644 --- a/src/tests/fixtures/notifications/teams_run_failed.json +++ b/src/tests/fixtures/notifications/teams_run_failed.json @@ -5,7 +5,7 @@ "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", "actions": [ { - "title": "Open in NaN", + "title": "View run", "type": "Action.OpenUrl", "url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222" } diff --git a/src/tests/layouts/notificationsDocs.test.ts b/src/tests/layouts/notificationsDocs.test.ts index f53096c6..5a6f3b7f 100644 --- a/src/tests/layouts/notificationsDocs.test.ts +++ b/src/tests/layouts/notificationsDocs.test.ts @@ -17,7 +17,7 @@ import openapi from '../../data/openapi.json'; * * The facts pinned here come from: * - devops AGENT-AUTOMATIONS-PHASE3-NOTIFICATIONS.md §2, §4, §5, §6, §7, §8. - * - cloud-api origin/main 48ea25e (0.8.262): handlers/notification_channels.go + * - cloud-api 0.8.263 (approval call to action, PR feat/notif-approval-cta): handlers/notification_channels.go * (routes, notifWriteBurst/RatePerSec, views), notifications/service.go * (codes, limits), notifications/payload.go (titles), signing.go * (nan_nsec_, v1= HMAC of ".", 24 h grace) and the golden files in @@ -109,6 +109,9 @@ const PORTAL_LABELS = [ 'Turned off: failing', 'Turned off: URL refused', 'Manage notifications', ]; +/** cloud-api notifications/payload.go actionLabel: the call to action of each event. */ +const CTA_LABELS = ['Review and approve', 'View run', 'Open automation', 'View triggers']; + /** nan-cli 0.1.34 `nan notifications` subcommands and the flags the page names. */ const CLI_COMMANDS = ['ls', 'show', 'add', 'edit', 'rm', 'test', 'rotate', 'deliveries', 'subscriptions']; const CLI_FLAGS = ['--kind', '--url-file', '--url-stdin', '--event', '--no-events', '--automation', '--name', '--enable', '--disable', '--limit', '--set', '--add', '--remove', '--clear', '--yes', '--json']; @@ -171,12 +174,34 @@ describe.each(Object.entries(pages))('Notifications page (%s)', (_locale, page) expect(body).toContain('**Post to a channel when a webhook request is received**'); expect(body).toContain('**Manage Webhooks**'); expect(body).toContain('**Copy Webhook URL**'); - expect(body).toContain('**Open in NaN**'); const teams = golden('teams_run_failed').attachments[0].content; - expect(teams.actions[0].title).toBe('Open in NaN'); + expect(teams.actions[0].title).toBe('View run'); expect(golden('discord_run_failed').allowed_mentions).toEqual({ parse: [] }); }); + // Owner decision 2026-10-14: approval.pending carries a prominent + // "Review and approve" call to action (approvals stay in the browser), and + // the other events a "View run" / "Open automation" / "View triggers" link. + test('the approval call to action and the link of every event, as the providers render them', () => { + for (const label of CTA_LABELS) expect(body, label).toContain(`**${label}**`); + const slack = golden('slack_approval_pending').blocks.find((b: { type: string }) => b.type === 'actions').elements[0]; + expect(slack.text.text).toBe('Review and approve'); + expect(slack.style).toBe('primary'); + const teams = golden('teams_approval_pending').attachments[0].content.actions[0]; + expect(teams.title).toBe('Review and approve'); + expect(teams.type).toBe('Action.OpenUrl'); + const discord = golden('discord_approval_pending').embeds[0]; + expect(discord.url).toMatch(/\?approval=1$/); + expect(discord.fields.map((f: { name: string }) => f.name)).toEqual(expect.arrayContaining(['Automation', 'Workspace', 'Expires', 'Needs approval', 'Next step'])); + expect(slack.url).toBe(golden('https_approval_pending').approve_url); + expect(body).toContain('`?approval=1`'); + // Digests and test messages keep the generic label. + expect(body).toContain('**Open in NaN**'); + expect(flat).toMatch(/never approve from Slack, Teams or Discord|Nunca se aprueba desde Slack, Teams o Discord/); + expect(flat).toMatch(/up to 5 actions|hasta 5 acciones/); + expect(flat).toMatch(/no longer pending|ya no está pendiente/); + }); + test('events: the five, four on by default, run.finished opt-in, only automation runs, never cancelled', () => { const table = body.slice(body.search(/^## (Events|Eventos)$/m)); for (const e of EVENTS) expect(table).toMatch(new RegExp(`^\\| \`${e.replace('.', '\\.')}\` \\|`, 'm')); @@ -267,6 +292,19 @@ print(verify(b, "${now}", "v1=${good}"), verify(b, "${now}", "v1=00,v1=${good}") expect(digest.text).toBe(golden('https_digest').text); expect(digest.url).toBe(golden('https_digest').url); + const approval = byEvent('approval.pending'); + const g = golden('https_approval_pending'); + expect(keys(approval)).toEqual(keys(g)); + expect(keys(approval.approval)).toEqual(keys(g.approval)); + expect(keys(approval.approval.actions[0])).toEqual(keys(g.approval.actions[0])); + expect(keys(approval.workspace)).toEqual(keys(g.workspace)); + expect(approval.text).toBe(g.text); + expect(approval.approval.actions.map((a: { text: string }) => a.text)).toEqual(g.approval.actions.map((a: { text: string }) => a.text)); + expect(approval.actions_url).toMatch(/\/runs\/…\?approval=1$/); + for (const f of ['actions_url', 'approve_url', 'workspace', 'approval', 'approval.state', 'approval.expires_at', 'approval.approval_kinds', 'approval.actions', 'approval.actions_total']) { + expect(body, f).toContain(`| \`${f}\` |`); + } + const t = byEvent('test'); expect(keys(t)).toEqual(keys(golden('https_test'))); expect(t.text).toBe(golden('https_test').text);